선착순 이벤트 쿠폰 발급 시스템을 구현하면서, 발생하는 동시성 문제를 해결하고, 성능이슈를 해결하는 과정을 다루고있습니다. 결론적으로는, Redisson 락의 성능 한계를 극복하고, Redis Lua Script를 통해 처리 속도를 향상시키는 과정을 다루고 있습니다.
📌 쿠폰 발급 시스템에서 발생하는 동시성 문제
선착순 쿠폰 발급 시스템에서는 동시에 수천 ~ 수만개의 요청이 발생하는데, 이를 적절히 처리하지 못하면 쿠폰이 초과 발급되거나, 데이터 정합성이 깨지는 동시성 문제가 발생합니다.
쿠폰발급 시스템에서의 동시성 문제의 주요 원인은 다음과 같습니다.
- 중복 발급 문제
- 동일한 사용자가 여러 번 쿠폰을 발급받을 가능성
- 중복 방지 로직이 없으면, 재고보다 많은 쿠폰이 발급될 위험이 있습니다.
- 초과 발급 문제
- 쿠폰 수량이 500개라고 가정할 때, 500명이 동시에 요청을 보낸다면 문제가 없지만, 1,000명이 동시에 요청을 보낸다면? → 쿠폰이 초과 발급될 가능성이 높습니다.
해당 프로젝트에서의 쿠폰발급서버 아키텍처는 다음과 같고, 해당 아키텍처에서는 두 가지 주요 동시성 문제를 해결해야 합니다.
동시성 문제1 : API 서버에서의 동시 요청 처리 (쿠폰 발급 가능 여부 확인)
- API 서버는 사용자의 요청을 빠르게 처리하고, 불필요한 DB 접근을 줄이는 역할을 합니다.
- 이 과정에서 동시에 여러 사용자가 쿠폰을 요청하면 쿠폰 중복발급 및 초과 발급 문제가 발생할 수 있습니다.
동시성 문제2 : 쿠폰 발급 서버에서의 실제 데이터베이스 트랜잭션 충돌 방지
- 쿠폰 발급 요청이 Redis Queue를 통해 전달되면, 쿠폰 발급 서버(Consumer)에서 DB 트랜잭션을 처리해야 하는데, 이때 여러 개의 발급 서버가 동시에 같은 쿠폰 데이터를 수정하면, 트랜잭션 충돌이 발생할 수 있습니다.
- 즉, 쿠폰발급 서버가 여러 개라면, 서로 다른 서버에서 동시에 Redis에서 데이터를 읽고, DB에서 트랜잭션을 수행할 가능성이 있으므로 1번만으로 완벽한 동시성 해결이 어려울 수 있습니다.
현재는 구조에서는 1번만 해결하면, DB 충돌 가능성이 낮아져 큰 문제는 없지만 트래픽이 매우 급증할때, Redis 서버가 죽을때등을 대비하여, 비관적 락(Pessimistic Lock)을 적용하였습니다.
밑에서 동시성 문제1번 문제를 해결하기위해, Redisson 분산 락 vs Redis Lua Script의 비교를 통해 자세히 알아보겠습니다.
📌 동시성 문제 해결 방법: Redisson 분산 락 vs Redis Lua Script
위의 1번 동시성 문제를 Redisson 분산 락 , Redis Lua Script 두 가지 방식으로 해결해보겠습니다.
✅ Redisson 분산 락을 활용한 동시성 제어 (V1)
쿠폰 발급 시 Redisson의 RLock을 활용하여 락을 획득한 후, 쿠폰 발급을 진행하는 방식
- Redisson은 Redis 기반의 분산 락을 제공하는 라이브러리로, 락을 활용하면 다수의 요청이 동일한 쿠폰 발급 로직을 실행하는 것을 방지할 수 있습니다.
- 즉, 한 번에 하나의 요청만 쿠폰을 발급하도록 제어하여 데이터 정합성을 유지합니다.
🔹 Redission락을 활용하여 동시성을 제어
public class DistributeLockExecutor {
private final RedissonClient redissonClient;
/**
* @param lockName
* @param waitMilliSecond 락을 획득하기 위해 기다릴 최대 시간(초과되면 락 획득에 실패)
* @param leaseMilliSecond 락이 유지되는 시간(지정된 시간이 지나면 자동해제)
* @param logic 락이 획득된 상태에서 실행할 작업.
*/
public void execute(String lockName, long waitMilliSecond, long leaseMilliSecond, Runnable logic) {
RLock lock = redissonClient.getLock(lockName);
try {
boolean isLocked = lock.tryLock(waitMilliSecond, leaseMilliSecond, TimeUnit.MILLISECONDS);
if(!isLocked){
throw new IllegalArgumentException("["+lockName+"] lock 획득 실패" );
}
logic.run();
} catch (InterruptedException e) {
log.error(e.getMessage(),e);
throw new RuntimeException(e);
}finally {
if(lock.isHeldByCurrentThread()){
lock.unlock();
}
}
}
}
- Redisson의 `tryLock()`을 사용하여 동시에 하나의 요청만 쿠폰 발급을 수행할 수 있도록 락을 획득
- `waitMilliSecond` 동안 락을 획득하려고 시도하고, leaseMilliSecond 이후에는 자동으로 해제
- 락 획득에 실패하면 예외 발생
🔹 쿠폰 발급 요청 처리
public void issue(long couponId, long userId) {
// 쿠폰 존재 검증
CouponRedisEntity coupon = couponCacheService.getCouponCache(couponId);
coupon.checkIssuableCoupon();
// Redis 분산Lock 적용
distributeLockExecutor.execute(getRedisLockName(couponId), 3000, 3000, () -> {
couponIssueRedisService.checkCouponIssueQuantity(coupon, userId);
issueRequest(couponId, userId);
});
}
private void issueRequest(long couponId, long userId) {
CouponIssueRequest couponIssueRequest = new CouponIssueRequest(couponId, userId);
try {
String value = objectMapper.writeValueAsString(couponIssueRequest);
redisRepository.sAdd(getIssueRequestKey(couponId), String.valueOf(userId));
// 발급에 성공했다면 쿠폰을 발급하고 쿠폰 발급 Queue에 적재
redisRepository.rPush(getIssueRequestQueueKey(), value);
} catch (JsonProcessingException e) {
throw new CouponIssueException(ErrorCode.FAIL_COUPON_ISSUE_REQUES);
}
}
- 쿠폰 정보를 `Redis`에서 가져온 후, Redisson 락을 획득한 후에만 쿠폰 발급 진행
- `checkCouponIssueQuantity()`를 통해 발급 가능 여부 확인 후 쿠폰발급 요청`issueRequest()` 수행
🚨해당 방식의 문제점
- 락 획득 실패 시 요청이 대기하거나 바로 실패
- 락을 획득하는 동안 대기 시간이 발생하여 성능 저하
- 네트워크 오버헤드 증가 (락 획득 및 해제 과정에서 Redis와 지속적인 통신 `sAdd`,`rPush`등 발생)
✅ Redis Lua Script를 활용한 원자적 처리 (V2)
Redisson 락을 제거하고, Redis Lua Script를 활용하여 모든 연산을 원자적으로 실행
- 원자적 실행: Redis는 싱글 스레드로 동작하며, Lua 스크립트는 실행 중간에 다른 명령어가 끼어들 수 없어 동시성 처리가 가능
- 락(Lock) 불필요: 스크립트 자체가 원자적으로 실행되므로 별도의 락이 필요 없음
- 네트워크 오버헤드 감소: 여러 Redis 명령어를 단일 스크립트로 묶어 통신 횟수를 최소화
🔹 Lua Script 활용하여 동시성 제어
public void issueRequest(long couponId, long userId, int totalIssueQuantity) {
String issueRequestKey = getIssueRequestKey(couponId);
String issueRequestQueueKey = getIssueRequestQueueKey();
CouponIssueRequest couponIssueRequest = new CouponIssueRequest(couponId, userId);
try {
String code = redisTemplate.execute(
issueScript,
List.of(issueRequestKey, issueRequestQueueKey), // KEYS
String.valueOf(userId), // ARGV[1] : 요청한 사용자ID
String.valueOf(totalIssueQuantity), // ARGV[2] : 발급 가능한 최대 쿠폰수량
objectMapper.writeValueAsString(couponIssueRequest) // ARGV[3] 직렬화된 요청객체(value)
);
CouponIssueRequestCode.checkRequestResult(CouponIssueRequestCode.findCode(code));
} catch (JsonProcessingException e) {
throw new CouponIssueException(ErrorCode.FAIL_COUPON_ISSUE_REQUES);
}
}
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])
redis.call('RPUSH', KEYS[2], ARGV[3])
return '1'
end
return '3'
""";
return RedisScript.of(script, String.class);
}
- 하나의 Lua Script에서 모든 연산을 원자적으로 처리
- `SISMEMBER`: 사용자가 이미 쿠폰을 발급받았는지 확인 (중복 요청 방지)
- `SCARD`: 현재까지 발급된 쿠폰 수량을 확인
- `SADD`: 발급 요청한 사용자를 Redis Set에 저장
- `RPUSH`: 발급 요청을 Queue에 적재하여 비동기 처리 가능
📌 장점
- 모든 연산을 Redis 내부에서 단일 Lua Script로 실행 → 네트워크 왕복 횟수 최소화
- 쿠폰 발급 가능 여부 확인 및 처리(SADD, SCARD, RPUSH)를 하나의 트랜잭션으로 실행 → 동시성 문제 해결
📌 성능 비교: Redisson 락 vs Redis Lua Script
앞서 구현한 Redission 락 방식과 Redis Lua Script의 성능을 비교하기위해 Locust 부하테스트 결과를 진행하였습니다.
Locust 부하테스트 결과
✅ Redission 락 방식(V1)
✅ Redis Lua Script (V2)
대표적인 성능지표( TPS, 응답시간, 실패율)를 비교해보면 다음과 같습니다.
지표 | Redission Lock 방식(V1) | Lua Script 방식(V2) | 개선 효과 |
TPS (RPS, 초당 요청 수) | 117.69 | 1277.14 | 10.8배 증가 |
평균 응답 시간 | 7903ms | 444ms | 17.8배 감소 |
실패율 | 54.3% (3,284건 실패) | 0.003% (8건 실패) | 99.99% 감소 |
두 방식 모두 동시성 문제 해결에는 성공했지만, Lua Script 방식이 훨씬 뛰어난 성능을 보였습니다.
먼저 Redisson 락 방식의 문제점은 다음과 같이 정리할 수 있습니다.
- 락 경합(Lock Contention)
- 여러 요청이 동시에 Redisson 락을 획득하려고 경쟁하면서, 대기 시간이 늘어나고 처리 속도가 느려집니다.
- 네트워크 오버헤드 증가
- 락 획득 (tryLock), 쿠폰 발급 요청, 락 해제까지 최소 3번 이상의 네트워크 왕복이 발생합니다.
- TPS(초당 처리량) 저하
- 락을 획득한 후 순차적으로 처리하기 때문에 동시 요청을 빠르게 처리하지 못해 초당 처리량이 낮아집니다.
- 실패율 증가
- 락 획득 실패 시 요청이 즉시 실패하면서 타임아웃이나 HTTP 500 오류가 자주 발생합니다.
- 응답 속도 지연
- 락 획득에 실패하면 재시도(waiting)가 반복되어 전반적인 응답 시간이 크게 지연됩니다.
다음은 Redis Lua Script를 통해 성능이 개선된 이유를 알아보겠습니다.
- 락 제거로 인한 성능 향상
- 락 없이 Lua Script를 이용해 원자적으로 처리하므로 락 경합 문제가 없습니다.
- 네트워크 오버헤드 감소
- Redisson과 달리 Lua Script를 한 번만 실행하여 로직을 처리하기 때문에 네트워크 왕복 횟수가 3회에서 1회로 줄어듭니다.
- TPS(초당 처리량) 대폭 증가
- 락 획득 및 해제 과정이 없으므로 병렬 처리가 가능해지며, TPS가 10배 이상 증가했습니다.
- 실패율 감소
- Lua Script 내부에서 쿠폰 발급 가능 여부를 즉시 확인해 불필요한 요청을 사전에 차단합니다.
- 응답 속도 단축
- 락을 사용하지 않고 네트워크 왕복을 줄여 응답 속도가 약 17.8배 향상되었습니다.
📌 결론
결론적으로 Redisson 락 방식은 락 획득 경쟁과 네트워크 오버헤드로 인해 TPS 성능이 낮았고, Redis Lua Script 방식은 원자적 연산을 통해 동시성을 제어하고, 네트워크 오버헤드를 제거하여 성능이 크게 향상되었습니다. 따라서 해당 프로젝트에서는 Redis Lua Script 방식을 적용하였습니다.
'BackEnd' 카테고리의 다른 글
계층적 캐싱(Caffeine + Redis)을 도입하여 쿠폰 발급 시스템 성능 최적화하기 (0) | 2025.03.20 |
---|---|
트래픽을 고려한 쿠폰 발급 서버 아키텍처 개선기 (0) | 2025.03.17 |
Spring 비동기 프로그래밍(@Async)과 ThreadPool 이해 (0) | 2025.03.09 |
스프링 IoC와 DI (0) | 2025.03.07 |
스프링에서 SOLID 원칙 알아보기 (0) | 2025.03.06 |