마케팅 이벤트 고부하 대응 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 경합이 검증된 적 없다.

이 과제는 goodsLimitedDrop(한정판 판매 회차) 게이트를 얹고, 20000TPS에서 오버셀 0건을 실증하는 동시성 전략을 확정한다. 기존 createPendingOrder 파이프라인은 그대로 재사용한다(신규 주문/재고 파이프라인 미신설).

Overview

  • 무엇을: goodsLimitedDrop Aggregate(대상 상품·오픈 시각·한정 수량·1인 한도·상태)를 도입하고, 판매 시작 게이트(FR-2) + 선착순 재고 차감(FR-3, 기존 흐름 재사용) + 소진 전 완충(FR-7) + 소진 후 즉시 거부(FR-8)를 구현한다.
  • : 20000TPS가 단일 stocks row에 낙관적 락 재시도로 몰리면 재시도 폭주로 P95·실패율 NFR을 못 지킨다. 초과 요청을 DB 앞단에서 차단할 완충·게이트가 필요하다.
  • 어떻게: Redis 원자 카운터를 입장 게이트(admission gate)로 DB 앞단에 두고, DB Stock.@Version을 최종 진실의 원천(SSOT)으로 유지하는 심층 방어(defense-in-depth). Redis DECR(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) — 멱등 조회 후 validateAndDeductStockGoodsDomainService.kt#createPendingOrder
재고 차감stock.requireSufficientstock.deductsave. Stock.@Version 낙관적 락GoodsDomainService.kt:100-110, Stock.kt:22-30
멱등goodsOrderRepository.findByIdempotencyKey(key) 존재 시 그대로 반환GoodsDomainService.kt:81
주문 UseCaseCreateGoodsOrderUseCaseTransactionTemplate으로 주문+결제 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/

문제점:

  1. 판매 시작 게이트 부재 — 상품 등록 즉시 구매 가능. “특정 시각부터 선착순” 표현 불가 (FR-1·FR-2 공백).
  2. 단일 row 낙관적 락 폭주 위험 — 20000TPS가 하나의 stocks row에 몰리면 @Version 충돌률이 피크에서 사실상 100%. 주문 생성 경로엔 재시도조차 없어 ObjectOptimisticLockingFailureException이 그대로 500. 재시도를 붙여도 각 재시도가 주문+결제 트랜잭션 전체 재실행 → 증폭 → P95 800ms·실패율 5% NFR 위반.
  3. 소진 후 완충 부재 — 소진 이후 초과 요청도 그대로 DB 쓰기 시도. 순간 부하가 DB에 직결.

TO-BE

  • goodsLimitedDrop Aggregate + 상태 머신(SCHEDULED/OPEN/SOLD_OUT/CLOSED).
  • 구매 경로: 판매 시작 게이트(시각) → Redis 입장 게이트(DECR) → 세마포어 완충 → 기존 createPendingOrder(무변경 재사용) → DB Stock.@Version 최종 봉쇄.
  • 오버셀 0건 + 소진 후 즉시 거부(DB 미도달) + 소진 전 완충 + 1인 한도.
  • 20000TPS 스파이크 k6 시나리오 + 리컨실리에이션 + 오버셀 알림(⑥ 연계, source=oversell severity=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 합류)

결정: LimitedDropgoods 도메인에 합류한다. 신규 도메인을 만들지 않는다. 상세 근거 ADR-002.

판단 축검토결론
데이터 소유LimitedDrop은 productId·stocks를 대상으로 함. goods가 이미 소유독립 데이터 소유 아님 → 합류
라이프사이클상품 등록 → 한정판 회차 개설 → 판매. goods Product·Stock 라이프사이클에 종속독립 아님 → 합류
변경 주기재고 차감 로직(createPendingOrder) 재사용 = goods와 동일 변경 주기독립 아님 → 합류
신규 도메인 시 비용LimitedDrop→Product/Stock를 크로스 도메인 ID 참조 + Gateway로 우회해야 함. createPendingOrder 직접 호출 불가(ADR-002 도메인 교차 참조 금지)신규 도메인은 불필요한 우회 유발 → 미채택
  • ADR-001 분류상 goods는 코어(거래) 도메인. LimitedDropCart·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/statusvalidatePurchasable()·상태 전이 메서드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)구매 트랜잭션 경계@Transactionalexecute(command)LimitedDropDomainService
CreateLimitedDropUseCase (application)판매자 회차 개설 + Redis 카운터 시드@Transactionalexecute(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 RootvalidatePurchasable()(시각 게이트 FR-2 + SOLD_OUT/CLOSED 거부), markSoldOut()·close()·open() 상태 전이, 생성 검증(openAt<closeAt, quantity>0, perUserLimit>0)
LimitedDropStatus상태 enumcanTransitTo(next) 전이 규칙. SCHEDULED/OPEN/SOLD_OUT/CLOSED
LimitedDropOversoldEvent도메인 이벤트리컨실리에이션 드리프트/오버셀 감지 시 발행. source=oversell, severity=critical 태그 (⑥ 연계)

서비스 클래스

클래스명역할입력 → 출력의존
LimitedDropDomainService구매 오케스트레이션purchase(command)GoodsOrderLimitedDropRepository, DropReservationStore, GoodsDomainService, DomainEventPublisher
DropReservationStoreImplRedis 게이트+완충 구현reserve/confirm/cancelStringRedisTemplate, 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(단일 스레드 직렬화) + DB Stock.@Version(최종 봉쇄). 락 대신 원자 연산으로 재시도 폭주 회피 (ADR-001).
  • 멱등 키: idempotencyKey — 예약(Redis 마커)·주문(DB unique) 양쪽에서 중복 차단. 이벤트 소비(오버셀 알림)는 eventId 기반 멱등(⑥ 규약).

상태 전이 표

현재 상태 × 이벤트다음 상태거부/비고
SCHEDULED × now≥openAt (첫 구매/조회)OPENRedis 카운터 시드 확인
SCHEDULED × 구매요청(now<openAt)SCHEDULED거부 425 TooEarly + openAt
OPEN × 구매요청(remaining>0)OPENAdmitted → 주문 생성
OPEN × remaining==0 도달SOLD_OUT이후 구매 즉시 409 SoldOut(FR-8)
SOLD_OUT × 구매요청SOLD_OUT거부 409 SoldOut (DB 미도달)
OPEN/SOLD_OUT × now≥closeAtCLOSED거부 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

레벨대상범위
domainLimitedDrop, LimitedDropStatus시각 게이트(before/after openAt), 상태 전이(canTransitTo 전수), 생성 검증, SOLD_OUT/CLOSED 거부
domainLimitedDropDomainServicereserve 결과별 분기(Admitted→주문, SoldOut→예외, Throttled, PerUserLimit), DB 실패 시 cancel 호출, 멱등(AlreadyReserved) — DropReservationStore·GoodsDomainService MockK
infraDropReservationStoreImplTestcontainers Redis. decr-if-positive 원자성(동시 N>수량 → 정확히 수량건 Admitted), 멱등 마커(동일 key 재-DECR 없음), 1인 한도 초과, cancel 복원, seedIfAbsent 재실행 안전
infraLimitedDropRepositoryImplTestcontainers MySQL. 저장·활성 회차 조회
presentationLimitedDropApiControllerMockMvc. 상태코드 매핑(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 카운터 seedIfAbsentdrop·카운터 확인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_dropsV41로 배정(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