Redis Queue + Redisson으로 순서 보장한 선착순 쿠폰 발급 시스템

송현진·2025년 4월 23일
0

Redis

목록 보기
3/5

단순한 Lua 검증만으로는 발급 순서가 꼬이는 문제가 있었기 때문에 큐를 함께 사용해 정확한 순서 보장이 가능하도록 개선했다.

⚠️문제 상황

기존에는 Redis Lua 스크립트 하나로 쿠폰 발급을 처리하고 있었다.

기존 흐름

  1. Controller -> CouponIssueService.issueCoupon() 호출

  2. Lua 스크립트로 다음을 처리

    • 이미 발급된 유저인지 확인
    • 재고 초과 여부 체크
    • Redis에 발급 기록 저장
  3. Kafka로 발급 이벤트 전송 -> DB 저장

이 방식으로 중복 발급 방지와 재고 초과 방지는 성공적이었지만 테스트로 동시에 여러명을 넣을 경우 발급 순서가 항상 뒤바껴서 발급되는 문제가 있었다.

✅ 해결 전략: Redis Queue + Redisson 분산 락

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 종료됨");
    }
}

🔁 개선 후 구조 흐름

  1. Controller -> Queue 등록
    사용자가 쿠폰 발급 요청 시 Redis 큐에 couponId:userId 형태로 등록

  2. RedisQueueWorker 시작
    등록된 쿠폰 ID 기준으로 큐를 계속 감시(BLPOP)한 후 순차적으로 꺼내 처리

  3. 락 걸기 (Redisson)
    한 번에 하나의 스레드만 couponId에 대한 처리 진행 가능

  4. Lua 실행
    중복 여부, 재고 초과 여부 확인 (Lua)

  5. Kafka 전송
    Lua 통과 시 Kafka 이벤트 전송 -> Consumer에서 DB 저장

  6. 실패 시 재큐잉 (최대 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 실행을 통해 중복 여부 및 재고를 검증을 하는 구조이다.

profile
개발자가 되고 싶은 취준생

0개의 댓글