신선식품 자사몰. 대규모 트래픽 선착순 쿠폰 발급 시스템이 이 프로젝트의 본론이다.
재고 10,000장 / 동시 요청 20,000명 / ramp-up 60초
-> 초과 발급 0건 / 1인 1매 / 번호 유실 0 / p99 0.247초 2026-08-31, AWS 실측
Java 21 / Spring Boot 4.0.5 / MySQL 8.4 / Valkey(Redis) 9.0 / AWS (Terraform) 기간 2026-07-31 ~ 2026-08-31, 5명
신선식품은 안 팔리면 재고가 남는 것이 아니라 폐기가 된다. 같은 상품이라도 입고 차수마다 소비기한이 다르고 그 차이가 그대로 판매 가능 기간이 된다. 그래서 이 도메인에는 성격이 다른 두 문제가 동시에 있다.
평상시 로트 단위 재고를 소비기한 임박순(FEFO)으로 정확히 깎는다 꾸준한 쓰기 경합
이벤트 임박 재고를 떨기 위한 선착순 쿠폰을 연다 한 순간에 몰리는 부하
선착순 쿠폰이 두 문제를 하나로 잇는다. 배치가 소비기한이 임박한 로트를 임박순 -> 판매율 저조순으로 고른다. 그 로트가 30% 정률 할인 쿠폰의 대상이 된다. 캠페인 대상 선정이 배치의 산출물이면서 이벤트의 입력이 된다.
커머스는 로트를 소진하고 캠페인은 남은 로트를 대상으로 삼는다.
커머스 경로
상품 등록 -> 로트 입고 -> 장바구니 -> 주문 생성(재고 예약) -> 결제 완료(예약 확정) -> 배송/클레임
캠페인 경로
일별 판매 집계 -> 소비기한 만료 처리 -> 캠페인 대상 로트 선정 -> 쿠폰 이벤트 열기
-> 선착순 발급(동시 2만) -> 종료/카운터 정산
주문이 그 시점 값을 전부 복사한다. cart 에서 소유권과 수량을 확인해 스냅샷을 뜨고 product 에서 상품명과 가격을 받아 주문 항목에 박는다. 나중에 상품 가격이 바뀌어도 이미 만든 주문이 따라 바뀌지 않는다.
재고는 예약과 확정으로 나뉜다. 주문 생성이 reserve 로 예약하고 결제 완료가 confirm 으로 확정한다. 결제가 실패하거나 취소되거나 만료되면 release 가 예약분만 해제한다.
| 배치 | 주기 | 하는 일 | 다음 단계에 넘기는 것 |
|---|---|---|---|
| 소비기한 만료 | 일 1회 | 경과 로트를 판매 불가로 전환하고 폐기 등록 | 가용 재고 0 이면 품절 |
| 일별 판매 집계 | 일 1회 | 결제 완료 주문 기준 daily_sales 적재 |
판매율 저조 판정의 원천 |
| 캠페인 대상 선정 | 이벤트 전 | 임박순에서 저조순으로 로트를 뽑는다 | 쿠폰 이벤트의 대상 목록 |
| 쿠폰 만료 / 이벤트 종료 | 주기 / 마감 후 | 조건부 UPDATE 로 상태 전이, Redis 네 키 정리 | 카운터 정산 |
| 정합성 검증 | 수동 | 발급 이력 300만 건 전체를 훑는다 | 리포트만. 고치지 않는다 |
팀은 회수 배치와 검증 배치를 섞지 않는다. 회수 배치는 고치고 검증 배치는 잰다. 검증이 고치면 재실행 결과가 달라져 요구사항을 위반한다.
팀이 DDL 32개 테이블을 13개 도메인으로 나눴다. 베이스 패키지의 직계 하위 패키지가 곧 도메인이다.
L2 order payment claim shipment review qna statistics 여러 도메인을 조립한다
|
L1 stock coupon cart 쓰기 경합이 있는 영역
|
L0 member product admin 아무것도 참조하지 않는 기준 데이터
|
common config 도메인 무지. 응답 봉투, 예외, 인증, 커서
아래로만 부르고 같은 층끼리는 직접 부르지 않는다. 같은 층이 필요하면 상위 도메인이 조립한다. ArchitectureTest 가 빌드에서 강제하므로 규칙을 어긴 코드는 check 에서 걸린다.
요구사항이 지연을 재지 않아서 팀은 요구 부하에서 발급 응답 p99 1초 이하라는 SLO 를 하나 더 걸었다. 팀은 그 한 줄에서 안쪽 값들을 거꾸로 깎아 내려갔다. 아래 넷이 선착순 발급에서 가장 품이 든 대목이다.
재고를 한 행에 두고 UPDATE 로 깎으면 그 행에 락이 몰린다. 발급이 전부 같은 행을 기다려서 그 구조가 처리량을 약 330 TPS 에 묶고 있었다. 상한을 지키려면 누군가는 "지금까지 몇 장 나갔나"를 봐야 하는데, 그 질문이 공유 자원 하나를 만든다.
그래서 팀은 상한 판정을 행마다 독립으로 만들었다. 발급 행을 넣을 때 그 행에 발급 개수 한도의 스냅샷을 박아 두고 CHECK 와 UNIQUE 로 막는다. 한도는 issue_limit 이고 이벤트를 열 때 coupon.total_quantity 에서 떠 온 값이다.
삽입 한 건이 다른 행을 안 보고 자기 행만으로 상한을 판정한다. 집계를 읽지 않으니 INSERT 끼리 기다릴 일이 없다.
chk_mc_issue_seq 상한 issue_seq <= issue_limit
uk_mc_coupon_seq 순번 중복 금지
uk_mc_coupon_member 1인 1매
이것이 문제의 성격을 바꿨다. 순번 발급기가 어디에 있든 초과 발급이 안 나므로 버전 사이의 차이가 "안전한가" 가 아니라 "얼마나 빠르고 얼마나 자주 실패하는가" 가 된다. 그래서 팀은 위쪽 구현을 v1 에서 v7 까지 자유롭게 바꿔 볼 수 있었다.
1. 자격 확인 기간, is_active, 대상 등급 (쿠폰 스냅샷 캐시로 DB 왕복 0)
2. 순번 확보 Lua 스크립트 한 번 <- 동시성도 병목도 전부 여기다
3. 발급 기록 인스턴스 큐 -> 배치 INSERT -> COMMIT (여기서 1인 1매가 최종 판정된다)
4. 응답 커밋과 Redis 확정 표시가 끝난 뒤
요청 스레드는 트랜잭션을 안 연다. 큐에 티켓을 넣고 자고 플러시 스레드만 DB 를 쓴다. 발급이 도는 동안 인스턴스 하나가 쓰는 커넥션은 1개다.
그러면서도 요청과 응답은 끝까지 동기다. 요청을 묶어 쓰지만 그것은 묶어서 쓰되 커밋까지 기다리는 것이지 나중에 쓰는 것이 아니다. 그래서 앱이 발급됐다고 답한 요청은 반드시 행이 있다.
2번에서 앱은 Redis 키 넷을 Lua 하나로 다룬다. 나눠 부르면 그 사이에 남이 끼어든다.
채택안은 v4 다. Redis 순번, 인스턴스별 큐, 벌크 INSERT, 가상 스레드.
안쪽(DB)이 바깥쪽(요청 예산)보다 길면 요청 스레드가 먼저 포기해 혼잡(503)으로 답한 뒤에 그 배치가 커밋된다. 실패했다고 답했는데 발급된 상태가 된다. 사용자가 안 돌아오면 그 슬롯은 죽은 재고다.
이 계층은 두 번 무너졌고 원인이 서로 달랐다.
- 적힌 값과 도는 값이 갈려서다. 팀이 HikariCP 의 하한 250ms 를 모르고 그보다 작게 적어 적힌 합과 도는 합이 달랐다. 전용 서버가 기동조차 못 해서 알았다.
- 세는 항목이 모자라서다. 예전 산식이 예산 안의 두 항목을 안 세고 통과라고 봤는데 실제 합은 예산을 넘고 있었다.
두 번 다 산식이 틀린 것이지 값이 틀린 것이 아니었다.
| 죽은 것 | 진행 중이던 발급 | 순번 처리 |
|---|---|---|
| 앱 인스턴스 | 응답도 못 간 채 큐째로 사라진다 | 재시도시 같은 번호로 발급 / 60초 뒤 회수 |
| Redis | 전부 완료된다 | 복구 뒤 재건이 free 를 채운다 |
| RDB | 전부 실패한다 | 재시도시 같은 번호로 발급 / 60초 뒤 회수 |
Redis 가 죽어도 진행 중인 발급은 안 끊긴다. 큐 항목이 번호와 회원과 응답 통로뿐이고 플러시 스레드는 DB 에만 쓰기 때문이다. 앱은 새 요청만 혼잡(503)으로 끊고 대체 순번 발급기를 두지 않는다.
키를 잃으면 DB 와 앱 큐에서 다시 세운다. 카운터가 서기 전까지 스크립트가 -2 로 요청을 막으므로 재건이 언제 돌든 틀린 번호가 안 나간다. 그 대신 재건이 끝날 때까지 발급이 멈추고 그 시간이 약 3.4초다.
RDB 가 죽으면 번호만 나가고 행이 안 만들어진다. DB 가 죽어도 Redis 는 멀쩡해서 INCR 이 계속 성공하기 때문이다. 그래도 앱이 그 번호를 반납하지 않는다. 반납하면 나중에 온 사람이 그 번호를 가져가고 먼저 온 사람은 재시도해서 맨 뒤로 간다.
팀이 장애를 주입해서 계측했다. 발급이 절반쯤 나간 지점에서 장애를 걸고 20초 유지했다.
| 회차 | 장애 | 발급 | 결번 | 초과 발급 | p99 |
|---|---|---|---|---|---|
| F-1b | 앱 컨테이너 급사 | 10,000 | 0 | 0 | 0.522초 |
| F-2e | 캐시 Multi-AZ 페일오버 | 9,989 | 11 | 0 | 1.041초 |
| F-3c | 앱 급사 + 캐시 페일오버 | 9,031 | 4 | 0 | 0.321초 |
셋 다 초과 발급 0, 1인 2매 0 이다. max_seq 는 회차마다 10,000 이라 번호는 끝까지 나갔고 그중 몇 장이 DB 에 못 들어갔느냐만 갈렸다.
팀은 못 지킨 것도 적어 둔다. F-2e 는 재고를 9,989장 내보내는 대신 p99 가 1.041초로 SLO 를 넘었다. 재고를 다 내보내는 것과 p99 1초를 장애 중에 동시에 지키지는 못한다.
| 문서 | 무엇 |
|---|---|
| fm-backend README | 기획부터 부하 시험까지 전체 |
| coupon.md | 선착순 쿠폰 설계 전체 |
| coupon-redis-keys.md | 발급이 쓰는 Redis 키 여섯 |
| redis-promotion-rebuild.md | 재건이 무엇이고 어떤 순서로 도나 |
| coupon-failure.md | 발급이 장애에 어떻게 반응하나 |
| 이름 | 맡은 영역 |
|---|---|
| devjohnpark | 선착순 쿠폰 / 인프라 / 공통 모듈 / 코드 검증 시스템 |
| muzimzz | 회원 / 인증 / 주문 / 결제 / 장바구니 |
| jaeungchoi | 상품 / 옵션 / 상품 이미지 / 재고 |
| gyudongjeong | 관리자 계정 / 인증 / 감사 로그 |
| taejoong15 | 재고(로트) / 캠페인 대상 / 상품 |