가상 대기열(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 Pump2초 주기로 admitted_count를 batch만큼 전진시키고 이탈자를 방출하는 스케줄 태스크. Redis 분산 락으로 클러스터 단일 실행
Heartbeat순번 조회(폴링)가 겸하는 생존 신호. 60초 미갱신 시 이탈 판정
Target (대상)대기열이 걸리는 단위. LIMITED_DROP:{dropId} 또는 TICKETING_EVENT:{eventId}
Fail-openRedis 장애 시 대기열을 우회해 다운스트림으로 직접 통과. 오버셀은 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#executeLimitedDropDomainService.purchase 오케스트레이션. 앞단 대기열 게이트 없음
한정판 컨트롤러LimitedDropApiController.kt:29@ConditionalOnProperty("limited-drop.enabled") 부팅 토글(빈 등록 자체 토글, no-conditional-on-property 위반). POST /limited-drops/{dropId}/orders
좌석 락SeatLockStoreImpl.kt#tryLock + RedisDistributedLock.kt#tryLockSET NX PX 분산 락, TTL 300초. seat:lock:{eventId}:{seatId}
티케팅 진입점SelectSeatsUseCase.kt#executeTicketingDomainService.tryLockSeats. 부팅 토글 없음, POST /events/{id}/seats/select 항상 노출
피처 플래그FeatureFlagEvaluator.kt#isEnabled + FeatureContext.ktdomain.common 인터페이스. FeatureDemoDomainService.kt#greetisEnabled(key, context, default)로 소비하는 선례 존재
도메인 이벤트PaymentEvent.kt (event.payment.payment.v1)sealed + @JsonTypeInfo(EXISTING_PROPERTY, "eventType") payload 판별, KafkaDomainEventPublisheraggregateId를 key로 발행
스케줄러 선례GuestExpiryScheduler.ktpresentation/{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 SaleCDN 정적 랜딩 → 가상 대기실이 2초당 배치 입장 → 재고당 토큰 1개 토큰 게이트 → Redis Lua 원자 차감 → Kafka 이행 분리 (medium)배치 admission 주기(2초) + 토큰 게이트 + 기존 Lua 원자 게이트 앞단 배치. 우리 구조와 1:1 대응CDN 정적 랜딩은 Non-Goal(배포 파이프라인 과제 소관)
Cloudflare Waiting RoomDurable Object Counter가 데이터센터 내 worker들의 admission 슬롯을 중앙 동기화. worker는 서로 직접 통신 없이 Counter로 클러스터 합계를 계산 (blog)중앙 카운터로 다중 인스턴스 admission 합계를 목표치 이내 유지. 우리의 Redis admitted_count + 분산 락 단일 전진이 이 “중앙 동기화 지점” 역할을 대체Durable Object(엣지 상태 프리미티브)는 우리 스택(모놀리스+Redis)에 없음 → Redis 원자 연산으로 등가 구현
Queue-itRedis 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 ControllerZooKeeper/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.yml backend 3 replica + nginx-lb)에서 별도 워커 서버는 운영·배포 단위만 늘린다. admission의 “단일 실행” 요구는 서버 분리가 아니라 Redis 분산 락으로 충족(3 replica 전부 스케줄러를 갖되 틱마다 1대만 락 획득). 폴링 조회는 요청-응답이라 API 서버가 담당, admission은 주기 실행이라 스케줄러가 담당 — 둘 다 같은 프로세스에 공존해도 무해.
  • 지속 20,000 TPS가 지표로 증명되면(아키텍처 진단 3단계) 별도 워커/MSA 분리를 재검토 — 이번 범위 밖.

시스템 역할 경계 (의무)

단위레이어역할소유 데이터/책임노출 인터페이스의존
VirtualQueueApiControllerpresentationenter/status/leave/stats REST 진입점라우팅·헤더→Command 변환POST/GET/DELETE /virtual-queues/**Enter/GetStatus/Leave/GetStats UseCase
AdmissionPumpSchedulerpresentation2초 주기 배치 admission·이탈 방출 트리거스케줄·분산 락 획득 시도·배치 스레드 보호(@Scheduled)RunAdmissionBatchUseCase, DistributedLock
EntryTokenGateInterceptorpresentation구매 앞단 입장 토큰 검증 게이트플래그 조회→토큰 검증→403 거부HandlerInterceptorEntryTokenGuard, FeatureFlagEvaluator(common)
VirtualQueueWebMvcConfiginfrastructure인터셉터를 2개 구매 경로에 등록path 매핑WebMvcConfigurer인터셉터
Enter/GetStatus/Leave/GetStats/RunAdmissionBatch UseCaseapplication행위 1개 오케스트레이션@Transactional 불요(Redis)execute(command)VirtualQueue/Admission DomainService
VirtualQueueDomainServicedomain진입·순번·상태·이탈·통계 비즈니스 로직멱등 진입·포화 거부·플래그 분기·상태 계산enter/status/leave/statsVirtualQueueStore, EntryTokenIssuer, FeatureFlagEvaluator
AdmissionDomainServicedomain배치 전진 + 이탈 방출admitted_count 전진 상한·stale 방출runBatch(target)VirtualQueueStore
VirtualQueueStoredomain (iface)큐 상태 Redis 게이트웨이(계약)아래 시그니처
EntryTokenIssuerdomain (iface)입장 토큰 발급(계약)issue(target, userId): EntryToken
EntryTokenGuarddomain.common (iface)입장 토큰 검증(교차 도메인)(계약)verify(target, userId, rawToken): Boolean
VirtualQueueStoreImplinfrastructureZSET/카운터/Lua 실 구현Redis 접근·Lua 로드·fail-open 전파VirtualQueueStoreStringRedisTemplate
HmacEntryTokenGatewayinfrastructureHMAC 서명·검증 + 1회성 마커서명 비밀·재사용 방지EntryTokenIssuer+EntryTokenGuardStringRedisTemplate, secret(env)
VirtualQueueMetricsBinderinfrastructure큐 길이 게이지·admission rate 지표Micrometer 등록MeterBinderVirtualQueueStore

인터페이스 시그니처 (구현자 간 해석 차이 제거)

// 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대기열 대상 VOtype+targetId 캡슐화, Redis 키 접두 구성 위임
QueuePosition순번·ETA 계산 VOof(rank, seq, admittedCount, batchSize, tickSeconds)admitted = seq <= admittedCount(고정 시퀀스 기준, §0-1), aheadCount = rank(표시용 동적값), etaSeconds 계산. 판정과 표시의 입력을 분리 — 외부에서 값 꺼내 계산 금지
EntryToken입장 토큰 VOraw + expiresAt, isExpired() 캡슐화
QueueStatus상태 응답 도메인 표현WAITING/ADMITTED + position + token 조합, 상태 전이 판단

서비스 클래스

클래스명역할입력 → 출력의존
VirtualQueueDomainService진입·상태·이탈·통계Command → QueueStatus/QueueStatsStore, 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.luamin(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)WAITINGheartbeat 갱신, 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}:waitingSorted Set (score=진입 시퀀스(seq), member=userId)30분 sliding(enter/admit/evict/heartbeat마다 PEXPIRE)pump sweep·leave·admit 시 ZREMZSCORE(admission 판정, 고정)·ZRANK(aheadCount 표시, 동적). 시퀀스 score가 순번 진실원(§0-1, epoch ms 폐기 — 동시각 다건 동률 방지)
queue:{type}:{id}:heartbeatSorted Set (score=마지막 폴링 ms)위와 동일sweep 시 ZREM이탈 판정(60s). waiting과 score 의미 상이 → 분리
queue:{type}:{id}:admitted_countString(정수 고수위)위와 동일pump admit.lua가 SET(전진)클러스터 admission 합계 단일 진실. 인스턴스 로컬 금지
queue:{type}:{id}:seqString(정수 INCR)위와 동일진입마다 INCR(단조)신설(§0-1/§0-2). 고정 순번 채번 원천 + admit.lua 전진 상한(seenTotal) — 두 역할 한 키 통일
queue:{type}:{id}:token:{userId}String(토큰 raw, 멱등·재사용 마커)300초 고정발급 SET NX, 소진 시 표시토큰 이중 발급·재사용 방지. fail-open은 mintStateless라 이 키 미사용
queue:activeSet(member={type}:{id})없음(pump가 seq 만료 확인 시 SREM 정리)enter 시 SADDpump 활성 대상 순회(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.lua seenTotal 상한=seq(ZCARD 아님). evict.luamaxEvictPerTick 상한(대량 동시 이탈 시 Redis 단일 스레드 블로킹 방지).
  • eviction 정책은 noeviction 유지(변경 안 함) — 같은 인스턴스의 오버셀 게이트 키(limited-drop·seat:lock)가 evict 대상이 되면 오버셀 위험이 새로 생기므로. prod maxmemory 상향(512MB 이상, docker-compose.prod.ymlredis override 신설)이 필요(대상 2개 동시 최대치 ~60MB 실측, Release Scenario·선행 인프라 작업 참조).

Testing Plan

레벨대상케이스
domain (Kotest BehaviorSpec + MockK)QueuePosition.of, EntryToken.isExpired, VirtualQueueDomainService, AdmissionDomainServiceETA/ahead 계산, 멱등 진입, 포화 거부, 플래그 OFF directEntry, admitted 시 토큰 발급, Redis 예외 fail-open, 배치 전진 상한, stale 방출
application (Kotest + MockK)Enter/GetStatus/Leave/GetStats/RunAdmissionBatch UseCaseDomainService 위임만 검증(execute ≤10줄)
infrastructure (Kotest + Testcontainers Redis)VirtualQueueStoreImpl(enter.lua/admit.lua/evict.lua), HmacEntryTokenGatewayenter.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.ymlredis 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.ymlredis 서비스 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 미접근)로 발급한다 — issueIfAbsentSET 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=ADMITTEDentryToken을 저장하고 구매 화면 전환(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}/ordersPOST /events/{id}/seats/select 요청에 X-Entry-Token: String 헤더 추가. 플래그 ON + 토큰 부재/위조/만료 → 403 { "code": "QUEUE_BYPASS_DENIED" }(FR-5). 플래그 OFF → 헤더 불요(기존과 동일). 기존 Request Body·경로는 불변 — FE는 헤더만 추가.

Open Questions — TDD 확정 결론

#질문결론
1admission 주기·인원, 클러스터 합계 유지2초당 100명(per-target), Redis 분산 락 배치 pump(리더 선출 아님). admit.luaadmitted_count를 상한 내 전진. batchSize/tick은 설정값, 부하 테스트 재조정
2폴링 vs SSE폴링(2~3초). SSE는 10만 장수명 연결·LoadShedding 고갈로 미채택. 폴링이 heartbeat 겸함
3티케팅 게이트 위치좌석 선택 앞에만. 구매 확정은 좌석 락 소유로 보호돼 재게이트 불요. 토큰 TTL=좌석 락 TTL 정합
4Redis 장애 폴백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-09Redis 계약 리뷰(20260709-redis-contract.md §0) 블로킹 4건 교정: (1) admission 판정을 ZRANK고정 seq(ZSCORE) 로 변경(leave 후 rank 붕괴에 의한 연쇄 과다 admission 차단), seq 키·enter.lua 신설 (2) admit.lua seenTotal 원천 ZCARDseq (3) fail-open 토큰 발급을 EntryTokenIssuer.mintStateless(Redis 미접근)로 분리 (4) fail-open 안전 근거를 “Redis 오버셀 게이트”→“MySQL(Stock.@Version·uk_tickets_active_seat)“로 정정. evict.lua maxEvictPerTick·prod maxmemory override 반영