마케팅 이벤트 고부하 대응 TDD
Background
근거 PRD: /Users/biuea/Desktop/dpdpdndn/프로젝트/스포츠앱/마케팅 이벤트 고부하 대응/PRD.md (검수 PASS)
선행 설계: ../도메인 경계 재설계/TDD.md, ADR-001(5계층 분류), ADR-002(관계·의존 방향), ADR-003(goods 제공 인터페이스 = UseCase 경계)
goods는 이미 고부하 구매 흐름의 뼈대를 갖췄다. GoodsDomainService.createPendingOrder(GoodsDomainService.kt#createPendingOrder)는 idempotencyKey로 중복 주문을 멱등 처리하고 validateAndDeductStock(GoodsDomainService.kt#validateAndDeductStock)에서 Stock.deduct(Stock.kt#deduct)로 재고를 차감한다. Stock은 @Version 낙관적 락(Stock.kt:22)을 가진다. 공백은 두 가지다 — ① 판매 시작 시각 게이트가 없어 등록 즉시 누구나 구매하고, ② 20000TPS 스파이크에서 단일 stocks row 경합이 검증된 적 없다.
이 과제는 goods에 LimitedDrop(한정판 판매 회차) 게이트를 얹고, 20000TPS에서 오버셀 0건을 실증하는 동시성 전략을 확정한다. 기존 createPendingOrder 파이프라인은 그대로 재사용한다(신규 주문/재고 파이프라인 미신설).
Overview
- 무엇을:
goods에LimitedDropAggregate(대상 상품·오픈 시각·한정 수량·1인 한도·상태)를 도입하고, 판매 시작 게이트(FR-2) + 선착순 재고 차감(FR-3, 기존 흐름 재사용) + 소진 전 완충(FR-7) + 소진 후 즉시 거부(FR-8)를 구현한다. - 왜: 20000TPS가 단일
stocksrow에 낙관적 락 재시도로 몰리면 재시도 폭주로 P95·실패율 NFR을 못 지킨다. 초과 요청을 DB 앞단에서 차단할 완충·게이트가 필요하다. - 어떻게: Redis 원자 카운터를 입장 게이트(admission gate)로 DB 앞단에 두고, DB
Stock.@Version을 최종 진실의 원천(SSOT)으로 유지하는 심층 방어(defense-in-depth). RedisDECR(Lua decr-if-positive)가 초과 요청을 DB 도달 전에 거부(FR-8)하고, 애플리케이션 세마포어가 입장 승인분의 DB 동시 쓰기를 완충(FR-7)하며,createPendingOrder+Stock.@Version이 물리적으로 오버셀을 봉쇄한다. 상세 근거는ADR-001.
Terminology
| 용어 | 정의 |
|---|---|
| LimitedDrop | 한정판 판매 회차. 대상 productId · openAt · closeAt · limitedQuantity · perUserLimit · status를 갖는 goods 도메인의 신규 Aggregate Root |
| 판매 시작 게이트(Sales-start gate) | now < openAt이면 구매를 거부하고 openAt을 응답에 담는 시각 기반 진입 통제 (FR-2) |
| 입장 게이트(Admission gate) | Redis 원자 카운터 DECR로 재고 슬롯을 선점·거부하는 DB 앞단 통제 (FR-8) |
| 완충(Buffer/Throttle) | 입장 승인분의 DB 동시 쓰기를 세마포어로 상한 제어하는 애플리케이션 스로틀 (FR-7) |
| 심층 방어(Defense-in-depth) | Redis 게이트(과다 입장 방지) + DB @Version(물리적 오버셀 봉쇄) 2중 방어 |
| 언더셀(Under-sell) | Redis는 차감됐으나 DB 주문이 롤백돼 슬롯이 미사용으로 남는 안전한 드리프트(오버셀의 반대) |
| 리컨실리에이션(Reconciliation) | Redis remaining과 DB stock을 주기 비교해 드리프트·오버셀을 감지하는 대사 작업 |
Define Problem
AS-IS
실제 코드(backend/src/main/kotlin/com/sportsapp/)를 읽어 확인한 현재 구조.
| 요소 | 현재 상태 | 근거 |
|---|---|---|
| 주문 생성 | createPendingOrder(userId, items, idempotencyKey) — 멱등 조회 후 validateAndDeductStock | GoodsDomainService.kt#createPendingOrder |
| 재고 차감 | stock.requireSufficient → stock.deduct → save. Stock.@Version 낙관적 락 | GoodsDomainService.kt:100-110, Stock.kt:22-30 |
| 멱등 | goodsOrderRepository.findByIdempotencyKey(key) 존재 시 그대로 반환 | GoodsDomainService.kt:81 |
| 주문 UseCase | CreateGoodsOrderUseCase가 TransactionTemplate으로 주문+결제 pending 생성. 재시도 없음 | CreateGoodsOrderUseCase.kt:47-62 |
| 재고 저장 | stocks 테이블 = product_id PK 단일 row/상품 | Stock.kt:12-17, StockRepositoryImpl.kt |
| 분산 락 | DistributedLock.tryLock/unlock = SET NX EX + Lua compare-and-del. StringRedisTemplate 사용 | DistributedLock.kt, RedisDistributedLock.kt |
| 좌석 락 선례 | SeatLockStore(domain gateway) → SeatLockStoreImpl(infra, Redis) | SeatLockStore.kt, SeatLockStoreImpl.kt |
| 낙관적 락 재시도 | AddCartItemUseCase 등 Cart 계열만 @Retryable. 주문 생성 경로엔 없음 | AddCartItemUseCase.kt:19 |
| 스로틀/세마포어 | 없음 | grep 결과 공집합 |
| 부하 하네스 | qa/load/k6/ticket-seat-select-spike.js 존재. goods 한정판 스파이크 시나리오 없음 | qa/load/k6/ |
문제점:
- 판매 시작 게이트 부재 — 상품 등록 즉시 구매 가능. “특정 시각부터 선착순” 표현 불가 (FR-1·FR-2 공백).
- 단일 row 낙관적 락 폭주 위험 — 20000TPS가 하나의
stocksrow에 몰리면@Version충돌률이 피크에서 사실상 100%. 주문 생성 경로엔 재시도조차 없어ObjectOptimisticLockingFailureException이 그대로 500. 재시도를 붙여도 각 재시도가 주문+결제 트랜잭션 전체 재실행 → 증폭 → P95 800ms·실패율 5% NFR 위반. - 소진 후 완충 부재 — 소진 이후 초과 요청도 그대로 DB 쓰기 시도. 순간 부하가 DB에 직결.
TO-BE
goods에LimitedDropAggregate + 상태 머신(SCHEDULED/OPEN/SOLD_OUT/CLOSED).- 구매 경로: 판매 시작 게이트(시각) → Redis 입장 게이트(
DECR) → 세마포어 완충 → 기존createPendingOrder(무변경 재사용) → DBStock.@Version최종 봉쇄. - 오버셀 0건 + 소진 후 즉시 거부(DB 미도달) + 소진 전 완충 + 1인 한도.
- 20000TPS 스파이크 k6 시나리오 + 리컨실리에이션 + 오버셀 알림(⑥ 연계,
source=oversellseverity=critical).
Architecture Benchmarking (의무)
| 제품/사례 | 해결 방식 | 참고할 패턴 | 미참고 사유 |
|---|---|---|---|
| Alibaba/Tmall 초읽기 세일(seckill) (InfoQ: Alibaba seckill architecture) | 재고를 Redis로 끌어올려 원자 DECR로 선점 판정, DB는 비동기 확정. 계층별 초과 트래픽 차단(front cutoff → cache → async DB) | ① Redis 원자 카운터를 재고 게이트로 DB 앞단 배치 채택 (오버셀 0 + DB 보호). ② 소진 후 즉시 컷오프(FR-8) 채택 | 재고를 Redis 단일 SSOT로 두고 DB를 비동기로만 두는 방식은 미채택 — 본 과제는 FR-3(기존 createPendingOrder+Stock.@Version 재사용) 전제라 DB가 최종 SSOT. Redis는 게이트로만 |
| Shopify Flash Sale / checkout throttle (Shopify: Surviving Flash Sales·checkout queue 원리) | 대기열·throttle로 체크아웃 유입을 상한 제어, 재고 확정은 DB 트랜잭션 | ① 입장 승인분의 DB 쓰기를 세마포어로 완충(FR-7) 채택. ② 정상 실패(품절)와 5xx 분리 측정 채택 | 프론트 가상 대기실 UI는 PRD Non-Goals — 백엔드 완충만 채택 |
| Nike SNKRS Line(선착순) (SNKRS 발매 방식) | 사이즈 선택 → 결제정보 → 짧은 대기 → 구매 성공/실패 확정. “결제 실패 시 카드 승인 미진행" | "구매 성공 = 선점(주문 생성) 시점, 결제는 별개”라는 PRD 정의와 정합 — 선점/결제 분리 확인용 | 추첨(Draw)은 1차 범위 밖(PRD Non-Goals) |
공통 결론: 재고 집계형 카운터는 좌석 같은 개별 식별자 락(
SET NX PX)이 아니라 원자 카운터가 자연스럽다(PRD Benchmarking 재확인). Redis 게이트 + DB@Version이중 방어가 20000TPS·오버셀 0의 최소 충분 조합.
Possible Solutions
방안 비교 (FR-4 동시성 전략)
전제: 한정판은 단일 stocks row에 전 트래픽이 집중. 20000TPS 관점에서 비교.
| 방안 | 설명 | 왜 채택 / 미채택 |
|---|---|---|
A. 기존 @Version 낙관적 락 + @Retryable | 주문 생성 경로에 @Retryable(ObjectOptimisticLockingFailureException)를 붙여 충돌 시 재시도 | 미채택(단독) — 단일 row에 20000TPS면 피크 충돌률≈100%. 각 재시도가 주문+결제 트랜잭션 전체 재실행 → 재시도 증폭으로 DB 커넥션·CPU 포화, P95 800ms·최종 실패율 5% NFR 위반. 오버셀은 막지만 처리량이 무너짐 |
B. 비관적 락 / 원자적 UPDATE ... WHERE quantity >= N | 단일 row를 행 락으로 직렬화하거나 조건부 원자 UPDATE 1문 | 부분 채택(폴백) — 재시도 폭주는 없으나 20000TPS 전량이 DB 단일 row 락에 직렬화 → DB가 병목. 게이트가 없어 소진 후 초과 요청도 DB에 도달(FR-8 미충족). Redis 게이트 장애 시 폴백 안전망으로만 유지 |
C. Redis 원자 카운터 입장 게이트 + DB @Version SSOT (채택) | Redis DECR(Lua decr-if-positive)가 재고 슬롯을 선점·거부(FR-8, DB 미도달) → 세마포어 완충(FR-7) → 기존 createPendingOrder가 DB에서 Stock.@Version으로 최종 차감 | 채택 — Redis 단일 스레드가 O(1) DECR로 초과분을 DB 앞단에서 컷오프해 DB 경합을 재고 수량 근처로 상한. Stock.@Version은 손대지 않고 재사용(FR-3). 이중 방어로 오버셀 0 물리 보장. 소진 후 즉시 거부 자연 충족(FR-8). Alibaba seckill 정합 |
| D. Redis 단일 SSOT(DB 비동기 확정) | 재고를 Redis에만 두고 DB는 사후 비동기 반영 | 미채택 — FR-3(기존 createPendingOrder+Stock.@Version 재사용, DB가 최종 SSOT) 위반. Redis 유실 시 재고 진실 상실 리스크. 지금 규모에 과함 |
| E. 프론트 가상 대기실 + 토큰 | FE 대기열로 유입 자체를 제어 | 미채택 — PRD Non-Goals(별도 FE 과제). 백엔드 게이트+완충으로 충분 |
단순함 우선 결론: C(Redis 게이트 + DB @Version SSOT). A(낙관적 재시도)는 단일 row 폭주로 미채택, B(비관적/원자 UPDATE)는 Redis 게이트 장애 시 폴백 안전망으로만 유지. Redis 게이트는 Stock.@Version을 대체하지 않고 앞단에 얹기만 한다 — 기존 파이프라인 무변경(FR-3).
Detail Design
도메인 바운디드 컨텍스트 경계 판단 (기존 goods 합류)
결정: LimitedDrop은 goods 도메인에 합류한다. 신규 도메인을 만들지 않는다. 상세 근거 ADR-002.
| 판단 축 | 검토 | 결론 |
|---|---|---|
| 데이터 소유 | LimitedDrop은 productId·stocks를 대상으로 함. goods가 이미 소유 | 독립 데이터 소유 아님 → 합류 |
| 라이프사이클 | 상품 등록 → 한정판 회차 개설 → 판매. goods Product·Stock 라이프사이클에 종속 | 독립 아님 → 합류 |
| 변경 주기 | 재고 차감 로직(createPendingOrder) 재사용 = goods와 동일 변경 주기 | 독립 아님 → 합류 |
| 신규 도메인 시 비용 | LimitedDrop→Product/Stock를 크로스 도메인 ID 참조 + Gateway로 우회해야 함. createPendingOrder 직접 호출 불가(ADR-002 도메인 교차 참조 금지) | 신규 도메인은 불필요한 우회 유발 → 미채택 |
- ADR-001 분류상
goods는 코어(거래) 도메인.LimitedDrop은Cart·GoodsOrder와 동급의 goods 신규 Aggregate Root. - 도메인 간 참조 없음 — LimitedDrop은 같은 도메인의
GoodsDomainService.createPendingOrder를 재사용(도메인 내 협력, ADR-002 위반 아님). - 미채택(신규
drop/promotion도메인) 사유: 독립 라이프사이클·데이터 소유·변경 주기 어느 것도 성립 안 함. 크로스 도메인 우회만 늘어남.
서버 토폴로지 설계
| 후보 | 과제 특성 매칭 | 채택 여부 |
|---|---|---|
| 단일 API 서버 (요청-응답) | 구매 = 동기 요청-응답(주문 생성 시점 성공 확정). 게이트·완충은 in-process + Redis | 채택 — 지금 규모에 충분 |
| 별도 워커 서버 (비동기 처리) | 결제 확정(markPaid)은 이미 기존 이벤트로 downstream 비동기. 주문 생성 자체는 동기여야 “성공” 확정 | 미채택 — 신규 워커 불필요 |
| 스케줄러 서버 (주기 실행) | 리컨실리에이션(Redis↔DB 대사)은 @Scheduled in-process 태스크로 충분 | 미채택(전용 서버) — API 서버 내 @Scheduled로 수용 |
| 소켓 서버 (실시간 양방향) | 대기실 실시간 순번 표시는 PRD Non-Goals | 미채택 |
결론: 단일 API 서버 + 인프로세스 Redis 게이트/세마포어 + @Scheduled 리컨실리에이션. 근거: 구매는 동기 요청-응답이고, 20000TPS 완충은 Redis(글로벌)+세마포어(인스턴스)로 처리 가능. 다중 인스턴스 확장 시 세마포어(인스턴스별)는 Redis 토큰 버킷으로 승격 필요 — Open Questions·Redis 후속 요구에 명시.
시스템 역할 경계 (의무)
| 단위 | 역할 | 소유 데이터/책임 | 노출 인터페이스 | 의존 |
|---|---|---|---|---|
LimitedDrop (Entity, domain) | 한정판 회차 Aggregate Root. 판매 시작 게이트·상태 전이 캡슐화 | openAt/closeAt/limitedQuantity/perUserLimit/status | validatePurchasable()·상태 전이 메서드 | common만 |
LimitedDropStatus (enum, domain) | 상태 전이 규칙 캡슐화 | 전이 표 | canTransitTo() | (없음) |
LimitedDropRepository (interface, domain) | LimitedDrop 영속화 계약 | — | 아래 시그니처 | (없음) |
DropReservationStore (gateway interface, domain) | Redis 입장 게이트 + 완충 계약 (SeatLockStore 선례) | — | 아래 시그니처 | (없음) |
LimitedDropDomainService (domain) | 게이트 검증 → 슬롯 선점 → 기존 createPendingOrder 재사용 → 실패 시 슬롯 반환 → 오버셀 이벤트 | 구매 오케스트레이션(도메인 규칙) | purchase()·openDrop()·getStats() | LimitedDropRepository, DropReservationStore, GoodsDomainService(동일 도메인), DomainEventPublisher |
PurchaseLimitedDropUseCase (application) | 구매 트랜잭션 경계 | @Transactional | execute(command) | LimitedDropDomainService |
CreateLimitedDropUseCase (application) | 판매자 회차 개설 + Redis 카운터 시드 | @Transactional | execute(command) | LimitedDropDomainService |
GetLimitedDropUseCase/GetLimitedDropStatsUseCase (application) | 상태·집계 조회 (FR-9) | 읽기 | execute(...) | LimitedDropDomainService |
DropReservationStoreImpl (infra) | Redis Lua decr-if-positive + 멱등 마커 + 1인 한도 + 인프로세스 세마포어 | Redis 키/스크립트 | DropReservationStore 구현 | StringRedisTemplate |
LimitedDropRepositoryImpl + JPA (infra) | LimitedDrop DB 매핑 | limited_drops 테이블 | Repository 구현 | LimitedDropJpaRepository |
LimitedDropApiController (presentation) | 라우팅·상태코드 매핑 | — | REST API(아래 계약) | UseCase들 |
DropReconciliationWorker (presentation, @Scheduled) | Redis↔DB 대사 → 오버셀 감지 시 이벤트 | — | 내부 스케줄 | UseCase 경유 |
인터페이스 시그니처
// domain/goods/repository/LimitedDropRepository.kt
interface LimitedDropRepository {
fun save(limitedDrop: LimitedDrop): LimitedDrop
fun findById(id: Long): LimitedDrop?
fun findOpenByProductId(productId: Long): LimitedDrop? // 활성 회차 1건
}
// domain/goods/gateway/DropReservationStore.kt (SeatLockStore 선례를 따르는 domain gateway)
interface DropReservationStore {
// 회차 개설 시 카운터 시드 (SET NX). 재실행 안전.
fun seedIfAbsent(dropId: Long, initialQuantity: Int, ttl: Duration)
// 입장 게이트(FR-8) + 1인 한도(FR-6) + 멱등 마커 + 완충 permit(FR-7)을 원자 판정. Admitted 시 permit 보유.
fun reserve(dropId: Long, userId: Long, quantity: Int, perUserLimit: Int, idempotencyKey: String): ReservationResult
// 성공: DECR 유지, permit만 반납
fun confirmSuccess(dropId: Long, userId: Long, idempotencyKey: String)
// 실패: DECR 복원(+1인 카운트 복원) + permit 반납 (언더셀 방지)
fun cancel(dropId: Long, userId: Long, quantity: Int, idempotencyKey: String)
fun remaining(dropId: Long): Int?
}
// domain/goods/gateway/ReservationResult.kt
sealed interface ReservationResult {
data object Admitted : ReservationResult
data object AlreadyReserved : ReservationResult // 멱등 재시도 (기존 슬롯)
data object SoldOut : ReservationResult // FR-8 즉시 거부
data object Throttled : ReservationResult // FR-7 완충 초과
data class PerUserLimitExceeded(val limit: Int) : ReservationResult // FR-6
}
// application/goods/usecase/PurchaseLimitedDropUseCase.kt
fun execute(command: PurchaseLimitedDropCommand): LimitedDropPurchaseResult
클래스 역할 정의
도메인 모델
| 클래스명 | 역할 | 핵심 책임 |
|---|---|---|
LimitedDrop | 한정판 회차 Aggregate Root | validatePurchasable()(시각 게이트 FR-2 + SOLD_OUT/CLOSED 거부), markSoldOut()·close()·open() 상태 전이, 생성 검증(openAt<closeAt, quantity>0, perUserLimit>0) |
LimitedDropStatus | 상태 enum | canTransitTo(next) 전이 규칙. SCHEDULED/OPEN/SOLD_OUT/CLOSED |
LimitedDropOversoldEvent | 도메인 이벤트 | 리컨실리에이션 드리프트/오버셀 감지 시 발행. source=oversell, severity=critical 태그 (⑥ 연계) |
서비스 클래스
| 클래스명 | 역할 | 입력 → 출력 | 의존 |
|---|---|---|---|
LimitedDropDomainService | 구매 오케스트레이션 | purchase(command) → GoodsOrder | LimitedDropRepository, DropReservationStore, GoodsDomainService, DomainEventPublisher |
DropReservationStoreImpl | Redis 게이트+완충 구현 | reserve/confirm/cancel | StringRedisTemplate, Lua |
실패 경로·동시성·멱등 (의무)
| 시나리오 | 처리 | 오버셀 영향 |
|---|---|---|
| 20000TPS 동시 폭주 | Redis DECR(Lua)가 원자적으로 정확히 limitedQuantity건만 Admitted. 초과분 SoldOut 즉시 거부(DB 미도달) | 0 (원자 카운터) |
Redis DECR 성공 후 DB createPendingOrder 실패(재고 소진 예외·예외) | catch에서 cancel() → DECR 복원 + permit 반납 | 0 (슬롯 반환) |
| DB 커밋은 됐으나 트랜잭션 후단 실패 | Redis는 이미 DECR 유지 → 최악 언더셀(안전). 리컨실리에이션이 복원 | 0 (언더셀은 오버셀 반대) |
| 멱등 재시도(동일 idempotencyKey) | Lua가 reserved:{key} 마커 확인 → AlreadyReserved 반환(재-DECR 안 함). DB는 findByIdempotencyKey로 기존 주문 반환 | 0 (중복 차감 없음) |
Redis 장애(DataAccessException) | 폴백 = fail-open to DB: 게이트 우회하고 createPendingOrder+Stock.@Version(방안 B, 비관적/원자 UPDATE 가능)로 진행. 완충 세마포어는 유지해 DB 보호. oversell 아닌 redis-degraded 알림 | 0 (Stock.deduct 가드가 최종 봉쇄) |
| 세마포어 완충 초과 | timeout 내 permit 미획득 → Throttled 429. 승인분만 DB 진입 | 0 |
| 판매 시작 전 | validatePurchasable()가 now<openAt 거부 → 425 + openAt. Redis 미접근 | 0 |
| 리컨실리에이션 드리프트 감지 | Redis remaining + DB deducted > limitedQuantity면 오버셀 → LimitedDropOversoldEvent 발행(⑥) | 감지·알림 |
- 동시성 전략: 단일 재고 카운터 경합 → Redis 원자
DECR(단일 스레드 직렬화) + DBStock.@Version(최종 봉쇄). 락 대신 원자 연산으로 재시도 폭주 회피 (ADR-001). - 멱등 키:
idempotencyKey— 예약(Redis 마커)·주문(DB unique) 양쪽에서 중복 차단. 이벤트 소비(오버셀 알림)는eventId기반 멱등(⑥ 규약).
상태 전이 표
| 현재 상태 × 이벤트 | 다음 상태 | 거부/비고 |
|---|---|---|
| SCHEDULED × now≥openAt (첫 구매/조회) | OPEN | Redis 카운터 시드 확인 |
| SCHEDULED × 구매요청(now<openAt) | SCHEDULED | 거부 425 TooEarly + openAt |
| OPEN × 구매요청(remaining>0) | OPEN | Admitted → 주문 생성 |
| OPEN × remaining==0 도달 | SOLD_OUT | 이후 구매 즉시 409 SoldOut(FR-8) |
| SOLD_OUT × 구매요청 | SOLD_OUT | 거부 409 SoldOut (DB 미도달) |
| OPEN/SOLD_OUT × now≥closeAt | CLOSED | 거부 409 Closed |
| CLOSED × 구매요청 | CLOSED | 거부 409 Closed |
| any × cancel(주문 취소→재고 복원) | 동일/OPEN | 기존 cancelPendingOrder 재사용 + Redis 복원 |
상태는 파생 계산(now·remaining 기준)과 영속 상태를 병용. 영속 status는 조회·집계 표기용, 게이트 판정은
validatePurchasable()가 now·remaining으로 실시간 결정(경계 오차 방지).
API 계약 (private-senior-fe 인계용)
| 메서드 | 경로 | 요청 | 성공 | 실패 |
|---|---|---|---|---|
| POST | /limited-drops (판매자) | {productId, openAt, closeAt, limitedQuantity, perUserLimit} | 201 {dropId, status, openAt} | 400 검증 |
| GET | /limited-drops/{dropId} | — | 200 {dropId, productId, status, openAt, closeAt, remaining, perUserLimit} | 404 |
| POST | /limited-drops/{dropId}/orders (구매) | header X-User-Id,Idempotency-Key; body {quantity} | 202 {orderId, dropId, status} (구매 성공=주문 PENDING) | 425 TooEarly {openAt} / 409 SoldOut / 409 Closed / 429 Throttled / 403 PerUserLimit |
| GET | /limited-drops/{dropId}/stats (FR-9) | — | 200 {successCount, soldOutRejectCount, tooEarlyRejectCount} | 404 |
구매 성공은 202 Accepted(주문 PENDING 생성=선점). 결제 확정은 기존 결제 흐름(별개). 정상 실패(425/409/403)는 5xx 에러율 계산에서 제외(NFR).
Component Diagram (Mermaid flowchart LR)
flowchart LR subgraph Presentation Controller["LimitedDropApiController"] Recon["DropReconciliationWorker"] end subgraph Application PurchaseUC["PurchaseLimitedDropUseCase"] end subgraph Domain DropSvc["LimitedDropDomainService"] GoodsSvc["GoodsDomainService (재사용)"] DropRepo["LimitedDropRepository"] ResStore["DropReservationStore"] end subgraph Infra ResImpl["DropReservationStoreImpl (Redis Lua)"] DropRepoImpl["LimitedDropRepositoryImpl"] end Controller --> PurchaseUC PurchaseUC --> DropSvc DropSvc --> ResStore DropSvc --> GoodsSvc DropSvc --> DropRepo ResImpl -.->|implements| ResStore DropRepoImpl -.->|implements| DropRepo Recon --> PurchaseUC
Sequence Diagram — 구매 (해피 + 소진 거부)
sequenceDiagram participant C as Controller participant U as PurchaseUseCase participant D as LimitedDropDomainService participant R as DropReservationStore(Redis) participant G as GoodsDomainService(DB) C->>U: execute(command) U->>D: purchase(command) D->>D: validatePurchasable() (시각 게이트) D->>R: reserve(dropId,user,qty,limit,key) alt remaining>0 R-->>D: Admitted (permit 보유) D->>G: createPendingOrder(재사용, Stock.@Version) G-->>D: GoodsOrder(PENDING) D->>R: confirmSuccess (permit 반납) D-->>U: order else remaining==0 R-->>D: SoldOut (DB 미도달) D-->>U: SoldOutException(409) end
ERD
limited_drops 신규 테이블. 상세 컬럼·인덱스·DDL은 private-senior-dba 후속(design-db)에서 확정. 요약만.
erDiagram PRODUCT ||--o| LIMITED_DROP : "회차 대상" STOCK ||--|| PRODUCT : "재고(재사용)" LIMITED_DROP { bigint id PK bigint product_id "goods Product ID (FK 컬럼 아님)" datetime open_at "판매 시작(6)" datetime close_at "판매 종료(6)" int limited_quantity "한정 수량" int per_user_limit "1인 한도(기본 1)" varchar status "SCHEDULED/OPEN/SOLD_OUT/CLOSED" }
senior-dba 요구:
limited_drops신규 테이블(마이그레이션V41__create_limited_drops.sql— ②가 V38~40 점유해 senior-dba가 V41 배정, 2026-07-03).product_id인덱스(활성 회차 조회),open_at인덱스.per_user_limit컬럼은 GET 상세 응답·FE QuantityStepper 상한에 노출. FR-9 집계는 별도 테이블 없이 Redis 카운터 +goods_orders조인으로 파생 권고(P2). NOT NULL 컬럼은 expand-contract(추가→백필→제약). DDL 전문은 마이그레이션이 SSOT.
Testing Plan
| 레벨 | 대상 | 범위 |
|---|---|---|
| domain | LimitedDrop, LimitedDropStatus | 시각 게이트(before/after openAt), 상태 전이(canTransitTo 전수), 생성 검증, SOLD_OUT/CLOSED 거부 |
| domain | LimitedDropDomainService | reserve 결과별 분기(Admitted→주문, SoldOut→예외, Throttled, PerUserLimit), DB 실패 시 cancel 호출, 멱등(AlreadyReserved) — DropReservationStore·GoodsDomainService MockK |
| infra | DropReservationStoreImpl | Testcontainers Redis. decr-if-positive 원자성(동시 N>수량 → 정확히 수량건 Admitted), 멱등 마커(동일 key 재-DECR 없음), 1인 한도 초과, cancel 복원, seedIfAbsent 재실행 안전 |
| infra | LimitedDropRepositoryImpl | Testcontainers MySQL. 저장·활성 회차 조회 |
| presentation | LimitedDropApiController | MockMvc. 상태코드 매핑(202/425/409/429/403), 구매/조회/집계 |
| scenario | 한정판 구매 E2E | 회차 개설→시드→동시 폭주(수량=100, 요청=500)→오버셀 0 + 성공 100 + 거부 400. 시작 전 거부, 소진 후 즉시 거부 |
| load(k6) | 마케팅 이벤트 스파이크 | qa/load/k6/goods-limited-drop-spike.js 신규. 20000TPS 도전, 도달 TPS·병목 기록, 오버셀 0 검증(사후 대사), 5xx<1% |
핵심 실패 경로 시나리오(테스트가 잡아야 함):
- 재고 100 · 동시 500 요청 → DB
Stock.quantity==0& 오버셀 0 & 성공 정확히 100. - Redis DECR 성공 후 강제 DB 예외 → Redis remaining 원복(cancel) 확인.
- 동일 idempotencyKey 2회 → 주문 1건 · Redis DECR 1회.
- now<openAt 구매 → 425 + openAt, Redis 미접근.
- Redis 다운(폴백) → 구매 성공 유지 & 오버셀 0(
Stock.@Version봉쇄) &redis-degraded경보.
Release Scenario — 무중단 배포 (의무)
기존 goods 구매 경로(createPendingOrder·CreateGoodsOrderUseCase·ADR-003 동결 UseCase)는 무변경. LimitedDrop은 순수 가산. 피처 플래그 + expand-contract.
| 단계 | 작업 | 전환 조건 | 롤백 |
|---|---|---|---|
| 1. 스키마(먼저) | V41__create_limited_drops.sql 가산(모두 nullable/기본값). 기존 테이블 무변경 | 마이그레이션 성공 | 역방향 DDL로 limited_drops drop (참조 코드 미배포 상태라 안전) |
| 2. 코드(플래그 OFF) | LimitedDrop 전 코드 배포, limited-drop.enabled=false. 엔드포인트 404/비활성 | 배포 성공, 기존 goods 회귀 0 | 플래그 이미 OFF — 무영향 |
| 3. 회차 시드 | 판매자 회차 개설 API로 drop 생성 + Redis 카운터 seedIfAbsent | drop·카운터 확인 | drop 삭제 |
| 4. 플래그 ON(회차별) | limited-drop.enabled=true. 게이트+완충 활성 | 스파이크 시나리오 오버셀 0·5xx<1% | 플래그 OFF → 즉시 비활성(기존 goods 무영향) |
| 5. Redis 게이트 서브플래그 | limited-drop.redis-gate.enabled 별도. 게이트 오작동 시 OFF → DB @Version+세마포어 폴백(방안 B) | 게이트 지표 정상 | 서브플래그 OFF → 폴백(오버셀 0 유지, 처리량만 감소) |
마이그레이션 번호 배정 방침: 형제 과제와 번호 충돌을 피하기 위해 “먼저 dev에 머지되는 쪽이 V38부터 순차 점유, 나중 쪽 재배정”을 따른다. ②(B2B 파트너 연동)가 V38~V40을 점유했으므로 ③
limited_drops는 V41로 배정(senior-dba 조정, 2026-07-03). 머지 순서가 바뀌면 재배정한다.
- 배포 순서: 스키마 먼저 → 코드(플래그 OFF) → 시드 → 플래그 ON. 하위 호환 API 가산만, 버저닝 불필요.
- 롤백: 단계별 역전. 최악의 경우
limited-drop.enabled=false한 번으로 전체 비활성, 기존 goods 흐름은 처음부터 무영향.
Observability (조건부 — 신규 기능·외부 연동)
- 지표(⑦ 옵저버빌리티 대시보드): QPS(시작 전후), 오버셀 카운터, Redis 게이트 거부율(SoldOut/Throttled/PerUserLimit), 입장→주문 P95,
Stock.@Version충돌·폴백 발생률. - 알림(⑥ 지능형 장애 알림): 오버셀 1건↑ →
LimitedDropOversoldEvent(source=oversell,severity=critical). Redis 폴백 진입 →redis-degraded(warning).
Open Questions
- 1인 구매 한도 기본값 → 1개 권고(PRD Open Q). perUserLimit 컬럼으로 회차별 조정 가능.
- 다중 API 인스턴스 확장 시 인프로세스 세마포어(FR-7)를 Redis 토큰 버킷으로 승격 필요 — 지금 단일 인스턴스 전제. Redis 후속 요구에 포함.
- 추첨(Draw) 방식 → 1차 범위 밖(PRD Non-Goals).
- FR-9 집계를 전용 테이블로 영속할지, Redis 카운터+주문 조인 파생으로 둘지 → 파생 권고(P2, senior-dba 판단).
Document History
| 날짜 | 변경 내용 |
|---|---|
| 2026-07-03 | 최초 작성 — AS-IS 실측, 동시성 전략 C(Redis 게이트+DB @Version) 채택, goods 합류 결정, 서버 토폴로지 단일 API, API 계약, 무중단 배포, Observability |