Skip to content

CodeinHyuk/fcfs-coupon-system

Repository files navigation

선착순 쿠폰 발급 시스템

한순간에 몰리는 쿠폰 요청을 Redis에서 빠르고 정확하게 접수하고, RabbitMQ와 MySQL로 안전하게 발급하는 비동기 처리 시스템입니다.

Java 21 · Spring Boot 3.5 · Redis 7.4 · RabbitMQ 4.1 · MySQL 8.4 · Gradle · Testcontainers · k6

Important

202 Accepted는 쿠폰 발급 완료가 아니라 재고 선점과 대기열 등록이 끝났다는 뜻입니다. 최종 발급 여부는 상태 조회 API 또는 MySQL 발급 이력으로 확인합니다.

왜 이 시스템을 만들었나요?

선착순 쿠폰 이벤트는 평소보다 이벤트 오픈 직후가 훨씬 어렵습니다. 수많은 사용자가 같은 재고를 동시에 확인하고 차감하기 때문에, 단순한 MySQL 동기 처리만으로는 다음 문제가 함께 나타납니다.

  • 재고 조회와 차감 사이의 경쟁 조건으로 인한 초과 발급
  • 재시도와 메시지 재전달로 인한 중복 발급
  • 요청 경로에서 발생하는 동기 DB 처리의 지연과 처리량 병목
  • Redis, RabbitMQ, MySQL 장애가 API까지 연쇄적으로 번지는 문제

이 프로젝트는 요청을 빨리 받는 일과 실제 쿠폰을 저장하는 일을 분리했습니다. API는 Redis Lua Script 한 번으로 접수를 끝내고, 발급 이력은 RabbitMQ Consumer가 JDBC Batch로 MySQL에 저장합니다. 처리 능력을 넘어선 요청은 이미 받은 작업을 위험에 빠뜨리지 않도록 Backpressure로 거부합니다.

한눈에 보기

질문 이 프로젝트의 답
동시 요청에도 재고가 정확한가? 재고 확인·차감·중복 등록·순번 생성을 하나의 Redis Lua Script로 원자 처리합니다.
API가 DB 처리까지 기다리는가? 기다리지 않습니다. 접수 후 202를 반환하고 MySQL 저장은 비동기로 처리합니다.
메시지가 두 번 오면 어떻게 되는가? request_id, (coupon_id, user_id) Unique Constraint로 결과가 한 번만 반영되게 합니다.
Publish 중 프로세스가 죽으면 유실되는가? Waiting → Inflight Lease로 이동시키고, 만료된 작업은 Reaper가 복구합니다.
큐가 밀리면 계속 받는가? 전체 Pipeline Depth가 High Watermark를 넘으면 503Retry-After로 신규 접수를 제한합니다.
의존성이 장애 나면 MySQL로 우회하는가? 우회하지 않습니다. Fail-closed로 DB를 보호하고 이미 접수한 작업은 복구 가능한 상태로 남깁니다.

아키텍처

flowchart LR
    Client[Client] -->|POST issue| API[Spring Boot API]
    API -->|1 atomic Lua script| Redis[(Redis)]
    Redis --> Waiting[Waiting ZSET]
    Waiting -->|lease claim| Dispatcher[Dispatcher]
    Dispatcher -->|persistent publish + confirm| Rabbit[(RabbitMQ)]
    Rabbit -->|at-least-once delivery| Consumer[Bounded Batch Consumer]
    Consumer -. manual ACK after commit .-> Rabbit
    Consumer -->|JDBC batch + commit| MySQL[(MySQL)]
    Consumer -->|ISSUED| Redis

    Reaper[Inflight Reaper] -. expired lease .-> Waiting
    Depth[Pipeline Depth Collector] -. Backpressure .-> API
    MySQL -. final ledger .-> Query[Status / My Coupons]
    Redis -. progress / position .-> Query
Loading

1. Admission: 한 번의 Lua Script로 접수합니다

쿠폰 신청 Hot Path에는 MySQL, JDBC, JPA 접근이 없습니다. 같은 쿠폰의 Redis Key는 {couponId} Hash Tag를 공유하고, Lua Script가 아래 작업을 하나의 원자적 경계에서 처리합니다.

이벤트 상태·기간 확인
→ 기존 참여자 확인
→ 잔여 재고 확인 및 1 차감
→ 참여자 Set 등록
→ 단조 증가 Sequence 생성
→ Waiting ZSET 등록
→ 사용자-요청 매핑과 WAITING 상태 저장

따라서 중복 요청은 재고를 다시 차감하지 않고 기존 requestId를 반환하며, 품절 요청은 Redis 상태를 전혀 변경하지 않습니다.

2. Dispatch: 삭제 대신 Lease로 이동합니다

Dispatcher는 Waiting 항목을 바로 꺼내 버리지 않습니다. Lua Script로 Waiting → Inflight를 원자적으로 이동시키고 Lease 만료 시각을 기록한 뒤 RabbitMQ에 영속 메시지를 발행합니다.

  • Publisher Confirm 성공: DISPATCHED로 전이
  • NACK, Timeout, Publish 예외: Waiting으로 복귀
  • Dispatcher 종료로 Lease 만료: Reaper가 Waiting으로 복구

Redis와 RabbitMQ를 분산 트랜잭션으로 묶는 대신, 작업이 중복될 수 있음을 인정하고 최종 결과를 멱등하게 만들었습니다.

3. Issue: 커밋 뒤에만 ACK합니다

Consumer는 크기 또는 대기 시간 기준으로 메시지를 모아 JDBC Batch로 저장합니다. 기본 튜닝 시작값은 batch-size=200, max-wait=20ms, concurrency=4, prefetch=200이며 모두 환경 변수로 조정할 수 있습니다.

RabbitMQ 수신
→ PROCESSING
→ MySQL Transaction + JDBC Batch
→ Commit
→ Redis ISSUED
→ Manual ACK

DB Commit 후 ACK 전에 Consumer가 종료되더라도 RabbitMQ가 메시지를 다시 전달합니다. 이때 MySQL의 두 Unique Constraint가 동일 요청과 동일 사용자의 중복 발급을 막고, 재전달은 이미 처리된 성공으로 흡수됩니다.

4. Query: 진행 상태와 최종 원장을 구분합니다

Redis는 대기 순번과 진행 상태를 빠르게 제공하고, MySQL coupon_issue는 최종 발급 이력을 보관합니다. Redis 상태가 TTL이나 장애 복구 과정에서 사라져도 같은 사용자와 쿠폰의 MySQL 이력이 있으면 ISSUED로 복원합니다.

정합성과 장애 대응

위험 방어 방식
재고 초과 차감 Redis Lua의 원자적 stock check/decrement
같은 사용자의 반복 클릭 Redis Participants Set + User-Request Hash
같은 메시지의 재전달 MySQL UNIQUE(request_id)
동일 사용자의 최종 중복 발급 MySQL UNIQUE(coupon_id, user_id)
Publish 직전·직후 장애 Inflight Lease + Publisher Confirm + Reaper
Redis 연결 실패·Timeout Circuit Breaker, 503, Retry-After: 3, MySQL Fallback 금지
MySQL 일시 장애 Retry Queue로 넘기고 예약 재고는 유지
Poison Message Batch 분할로 실패 메시지를 격리한 뒤 DLQ 이동
영구 실패 보상 멱등 Lua Script로 ISSUED 여부를 확인한 뒤 한 번만 RELEASED 처리
저장소 간 상태 불일치 Reconciliation으로 MySQL 발급 이력과 Redis 상태 비교·감지

Backpressure는 Waiting, Inflight, RabbitMQ Main/Retry/DLQ, Consumer Buffer, Executor Queue를 합산한 Pipeline Depth를 사용합니다. 기본 High/Low Watermark는 10,000/8,000이며, 상태가 오래 갱신되지 않아도 안전하지 않다고 판단해 접수를 닫습니다. 거부된 요청은 Lua Script를 실행하기 전 차단되므로 재고와 대기열을 변경하지 않습니다.

트러블슈팅에서 배운 것

1. MySQL 동기 발급을 Admission에서 분리했습니다

요청마다 MySQL 조회와 INSERT를 수행하는 동기 경로는 비동기 경로보다 높은 지연을 보였습니다. Redis Admission과 비동기 영속화로 경계를 나눈 뒤, 동일한 로컬 20 TPS · 10초 비교에서 P95 중앙값이 278.66ms → 29.20ms로 낮아졌습니다. 현재 증적으로는 Connection Pool, Lock, CPU 중 하나를 단독 원인으로 확정하지 않습니다.

2. 재고와 중복 검사를 하나의 원자 연산으로 묶었습니다

재고 확인, 차감, 참여자 등록을 여러 Redis 명령으로 나누면 그 사이에 다른 요청이 끼어들 수 있습니다. Lua Script로 경계를 합친 뒤 중복 Smoke Test에서는 1건 접수 + 10건 중복, 재고 5 → 4, Waiting 1을 확인했습니다. 품절 Smoke Test에서는 11건 중 5건만 접수, 재고 0, Waiting 5를 확인했고 Testcontainers 동시성 불변식 테스트도 통과했습니다.

3. 처리량보다 이미 받은 작업의 보존을 우선했습니다

Consumer를 concurrency=1, prefetch=1, batch-size=1로 제한해 의도적으로 적체를 만들었습니다. 총 1,001건 중 215건은 접수되고 786건은 Backpressure로 거부됐으며, Peak 시점에는 RabbitMQ Ready 2건, Unacked 1건, MySQL 발급 191건이 관측됐습니다. Consumer를 정상 설정으로 복구한 뒤에는 접수된 215건이 중복 없이 모두 발급됐습니다.

4. 장애 시 빠른 우회보다 Fail-closed를 선택했습니다

Redis 중단 부하에서는 31건이 모두 503으로 거부됐고, 재고 100개·Waiting 0·Inflight 0·MySQL 발급 0건을 유지해 End-to-end Fail-closed를 확인했습니다. 이 실행은 503 응답 본문을 보존하지 않아 stale Backpressure Gate와 Circuit Breaker의 기여를 분리하지 않습니다. 별도 복구 확인에서는 두 요청이 COUPON_ISSUE_TEMPORARILY_UNAVAILABLE을 반환했고, 통합 테스트에서는 Redis 실패 3회 뒤 Circuit Breaker가 열린 다음 호출이 100ms 이내에 UNAVAILABLE로 종료됨을 확인했습니다. MySQL 중단 시에는 65건의 예약을 보존하고, 복구 후 65건을 중복 없이 발급했습니다.

5. 실패한 성능 테스트도 결과로 남겼습니다

로컬 500 TPS Spike에서는 연결 거부와 응답/저장 수 불일치가 발생했습니다. 부하 발생기와 애플리케이션, Docker 인프라가 같은 호스트 자원을 공유한 실행이어서 애플리케이션 포화와 환경적 자원 경합을 분리할 수 없었습니다. 이 실행은 안정 성능 근거에서 제외하고, 환경을 격리해 다시 측정할 후속 과제로 남겼습니다. 자세한 내용은 README 마지막의 성능 검증 결과와 환경적 한계에 정리했습니다.

더 자세한 문제 정의와 후속 과제는 트러블슈팅 문서, 설계 선택의 배경은 아키텍처 결정에서 확인할 수 있습니다.

기술 스택

영역 기술 선택 이유
Application Java 21, Spring Boot 3.5, Spring MVC 익숙한 명령형 모델과 검증된 Servlet 기반 운영 생태계
Admission Redis, Lettuce, Lua Script 짧은 원자 연산으로 재고·중복·순번을 함께 처리
Messaging RabbitMQ Publisher Confirm, Manual ACK, Retry/DLQ가 필요한 작업 큐에 적합
Persistence MySQL 8, JDBC Batch, Flyway 최종 발급 원장과 Unique Constraint, 대량 쓰기
Resilience Resilience4j, Lease/Reaper, Backpressure 장애 격리와 복구 가능한 비동기 처리
Observability Actuator, Micrometer, Prometheus Admission부터 DB Batch까지 병목을 수치로 확인
Verification JUnit 5, AssertJ, Testcontainers, k6 동시성·실제 인프라·부하 시나리오 검증

프로젝트 구조

src/main/java/com/example/coupon
├── admission   # Redis Lua 접수, HTTP API, Backpressure
├── dispatch    # Waiting/Inflight Lease, Publisher Confirm, Reaper
├── consumer    # Bounded Buffer, JDBC Batch, Manual ACK
├── recovery    # Retry/DLQ, Circuit Breaker, 보상, Reconciliation
├── event       # 쿠폰 이벤트 생성·오픈·중지·종료
├── query       # 신청 상태·대기 순번·사용자 쿠폰 조회
└── security    # 사용자/관리자 인증 경계

src/main/resources
├── redis       # Admission·상태 전이·복구·보상 Lua Script
└── db/migration

performance     # k6/JMeter 시나리오와 재현 PowerShell 도구
docs            # PRD, 아키텍처 결정, 상세 트러블슈팅

API

Method Endpoint 설명
POST /api/v1/admin/coupons 이벤트 생성과 Redis 초기화 (ROLE_ADMIN)
POST /api/v1/admin/coupons/{couponId}/open 이벤트 오픈 (ROLE_ADMIN)
POST /api/v1/admin/coupons/{couponId}/pause 이벤트 일시 중지 (ROLE_ADMIN)
POST /api/v1/admin/coupons/{couponId}/close 이벤트 종료 (ROLE_ADMIN)
POST /api/v1/coupons/{couponId}/issues 쿠폰 신청, Idempotency-Key 지원
GET /api/v1/coupons/{couponId}/requests/current 현재 사용자의 신청 상태
GET /api/v1/coupons/{couponId}/waiting-position 현재 사용자의 대기 순번
GET /api/v1/users/me/coupons MySQL 원장 기준 발급 쿠폰 목록

신청 API는 신규 접수 202, 기존 신청 200, 품절·미오픈·중지 409, Backpressure·Redis 장애 503을 반환합니다. 전체 요청·응답 스키마는 애플리케이션 실행 후 Swagger UI에서 확인할 수 있습니다.

로컬 실행

요구 사항

  • Java 21
  • Docker Desktop 또는 Docker Engine + Compose

1. 인프라 실행

docker compose up -d
docker compose ps

기본 설정은 로컬 개발 편의를 위한 값입니다. 별도 .env를 사용한다면 MySQL과 RabbitMQ 자격 증명을 Spring Boot 실행 환경에도 동일하게 주입해야 합니다.

2. 애플리케이션 실행

Dispatcher가 처리할 쿠폰 ID를 환경 변수에 등록합니다. 여러 ID는 쉼표로 구분합니다.

$env:COUPON_DISPATCH_COUPON_IDS='1001'
.\gradlew.bat bootRun

macOS/Linux에서는 다음처럼 실행할 수 있습니다.

COUPON_DISPATCH_COUPON_IDS=1001 ./gradlew bootRun

로컬 기본 계정은 coupon-user/change-me-user, 관리자 계정은 coupon-admin/change-me-admin입니다. 외부 환경에서는 COUPON_USER_PASSWORD, COUPON_ADMIN_PASSWORD를 반드시 교체해야 합니다.

3. 확인

용도 URL 인증
Health http://localhost:8080/actuator/health 불필요
Metrics http://localhost:8080/actuator/metrics Basic Auth
Prometheus http://localhost:8080/actuator/prometheus Basic Auth
OpenAPI http://localhost:8080/swagger-ui/index.html 불필요
RabbitMQ Management http://localhost:15672 coupon/coupon 기본값

테스트와 관측

.\gradlew.bat clean test

Docker가 실행 중이면 Testcontainers가 실제 Redis, RabbitMQ, MySQL을 사용해 다음을 검증합니다.

  • 재고보다 많은 동시 요청과 의도적인 중복 요청
  • Waiting/Inflight Claim과 만료 Lease 복구
  • Publisher Confirm 실패 후 재대기
  • JDBC Batch의 중복 메시지 멱등 처리와 Commit 후 ACK
  • Redis·MySQL 장애, Retry/DLQ, 보상, Reconciliation
  • 운영 인증과 성능 테스트 전용 Endpoint 격리

최근 Docker 통합 테스트 결과

2026-07-19 Docker Desktop 28.5.1에서 이전에 Skip됐던 Docker 의존 테스트 25개를 포함해 전체 테스트를 강제 재실행했습니다.

Test Suite 전체 테스트 성공 실패 오류 Skip Gradle 결과
22개 44개 44개 0개 0개 0개 BUILD SUCCESSFUL (4m 38s)

Redis Lua 동시성, Waiting/Inflight Lease, RabbitMQ Publisher Confirm과 Retry/DLQ, JDBC Batch 멱등성, 보상·Reconciliation이 모두 실제 컨테이너 환경에서 통과했습니다. 이 결과는 기능·정합성 검증이며 최대 TPS를 보장하는 성능 측정은 아닙니다.

주요 커스텀 메트릭은 coupon.admission.*, coupon.pipeline.depth, coupon.queue.*, coupon.dispatch.*, coupon.consumer.*, coupon.retry.*, coupon.dlq.*, coupon.reconciliation.*입니다. 사용자 ID나 Request ID처럼 Cardinality가 큰 값은 태그로 사용하지 않습니다.

성능 테스트는 별도 perf 프로필에서만 열리는 MySQL 동기 Baseline Endpoint와 제품 Async Endpoint를 같은 조건으로 비교합니다. 실행 방법은 성능 테스트 가이드를 참고하세요.

현재 범위

구현됨 후속 과제
Redis Lua Admission과 대기 순번 부하 발생기를 분리한 고부하 구간 재측정
Waiting/Inflight Lease와 Reaper 격리된 인프라에서 처리 경계와 임계값 재검증
RabbitMQ Confirm, Retry, DLQ JDBC Batch Fill Ratio·Rows/sec 시계열 비교
JDBC Batch Consumer와 멱등 저장 Prometheus 장기 시계열과 대시보드
Backpressure, Circuit Breaker JWT/OAuth2 기반 운영 인증 전환
보상, Reconciliation, 사용자 조회 프론트엔드 이벤트·대기 화면

성능 검증 결과와 환경적 한계

무거운 원시 결과 파일은 저장소에 포함하지 않습니다. 대신 비교 조건, 핵심 수치, 최종 정합성, 실패한 실행의 해석까지 이 문서에 남깁니다. 아래 결과는 모두 2026-07-18 단일 로컬 Docker Desktop 환경에서 얻었습니다.

이 환경에서 비동기 파이프라인의 최종 정합성까지 확인한 최고 안정 부하는 100 TPS였습니다. 이는 시스템의 절대 최대 처리량이 아니라, 현재 측정 구성에서 요청 접수부터 MySQL 발급까지 일치함을 검증한 가장 높은 부하 구간입니다.

Async Pipeline vs MySQL Baseline

공식 비교는 쿠폰 50,000개, 고유 userId/requestId, 20 TPS, 10초, 경로별 3회 반복 조건으로 수행했습니다. Warm-up과 진단 실행은 제외했고, 각 실행이 끝난 뒤 issued == accepted, 중복 사용자·요청 0건, Waiting·Inflight 0건을 확인했습니다.

경로 TPS 중앙값 P50 중앙값 P95 중앙값 P99 중앙값 중복 / 초과 발급
Redis + RabbitMQ Async Pipeline 19.997 10.59 ms 29.20 ms 61.01 ms 0 / 0
MySQL 동기 Baseline 20.021 39.45 ms 278.66 ms 463.67 ms 0 / 0

이 조건에서 Async P95는 Baseline보다 약 89.5% 감소해 Baseline의 약 1/9.54 수준으로 관측됐습니다. 입력 부하가 20 TPS로 고정되어 두 경로의 TPS가 거의 같으므로, 이 비교가 보여주는 것은 동일한 낮은 부하에서 동기 DB 작업을 요청 경로에서 분리했을 때의 응답 지연 차이입니다.

최종 정합성을 확인한 최고 안정 구간: 100 TPS

100 TPS · 20초 단계 실행에서는 2,000건을 접수했고 MySQL에도 2,000건이 발급됐습니다. P50은 9.16ms, P95는 45.78ms였으며, 중복 사용자·중복 요청·초과 발급은 없고 종료 시 Waiting과 Inflight도 모두 비워졌습니다.

요청률 실행 시간 접수 최종 발급 P50 P95 정합성
100 TPS 20초 2,000건 2,000건 9.16 ms 45.78 ms 통과

따라서 100 TPS는 이 단일 로컬 환경에서 최종 발급까지 일관되게 재현한 실질적인 최고 안정 수치로 기록합니다. 더 높은 처리량의 가능성을 포함하거나 배제하지 않으며, 검증된 범위만 명시합니다.

500 TPS Spike: 자원 경합과 관측 분해능의 한계

500 TPS · 10초 Spike에서는 실제 요청률이 280.05 TPS에 머물렀고, Dropped Iteration 1,835건과 연결·기타 오류 301건이 발생했습니다. k6가 확인한 202 응답은 20건이었지만 MySQL에는 108건이 발급돼 응답과 최종 저장 수도 일치하지 않았습니다.

이 실행에서는 k6, Spring Boot 애플리케이션, MySQL·Redis·RabbitMQ Docker Container가 같은 호스트의 CPU, Memory, Network Stack을 공유했습니다. 따라서 연결 오류와 Dropped Iteration에는 부하 발생기와 서버의 Resource Contention이 강한 교란 변수로 포함됩니다. 동시에 당시 Prometheus 자료는 전후 Snapshot 중심이었고 요청별 Correlation Evidence가 없어, 다음 두 경우를 분리할 만큼 관측 분해능이 충분하지 않았습니다.

  • 애플리케이션이 요청을 처리하기 전에 연결이 실패한 경우
  • Redis Lua는 실행됐지만 클라이언트가 202 응답을 받지 못한 경우

결과적으로 이 실행만으로 애플리케이션의 최대 처리량이나 단일 병목 지점을 확정할 수 없습니다. 안정 성능 결과에서는 제외하되, 다음 검증을 위한 경계 조건으로 사용합니다.

후속 검증 목적
k6와 애플리케이션 Host 분리 부하 발생기와 서버의 CPU·Network 자원 경합 제거
MySQL·Redis·RabbitMQ 자원 격리 인프라별 포화 지점과 Queue Drain 능력 분리
고해상도 시계열 수집 CPU, Connection, Queue, Redis Command 지연의 시간 상관관계 확인
Request ID 기반 추적 HTTP 응답, Lua 접수, RabbitMQ 전달, MySQL 저장의 건별 정합성 연결

재현 도구는 performance/Run-Measurement.ps1, 테스트 시나리오는 performance/scenarios/coupon-admission.js, 종료 정합성 검증은 performance/Verify-Coupon.ps1에 있습니다.

About

Redis 대기열과 비동기 발급 구조를 적용한 선착순 쿠폰 시스템

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors