RedisQueueWorker 구조 개선 (while 제거)

송현진·2025년 4월 29일
0

Architecture

목록 보기
2/18

오늘은 Redis 기반 선착순 쿠폰 발급 시스템의 RedisQueueWorker를 리팩토링할 필요성을 고민했다.

⚠️ 기존 문제점

초기에는 큐 처리를 위해 processQueue() 메서드 안에서 재귀적으로 blockingPop을 호출하고 데이터를 가져오지 못하면 while 루프를 통해 다시 시도하는 구조를 사용했다. 이 방식은 큐에 데이터가 없을 때 워커가 무한 대기하거나 불필요하게 스레드를 점유하고 있는 문제가 발생했다. 특히 while 루프와 재귀 호출이 겹치면서 워커의 흐름을 명확하게 제어하기 어려워졌고 스레드 누수나 리소스 낭비 위험도 존재했다. 구조적으로도 큐가 비어 있는 상황에서 계속해서 반복 대기하거나 예외가 발생했을 때 워커가 정상적으로 종료되지 않는 문제가 있었다.

문제 코드

private void processQueue(long couponId) {
    int total = redisService.getTotalCount(couponId);

    // 현재 발급 수량 체크
    int current = redisService.getCurrentCount(couponId);
    if (current >= total) {
        log.info("🎯 couponId={} 발급 완료({}/{}) - 워커 종료", couponId, current, total);
        cleanupCouponData(couponId);
        return;
    }

    // 1번만 blocking pop 시도 (최대 5초 대기)
    String data = redisService.blockingPopQueue(couponId);
    if (data == null) {
        log.info("대기 시간 초과, 처리할 요청 없음: couponId={}", couponId);
        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("🎯 재고 소진, worker 종료: couponId={}", couponId);
            cleanupCouponData(couponId);
        }
        case ALREADY_ISSUED -> log.warn("🚫 중복 발급 시도 무시: couponId={} userId={}", couponId, userId);
        default -> log.error("❌ 예기치 않은 결과: {} for couponId={} userId={}", result, couponId, userId);
    }

    Executors.newSingleThreadExecutor().submit(() -> processQueue(couponId));
}

개선 방향 및 결과

이 문제를 해결하기 위해 while 루프를 완전히 제거하고 blockingPop을 통해 한 번 데이터 처리를 시도한 후 결과에 따라 다음 행동을 결정하는 방식으로 구조를 개선했다. 데이터가 있으면 바로 발급 처리를 이어가고 데이터가 없거나 재고가 소진된 경우에는 워커를 깔끔하게 종료하도록 변경했다. 이로 인해 워커의 라이프사이클이 명확해지고 불필요한 스레드 생성이나 무한 대기 없이 자연스럽게 발급 프로세스를 이어갈 수 있게 되었다. 결과적으로 구조가 단순하고 명확해졌으며 리소스를 효율적으로 사용할 수 있는 워커를 만들 수 있었다.

개선된 코드

private void processNext(long couponId) {
    Executors.newSingleThreadExecutor().submit(() -> {
        try {
            String data = redisService.blockingPopQueue(couponId);
            if (data == null) {
                int current = redisService.getCurrentCount(couponId);
                int total   = redisService.getTotalCount(couponId);
                if (current >= total) {
                    log.info("🎯 발급 완료(초기 검사): couponId={}, {}/{}", couponId, current, total);
                    cleanupCouponData(couponId);
                }
                return;
            }

            long userId = Long.parseLong(data.split(":", 2)[1]);
            CouponIssueEnum result = redisService.tryIssueCoupon(
                    couponId, userId, redisService.getTotalCount(couponId)
            );

            switch (result) {
                case SUCCESS -> {
                    couponIssueProducer.sendIssueEvent(couponId, userId);
                    log.info("✅ 발급 성공: couponId={}, userId={}", couponId, userId);
                    processNext(couponId);
                }
                case ALREADY_ISSUED -> {
                    log.warn("🚫 중복 발급 시도 무시: couponId={} userId={}", couponId, userId);
                    processNext(couponId);
                }
                case OUT_OF_STOCK -> {
                    log.info("🎯 재고 소진, worker 종료: couponId={}", couponId);
                    startedCoupons.remove(couponId);
                }
                default -> {
                    log.error("❌ 예기치 않은 결과: {} for couponId={} userId={}", result, couponId, userId);
                    startedCoupons.remove(couponId);
                }
            }
        } catch (Exception e) {
            log.error("RedisQueueWorker 처리 중 예외", e);
            startedCoupons.remove(couponId);
        }
    });
}

📝 배운점

이번 개선 작업을 통해 while 기반 반복 구조가 가져오는 숨은 리스크를 직접 경험할 수 있었다. 단순히 반복하며 대기하는 구조는 예상치 못한 상황(큐가 비었거나 재고가 소진된 경우)에서 워커를 적절히 종료시키지 못하고 오히려 시스템 자원을 낭비하는 결과를 초래할 수 있었다. 반면 event-driven 방식으로 설계하면 발급이 필요한 경우에만 깔끔하게 처리가 이어지고 더 이상 필요 없을 때는 자연스럽게 워커를 종료할 수 있어 전체 시스템이 훨씬 안정적이고 효율적으로 동작함을 느꼈다. 앞으로도 반복 구조를 설계할 때는 단순 while 루프보다 명확한 종료 조건과 자연스러운 흐름 제어를 먼저 고민하는 습관을 가져야겠다.

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

0개의 댓글