Redis 기반 선착순 쿠폰 발급 시스템의 핵심 컴포넌트인 RedisQueueWorker를 멀티 서버 환경에서도 안전하게 동작하도록 리팩토링했다. 기존 구조에서 발생할 수 있는 중복 워커 실행, Kafka 중복 전송 문제를 방지하기 위해 Redis 락(setIfAbsent)을 도입했다. 쿠폰 발급 종료 상태를 명시적으로 나타내는 cleanKey를 도입해 이벤트 중복 방지 및 발급 완료 여부를 명확히 표현했다. 그리고 재귀 호출 방식을 유지하면서 동기 처리와 순차 보장을 단순하고 안정적으로 처리하도록 구성했다.
쿠폰 발급 요청이 발생하면 Redis 큐에 유저 정보를 적재한 후 CouponQueueEventDto
이벤트가 발행되어 워커가 실행되도록 트리거된다. 그러나 이 이벤트는 단일 서버가 아닌 N대의 서버에서 동시에 수신될 수 있다. 특히 메시지 브로커를 거치지 않고 단순 @EventListener
방식일 경우에는 WAS 여러 인스턴스에서 동시에 수신해 동일한 쿠폰 ID를 각각 처리할 가능성이 있다.
Kafka 메시지 중복 전송
여러 워커가 동시에 Kafka로 발급 성공 메시지를 전송하면 DB에는 같은 유저가 여러 번 저장되거나 충돌이 발생할 수 있다.
중복 cleanup 호출
한 워커가 Redis 상태를 정리하고 삭제한 뒤 다른 워커는 이미 삭제된 키를 접근하려 하면서 문제가 발생할 수 있다.
불필요한 리소스 낭비
큐는 하나인데 여러 워커가 동시에 blockingPop
을 기다리면서 CPU/Thread 리소스를 낭비하게 된다.
String lockKey = String.format("coupon:%d:running", couponId);
// 동일 쿠폰 워커 중복 실행 방지 (멀티 서버 대비용)
Boolean lockAcquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "true", Duration.ofMinutes(10));
if (Boolean.FALSE.equals(lockAcquired)) {
log.info("⛔ 워커 이미 실행 중: couponId={}", couponId);
return;
}
executor.submit(() -> {
try {
processQueue(couponId, cleanKey);
} finally {
redisTemplate.delete(lockKey); // 워커 종료 시 락 해제
}
});
SETNX
명령어을 통해 동일 쿠폰 ID에 대해 하나의 서버만 워커를 실행하도록 제한했다. TTL을 설정해 서버 장애 시 락이 영구적으로 남지 않게 하고 워커 종료 시에는 finally 블록에서 락을 해제한다.
String cleanKey = String.format("coupon:%d:done", couponId);
if (Boolean.TRUE.equals(redisTemplate.hasKey(cleanKey))) {
log.info("✅ 이미 발급 완료된 쿠폰: couponId={}", couponId);
return; // 재실행 방지
}
cleanKey
는 락과 다른 개념이다. 락은 “현재 워커 실행 중”을 의미하지만 cleanKey는 “이미 발급이 끝난 쿠폰임”을 명확히 알려주는 역할을 한다. 이벤트가 중복으로 발행된 경우 이를 통해 워커 자체가 아예 실행되지 않도록 방지할 수 있다.
redisTemplate.opsForValue().set(cleanKey, "done", Duration.ofMinutes(10));
동일한 쿠폰 ID에 대해 단 하나의 서버만 워커를 실행하도록 Redis 락을 통해 제어함으로써 중복 발급, 중복 메시지 전송 등의 리스크를 제거했다. 락 키에 TTL을 설정하여 예외 상황에서도 락이 영구적으로 남는 문제를 방지하고 장애 복구 가능성도 확보했다.
발급 성공 시 Kafka로 이벤트를 전송하는 구조에서 중복 워커가 존재하면 Kafka 메시지가 여러 번 전송되어 DB에 중복 저장되거나 무결성 제약 조건이 깨질 수 있었다.락을 도입한 이후 이벤트 메시지는 1회만 전송되므로 데이터 일관성과 정합성이 유지된다.
기존에는 여러 서버가 동시에 blockingPopQueue()
를 호출하며 동일한 큐를 차지하려 했기 때문에 스레드와 CPU 리소스가 낭비되고 성능도 불안정했다. 하지만 이제 단 하나의 서버에서만 워커를 실행하므로 시스템 자원을 효율적으로 사용할 수 있게 되었다.
이벤트를 잘못 여러 번 발행했거나 사용자가 새로고침으로 중복 요청했을 경우 cleanKey
가 없으면 다시 워커가 실행될 위험이 있었다. 발급 완료 후 Redis에 cleanKey
를 설정하여 "이미 발급 종료된 쿠폰"임을 기록해서 중복 실행을 사전에 차단할 수 있게 되었다 이 키는 TTL을 두어 이후 이벤트가 다시 열릴 수 있는 구조(재오픈 이벤트)까지 유연하게 대비할 수 있다.
큐에서 유저를 하나 꺼내 처리한 뒤 다음 유저가 존재하면 재귀 호출을 통해 이어서 처리하는 구조를 유지했다. 이를 통해 코드의 흐름이 직관적이고 순차적인 동작을 자연스럽게 표현할 수 있었다.
이번 리팩토링을 통해 단순히 Redis 큐에서 유저를 꺼내 처리하는 로직이라 하더라도 멀티 서버 환경에서는 예기치 않은 복잡성이 발생할 수 있다는 사실을 알 수 있었다. 이벤트 중복 수신, 큐의 중복 소비, Kafka 중복 메시지 전송 등은 단일 서버에서는 잘 드러나지 않는 문제지만 시스템이 수평 확장되면서 반드시 고려해야 할 핵심 이슈가 되었다. 이를 통해 분산 환경에서 공유 자원을 다룰 때는 반드시 락과 상태 캐시 전략을 적절히 잘 사용해야 한다. 이렇게 실시간성과 안정성이 동시에 요구되는 시스템에서는 락, 상태 캐시, TTL 같은 인프라 수준의 설계 요소도 굉장히 중요하다는 걸 알게 되었다. 그리고 일반적으로 Event-driven
시스템에서는 메시지를 수신하고 1건만 처리하는 구조가 자연스럽지만 선착순 쿠폰 발급과 같이 "큐가 빌 때까지 계속 처리"하는 요구사항에서는 재귀 호출이 오히려 더 명확하고 간결한 제어 흐름을 제공할 수 있다는 점을 배웠다.