기존에는 분산락을 사용했었습니다.
왜 동시성 제어 시 여러 선택지가 있는데, 분산락을 사용했을까요?
- 낙관적 락과 비관적 락의 선택지
분산락을 채택하기 이전에는 비관적 락으로 동시성 제어 하는 것으로 선택했습니다.
비관적 락으로 데이터를 조회하게 되면 해당 트랜잭션이 끝나기 전까지는 비관적 락을 사용한 데이터에 데이터를 Insert 를 할 수 없게 됩니다.
하지만 성능상 이슈가 있었습니다.
낙관적 락과 비관적 락의 성능 문제
공통점 - 기본적으로 다수의 쓰레드에서 DB 에 Select 문을 날려야 하는 구조이기 때문에 DB CPU 의 점유율이 요청 쓰레드에 비례해서 상승하게 됩니다.
🔐 비관적 락 - Lock 이 필요하지 않은 상황에서도 Lock 을 사용하기 때문에, 트래픽이 많은 경우에는 O(N^2) 정도까지 성능이 저하된다는 문제점이 있습니다. 그래서 다른 요청들이 Blocking 이 되어 타임아웃이 될 수 있습니다.
🔐 낙관적 락 - 충돌 발생 시 개발자가 수동으로 롤백처리를 해줘야 합니다. 낙관적 락은 충돌이 많이 예상되거나, 충돌이 발생했을 때 손실 비용이 많이 들지 않는 곳에 적합합니다.
저희의 쿠폰 발급 로직은 최대 3600TPS 까지를 목표로 개발을 진행했습니다.
비관적 락 - TPS 1400, DB CPU 60% 낙관적 락 - TPS 1800, DB CPU 70% / 충돌 발생 시 손실 비용 높음 DB 의 CPU 가 높아진다면 예상치 못한 DB 오류가 발생할 수 있습니다. 그래서 대용량 트래픽 핸들링 시 CPU 까지도 고려해야 합니다.
그래서 분산락을 적용했습니다.
분산락을 적용하니 DB의 CPU 점유율은 30% 대로 감소하였습니다.
하지만 여기서 문제가 생겼습니다.
분산락은 느리다.
TPS 가 200 ~ 250 대 밖에 안 나왔습니다.
TPS 가 느려지니 응답 시간도 4000ms~6000ms 까지 길어지게 되었습니다.
이렇게 되면 정상적인 이벤트를 기대할 수 없을 것 입니다.
왜 어플리케이션 단에 분산락을 적용하면 TPS 가 이렇게 낮을까?
아래의 사진을 보자
각 모듈마다 할당 쓰레드가 존재합니다.
분산락을 적용하지 않으면 멀티 쓰레드로 작업을 병렬적으로 처리하게 됩니다.
사진이 예시로 딱 들어 맞는 것은 아니지만
로직에 분산락을 적용하면 해당 로직은 단 한 쓰레드에서만 돌아갈 수 있게 됩니다.
DB에 락을 걸어, 멀티 쓰레드에서 계속 쿼리를 날리는 것보다 분산락을 적용하여 하나씩만 쿼리를 날리니, 더 느려졌습니다.
그럼 어떻게 해야 할까요?
우리의 목표는 TPS 3600
과감하게 분산락을 없애는 것을 선택했습니다.
그러면 Lock 을 적용하지 않고 동시성을 어떻게 제어하고, 어떻게 TPS 를 높일 수 있을까요?
private final RedisRepository redisRepository;
@Transactional
public void couponValidate(String couponId, String userId, Integer maxQuantity) {
// 1. 같은 유저 중복 발급 확인
if (redisRepository.sIsMember(userId, couponId)) {
throw ... // 생략
}
// 2. 쿠폰 발급 최대 수량 확인
if (redisRepository.sCard() >= maxQuantity) {
throw ... // 생략
}
// 3. 발급
issue(couponId, userId);
}
@Transactional
public void issue(String couponId, String userId) {
// 발급 로직
}
여기서 동시성 문제가 발생하는 부분은 여기에 있습니다.
1, 2, 3 번에 있습니다.
1번과 3번 사이에는 동시성 문제가 많이 발생하지 않는다고 해도 따닥 이슈가 있죠
2번과 3번 사이 동시에 다른 쓰레드에서도 수량 검증을 통과 할 수도 있습니다.
그러다 한 쓰레드가 먼저 발급을 하면, 다른 쓰레드도 마찬가지로 발급에 성공하죠.
그러면 저희는 이 메서드들을 하나로 묶어서 처리해야 합니다.
이것을 Lua Script 로 처리합니다.
private RedisScript<String> issueRequestScript() {
String script = """
if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 1 then
return '2'
end
if tonumber(ARGV[2]) > redis.call('SCARD', KEYS[1]) then
redis.call('SADD', KEYS[1], ARGV[1])
return '1'
end
return '3'
""";
// 1. 유저 중복 확인
// 2. 쿠폰 발급 수량 확인 과 동시에 발급
// 3. 그 외는 3으로 응답
return RedisScript.of(script, String.class);
}
레디스는 싱글 쓰레드이니, 해당 Lua Script 가 끝나기 전까진 다른 로직을 처리 할 수 없습니다.
그러나 빠른 속도로 처리할 수 있습니다.
여기까지만 해도 TPS 가 200 - 250 대에서 4500 - 5000 대로 개선할 수 있습니다.
그럼 DB 에 적재는 언제 할 까요?
Kafka 에 Queue 를 쌓아두고 순차적으로 처리하면 됩니다.
이 부분은 다시 비관적 락을 사용하는 것으로 결정했습니다.
Queue 에서 Consume 하는 속도가 있기 때문에 그래도 전보단 평균 CPU 점유율이 오르지 않을 것입니다.
Queue 에 100,000 개 이상의 요청 만큼 쌓여있는 것이 아니라면 더욱 그렇습니다.
Before & After
Before(10,000 개 테스트 / User 1000)
최대 TPS | 250 |
평균 Response Time | 5000ms |
평균 DB CPU | 30% |
After (150,000 개 테스트 / User 1000)
최대 TPS | 5200 |
평균 Response Time | 150ms |
평균 DB CPU | 50% |