RedisQueueWorker 구조 개선 (Polling -> Event-driven Blocking)

송현진·2025년 4월 30일
0

동시성

목록 보기
1/2

⚠️ 문제 상황

기존 구조는 @PostConstruct로 워커를 띄워 ScheduledExecutorService를 이용해 Redis 큐를 주기적으로 감시하는 Polling 방식이었다. 하지만 이 방식에는 다음과 같은 단점이 있었다.

  • 항상 워커가 실행 중이기 때문에 Redis 큐에 아무 데이터가 없어도 계속 polling을 수행 -> 불필요한 리소스 낭비
  • 여러 서버가 동시에 띄워질 경우 서버 수만큼 워커가 떠서 발급 요청을 병렬 처리 -> 발급 순서 꼬임, 재고 초과, 중복 발급 발생
  • Polling 주기를 너무 짧게 하면 CPU 사용량 증가, 길게 하면 실시간성이 떨어짐
  • 실제 테스트에서 쿠폰 수량이 100개인데 서버 2대에서 각각 100개씩 발급해 200개가 발급되는 문제 발생

🔄 개선 목표

  • Redis 큐에 데이터가 들어왔을 때만 워커가 실행되도록 변경 (이벤트 기반)
  • 쿠폰 ID마다 동시에 하나의 워커만 실행되도록 보장
  • 서버가 여러 대여도 발급 수량 초과 없이 정확히 totalQuantity만큼만 발급
  • 불필요한 polling 제거로 리소스 효율 향상

🛠️ 개선 구현 상세

  1. 이벤트 기반 구조로 변경
    이제는 Redis 큐에 발급 요청이 들어올 때마다 ApplicationEventPublisher를 통해 이벤트를 발행한다. 워커는 이 이벤트를 @EventListener로 감지하고 큐를 처리한다. 즉, 발급 요청이 있을 때만 워커가 실행된다.

    publisher.publishEvent(new CouponQueueEventDto(couponId, getTotalCount(couponId)));
  2. 락을 사용해 단일 워커 보장
    쿠폰별로 동시에 여러 워커가 실행되는 것을 막기 위해 Redis 분산 락을 사용했다. 쿠폰별 락 키는 coupon:{id}:lock이고 SETNX + TTL을 사용해 락을 획득한 워커만 큐를 처리하게 한다.

  3. ExecutorService로 비동기 처리
    기존 ScheduledExecutorService는 주기적으로 반복 실행되는데 지금 구조는 큐에 데이터가 들어올 때만 실행되면 되므로 ExecutorService가 더 적절하다. 워커는 하나의 요청을 처리한 뒤 재귀적으로 다음 요청을 이어서 처리하며 큐가 빌 때까지 순차적으로 발급을 수행한다.

  4. 워커 종료 시 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);
    }
}

✅ 결과

  • 서버 수에 상관없이 정확히 N개만 발급
  • 발급 요청이 있을 때만 워커 실행 -> 불필요한 CPU 사용 감소
  • 동시에 여러 서버에서 발급 요청이 몰려도 락 기반으로 단일 워커만 실행되어 안정성 확보
  • 발급 순서가 Redis 큐에 적재된 순서대로 처리되어 순서 보장
  • Redis 키 정리도 잘 수행되어 메모리 누수 없음

📝 배운점

이번 개선을 통해 이벤트 기반 아키텍처의 장점과 Redis 락의 사용법을 제대로 이해하게 됐다. 단순한 Polling 방식은 구현은 쉽지만 실시간성, 리소스 효율, 동시성 처리 면에서 비효율적일 수 있다는 것을 체감했다.

Redis의 SETNX + EXPIRE 구조는 분산 락을 안전하게 구현할 수 있는 간단하지만 강력한 방법이었다. 멀티 서버 환경에서도 race condition을 방지하며 순차적인 처리를 보장하려면 락과 큐 처리 흐름을 적절히 조합하는 것이 중요하다는 사실을 알게 되었다.

아직 cleanKey를 통한 워커 중복 방지 완성도는 개선 중이지만 락만으로도 충분히 정확한 수량 보장과 안정성을 확보할 수 있다는 점을 확인했다. 이후에는 만료된 쿠폰 이벤트의 완전한 종료를 cleanKey로 보완할 예정이다.

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

0개의 댓글