단순한 Lua 검증만으로는 발급 순서가 꼬이는 문제가 있었기 때문에 큐를 함께 사용해 정확한 순서 보장이 가능하도록 개선했다.
기존에는 Redis Lua 스크립트 하나로 쿠폰 발급을 처리하고 있었다.
Controller -> CouponIssueService.issueCoupon()
호출
Lua 스크립트로 다음을 처리
Kafka로 발급 이벤트 전송 -> DB 저장
이 방식으로 중복 발급 방지와 재고 초과 방지는 성공적이었지만 테스트로 동시에 여러명을 넣을 경우 발급 순서가 항상 뒤바껴서 발급되는 문제가 있었다.
Redis 큐로 순서를 먼저 정해놓고 큐에서 순차적으로 Redisson으로 락을 걸어 순차성을 더 확실히 보장해서 Kafka로 넘겨서 처리했다.
Controller
@PostMapping("/{couponId}/issue")
public ResponseEntity<BasicResponseDto> issueCoupon(
@PathVariable Long couponId,
@AuthenticationPrincipal UserDetailsImpl userDetails
) {
redisCouponService.pushQueue(couponId, userDetails.getUser().getId());
return ResponseEntity.ok(BasicResponseDto.addSuccess("요청이 큐에 등록되었습니다"));
}
RedisQueueWorker (큐 처리 + 락)
@Slf4j
@Component
@RequiredArgsConstructor
public class RedisQueueWorker {
private final RedisCouponService redisCouponService;
private final CouponIssueService couponIssueService;
private final StringRedisTemplate redisTemplate;
private final RedissonClient redissonClient;
// 이미 시작한 쿠폰 ID를 기록하는 Set (중복 스레드 방지)
private final Set<Long> startedCoupons = ConcurrentHashMap.newKeySet();
// 일정 간격으로 active 쿠폰을 감시
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
// 루프 실행 여부 플래그
private final AtomicBoolean running = new AtomicBoolean(true);
// 쿠폰 재처리 시도 횟수 저장 (최대 3회까지 시도)
private final Map<String, Integer> retryMap = new ConcurrentHashMap<>();
private static final int MAX_RETRIES = 3;
private static final String ACTIVE_COUPON_SET_KEY = "coupon:active:ids";
@PostConstruct
public void startWorker() {
scheduler.scheduleAtFixedRate(() -> {
try {
// 2초마다 발급 가능한 쿠폰 목록 확인
Set<String> activeCouponIds = redisTemplate.opsForSet().members(ACTIVE_COUPON_SET_KEY);
if (activeCouponIds == null || activeCouponIds.isEmpty()) return;
for (String couponIdStr : activeCouponIds) {
Long couponId = Long.valueOf(couponIdStr);
if (startedCoupons.contains(couponId)) continue;
startedCoupons.add(couponId);
Executors.newSingleThreadExecutor().submit(() -> {
while (running.get()) {
try {
// 큐에서 대기 중인 요청 하나 꺼냄 (5초 블로킹)
String data = redisCouponService.blockingPopQueue(couponId);
if (data == null) continue;
String[] parts = data.split(":");
Long userId = Long.valueOf(parts[1]);
String key = couponId + ":" + userId;
String lockKey = "lock:coupon:" + couponId;
RLock lock = redissonClient.getLock(lockKey);
boolean locked = lock.tryLock(1, 3, TimeUnit.SECONDS); // 락 획득 시도
if (locked) {
try {
couponIssueService.issueCoupon(couponId, userId); // Lua + Kafka 처리
retryMap.remove(key); // 성공 시 재시도 기록 제거
} catch (Exception e) {
// 실패 시 재시도 처리
int retryCount = retryMap.getOrDefault(key, 0);
if (retryCount < MAX_RETRIES) {
retryMap.put(key, retryCount + 1);
redisCouponService.pushQueueFront(couponId, userId); // 앞에 다시 넣기
log.warn("❌ 쿠폰 발급 실패 - 재큐잉: couponId={}, userId={}, count={}", couponId, userId, retryCount + 1);
} else {
retryMap.remove(key);
log.error("🚨 쿠폰 발급 재시도 초과 - 처리 포기: couponId={}, userId={}", couponId, userId);
}
} finally {
lock.unlock();
}
} else {
// 락 획득 실패 시 재큐잉
log.warn("🔒 락 획득 실패 - 재큐잉: couponId={}, userId={}", couponId, userId);
redisCouponService.pushQueueFront(couponId, userId);
}
} catch (Exception e) {
log.error("RedisQueueWorker 오류 발생 - couponId={}", couponId, e);
}
}
});
}
} catch (Exception e) {
log.error("[Worker Scheduler] 쿠폰 감지 중 오류 발생", e);
}
}, 0, 2, TimeUnit.SECONDS);
}
@PreDestroy
public void stopWorker() {
this.running.set(false);
this.scheduler.shutdown();
log.info("🛑 RedisQueueWorker 종료됨");
}
}
Controller -> Queue 등록
사용자가 쿠폰 발급 요청 시 Redis 큐에 couponId:userId
형태로 등록
RedisQueueWorker 시작
등록된 쿠폰 ID 기준으로 큐를 계속 감시(BLPOP
)한 후 순차적으로 꺼내 처리
락 걸기 (Redisson)
한 번에 하나의 스레드만 couponId
에 대한 처리 진행 가능
Lua 실행
중복 여부, 재고 초과 여부 확인 (Lua)
Kafka 전송
Lua 통과 시 Kafka 이벤트 전송 -> Consumer에서 DB 저장
실패 시 재큐잉 (최대 3회)
처리 실패 시 다시 큐 앞에 넣고 최대 3번까지 재시도한 후 초과 시 포기
Redis 큐와 Redisson 락을 조합한 구조 덕분에 대부분의 요청이 정확한 순서로 처리되었고 중복 발급과 재고 초과도 잘 방지되었다. 하지만 테스트 과정에서 발급 대상 100명 중 1명이 누락되는 이슈가 발생했다. 분석한 결과 큐 99번 유저가 처리되는 동안 다른 유저가 Lua를 먼저 실행해 Redis의 count를 100으로 증가시켜버려서 99번 유저가 Kafka로 전송되지만 Lua에서 이미 재고 초과로 판단되어 처리가 실패하는 경우였다. Kafka Consumer에서 예외 발생 후 롤백은 되지만 Redis 발급 수량은 롤백되지 않기 때문이었다. 결론적으로 Lua가 병렬로 실행되는 구조에서는 락을 걸어도 Redis 상태와 Kafka 처리 간의 타이밍 이슈로 인해 완전한 순서 보장은 어렵다.
단순히 Lua로 동시성 처리와 순서 보장에 한계가 있었고 발급 요청 순서를 Redis 큐로 통제하고 처리 시점에 Redisson 락을 적용한 구조는 꽤 안정적이었지만 Lua 실행이 병렬적으로 수행되면서 Redis 상태와 Kafka 처리 시점 간의 타이밍 문제가 발생했다. 결국 Redis 발급 수량과 DB 상태가 일관되지 않는 문제가 생길 수 있고 이는 실시간 동시 처리 시스템에 치명적일 수 있다.
그래서 구조를 Queue -> Kafka -> Lua 로 바꿔볼 계획이다.
Redis 큐에서 꺼낸 순서 그대로 Kafka로 전송하고 Kafka Consumer에서 Lua 실행을 통해 중복 여부 및 재고를 검증을 하는 구조이다.