🚀선착순 쿠폰 발급 시스템 - 7


Phase3 : 비동기 구조로 전환 + 순서보장

문제

  • 이전 글에서 말했던데로 Redis와 DB 재고 불일치 보상 트랜잭션이 실패할 수 있는점과, 중복 발급 검증이 DB에서만 이뤄저서 불필요한 커넥션 소비가 있을수 있다는 점이 있었다.
  • 또 redis의 도입으로 당첨된 요청만 DB 경쟁에 참여하는 식으로 개선을 했지만 여전히 이 성공한 요청(쿠폰 재고)만큼이 여전히 DB 경쟁을 하고 처리 또한 동기적으로 진행이 된다. api의 응답도 동기 구조이기 때문에 클라이언트는 발급이 완료될 때까지는 HTTP 커넥션을 유지해야했다.

Hikaricp_connections 지표(VU=200,500,1000) seukeulinsyas-2026-04-12-ojeon-12-18-20.png|697

seukeulinsyas-2026-04-12-ojeon-12-18-31.pngseukeulinsyas-2026-04-12-ojeon-12-18-41.png

구간 active pending
재고 발급 구간 ≈커넥션 max(10)(포화) ↑ 상승
재고 소진 이후 급감 ≈0
  • 재고 발급 구간에는 여전히 DB 커넥션이 포화된 상태임

쿠폰 발급 엔드포인트 응답 시간 seukeulinsyas-2026-04-12-ojeon-12-18-51.png

  • 선착순 보장이 안된다는 점이있었다. redis의 원자 연산으로 정합성은 지켜진다. (재고가 100개면 딱 100개만 발급)
    • 실제로 순서 역전이 발생을 하는지 부하테스트를 진행하면서 확인을 해봤다. /issue api 요청을 받을때의 시간을 측정해서 DB에 저장하고 이후 먼저 도착했지만 실패한 케이스가 있는지를 확인하는 api를 만들어 부하테스트 진행후 teardown()에서 실행을 하여 측정을 했다.
    • 그 결과 (VU 500 동시요청, 재고 250개) 총 27,328건의 요청중 성공은 250건 발급 실패는 27,078건 -> 순서역전은 1,056건이 있었다. 즉 먼저 요청을 했지만 나중에 요청을 한사람이 쿠폰을 발급받는 문제가 있는걸 확인했다.
  • 원인은
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    private CouponIssueResult issueWithRedis(CouponIssueRequest request) {
      // fast-fail (최적화용; correctness는 decreaseStock()이 보장)
      if (!issuanceStrategy.hasStock(request.code())) {
          ....
          return CouponIssueResult.fail("쿠폰이 모두 소진되었습니다.");
      }
    
      // Redis 원자 재고 예약
      if (!issuanceStrategy.decreaseStock(request.code())) {
          ....
          return CouponIssueResult.fail("쿠폰이 모두 소진되었습니다.");
      }
    
      // DB 트랜잭션 (자체 @Transactional)
    }
    
  • 재고가 있는지 판단하는 hasStock()하고 redis 재고 감소 하는 decreaseStock()이 단계 사이에서 스레드간 스케줄링으로 순서가 역전이 될수 있었다.

    hasStock()을 분리했던 이유는 명백히 재고가 0일때 decreasStock()자체를 건너뛰는 최적화용이 었다.

어떻게 해결?

  • 가장 큰 문제가 쿠폰 발급 방식 자체가 동기적이기 때문에 DB 커넥션 풀 고갈로 응답 지연이 생기는것과 선착순 보장도 되지 않고 있었다는 점이다.
  • 그래서 개선을 해야할 점은
    1. DB 병목 해소
    2. 선착순 보장
    3. API 서버 응답 시간 단축
    4. Tomcat 스레드 고갈 방지
  • 결국 비동기 구조로 넘어가야하는데 seukeulinsyas-2026-04-11-ohu-10-47-42.png
  • 이런식으로 구상을 해봤다.
    • Redis는 원자적인 연산으로 재고 관리를 진행한다.
    • Redis의 빠른 연산으로 즉각적으로 Client에게 응답을 보낸다. (너 쿠폰 찜~)
    • 찜한 요청을 Queue에 순차적으로 집어넣어서 Consumer가 이를 받아 처리를 한다.
    • 결국 DB는 부하가 줄어들게 되고
    • Redis로 재고를 원자적으로 감소시키기 때문에 재고가 1개 남았을 때 먼저 요청한 사람이 반드시 쿠폰을 발급을 받을 수 있다.

더 세부적인 아키텍쳐

  1. API 응답이 빨라야한다.
    • 발급 요청을 받은 API 서버가 DB 트랜잭션까지 들고 있으면 트래픽이 몰릴수록 응답 지연이 커지니까? -> 일단 쿠폰 발급 찜만 하고 가능한 빨리 응답을 해야한다
  2. 재고 차감과 큐에 넣는건 한 번에 일어나야 한다
    • 재고 차감 하고 대기열에 적재를 하면 그 틈에서 race condition이 발생할 수 있다.
  3. 선착순을 보장할 단일 기준이 필요하다
    • HTTP 요청은 여러 스레드에서 동시에 들어오는데 선착순을 명확히 보장하려면 어떤 요청이 먼저 처리돼야하는가를 판단할 기준이 필요하다
  4. 처리중과 처리완료를 구분해야한다
    • 비동기 구조에서는 한 사용자의 요청이 이미 접수됐지만 아직 DB에는 반영이 끝나지 않은 상태가 존재한다 -> 만약 이 상태를 표현하지 못하면 같은 사용자가 짧은 시간안에 다시 요청을 보냈을 때 정상 중복인지 아직 처리 중인 것인지를 구분할 수 없다.
  5. 비동기 결과를 사용자에게 전달할 경로가 필요하다.
    • 비동기로 202를 반환하는건 단순히 “지금 남아 있는 재고안에서 당신의 쿠폰 발급 요청이 찜 되었습니다”를 표현하는 것으로 실제 이 쿠폰 발급 요청이 어떻게 끝났는지를 따로 받아볼 필요가 있다.
  6. Consumer 장애가 생기면 나중에 복구가 되었을때 다시 이어서 처리할 수 있어야 한다.
    • 메시지를 받고 처리 중에 어떤 이유로 처리가 진행이 안되는 경우 재처리가 가능해야함

API는 발급 요청을 빠르게 접수하는 역할로~

seukeulinsyas-2026-04-13-ohu-11-54-18.png

1번(API 응답이 빨라야한다) + 2번(재고 차감과 큐에 넣는건 한 번에 일어나야 한다) Redis에 장점인 빠른 원자적인 연산으로 -> 빠른 응답 가능 원자적이기 때문에 race condition 방지 가능

선착순 기준을 만들기 위해서 Stream 사용

  • 이제 3번 선착순(순서)이 보장이 되어야 하는데 그러기 위해서는
    1. 요청이 들어온 순서가 기록이 되어야하고
    2. 그 순서를 기준으로 소비 할 수 있어야하고
    3. 장애가 생겨도 미처리 메시지를 추적할 수 있어야 한다
  • 이런 걸 가능하게 하는 메시지 큐를 비교하던중 Redis Streams를 선택했다.
    • 고려대상은 Redis Streams vs Kafka vs RabbitMQ 였는데 Redis Streams을 선택했다.
    • 가장 큰 이유는 Redis를 이미 사용하고 있어서 인프라 추가 없이 사용할 수 있어서이고, kafka같은 경우에는 너무 오버엔지니어링이라는 판단이 들어서였다. 제대로 익히고 쓰기에도 시간이 걸릴것 같았고, kafka같은 경우에는 수십만에서 수백만의 메시지를 처리할때나 사용하는거로 알고 있는데 그정도로 과한 처리량은 적절하지 않다고 생각을 해서이다.
  • Stream의 특징중 메시지 보존, 시간순 정렬 가능, 재처리 가능 이라는 특징이 딱 정합한 선택이었다.

처리중, 처리완료를 구분해야했다.

  • 이걸 구분을 해야 중복 요청에 대해 처리중인 상태에서의 중복 요청인지 이미 처리가 완료된 상태에서 중복 요청인지를 구분할 수 있다.

    issued : 이미 DB 반영까지 끝난 사용자(userId) 목록 inflight : 요청이 접수됐지만 아직 최종 처리 중인 사용자(userId)목록

  • 아무래도 비동기 시스템이기 때문에 이런 상태를 추적하기 위한 상태값이 있어야한다.

    비동기 처리 결과를 사용자에게 전달할 방법이 필요하다

  • 사용자가 나의 쿠폰 요청이 완전히 처리가 되었는지에 대한 결과를 받기 위해서는 식별자 + 전달할 방법이 필요했다.
  • 그래서 ticketId를 api 응답에 포함시켜서 반환을 하고 이 ticketId로 나의 쿠폰 발급이 pending 상태인지 completed, 상태인지 fail상태인지, already_issued 상태인지를 아는데 사용할수있다.
  • 또 이 발급 상태를 전달하는 방법에서는 SSE를 선택했다. 아무래도 비동기 처리 방식이기 때문에 언제 처리될지 예상할 수 없는 상태에서 폴링 기법보다는 커넥션을 유지하면서 결과가 나오면 바로 전달할 수 있기 때문이었다.

장애 복구가 가능해야한다.

  • 이 부분은 Strema의 장점인 메시지를 보존하고 있기 때문에 실제 메시지를 ACK를 했는지 아니면 ACK도 하기 전에 죽었는지등을 Redis Stream은 PEL(대기 목록)를 통해 기록을 하기 때문에 이를 이어서 처리를 할 수 있다.

최종 아키텍처

seukeulinsyas-2026-04-15-ohu-3-42-01.png

  1. (Coupon-api) - 발급 요청(/coupons/issue)
    • 발급 요청을 받으면 CoponIssueProducer에서 Lua 스크립트로 원자적 연산 실행함
      • 이미 발급된 유저인지, 처리 중인 유저인지, 쿠폰 키가 있는지, 재고 소진인지, 재고를 감소시키고 inflight SET에 userId 추가, Stream(XADD)에 발급 요청 메시지 추가 -> 성공 시 남은 재고를 반환
    • Lua 스크립트가 성공하면 ticketId 생성하고 Redis에 PENDING 상태로 저장후 202 Accepted로 응답한다
  2. (Coupon-api)-SSE 구독
    • 사용자는 발급 받은 ticketId를 통해 SSE 연결을 한다.
    • SseEmitterManager.subscribe()
      • Redis에서 ticketId로 티켓을 조회 -> PENDING 상태가 아니면 status 이벤트 즉시 전송, 존재하지 않으면 error 이벤트 즉시 전송
      • PENDING 상태이면 SseEmitter생성 + emitters Map에 등록 -> Redis Pub/Sub 채널 리스너 등록(ticketId를 포함해서) -> 리스너 등록 후 티켓 상태를 재확인(리스너 등록 전에 Consumer가 완료했을 수도 있으니) -> 이미 완료 상태이면 즉시 전송 + 리스너 해제
  3. (Coupon-Consumer)-Stream 소비
    • XREADGROUP으로 메시지 읽어와서 소비(BLOCK 세팅(새 메시지 없으면 대기 후 재시도))
    • 현재는 단일 스레드(컨슈머 한개)
  4. (Coupon-Consumer)-DB처리 및 상태 변경
    • DB INSERT 성공하면 Redis에서 inflight SET -> issued SET 전이
    • Redis에서 티켓 상태 COMPLETED로 업데이트
    • Redis Pub/Sub로 상태 전달 SseEmitterManager에서 SSE 이벤트 전송
    • ACK 누락시 - DB INSERT 과정은 정상적으로 진행이 되었는데 그 이후에 ACK를 날리지 못하고 consumer가 죽어서 아직 inflight상태로 되어있어서 동일한 메시지로 재시도를 했을 때 기존에 있던 DB record 와 유니크 제약으로 충돌이 생기면 DataIntegrityViolationException이 발생하고 그러면 이 경우는 ACK를 날리지 못하고 죽은 경우밖에 없으니 실제 DB에 있는 재고 같은 경우는 롤백으로 복구가 되고 Redis의 경우는 이미 소진을 했던 상태이기 때문에 복구는 안하고 상태만 inflight SET -> issued SET 전이, COMPLETED변경한 상태로 응답을 보낸다
  5. DLQ 처리(maxRetry초과)
    • DB를 조회해서 이미 쿠폰이 발급이 되었는지를 조회를 해서
    • 이미 발급되었다면 DB는 커밋이 성공을 했는데 ACK 하기 전에 Consumer가 죽은거라고 판단하고 inflight -> issued로 전이하고 -> 티켓의 상태는 COMPLETED로 -> 상태를 Pub/Sub로 전달
    • 만약 발급이 안되었다면 확실한 실패로 간주하고 -> inflight SET 제거 + 재고 복구 -> 티켓 상태는 FAILED -> 상태를 Pub/Sub로 전달 -> DLQ Stream에 메시지 기록
  6. Pending 메시지 복구
    • consumer가 비정상적으로 종료되면 ACK가 없는 메시지가 PEL에 남는데 60초 이상 유휴 메시지를 탐색해서 XCLAIM으로 소유권 확보 후 재처리 진행

부하테스트

기존 동기 구조에서는 /coupons/issue API의 응답 시간과 HikariCP 커넥션 상태를 같이 보면 병목을 어느 정도 설명할 수 있었다.

그런데 Phase3에서는 이 지표만 보면 안 된다. 이유는 이제 /coupons/issue는 쿠폰을 DB에 저장할 때까지 기다리지 않는다. API 서버는 Redis Lua 스크립트로 중복 여부 확인 -> 재고 감소 -> inflight 등록 -> Stream 적재까지만 원자적으로 끝내고 202 Accepted를 반환한다. 실제 DB 저장은 consumer가 나중에 처리한다.

그래서 Phase3 부하테스트에서는 지표를 두 개로 나눠서 봐야했다.

  • Admission: 사용자의 요청을 API가 받아들여서 Redis Stream에 넣는 구간
  • Drain: Stream에 들어간 요청을 consumer가 읽어서 DB에 최종 반영하는 구간

즉 P3-1에서 응답 시간이 낮게 나왔다고 해서 “쿠폰 발급이 DB까지 100ms 안에 끝났다”라고 해석하면 안 된다. 이 값은 API가 발급 요청을 안전하게 접수하고 202를 반환하는 시간이다.

부하테스트를 어떻게 나눴나?

이번에는 하나의 테스트로 끝내지 않고 목적별로 4개 시나리오를 나눴다.

구분 확인하려는 것 의미
P3-1 Admission 처리량 API가 DB를 기다리지 않고 얼마나 빠르게 요청을 접수하는지
P3-2 Consumer drain 접수된 요청이 실제 DB 발급까지 얼마나 걸리는지
P3-3 정합성 재고보다 많이 요청해도 정확히 재고 수만큼만 발급되는지
P3-4 장애 복구 consumer가 처리 중 죽어도 PEL 메시지를 다시 처리할 수 있는지

이렇게 나눈 이유는 Phase3의 개선 포인트가 “모든 작업을 한 번에 빠르게 끝낸다”가 아니기 때문이다. 핵심은 사용자 요청을 빠르게 접수하고, DB 작업은 consumer가 감당 가능한 속도로 분리해서 처리하는 것이다.

결과

P3-1. API Admission

항목 결과
조건 500 VU, 30초, stock 1,000,000, consumer running
요청 수 396,937
accepted 396,921
accepted TPS 6,595/s
accepted p95 112.85ms
system error rate 0%

이 결과는 API 서버가 DB 트랜잭션을 기다리지 않고 Redis Lua + Stream 적재만으로 요청을 받아들이기 때문에 나온 값이다.

중요한 점은 이 112.85ms쿠폰 발급 완료 시간이 아니라는 것이다. 이 값은 사용자의 요청이 선착순 판정에 들어갔고, Stream에 적재되었고, 이후 consumer가 처리할 수 있는 상태가 되기까지의 시간이다.

테스트 직후 Redis 상태는 다음과 같았다.

항목
Redis remaining stock 603,079
Redis inflight 384,997
Redis issued 11,924
DB issued 11,923

여기서 inflight가 많이 남아있는 것은 실패가 아니다. 오히려 Phase3의 의도에 가깝다. API는 빠르게 요청을 받아 Stream에 쌓고, consumer는 DB가 감당 가능한 속도로 천천히 처리한다. 그래서 P3-1은 “최종 발급 완료”가 아니라 입장 처리량이 DB 처리량으로부터 분리되었는지를 보는 테스트다.

또 Redis 기준으로 보면 아래처럼 재고 합계도 맞았다.

1
remaining 603,079 + inflight 384,997 + issued 11,924 = 1,000,000

즉 admission 단계에서 재고가 사라지거나 중복으로 잡히는 문제는 없었다.

P3-2. Consumer Drain

항목 결과
accepted 500
completed 500
completion rate 100%
admission p95 133.37ms
E2E p95 4.66s
DLQ 0

P3-2는 API 응답 시간이 아니라 accepted 된 요청이 DB에 최종 저장되기까지 걸린 시간을 본다.

여기서는 500건이 모두 DB에 저장되었고 DLQ도 없었다. E2E p95가 4.66초라는 것은 사용자가 202 응답을 받은 뒤, 실제 쿠폰 발급 완료 이벤트를 받기까지 consumer 처리 시간이 추가로 걸린다는 뜻이다.

이 수치는 Phase3의 병목이 어디로 이동했는지를 보여준다. 기존에는 API 요청 스레드가 DB 커넥션을 잡고 기다렸다. Phase3에서는 API는 빠르게 반환하고, DB 병목은 consumer drain 구간으로 이동한다. 그래서 사용자의 HTTP 요청은 짧아지고, 최종 완료는 비동기 상태 조회나 SSE로 전달해야 한다.

P3-3. 재고 정합성

항목 결과
요청 수 1,000
stock 100
accepted 100
completed 100
rejected 900
E2E p95 1.06s
inflight 0
DLQ 0

이 테스트는 요청이 재고보다 훨씬 많을 때도 정확히 재고 수만큼만 발급되는지 확인하는 테스트다.

결과는 1,000건 중 100건만 accepted 되었고, 그 100건이 모두 completed 되었다. 나머지 900건은 rejected 되었다. 즉 Redis Lua 스크립트가 admission 단계에서 재고 초과 요청을 잘 잘라냈고, consumer도 accepted 된 요청만 DB에 반영했다.

여기서 중요한 개선점은 DB까지 가지 않아도 실패를 빠르게 결정할 수 있다는 점이다. 이전 구조에서는 성공 후보들이 DB 커넥션 경쟁에 들어갔지만, Phase3에서는 Redis에서 먼저 정리된다.

P3-4. 장애 복구

항목 결과
pending으로 만든 메시지 500
최종 DB issued 500
Redis inflight 0
Redis remaining stock 0
DLQ 0

P3-4는 속도 테스트가 아니라 복구 테스트다.

의도적으로 consumer가 메시지를 읽고 ACK 하지 않은 상태를 만들었다. 그러면 Redis Stream의 PEL에 pending 메시지가 남는다. 이후 recovery consumer가 XCLAIM으로 해당 메시지를 가져와 다시 처리하는지 확인했다.

결과적으로 500건 모두 DB에 발급되었고, inflight도 0으로 비워졌고, DLQ도 발생하지 않았다. 비동기 구조에서는 “응답을 빨리 주는 것”만으로는 부족하다. consumer가 중간에 죽었을 때 메시지를 잃지 않고 다시 처리할 수 있어야 운영 가능한 구조가 된다.

그래서 무엇이 개선되었나?

이번 Phase3의 개선은 단순히 “응답 시간이 빨라졌다”가 아니다.

정확히는 다음과 같이 바뀌었다.

  • API 요청 스레드가 DB 트랜잭션 완료까지 기다리지 않게 되었다.
  • 선착순 판정, 중복 요청 차단, 재고 감소가 Redis Lua 안에서 원자적으로 처리된다.
  • accepted 된 요청은 Stream에 남기 때문에 consumer 처리 속도와 API 처리 속도를 분리할 수 있다.
  • DB 부하는 consumer가 감당 가능한 속도로 흘려보낼 수 있다.
  • consumer 장애 시 PEL/XCLAIM 기반으로 미처리 메시지를 복구할 수 있다.

즉 Phase3의 핵심은 API admission은 빠르게, DB finalization은 안정적으로 분리한 것이다.

물론 이 구조에도 남은 한계는 있다. 현재 기준은 단일 consumer baseline이다. consumer 처리량 자체를 더 높이려면 consumer scale-out, 파티셔닝, 순서 보장 범위 같은 문제를 추가로 봐야 한다. 또 Redis Stream의 XLEN은 ACK 이후에도 바로 줄어드는 값이 아니기 때문에 backlog 판단 지표로 그대로 쓰면 안 된다. 실제 운영에서는 inflight, pending, DB issued, DLQ를 함께 봐야 한다.

그래도 이번 부하테스트를 통해 Phase3 구조가 의도한 방향으로 동작한다는 것은 확인할 수 있었다. API는 높은 동시 요청을 빠르게 받아들이고, 최종 발급은 consumer가 책임지며, 실패한 consumer 작업도 복구할 수 있는 구조가 되었다.