Redis Queue + Lua + Kafka 기반 선착순 쿠폰 발급 구조 개선기

송현진·2025년 4월 24일
0

Redis

목록 보기
4/5

⚠️ 기존 문제

이전까지는 Redisson 분산 락 + Lua 기반의 처리 구조를 사용했지만 발급 요청을 큐에 등록하기 전에 Lua 검증이 병렬로 수행되다 보니 Redis 상태(count 증가)와 Kafka 이벤트 전송/DB 저장 사이의 타이밍 이슈가 생겨 순서대로 큐에 들어간 유저가 Kafka 전송 직전에 실패하거나 발급 순서가 꼬이는 문제가 발생했다.

🔁 전체 구조 흐름

사용자 요청
 ↓
Controller -> Redis 큐에 userId 등록 (순서 보장)
 ↓
RedisQueueWorker -> 큐에서 userId 꺼냄 (순서대로)
 ↓
Lua 스크립트 실행 (중복/재고 체크 및 count 증가)
 ↓
KafkaProducer -> 발급 성공 이벤트 전송
 ↓
KafkaConsumer -> 트랜잭션 분리된 saveCouponIssue()에서 DB 저장

✅ 개선 포인트

  1. Redis Queue를 중심으로 발급 순서 보장

    사용자 요청 시 Controller → RedisCouponService.pushQueue() 에서 couponId:userId 형식으로 Redis 큐에 등록한다. 그러면 큐에 등록된 순서가 곧 발급 순서가 되므로 사용자 요청 순서를 자연스럽게 보장 가능하다.

  2. Lua로 중복 발급 및 재고 초과 체크 (원자성 보장)

    RedisQueueWorker가 큐에서 하나씩 꺼내 처리할 때 Lua 스크립트를 실행

    • 이미 발급받은 유저인지 확인
    • 현재 발급된 수량과 총 수량 비교
    • 통과 시 count 증가 + user 발급 기록 저장

    Redis 단에서 원자적으로 실행되므로 중복 체크와 재고 소진 처리가 정확하게 동작한다.

  3. KafkaProducer - send().get()toCompletableFuture().whenComplete()로 변경

    원래는 send().get()으로 동기 방식 전송을 사용해서 느리고 병목 발생했었다. 하지만 지금은 send().toCompletableFuture().whenComplete()로 비동기 전송과 로그 콜백으로 처리 결과를 추적한다.

    kafkaTemplate.send(TOPIC, couponId.toString(), event)
            .toCompletableFuture().whenComplete((result, ex) -> {
                if (ex != null) {
                    log.error("Kafka 메시지 전송 실패: couponId={}, userId={}, error={}", 
                    			couponId, userId, ex.getMessage());
                } else {
                    log.info("[Kafka] 쿠폰 발급 이벤트 전송 : couponId={}, userId={}", couponId, userId);
                }
            });
  4. KafkaConsumer에서 트랜잭션 분리하여 DB 저장

    Kafka 메시지를 수신한 후 DB 저장을 @Transactional 메서드로 분리

    @KafkaListener(topics = "coupon.issue", groupId = "coupon-consumer-group", concurrency = "1")
    public void consume(CouponIssueEventDto event) {
        log.info("[Kafka] 쿠폰 발급 이벤트 수신 : couponId={}, userId={}", event.getCouponId(), event.getUserId());
    
        try {
            saveCouponIssue(event);
        } catch (DataIntegrityViolationException e) {
            log.info("중복 insert 무시: couponId={}, userId={}", event.getCouponId(), event.getUserId());
            return;
        } catch (ErrorException e) {
            log.warn("🚫 쿠폰 없음 또는 이미 삭제된 쿠폰: couponId={}, userId={}, message={}", 
            			event.getCouponId(), event.getUserId(), e.getMessage());
        } catch (Exception e) {
            log.error("❌ Kafka 처리 중 예외 발생 - 재시도됨", e);
            throw e;
        }
    }
    
    @Transactional
    public void saveCouponIssue(CouponIssueEventDto event) {
    	  // 쿠폰 발급 저장 로직
    }

    예외 상황에 따라 다음과 같이 처리했다.

    • DataIntegrityViolationException: 중복 insert가 발생할 시 로그만 남기고 무시

    • CouponNotFound: 삭제된 쿠폰 -> WARN 로그

    • 그 외 예외: Kafka 재시도 유도 -> 메시지 재소비

      @Bean
      public DefaultErrorHandler kafkaErrorHandler() {
          // 메시지 무시
          DefaultErrorHandler errorHandler = new DefaultErrorHandler(
                  new FixedBackOff(0L, 0) // 재시도 없이 바로 skip
          );
      
           errorHandler.addNotRetryableExceptions(DataIntegrityViolationException.class);
          return errorHandler;
      }

    Redis에서는 이미 count 증가와 user 기록이 저장되었기 때문에 DB 저장 실패 시 rollback 필요 없다는 생각이다.

이렇게 구현함으로써 Redis Queue로 발급 순서를 보장받고 Lua로 중복 발급 및 재고 초과 체크를 Redis 단에서 정확히 처리한다. 그후 Kafka 비동기 전송으로 빠른 속도와 Consumer 트랜잭션 분리로 인한 안정적인 비동기 저장 구조가 완성되었다.

📝배운점

동시성 제어와 순서 보장은 큐 기반 구조로 통제하는 게 가장 효과적이라는 걸 확실히 느꼈다. Redis의 Lua 스크립트는 중복 발급 방지와 재고 체크에는 굉장히 빠르고 정확했지만 이걸 발급 순서까지 책임지게 하려다 보니 문제가 생겼다.

구조를 Queue -> Kafka -> Lua로 바꿔보려고 했었다. 이러면 Kafka에서 메시지 처리하면서 Lua를 실행해서 Redis와 DB 상태를 동시에 조정할 수 있을 거라 기대했는데 막상 구현해보니 Lua 실행 타이밍이 꼬이거나 Redis 상태가 먼저 바뀌지 않아서 문제가 생겼다. 특히 Kafka Consumer는 기본적으로 병렬로 동작하기 때문에 큐에 먼저 들어간 유저가 나중에 처리되는 일도 발생해서 정확한 순서 보장에 실패했다.

결국 이 문제를 해결하기 위해 Queue ->Lua -> Kafka 구조로 다시 정비했다. 발급 요청이 큐에 등록되고 순서대로 꺼낸 다음 Lua로 먼저 Redis 상태를 확정 짓고 나서 Kafka로 넘기니 순서 보장과 정확성 모두 해결됐다. 내가 한 방식이 옳은 지는 모르겠지만 선착순 쿠폰 발급 서비스를 하면서 많은 삽질을 했지만 구현을 하니 뿌듯했다.

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

0개의 댓글