기존 구조는 @PostConstruct
로 워커를 띄워 ScheduledExecutorService
를 이용해 Redis 큐를 주기적으로 감시하는 Polling 방식이었다. 하지만 이 방식에는 다음과 같은 단점이 있었다.
이벤트 기반 구조로 변경
이제는 Redis 큐에 발급 요청이 들어올 때마다 ApplicationEventPublisher
를 통해 이벤트를 발행한다. 워커는 이 이벤트를 @EventListener
로 감지하고 큐를 처리한다. 즉, 발급 요청이 있을 때만 워커가 실행된다.
publisher.publishEvent(new CouponQueueEventDto(couponId, getTotalCount(couponId)));
락을 사용해 단일 워커 보장
쿠폰별로 동시에 여러 워커가 실행되는 것을 막기 위해 Redis 분산 락을 사용했다. 쿠폰별 락 키는 coupon:{id}:lock
이고 SETNX
+ TTL
을 사용해 락을 획득한 워커만 큐를 처리하게 한다.
ExecutorService로 비동기 처리
기존 ScheduledExecutorService는 주기적으로 반복 실행되는데 지금 구조는 큐에 데이터가 들어올 때만 실행되면 되므로 ExecutorService가 더 적절하다. 워커는 하나의 요청을 처리한 뒤 재귀적으로 다음 요청을 이어서 처리하며 큐가 빌 때까지 순차적으로 발급을 수행한다.
워커 종료 시 Redis 정리
워크 완료 조건은 (현재 발급 수량 >= total) 이다. 이 조건을 만족하면 Redis의 관련 키들을 삭제해 리소스를 정리한다.
private static final long LOCK_EXPIRE_SEC = 60L;
@EventListener
public void onCouponQueued(CouponQueueEventDto event) {
long couponId = event.getCouponId();
String lockKey = String.format("coupon:%d:lock", couponId);
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "locked", Duration.ofSeconds(LOCK_EXPIRE_SEC));
if (!Boolean.TRUE.equals(locked)) {
log.info("⛔ 워커 이미 실행 중: couponId={}", couponId);
return;
}
executor.submit(() -> processQueue(couponId, lockKey));
}
private void processQueue(long couponId, String lockKey) {
int total = redisService.getTotalCount(couponId);
int current = redisService.getCurrentCount(couponId);
String data = redisService.blockingPopQueue(couponId);
if (data == null) {
if (current >= total) {
log.info("🎯 couponId={} 발급 완료({}/{}) - 워커 종료", couponId, current, total);
cleanupCouponData(couponId);
} else {
log.info("⏳ 대기 시간 초과, 아직 남은 수량 있음: couponId={}", couponId);
}
redisTemplate.delete(lockKey);
return;
}
long userId = Long.parseLong(data.split(":")[1]);
CouponIssueEnum result = redisService.tryIssueCoupon(couponId, userId, total);
switch (result) {
case SUCCESS -> {
log.info("✅ 발급 성공: couponId={}, userId={}", couponId, userId);
couponIssueProducer.sendIssueEvent(couponId, userId);
}
case OUT_OF_STOCK -> log.info("🎯 재고 소진: couponId={}", couponId);
case ALREADY_ISSUED -> log.warn("🚫 중복 발급 시도: couponId={}, userId={}", couponId, userId);
default -> log.error("❌ 예기치 않은 결과: {} for couponId={} userId={}", result, couponId, userId);
}
processQueue(couponId, lockKey);
}
private void cleanupCouponData(long couponId) {
String queueKey = String.format("coupon:%d:queue", couponId);
Long queueSize = redisTemplate.opsForList().size(queueKey);
int current = redisService.getCurrentCount(couponId);
int total = redisService.getTotalCount(couponId);
if (current >= total) {
log.info("✅ Redis 정리 시작: couponId={}", couponId);
redisTemplate.delete(queueKey); // 무조건 queue는 제거
String userPattern = String.format("coupon:%d:user:*", couponId);
Set<String> userKeys = redisTemplate.keys(userPattern);
if (userKeys != null && !userKeys.isEmpty()) {
redisTemplate.delete(userKeys);
}
redisTemplate.delete(Arrays.asList(
String.format("coupon:%d:count", couponId),
String.format("coupon:%d:total", couponId),
String.format("coupon:%d:expire", couponId)
));
log.info("🧹 Redis 정리 완료: couponId={}", couponId);
} else {
log.info("🚫 Redis 정리 조건 미충족: couponId={}, current={}, total={}, queueSize={}",
couponId, current, total, queueSize);
}
}
이번 개선을 통해 이벤트 기반 아키텍처의 장점과 Redis 락의 사용법을 제대로 이해하게 됐다. 단순한 Polling 방식은 구현은 쉽지만 실시간성, 리소스 효율, 동시성 처리 면에서 비효율적일 수 있다는 것을 체감했다.
Redis의 SETNX + EXPIRE
구조는 분산 락을 안전하게 구현할 수 있는 간단하지만 강력한 방법이었다. 멀티 서버 환경에서도 race condition을 방지하며 순차적인 처리를 보장하려면 락과 큐 처리 흐름을 적절히 조합하는 것이 중요하다는 사실을 알게 되었다.
아직 cleanKey
를 통한 워커 중복 방지 완성도는 개선 중이지만 락만으로도 충분히 정확한 수량 보장과 안정성을 확보할 수 있다는 점을 확인했다. 이후에는 만료된 쿠폰 이벤트의 완전한 종료를 cleanKey
로 보완할 예정이다.