가상 대기열(Virtual Queue) 트래픽 제어 TDD
Background
근거 PRD: /Users/biuea/Desktop/dpdpdndn/프로젝트/스포츠앱/가상 대기열 트래픽 제어/20260709-가상대기열-prd.md
[전체 시스템 아키텍처 진단]의 진화 2단계다. 판매 시작 시각의 20,000 TPS 유입이 대기열 없이 앱에 그대로 도달한다. 현재 방어선(LoadSheddingFilter 세마포어 200 fast-fail, reserve.lua 원자 재고 게이트, SeatLockStore 좌석 락, event.payment.payment.v1 비동기 확정)은 전부 “들어온 요청을 안전하게 처리” 하는 계층이며, 유입 총량 자체를 배치로 줄이는 계층은 0건이다(PRD Background grep 확인). 이번 과제는 그 앞단에 가상 대기열을 추가한다 — 기존 방어선은 하나도 대체하지 않는다.
Overview
- 무엇을: 한정판 구매(
PurchaseLimitedDropUseCase진입점)·티케팅 좌석 선택(SelectSeatsUseCase진입점) 두 경로 앞단에 Redis Sorted Set 순번 큐 + 중앙화 배치 admission + HMAC 서명 입장 토큰 게이트를 신설한다. - 왜: 유입 총량을 클러스터 전체 2초당 100명(=50 TPS)으로 조여, 다운스트림(기존 Lua 재고 게이트·좌석 락)에 도달하는 트래픽을 목표치 이내로 유지한다.
- 어떻게: 신규 바운디드 컨텍스트
domain/virtualqueue가 큐 상태(전부 Redis, 신규 DB 테이블 0개)를 소유한다. Redis 분산 락으로 단일 인스턴스만 배치 admission을 수행해(인스턴스 로컬 카운터 금지) 클러스터 전체 admission을 목표치 이내로 유지한다. 입장 토큰 검증은domain.common.EntryTokenGuard(선례:DistributedLock·FeatureFlagEvaluator) 뒤에 숨긴 HandlerInterceptor가 구매 API 앞단에서 수행한다. 대기열 경유 여부는 기존FeatureFlagEvaluator로 런타임 토글해 무중단 롤백(3초)한다.
Terminology
| 용어 | 정의 |
|---|---|
| Virtual Queue | 판매 시작 시각 폭주를 앞단에서 흡수하는 가상 대기열. 순번 부여 + 배치 입장 허용 |
| Admission (입장 허용) | 대기열 선두 배치를 다운스트림으로 통과시키는 행위. admitted_count 고수위(high-water) 전진 |
| Entry Token (입장 토큰) | admission된 사용자에게 발급하는 HMAC-SHA256 서명 토큰. TTL 5분. 구매 API 앞단에서 검증 |
| Admission Pump | 2초 주기로 admitted_count를 batch만큼 전진시키고 이탈자를 방출하는 스케줄 태스크. Redis 분산 락으로 클러스터 단일 실행 |
| Heartbeat | 순번 조회(폴링)가 겸하는 생존 신호. 60초 미갱신 시 이탈 판정 |
| Target (대상) | 대기열이 걸리는 단위. LIMITED_DROP:{dropId} 또는 TICKETING_EVENT:{eventId} |
| Fail-open | Redis 장애 시 대기열을 우회해 다운스트림으로 직접 통과. 오버셀은 MySQL 최종 정합 방어(Stock.@Version·유니크 제약)가 막으므로 안전(Redis 게이트 아님, §0-4) |
Define Problem
AS-IS
| 지점 | 파일#메서드 | 현재 동작 |
|---|---|---|
| 부하 셰딩 | LoadSheddingFilter.kt#doFilterInternal | 세마포어 200 초과분을 503 fast-fail. 큐잉·순번 없음, “누구를 먼저”를 통제 못 함 |
| 한정판 재고 게이트 | DropReservationStoreImpl.kt#reserve + reserve.lua | 재고 원자 차감 + 완충 세마포어 200. “단일 인스턴스 전제”(클래스 주석 L29), 인스턴스 로컬. 대기열 아님 |
| 한정판 진입점 | PurchaseLimitedDropUseCase.kt#execute | LimitedDropDomainService.purchase 오케스트레이션. 앞단 대기열 게이트 없음 |
| 한정판 컨트롤러 | LimitedDropApiController.kt:29 | @ConditionalOnProperty("limited-drop.enabled") 부팅 토글(빈 등록 자체 토글, no-conditional-on-property 위반). POST /limited-drops/{dropId}/orders |
| 좌석 락 | SeatLockStoreImpl.kt#tryLock + RedisDistributedLock.kt#tryLock | SET NX PX 분산 락, TTL 300초. seat:lock:{eventId}:{seatId} |
| 티케팅 진입점 | SelectSeatsUseCase.kt#execute | TicketingDomainService.tryLockSeats. 부팅 토글 없음, POST /events/{id}/seats/select 항상 노출 |
| 피처 플래그 | FeatureFlagEvaluator.kt#isEnabled + FeatureContext.kt | domain.common 인터페이스. FeatureDemoDomainService.kt#greet가 isEnabled(key, context, default)로 소비하는 선례 존재 |
| 도메인 이벤트 | PaymentEvent.kt (event.payment.payment.v1) | sealed + @JsonTypeInfo(EXISTING_PROPERTY, "eventType") payload 판별, KafkaDomainEventPublisher가 aggregateId를 key로 발행 |
| 스케줄러 선례 | GuestExpiryScheduler.kt | presentation/{domain}/scheduler/에 위치, UseCase 경유, 플래그로 스킵, try-catch로 배치 스레드 보호 |
| 인터셉터 | (없음) | 레포에 WebMvcConfigurer/HandlerInterceptor 0건 — 신규 도입 |
핵심 공백: 순번 큐·중앙 admission 카운터·입장 토큰이 전혀 없다. 유입 제어 계층 부재.
TO-BE
flowchart LR subgraph Client["RN 앱"] Waiting[대기실 화면] end subgraph VQ["virtualqueue 컨텍스트 (신규)"] VQCtrl[VirtualQueueApiController] Pump[AdmissionPump 스케줄러] VQStore[(Redis queue:*)] end subgraph Gate["구매 앞단 게이트 (신규)"] Interceptor[EntryTokenGate Interceptor] end subgraph Down["기존 다운스트림 (불변)"] Purchase[PurchaseLimitedDrop / SelectSeats] LuaGate[(reserve.lua / seat:lock)] end Waiting -->|enter/poll| VQCtrl VQCtrl --> VQStore Pump -->|배치 전진·이탈방출| VQStore Waiting -->|입장토큰| Interceptor Interceptor -->|검증 통과| Purchase Purchase --> LuaGate
Architecture Benchmarking (의무)
| 제품/사례 | 해결 방식 | 참고할 패턴 | 미참고 사유 |
|---|---|---|---|
| Trip.com Flash Sale | CDN 정적 랜딩 → 가상 대기실이 2초당 배치 입장 → 재고당 토큰 1개 토큰 게이트 → Redis Lua 원자 차감 → Kafka 이행 분리 (medium) | 배치 admission 주기(2초) + 토큰 게이트 + 기존 Lua 원자 게이트 앞단 배치. 우리 구조와 1:1 대응 | CDN 정적 랜딩은 Non-Goal(배포 파이프라인 과제 소관) |
| Cloudflare Waiting Room | Durable Object Counter가 데이터센터 내 worker들의 admission 슬롯을 중앙 동기화. worker는 서로 직접 통신 없이 Counter로 클러스터 합계를 계산 (blog) | 중앙 카운터로 다중 인스턴스 admission 합계를 목표치 이내 유지. 우리의 Redis admitted_count + 분산 락 단일 전진이 이 “중앙 동기화 지점” 역할을 대체 | Durable Object(엣지 상태 프리미티브)는 우리 스택(모놀리스+Redis)에 없음 → Redis 원자 연산으로 등가 구현 |
| Queue-it | Redis Sorted Set 순번(ZADD/ZRANK, O(log N)) + 리더 선출 Admission Controller가 5~10초 주기로 다운스트림 충돌률을 읽어 배치 크기 동적 조정, Controller 사망 시 최근값 폴백 (queue-it) | 순번=Sorted Set, 주기적 배치 전진, fail-safe 폴백 | 별도 리더 선출 프레임워크(ZooKeeper/Raft)는 지금 규모에 과함 → Redis 분산 락(SET NX PX, 기존 RedisDistributedLock 재사용)으로 “틱마다 한 인스턴스만 전진”을 등가로 달성. 충돌률 기반 동적 배치 조정도 초기엔 고정값 + 부하 테스트 재조정으로 단순화 |
| Redis Lua rate-limit 패턴 | EVAL로 read-decide-update를 단일 왕복 원자 실행해 TOCTOU 경쟁 제거, Redis가 admission 단일 진실원 (callr) | admission 판정을 Lua로 원자화(admit.lua) — 기존 reserve.lua/UNLOCK_LUA 선례와 동일 | 순수 per-request 토큰 버킷은 “배치 주기 입장”(대기실 UX·ETA 계산)과 맞지 않아 배치 pump로 대체 |
Possible Solutions
방안 비교
| 방안 | 설명 | 왜 채택/미채택 |
|---|---|---|
| A. Redis 분산 락 배치 pump + Sorted Set 순번 + HMAC 토큰 (채택) | 순번은 enter.lua로 고정 시퀀스(INCR seq) 를 ZADD score로 부여(멱등). @Scheduled pump가 틱마다 Redis 분산 락을 잡은 단일 인스턴스만 admitted_count를 batch만큼 전진(admit.lua, 상한 seq). 입장은 상태 폴링 시 seq ≤ admitted_count(rank 아님 — §0-1 연쇄 admission 방지)면 HMAC 토큰 지연 발급(mint). 게이트는 구매 앞단 인터셉터가 HMAC 검증 | 채택. 기존 자산(RedisDistributedLock·Lua·FeatureFlagEvaluator) 최대 재사용, 리더 선출 프레임워크 없이 클러스터 단일 전진 보장, DB 스키마 변경 0. Trip.com·Cloudflare 패턴 정합 |
| B. 리더 선출 Admission Controller | ZooKeeper/Raft로 리더 1대를 뽑아 그 인스턴스만 admission 수행 | 미채택 — 지금 규모(로컬 docker 3 replica)에 리더 선출 인프라는 과한 운영 복잡도. Redis 분산 락이 “틱당 단일 실행”을 충분히 보장(락 미획득 인스턴스는 다음 틱 대기, 리더 사망=다음 틱 자동 승계) |
| C. 인스턴스 로컬 카운터 배치 | 각 인스턴스가 자기 세마포어로 배치 허용 | 미채택 — PRD NFR 명시 금지. N 인스턴스면 클러스터 admission이 N배로 목표치 초과. DropReservationStoreImpl 완충 세마포어의 “단일 인스턴스 전제” 한계를 그대로 답습 |
| D. SSE로 순번 푸시 | 대기 사용자마다 SSE 연결 유지, 순번 변동을 서버가 푸시 | 미채택 — 최대 10만 동시 대기 × 3 replica = 장수명 연결 폭증. LoadSheddingFilter 세마포어(200)가 장수명 요청에 고갈, FD/스레드 비용 과다. 폴링(2~3초, ZRANK O(log N), P95 300ms 예산)이 배치 주기(2초)와 자연 정합하고 heartbeat를 겸함 (Open Question 2 확정: 폴링 채택) |
| E. 상태 저장(opaque) 토큰 | 토큰=랜덤 UUID를 Redis 저장, 게이트가 Redis 조회로 검증 | 부분 채택(재사용 방지 마커에만). 순수 상태 저장 검증은 구매 hot path에 Redis 조회를 강제해 Redis 장애 시 검증 불가. HMAC 무상태 검증(Redis 불필요)을 주 검증으로, 재사용 방지 1회성 마커는 best-effort로만 |
Detail Design
도메인 바운디드 컨텍스트 경계 판단
결정: 신규 바운디드 컨텍스트 domain/virtualqueue 분리.
- 왜 분리(채택): ① 독립 라이프사이클 — 큐 진입/입장/토큰은 goods·ticketing 어느 도메인도 소유하지 않는 별개 관심사. ② 독립 데이터 소유 — 큐 상태 전부 Redis
queue:*, goods/ticketing 테이블과 무관. ③ 두 도메인이 공통으로 의존 — goods·ticketing 어느 한쪽에 얹으면 나머지가 그 도메인을 교차 참조하게 되어no-cross-context위반. ④ 의존 방향 정상 — goods/ticketing(주문 컨텍스트) → virtualqueue(공용 상위 게이트). virtualqueue는 대상을 ID(dropId/eventId) +QueueTargetType로만 안다, 역참조 0. - 미채택(기존 도메인 합류): goods 또는 ticketing에 합류 → 위 ③ 위반. common에 전부 담기 → common 비대화, 큐는 엔티티·UseCase·컨트롤러를 가진 온전한 컨텍스트라 common(순수 계약)에 안 맞음.
- 교차 접점은
domain.common으로만: goods/ticketing이 큐 컨텍스트를 호출하지 않는다. 구매 앞단 검증은domain.common.EntryTokenGuard(선례DistributedLock·FeatureFlagEvaluator와 동일 위치) 뒤에 숨긴다. 큐 관리(enter/status/admission)는 virtualqueue 자체 컨트롤러/스케줄러가 담당하며 goods/ticketing은 그 존재를 모른다.
서버 토폴로지 설계
결정: 단일 API 서버 프로세스 내 배치(별도 서버 신설 없음). 단, admission pump는 클러스터 단일 실행으로 논리 분리.
- 후보: (a) API 서버 + 별도 admission worker 서버 (b) 단일 API 서버에 스케줄러 내장. (b) 채택 — 지금 규모(
docker-compose.lb.ymlbackend 3 replica + nginx-lb)에서 별도 워커 서버는 운영·배포 단위만 늘린다. admission의 “단일 실행” 요구는 서버 분리가 아니라 Redis 분산 락으로 충족(3 replica 전부 스케줄러를 갖되 틱마다 1대만 락 획득). 폴링 조회는 요청-응답이라 API 서버가 담당, admission은 주기 실행이라 스케줄러가 담당 — 둘 다 같은 프로세스에 공존해도 무해. - 지속 20,000 TPS가 지표로 증명되면(아키텍처 진단 3단계) 별도 워커/MSA 분리를 재검토 — 이번 범위 밖.
시스템 역할 경계 (의무)
| 단위 | 레이어 | 역할 | 소유 데이터/책임 | 노출 인터페이스 | 의존 |
|---|---|---|---|---|---|
VirtualQueueApiController | presentation | enter/status/leave/stats REST 진입점 | 라우팅·헤더→Command 변환 | POST/GET/DELETE /virtual-queues/** | Enter/GetStatus/Leave/GetStats UseCase |
AdmissionPumpScheduler | presentation | 2초 주기 배치 admission·이탈 방출 트리거 | 스케줄·분산 락 획득 시도·배치 스레드 보호 | (@Scheduled) | RunAdmissionBatchUseCase, DistributedLock |
EntryTokenGateInterceptor | presentation | 구매 앞단 입장 토큰 검증 게이트 | 플래그 조회→토큰 검증→403 거부 | HandlerInterceptor | EntryTokenGuard, FeatureFlagEvaluator(common) |
VirtualQueueWebMvcConfig | infrastructure | 인터셉터를 2개 구매 경로에 등록 | path 매핑 | WebMvcConfigurer | 인터셉터 |
| Enter/GetStatus/Leave/GetStats/RunAdmissionBatch UseCase | application | 행위 1개 오케스트레이션 | @Transactional 불요(Redis) | execute(command) | VirtualQueue/Admission DomainService |
VirtualQueueDomainService | domain | 진입·순번·상태·이탈·통계 비즈니스 로직 | 멱등 진입·포화 거부·플래그 분기·상태 계산 | enter/status/leave/stats | VirtualQueueStore, EntryTokenIssuer, FeatureFlagEvaluator |
AdmissionDomainService | domain | 배치 전진 + 이탈 방출 | admitted_count 전진 상한·stale 방출 | runBatch(target) | VirtualQueueStore |
VirtualQueueStore | domain (iface) | 큐 상태 Redis 게이트웨이 | (계약) | 아래 시그니처 | — |
EntryTokenIssuer | domain (iface) | 입장 토큰 발급 | (계약) | issue(target, userId): EntryToken | — |
EntryTokenGuard | domain.common (iface) | 입장 토큰 검증(교차 도메인) | (계약) | verify(target, userId, rawToken): Boolean | — |
VirtualQueueStoreImpl | infrastructure | ZSET/카운터/Lua 실 구현 | Redis 접근·Lua 로드·fail-open 전파 | VirtualQueueStore | StringRedisTemplate |
HmacEntryTokenGateway | infrastructure | HMAC 서명·검증 + 1회성 마커 | 서명 비밀·재사용 방지 | EntryTokenIssuer+EntryTokenGuard | StringRedisTemplate, secret(env) |
VirtualQueueMetricsBinder | infrastructure | 큐 길이 게이지·admission rate 지표 | Micrometer 등록 | MeterBinder | VirtualQueueStore |
인터페이스 시그니처 (구현자 간 해석 차이 제거)
// domain/virtualqueue/vo
enum class QueueTargetType { LIMITED_DROP, TICKETING_EVENT }
data class QueueTarget(val type: QueueTargetType, val targetId: Long) // 다른 도메인은 ID(Long)만
// domain/virtualqueue/gateway/VirtualQueueStore.kt
interface VirtualQueueStore {
// 멱등 진입: enter.lua로 원자 처리 — 기존이면 기존 seq 유지, 신규면 INCR seq 채번 후 ZADD(score=seq).
// 반환=부여된 고정 시퀀스(seq, 1-based). 포화(ZCARD≥max) 시 null. (redis-contract §0-1: now(ms) score 폐기)
fun enterIfAbsent(target: QueueTarget, userId: Long, maxCapacity: Int): Long?
fun rankOf(target: QueueTarget, userId: Long): Int? // ZRANK — 표시용 aheadCount(동적, 이탈 시 전진). 없으면 null
fun seqOf(target: QueueTarget, userId: Long): Long? // ZSCORE — admission 판정용 고정 시퀀스(제거 영향 없음). 없으면 null
fun waitingSize(target: QueueTarget): Long // ZCARD (게이지용)
fun admittedCount(target: QueueTarget): Long // GET admitted_count (없으면 0)
fun touchHeartbeat(target: QueueTarget, userId: Long) // ZADD heartbeat score=now(ms)
fun leave(target: QueueTarget, userId: Long) // ZREM waiting+heartbeat, DEL token marker
fun advanceAdmission(target: QueueTarget, batchSize: Int): Long // admit.lua: min(count+batch, seq). seenTotal 원천=seq(ZCARD 아님, §0-2)
fun sweepStale(target: QueueTarget, staleBeforeEpochMs: Long, maxEvictPerTick: Int): Int // evict.lua: 상한 내 방출. 반환=방출 수
fun activeTargets(): Set<QueueTarget> // SMEMBERS queue:active
fun registerActive(target: QueueTarget) // SADD queue:active (enter.lua 내)
}
// domain/virtualqueue/gateway/EntryTokenIssuer.kt
interface EntryTokenIssuer {
// 멱등: 동일 (target,userId) 재발급 시 기존 토큰 반환(마커 SET NX EX 300). TTL 5분. Redis 의존
fun issueIfAbsent(target: QueueTarget, userId: Long): EntryToken
// fail-open 전용: HMAC 서명만 수행, Redis 미접근(멱등 마커 생략). Redis 장애에도 발급 가능 (§0-3)
fun mintStateless(target: QueueTarget, userId: Long): EntryToken
}
data class EntryToken(val raw: String, val expiresAt: ZonedDateTime)
// domain/common/EntryTokenGuard.kt (교차 도메인 검증 — DistributedLock 선례 위치)
interface EntryTokenGuard {
fun verify(targetType: String, targetId: Long, userId: Long, rawToken: String?): Boolean
}API 계약은 아래 “FE/외부 계약 — API 명세” 섹션에서 시그니처 수준으로 확정한다.
클래스 역할 정의
도메인 모델 (Rich Domain — Redis 상태의 순수 계산·검증 캡슐화)
| 클래스명 | 역할 | 핵심 책임 |
|---|---|---|
QueueTarget | 대기열 대상 VO | type+targetId 캡슐화, Redis 키 접두 구성 위임 |
QueuePosition | 순번·ETA 계산 VO | of(rank, seq, admittedCount, batchSize, tickSeconds) → admitted = seq <= admittedCount(고정 시퀀스 기준, §0-1), aheadCount = rank(표시용 동적값), etaSeconds 계산. 판정과 표시의 입력을 분리 — 외부에서 값 꺼내 계산 금지 |
EntryToken | 입장 토큰 VO | raw + expiresAt, isExpired() 캡슐화 |
QueueStatus | 상태 응답 도메인 표현 | WAITING/ADMITTED + position + token 조합, 상태 전이 판단 |
서비스 클래스
| 클래스명 | 역할 | 입력 → 출력 | 의존 |
|---|---|---|---|
VirtualQueueDomainService | 진입·상태·이탈·통계 | Command → QueueStatus/QueueStats | Store, Issuer, FeatureFlagEvaluator |
AdmissionDomainService | 배치 전진+이탈 방출 | QueueTarget → 전진/방출 수 | Store |
VirtualQueueDomainService.status가 핵심 로직: heartbeat 갱신 → rank(ZRANK, 표시용)·seq(ZSCORE, 판정용)·admittedCount 조회 →QueuePosition.of(rank, seq, admittedCount, ...)계산 →admitted(seq ≤ admittedCount)면EntryTokenIssuer.issueIfAbsent+store.leave(큐에서 제거해 재계수 방지) →QueueStatus반환. admission 판정을 고정 seq로 하므로, 앞선 사용자가 leave로 빠져 rank가 붕괴해도 뒤 사용자가 다음 틱 전에 연쇄 admission되지 않는다(§0-1 블로킹 결함 교정). UseCase는 이 한 메서드만 호출(≤10줄).if+throw·상태 비교는 전부 도메인 내부.- 플래그 분기:
enter에서featureFlagEvaluator.isEnabled(FLAG_KEY, FeatureContext.of(userId), false)— OFF면 즉시QueueStatus.directEntry(token=즉시발급)반환(대기 없이 통과). ON이면 정상 큐 진입. 인터셉터도 같은 플래그를 조회해 OFF면 검증 스킵 → 양쪽 경로 일관.
실패 경로·동시성·멱등 (해피 패스만 있는 설계는 미완성)
| 관심사 | 설계 |
|---|---|
| Redis 장애 (fail-open, OQ4 확정) | Store가 DataAccessException을 삼키지 않고 전파(RedisDistributedLock·DropReservationStore 선례). DomainService enter/status가 catch → redis-degraded 지표+알림 후 directEntry 폴백. 폴백 토큰 발급은 issueIfAbsent(내부 SET NX가 Redis 의존이라 fail-open에서 재실패)가 아니라 EntryTokenIssuer.mintStateless(HMAC 서명만, Redis 미접근, §0-3) 로 한다. 인터셉터도 HMAC 무상태 검증이라 Redis 없이 기발급 토큰 검증 가능. 최종 안전판은 Redis 오버셀 게이트가 아니라 MySQL이다(§0-4) — 한정판 Stock.@Version 낙관적 락, 티케팅 tickets.uk_tickets_active_seat 유니크 제약(V15__create_ticket_orders_tickets.sql:45-49)이 오버셀을 막는다. reserve.lua/seat:lock은 같은 Redis 인스턴스라 함께 죽으므로 안전 근거가 될 수 없다. 5xx 유발 없음 → 5xx<1% 유지. 트레이드오프: 유입 제어 일시 상실 + 좌석 조기 거부 상실(중복 선택은 결제 단계 DB 제약에서 1명만 성공) |
| admission 동시성 (클러스터 단일) | pump가 DistributedLock.tryLock("queue:admission:{type}:{id}", instanceId, PX 1900ms) 획득 시에만 전진. 락 TTL(1.9초) < 틱(2초)이라 다음 틱 전 자동 해제. 락 미획득 인스턴스는 스킵. 최악(락 소실로 2대 동시 전진) 시 admitted가 일시 초과 → 다운스트림 트래픽만 증가, 오버셀은 MySQL이 차단. admit.lua가 min(count+batch, seq)로 상한(seenTotal 원천=seq, §0-2) |
| 진입 멱등 (FR-2) | enter.lua 원자 처리 — 동일 userId 재진입 시 기존 seq 유지(새 채번 없음). 반환 seq 동일. 신규만 INCR seq로 고정 순번 채번(동시각 다건 진입의 score 동률·FIFO 붕괴 방지) |
| 토큰 발급 멱등 | SET NX token:{userId} <tokenRaw> EX 300 — 재폴링 시 동일 토큰 반환. 이중 발급 없음. (fail-open 경로는 mintStateless라 마커 생략) |
| 토큰 재사용 방지 | best-effort 1회성 마커(구매 성공 시 소진 표시). Redis 장애 시 검증은 HMAC+만료로만(재사용 방지는 degrade) — MySQL 최종 정합 방어가 오버셀을 막으므로 허용 |
| 이탈 (FR-8) | pump가 sweepStale로 heartbeat < now-60s인 member를 waiting에서 상한(maxEvictPerTick, §2) 내 ZREM → 뒤 순번(rank) 자연 전진. seq는 불변이라 admission 판정에 영향 없음. 재진입 시 새 seq(맨 뒤) |
| at-least-once 틱 | 틱 중복 실행돼도 advanceAdmission 상한·sweepStale 멱등(이미 제거된 member ZREM은 no-op) |
| 순서 | 발행 순서 의존 설계 없음. 순번 순서는 waiting ZSET의 진입 시퀀스 score(INCR) 가 단일 진실 — 진입 시각(ms)이 아님(20,000 TPS 동시각 다건의 score 동률 방지) |
상태 전이 표
| 현재 상태 × 이벤트 | 다음 상태 | 거부/비고 |
|---|---|---|
| (없음) × enter(플래그 ON, 미포화) | WAITING | 고정 seq 부여(INCR) |
| (없음) × enter(플래그 ON, 포화 ZCARD≥10만) | 거부 | 429 “잠시 후 재시도”(FR-7) |
| (없음) × enter(플래그 OFF) | DIRECT_ADMITTED | 대기 없이 토큰 즉시 발급 |
| WAITING × enter(재요청, 멱등) | WAITING | 기존 seq 반환(FR-2), 새 순번 없음 |
| WAITING × poll(seq ≤ admittedCount) | ADMITTED | 토큰 발급 + 큐에서 제거 (rank 아님 — §0-1) |
| WAITING × poll(seq > admittedCount) | WAITING | heartbeat 갱신, aheadCount(rank)/ETA 갱신 |
| WAITING × heartbeat 60s 미갱신(pump sweep) | EVICTED | 큐에서 제거, 재진입 시 새 순번(FR-8) |
| ADMITTED × 구매 완료(토큰 검증 통과) | (종료) | 다운스트림 진입 |
| ADMITTED × 토큰 TTL 5분 초과 | EXPIRED | 구매 API 403, 재진입 필요(FR-4) |
| ADMITTED/무 × 구매 API 호출(토큰 없음·위조·만료) | 거부 | 403 “대기열 우회”(FR-5) |
티케팅 게이트 위치 (OQ3 확정)
결정: 좌석 선택(SelectSeatsUseCase) 앞에만 게이트. 구매 확정(PurchaseTicketsUseCase) 앞에는 게이트하지 않음.
- 좌석 선택이 최초 희소 자원(좌석 락 TTL 300초) 경합 지점 — 여기서 유입을 조이면 충분. 구매 확정은 이미 좌석 락을 보유한 사용자만 도달하므로 추가 대기열 불필요.
- 재대기 정책: 입장 토큰 TTL(5분) = 좌석 락 TTL(300초) 정합(PRD NFR). 좌석 선택 후 결제까지 시간이 걸려 입장 토큰이 만료돼도,
PurchaseTicketsUseCase는 좌석 락 소유 로 보호되므로(토큰 게이트 없음) 재대기 없이 결제 진행 가능. 좌석 락까지 만료되면 좌석을 잃고 재선택=재대기 — 이는 기존 좌석 락 정책 그대로.
Component Diagram (Mermaid flowchart LR)
flowchart LR subgraph Presentation Ctrl[VirtualQueueApiController] Pump[AdmissionPumpScheduler] Intc[EntryTokenGateInterceptor] end subgraph Application EnterUC[EnterQueueUseCase] StatusUC[GetQueueStatusUseCase] BatchUC[RunAdmissionBatchUseCase] end subgraph Domain VQDS[VirtualQueueDomainService] ADS[AdmissionDomainService] Store[VirtualQueueStore iface] Issuer[EntryTokenIssuer iface] Guard[common.EntryTokenGuard iface] end subgraph Infrastructure StoreImpl[VirtualQueueStoreImpl] Hmac[HmacEntryTokenGateway] end Ctrl --> EnterUC Ctrl --> StatusUC Pump --> BatchUC EnterUC --> VQDS StatusUC --> VQDS BatchUC --> ADS VQDS --> Store VQDS --> Issuer ADS --> Store Intc --> Guard StoreImpl -.implements.-> Store Hmac -.implements.-> Issuer Hmac -.implements.-> Guard
Sequence Diagram — 폴링 입장 + 구매 게이트
sequenceDiagram participant App as RN 대기실 participant Ctrl as VirtualQueueApiController participant DS as VirtualQueueDomainService participant Redis as Redis queue:* participant Pump as AdmissionPump participant Intc as EntryTokenGate participant Buy as Purchase/SelectSeats App->>Ctrl: POST enter (X-User-Id) Ctrl->>DS: enter(target,userId) DS->>Redis: enter.lua (INCR seq + ZADD score=seq) Redis-->>App: seq(고정 순번) loop 2~3초 폴링 App->>Ctrl: GET status Ctrl->>DS: status(target,userId) DS->>Redis: heartbeat + ZSCORE(seq) + ZRANK(ahead) + admitted_count Pump->>Redis: (2초) admit.lua 전진(상한 seq) + evict.lua alt seq <= admittedCount DS->>Redis: issue token(SET NX) + leave DS-->>App: ADMITTED + entryToken else DS-->>App: WAITING + aheadCount(rank) + ETA end end App->>Intc: 구매 요청 + X-Entry-Token Intc->>Intc: HMAC 검증 + 만료 확인 Intc->>Buy: 통과 (또는 403)
ERD
해당 없음 — 신규 DB 테이블 0개. 큐 상태(순번·admission·heartbeat·토큰)는 전부 Redis에 보관한다. Stock.@Version·seat:lock이 최종 오버셀 방어이고 큐는 유입 제어만 담당하므로 큐 상태의 영속화(RDB)는 불필요하다. 아래 Redis 키 계약이 ERD를 대신한다.
Redis 키 계약 (design-db 대신 — 상세는 BE-01 산출 virtual-queue-keys.md)
전체 계약·raw 검증 로그는 20260709-redis-contract.md가 SSOT. 요약:
| 키 패턴 | 자료구조 | TTL | 무효화 | 근거 |
|---|---|---|---|---|
queue:{type}:{id}:waiting | Sorted Set (score=진입 시퀀스(seq), member=userId) | 30분 sliding(enter/admit/evict/heartbeat마다 PEXPIRE) | pump sweep·leave·admit 시 ZREM | ZSCORE(admission 판정, 고정)·ZRANK(aheadCount 표시, 동적). 시퀀스 score가 순번 진실원(§0-1, epoch ms 폐기 — 동시각 다건 동률 방지) |
queue:{type}:{id}:heartbeat | Sorted Set (score=마지막 폴링 ms) | 위와 동일 | sweep 시 ZREM | 이탈 판정(60s). waiting과 score 의미 상이 → 분리 |
queue:{type}:{id}:admitted_count | String(정수 고수위) | 위와 동일 | pump admit.lua가 SET(전진) | 클러스터 admission 합계 단일 진실. 인스턴스 로컬 금지 |
queue:{type}:{id}:seq | String(정수 INCR) | 위와 동일 | 진입마다 INCR(단조) | 신설(§0-1/§0-2). 고정 순번 채번 원천 + admit.lua 전진 상한(seenTotal) — 두 역할 한 키 통일 |
queue:{type}:{id}:token:{userId} | String(토큰 raw, 멱등·재사용 마커) | 300초 고정 | 발급 SET NX, 소진 시 표시 | 토큰 이중 발급·재사용 방지. fail-open은 mintStateless라 이 키 미사용 |
queue:active | Set(member={type}:{id}) | 없음(pump가 seq 만료 확인 시 SREM 정리) | enter 시 SADD | pump 활성 대상 순회(KEYS/SCAN 회피) |
queue:admission:{type}:{id} | String (SET NX PX 락) | 1900ms | 락 자연 만료 | pump 클러스터 단일 전진 |
- enter/admit/evict는 원자성이 필요해 Lua(
enter.lua/admit.lua/evict.lua)로 처리 —enter.lua는 “기존 여부 확인 → 없으면 INCR seq → ZADD”를 한 번에 원자 실행(동시 신규 진입의 시퀀스 낭비·경쟁 방지, §2).admit.luaseenTotal 상한=seq(ZCARD 아님).evict.lua는maxEvictPerTick상한(대량 동시 이탈 시 Redis 단일 스레드 블로킹 방지). - eviction 정책은
noeviction유지(변경 안 함) — 같은 인스턴스의 오버셀 게이트 키(limited-drop·seat:lock)가 evict 대상이 되면 오버셀 위험이 새로 생기므로. prodmaxmemory상향(512MB 이상,docker-compose.prod.yml에redisoverride 신설)이 필요(대상 2개 동시 최대치 ~60MB 실측, Release Scenario·선행 인프라 작업 참조).
Testing Plan
| 레벨 | 대상 | 케이스 |
|---|---|---|
| domain (Kotest BehaviorSpec + MockK) | QueuePosition.of, EntryToken.isExpired, VirtualQueueDomainService, AdmissionDomainService | ETA/ahead 계산, 멱등 진입, 포화 거부, 플래그 OFF directEntry, admitted 시 토큰 발급, Redis 예외 fail-open, 배치 전진 상한, stale 방출 |
| application (Kotest + MockK) | Enter/GetStatus/Leave/GetStats/RunAdmissionBatch UseCase | DomainService 위임만 검증(execute ≤10줄) |
| infrastructure (Kotest + Testcontainers Redis) | VirtualQueueStoreImpl(enter.lua/admit.lua/evict.lua), HmacEntryTokenGateway | enter.lua seq 채번·멱등, ZSCORE/ZRANK 분리, admission 후 leave에도 뒤 사용자 연쇄 미admission(§0-1 회귀), advance 상한=seq, sweep maxEvictPerTick, HMAC 라운드트립·위조·만료 거부, mintStateless(Redis 미접근) |
| presentation (Kotest + MockMvc/Testcontainers) | Controller, EntryTokenGateInterceptor, 스케줄러 | enter/status API, 포화 429, 우회 403, 플래그 OFF 스킵, 분산 락 단일 전진 |
| scenario (E2E) | enter→poll→admit→token→구매 (goods + ticketing 양 경로), 플래그 ON/OFF, Redis 장애 fail-open, 풀부팅 1개(빈 충돌 방지 — MEMORY 교훈) | 전 흐름 |
핵심 실패 경로 시나리오: Redis 다운 시 전 구매가 5xx 없이 통과, 위조/만료 토큰 403, admission 2배 전진 시에도 오버셀 0, 60초 미폴링 사용자 방출 후 뒤 순번 전진.
Release Scenario — 무중단 배포 (의무)
전제: 순수 추가 + 피처 플래그 OFF 기본. DB 마이그레이션 없음 → expand-contract 불필요.
| 단계 | 내용 | 전환 조건 | 롤백 |
|---|---|---|---|
| 0. prod Redis maxmemory 상향 (선행, owner: INFRA-01) | docker-compose.prod.yml에 redis override 신설 — --maxmemory 512mb 이상, noeviction 유지. 1단계 코드 배포의 필수 선행 조건 | prod compose CONFIG GET maxmemory ≥ 512mb·maxmemory-policy=noeviction 확인 | 이전 compose로 되돌려 재기동(base 256mb 복귀) |
| 1. 코드 배포(플래그 OFF) | virtualqueue 컨텍스트·인터셉터·스케줄러 전부 배포. 플래그 virtual-queue.enabled 기본 false → 인터셉터 검증 스킵·enter는 directEntry 반환. 기존 동작 무변화 | 0단계 완료 + main 머지 → dev 배포(무게이트) | 이전 태그 재기동(추가 코드라 롤백 안전) |
| 2. 카나리 ON(퍼센티지) | FeatureFlagEvaluator 퍼센티지 롤아웃으로 대상 회차·소수 userId만 대기열 경유. FeatureContext.of(userId) 버케팅 | 대기실 지표(admission rate·이탈률) 정상 | 플래그 OFF(3초, PRD NFR) — 배포 없이 직접 구매 복귀 |
| 3. 전량 ON | 판매 시작 회차에 100% ON | 부하 테스트·카나리 통과 | 플래그 OFF |
| 4. 튜닝 | batchSize/tick를 상시 트래픽 시뮬레이터 결과로 재조정(설정값, 무배포) | — | 이전 설정값 |
- 배포 순서: 코드 먼저(스키마 없음). Redis 키는 런타임 생성(추가적, 사전 시드 불요).
- prod Redis maxmemory 선행 반영 (owner: INFRA-01, 위 0단계): 코드 배포(1단계) 전에
docker-compose.prod.yml에redis서비스 override를 신설해--maxmemory 512mb이상(현재 base 256MB만 상속, prod에 redis 섹션 부재)으로 상향한다.noeviction은 유지(완화 시 오버셀 게이트 키가 evict 대상). 미반영 시 마케팅 피크에 대기열 자신의 메모리가 OOM을 유발해 자기 fail-open되는 자기잠식 위험(정합성은 MySQL이 지키나 유입 제어 목적 상실). 실측: 대상 2개 동시 최대 ~60MB(20260709-redis-contract.md§4). 티켓:tickets/INFRA-01-prod-redis-maxmemory-override.md. limited-drop.enabled(부팅 토글)와 독립: 한정판은 부팅 토글 ON 상태에서만 대기열 플래그가 유효(FR-9). 티케팅은 대기열 플래그가 유일 축. 부팅 토글의 런타임 이관은 Non-Goal(PRD Open Questions).- 플래그 제거 시점: 기능 안정화 후 directEntry 분기·플래그 조회를 유지할지(영구 안전장치) 결정 — 대기열은 상시 롤백 장치로 남기는 것을 권장(제거 안 함).
데이터 마이그레이션 계획
해당 없음 — 신규 컬럼·테이블·백필 없음. 큐 상태는 전부 Redis 런타임 생성이라 마이그레이션·듀얼라이트·배치 백필이 필요 없다.
Security Information (OQ5 확정)
- 입장 토큰 위변조 방지: HMAC-SHA256 서명. payload =
{targetType}|{targetId}|{userId}|{expiresAtEpochSec},token = base64url(payload) + "." + base64url(HMAC_SHA256(secret, payload)). - 비밀 관리:
virtual-queue.token.secret을 환경 변수로 주입(코드·로그·응답에 절대 미노출). 로컬 dev와 prod compose에서 상이한 값. 미설정 시 부팅 실패(빈 검증)로 약한 기본값 방지. - 검증(인터셉터): ① 서명 일치 ② 만료(
expiresAt > now) ③ path의 targetId·X-User-Id헤더와 payload 일치. 하나라도 불일치 → 403(대기열 우회). 무상태 검증이라 Redis 없이 동작(장애 내성). - fail-open 발급: Redis 장애 시 폴백 토큰은
mintStateless(HMAC 서명만, Redis 미접근)로 발급한다 —issueIfAbsent의SET NX가 fail-open에서 재실패하는 것을 방지(§0-3). 재사용 방지 마커는 이 경로에서 생략(이미 best-effort). - 재사용 방지: best-effort 1회성 마커(Redis). Redis 장애 시 재사용 방지만 degrade(HMAC+만료는 유효). 최종 오버셀 방어는 Redis 게이트가 아니라 MySQL(§0-4) — 한정판
Stock.@Version, 티케팅uk_tickets_active_seat유니크 제약이 이중 발급을 봉쇄한다. - 권한:
/virtual-queues/**는 기존X-User-Id헤더 관례(/limited-drops/**동일)로permitAll+ 헤더 식별. 봇·매크로·캡차는 Non-Goal(PRD).
Observability
- 지표(Micrometer → 옵저버빌리티 스택, 소스 태그
virtual-queue):virtual_queue.length(게이지, ZCARD/타깃),virtual_queue.admission_rate(전진/초),virtual_queue.token(발급/소진/만료 카운터),virtual_queue.wait_seconds(Timer P50/P95/P99),virtual_queue.bypass_attempt(인터셉터 403 카운터),virtual_queue.redis_degraded(fail-open 카운터). - 신규 Kafka 토픽 없음(단순함 우선): FR-12 이탈률 등 집계 지표는 Micrometer 카운터 + 옵저버빌리티 스택으로 산출. 무관한 구독 도메인이 아직 없어 Layer 2(Kafka) 미도입. 후에 churn-analytics 도메인이 원시 이벤트 스트림을 요구하면
event.virtualqueue.entry.v1로 승격(그때 판단). - 알림(지능형 장애 알림, 소스
virtual-queue): 큐 길이 > 최대의 90%, admission rate가 목표 대비 50% 이하 3분 지속, 우회 시도 임계 초과. 오버셀 감시(소스oversell)는 기존 기준 그대로.
FE/외부 계약 — API 명세 (FE 시니어가 대기실 화면 설계 근거)
{type} ∈ limited-drop | ticketing-event. 인증: X-User-Id 헤더(기존 관례).
1. 대기열 진입 — POST /virtual-queues/{type}/{targetId}/entries
Request Header: X-User-Id: Long. Body: 없음.
Response 200 (QueueEntryResponse):
{
"status": "WAITING" | "ADMITTED" | "DIRECT_ADMITTED", // String enum
"position": Long?, // 순번(1-based), ADMITTED/DIRECT는 null
"aheadCount": Long?, // 앞선 대기 인원, WAITING만
"etaSeconds": Long?, // 예상 대기 초, WAITING만
"entryToken": String?, // ADMITTED/DIRECT_ADMITTED일 때만
"tokenExpiresAt": String? // ISO-8601, entryToken 있을 때
}
Response 429 (QueueFullResponse): { "code": "QUEUE_FULL", "detail": "잠시 후 다시 시도" } (FR-7)
2. 순번·상태 조회(폴링+heartbeat) — GET /virtual-queues/{type}/{targetId}/entries/me
Request Header: X-User-Id: Long. Response 200: 위 QueueEntryResponse와 동일 스키마. 클라이언트는 2~3초 간격 폴링(이 호출이 heartbeat 겸함). status=ADMITTED면 entryToken을 저장하고 구매 화면 전환(FR-10). P95 300ms.
Response 404: 큐에 없음(이탈·미진입) → FE는 재진입 유도.
3. 대기열 이탈(선택) — DELETE /virtual-queues/{type}/{targetId}/entries/me
Response 204. 명시적 이탈. 미호출 시에도 60초 heartbeat 미갱신으로 자동 방출(FR-8).
4. 운영자 통계(FR-11) — GET /virtual-queues/{type}/{targetId}/stats
Response 200 (QueueStatsResponse):
{
"waitingCount": Long, // 현재 대기 인원
"admittedCount": Long, // 누적 입장 허용
"admissionRatePerSec": Double,
"avgWaitSeconds": Double,
"p95WaitSeconds": Double
}
5. 구매 API 게이트(기존 엔드포인트에 헤더 추가 — 인터셉터가 검증)
POST /limited-drops/{dropId}/orders와POST /events/{id}/seats/select요청에X-Entry-Token: String헤더 추가. 플래그 ON + 토큰 부재/위조/만료 → 403{ "code": "QUEUE_BYPASS_DENIED" }(FR-5). 플래그 OFF → 헤더 불요(기존과 동일). 기존 Request Body·경로는 불변 — FE는 헤더만 추가.
Open Questions — TDD 확정 결론
| # | 질문 | 결론 |
|---|---|---|
| 1 | admission 주기·인원, 클러스터 합계 유지 | 2초당 100명(per-target), Redis 분산 락 배치 pump(리더 선출 아님). admit.lua가 admitted_count를 상한 내 전진. batchSize/tick은 설정값, 부하 테스트 재조정 |
| 2 | 폴링 vs SSE | 폴링(2~3초). SSE는 10만 장수명 연결·LoadShedding 고갈로 미채택. 폴링이 heartbeat 겸함 |
| 3 | 티케팅 게이트 위치 | 좌석 선택 앞에만. 구매 확정은 좌석 락 소유로 보호돼 재게이트 불요. 토큰 TTL=좌석 락 TTL 정합 |
| 4 | Redis 장애 폴백 | fail-open(전원 통과). 안전 근거는 Redis 게이트가 아니라 MySQL 최종 정합 방어(Stock.@Version·uk_tickets_active_seat, §0-4)가 오버셀을 막는다는 것. 5xx<1% 유지. 폴백 토큰은 mintStateless. 트레이드오프: 유입 제어 + 좌석 조기 거부 일시 상실(가용성 우선) |
| 5 | 토큰 위변조 방지 | HMAC-SHA256 무상태 서명 + env 비밀 + best-effort 1회성 마커 |
| — | 부팅 토글 런타임 이관 | Non-Goal(PRD). 이번 미이관 |
| — | 신규 Kafka 토픽 | 미도입(구독 도메인 부재). Micrometer로 집계. 후속 승격 여지 |
Document History
| 날짜 | 변경 내용 |
|---|---|
| 2026-07-09 | 최초 작성 — PRD FR-1~12·NFR 근거. 신규 domain/virtualqueue 컨텍스트, Redis 분산 락 배치 pump + Sorted Set 순번 + HMAC 토큰 게이트 확정. Open Questions 5건 결론. DB 스키마 변경 0·신규 Kafka 토픽 0(단순함 우선). BE 티켓 11건 분해(별첨) |
| 2026-07-09 | Redis 계약 리뷰(20260709-redis-contract.md §0) 블로킹 4건 교정: (1) admission 판정을 ZRANK→고정 seq(ZSCORE) 로 변경(leave 후 rank 붕괴에 의한 연쇄 과다 admission 차단), seq 키·enter.lua 신설 (2) admit.lua seenTotal 원천 ZCARD→seq (3) fail-open 토큰 발급을 EntryTokenIssuer.mintStateless(Redis 미접근)로 분리 (4) fail-open 안전 근거를 “Redis 오버셀 게이트”→“MySQL(Stock.@Version·uk_tickets_active_seat)“로 정정. evict.lua maxEvictPerTick·prod maxmemory override 반영 |