참고 영상
대용량 선착순 예매 시스템 — Redis + Express 완전 구현 가이드
KTX/SRT급 수백만 동시 접속 환경에서 순서 보장, 초과 예매 방지, 중복 예매 방지를 만족하는 대기열 기반 예매 시스템의 설계·구현·운영 전 과정을 다룹니다.
기술 스택: Node.js, Express, Redis (Sorted Set + Lua), RabbitMQ, PostgreSQL
핵심 아키텍처: 3단계 분리 — 대기열 등록 → 입장 처리(스케줄러) → 예매 처리(RDB)
0. 목차 및 설계 점검
0.1 전체 목차
장 | 제목 | 핵심 내용 |
0 | 목차 및 설계 점검 | 전체 구조, 요구사항 점검, Mermaid 가이드 |
1 | 아키텍처 개요 및 요구사항 | 3단계 아키텍처, 설계 원칙 |
2 | Express 프로젝트 구조 및 환경설정 | 프로젝트 구조, Docker, 환경변수 |
3 | Redis 데이터 모델 설계 | 키 설계, ZSET, Cluster Hash Tag |
4 | 1단계 — 대기열 등록 API | join/rejoin, ZADD NX |
5 | 폴링 API 및 대기상태 조회 | ZRANK, Adaptive Polling, SRT 분석 |
6 | 2단계 — 입장처리 스케줄러 | ZPOPMIN, batchSize, Redlock |
7 | 3단계 — 예매처리 Lua 동시성제어 | Lua Script, 좌석 선점 SET NX |
8 | RDB / MQ 비동기 저장 | MQ Worker, 정합성 reconcile |
9 | 클라이언트 구현 가이드 | 폴링, 상태 머신, fail-open 방지 |
10 | 운영 및 부하테스트 체크리스트 | k6, 모니터링, 장애 대응 |
11 | 부록 — 인프라 및 확장 패턴 | Nginx, WebFlux, 결제 분리, 상용 솔루션 |
0.2 설계 요구사항 점검 결과
요구사항 | 반영 장 | 비고 |
3단계 분리 (대기열→입장→예매) | 1, 4, 6, 7 | 핵심 아키텍처 |
Redis Sorted Set 대기열 | 3, 4 | ZADD NX, timestamp score |
UUID 토큰 (로그인 전) | 4, 9 | KTX 방식 |
스케줄러 TPS 기반 입장 | 1, 6 | batchSize 산정 |
Active User TTL 3분 | 1, 6 | KTX 실제 동작 |
Lua Script 원자적 좌석 차감 | 7 | DECRBY 한계 설명 포함 |
초과 예매 X / 중복 예매 X / 순서 O | 1, 7, 10 | 3대 무결성 |
폴링 (WebSocket/SSE 대신) | 1, 5, 9 | 50만 연결 유지 비용 |
Adaptive Polling + Jitter | 5, 9 | 서버 주도 nextPollMs |
MQ vs Sorted Set 비교 | 1, 8 | 앞 Redis + 뒤 MQ 하이브리드 |
중복 클릭/F5 멱등성 | 3, 4 | ZADD NX |
허트비트 미사용 트레이드오프 | 1, 5 | ZSET 개별 TTL 불가 |
Active User 누적 문제 | 6 | TTL + Lua 2차 방어 |
좌석 선점 SET NX | 7 | 특정 자리 지정 확장 |
결제 분리 (예매만) | 1, 11 | 복잡도 제거 |
MQ → Worker → RDB | 8 | 비동기 저장 |
부하 테스트 / 봇 시뮬레이션 | 10 | k6 시나리오 |
Redis Cluster + Hash Tag | 3 | Lua 멀티키 |
Redlock (스케줄러 1대) | 6 | 다중 실행 방지 |
reconcile 정합성 검증 | 8, 10 | Redis |
fail-open 방지 (비행기 모드) | 9, 11 | 프론트 로직 오류 |
SRT 실제 폴링 분석 | 5, 11 | JSONP, TTL, Sticky |
보강된 항목 (11장 부록 + Mermaid 다이어그램)
요구사항 | 이전 상태 | 보강 위치 |
Nginx/커널 튜닝 | 미반영 | 11장 부록 |
WebFlux/Netty 논블로킹 IO | 미반영 | 11장 부록 |
Chunked Response (0.8KB) | 미반영 | 11장 부록 |
상용 대기열 (Netfunnel 등) | 미반영 | 11장 부록 |
Redis 분리 구성 옵션 | 일부만 | 3장, 11장 |
이벤트 OPEN/CLOSE 라이프사이클 | 일부만 | 4장, 10장 |
Mermaid 아키텍처 다이어그램 | 없음 | 전체 가이드 |
0.3 전체 시스템 한눈에 보기
flowchart TB
subgraph Client["클라이언트 (브라우저)"]
UI["예매 UI"]
Poll["폴링 루프<br/>(Adaptive + Jitter)"]
end
subgraph Gateway["앞단"]
LB["Load Balancer<br/>(Nginx / ALB)"]
API1["Express API #1"]
API2["Express API #2"]
APIN["Express API #N"]
end
subgraph Memory["Redis Cluster (인메모리)"]
WQ["waiting_queue<br/>Sorted Set"]
AU["active_users<br/>Sorted Set + TTL"]
SEAT["seats:remaining<br/>String"]
DONE["booking:done<br/>String NX"]
end
subgraph Backend["백엔드 처리"]
SCH["Scheduler<br/>(1대, Redlock)"]
MQ["RabbitMQ<br/>(선택)"]
WRK["Worker"]
RDB["PostgreSQL<br/>(RDB)"]
end
UI -->|"POST /queue/join"| LB
Poll -->|"GET /status/poll"| LB
UI -->|"POST /booking/reserve"| LB
LB --> API1 & API2 & APIN
API1 & API2 & APIN --> WQ & AU & SEAT & DONE
SCH -->|"ZPOPMIN → ZADD"| WQ
SCH --> AU
API1 -->|"Lua Script"| SEAT
API1 -->|"publish"| MQ
MQ --> WRK --> RDB
style Memory fill:#ffebee,stroke:#c62828
style Gateway fill:#e3f2fd,stroke:#1565c0
style Backend fill:#f3e5f5,stroke:#6a1b9aMermaid
복사
0.4 3단계 + 3대 무결성 매핑
flowchart LR
subgraph P1["Phase 1: 대기열"]
A1["POST /queue/join"]
A2["ZADD NX<br/>score=timestamp"]
A3["순서 보장 ✅"]
end
subgraph P2["Phase 2: 입장"]
B1["Scheduler tick"]
B2["ZPOPMIN → active_users"]
B3["TTL 3분"]
end
subgraph P3["Phase 3: 예매"]
C1["POST /booking/reserve"]
C2["Lua: check + decr"]
C3["초과예매X ✅<br/>중복예매X ✅"]
end
P1 -->|"TPS 기반 유량제어"| P2
P2 -->|"ACTIVE만 허용"| P3
P3 -->|"MQ → RDB"| DB[("PostgreSQL")]
style A3 fill:#c8e6c9
style C3 fill:#c8e6c9Mermaid
복사
0.5 Mermaid 다이어그램 사용법
본 가이드는 Mermaid 문법으로 다이어그램을 그렸습니다. draw.io처럼 별도 도구 없이 Markdown 안에서 바로 렌더링됩니다.
지원 환경
도구 | Mermaid 지원 |
GitHub / GitLab | |
VS Code + Markdown Preview | |
Obsidian | |
Notion | |
Cursor |
주요 다이어그램 유형 (본 시리즈에서 사용)
graph TD
A["flowchart / graph<br/>아키텍처, 흐름도"]
B["sequenceDiagram<br/>API 호출 순서"]
C["stateDiagram-v2<br/>상태 전이"]
D["erDiagram<br/>데이터 관계"]
E["gantt<br/>운영 타임라인"]
style A fill:#e3f2fd
style B fill:#fff3e0
style C fill:#f3e5f5
style D fill:#e8f5e9
style E fill:#fce4ecMermaid
복사
작성 예시
```mermaid
sequenceDiagram
Client->>API: POST /queue/join
API->>Redis: ZADD waiting_queue
Redis-->>API: OK
API-->>Client: { token: uuid }
```
Markdown
복사
0.6 빠른 시작 가이드
flowchart TD
START(["문서 읽기 시작"]) --> Q1{"역할이?"}
Q1 -->|"아키텍트 / 설계"| D1["1_아키텍처 개요"]
Q1 -->|"백엔드 개발"| D2["2_Express 셋업"]
Q1 -->|"Redis 담당"| D3["3_Redis 모델"]
Q1 -->|"프론트 개발"| D4["9_클라이언트"]
Q1 -->|"운영 / SRE"| D5["10_운영·부하테스트"]
D1 --> D2 --> D3
D3 --> D4B["4→5→6→7→8 순서 구현"]
D4B --> D5
style START fill:#4caf50,color:#fffMermaid
복사
1.
설계 이해 → 1장 아키텍처 개요
2.
환경 구축 → 2장 Express + docker compose up
3.
Redis 모델 → 3장 Redis 데이터 모델
4.
순서대로 구현 → 4 → 5 → 6 → 7 → 8장
5.
프론트 연동 → 9장 클라이언트
6.
오픈 전 점검 → 10장 운영
7.
인프라 확장 → 11장 부록
1. 아키텍처 개요 및 요구사항
1.1 문제 정의
KTX 설날 예매처럼 오전 7시에 수백만 명이 동시에 클릭하는 환경에서는 아래 구조가 무너집니다.
flowchart LR
U["👤 사용자<br/>200만+ 동시"] --> API["API 서버 N대<br/>(스케일 아웃)"]
API --> DB[("RDB<br/>💥 병목")]
DB -.-x FAIL["장애"]
style DB fill:#ffcdd2,stroke:#c62828
style FAIL fill:#c62828,color:#fffMermaid
복사
•
API를 아무리 스케일 아웃해도 RDB가 병목
•
RDB는 스케일 아웃이 어렵고, 비용이 기하급수적으로 증가
•
수백만 동시 요청을 RDB가 직접 받으면 거의 확실히 장애
1.2 핵심 요구사항 (Non-Negotiable)
요구사항 | 설명 | 구현 핵심 |
순서 보장 O | 먼저 들어온 사람이 먼저 예매 | Redis Sorted Set (score = timestamp) |
초과 예매 X | 총 좌석수를 넘지 않음 | Redis Lua Script 원자적 차감 |
중복 예매 X | 같은 사람/같은 자리 중복 방지 | Sorted Set 멱등성 + SET NX |
트래픽 과중 | 수백만 동시 접속 | DB 직접 접근 차단, 인메모리(Redis) 버퍼 |
mindmap
root((예매 시스템<br/>3대 무결성))
순서 보장
Sorted Set
timestamp score
ZRANK 조회
초과 예매 방지
Lua Script
check + decr
원자적 실행
중복 예매 방지
ZADD NX
SET NX
booking:done
트래픽 흡수
Redis 인메모리
DB 직접 접근 차단
스케줄러 유량 제어Mermaid
복사
1.3 3단계 아키텍처 (핵심)
본 설계에서 제시한 3단계 분리가 이 설계의 뼈대입니다.
flowchart TB
subgraph P1["📋 Phase 1: 대기열 등록"]
direction TB
P1A["수백만 요청 수신"]
P1B["UUID + timestamp 생성"]
P1C["ZADD NX → waiting_queue"]
P1D["DB 접근 0건"]
P1A --> P1B --> P1C --> P1D
end
subgraph P2["🚪 Phase 2: 입장 처리"]
direction TB
P2A["스케줄러 1초 tick"]
P2B["ZPOPMIN batchSize명"]
P2C["ZADD → active_users"]
P2D["TTL 3분 설정"]
P2A --> P2B --> P2C --> P2D
end
subgraph P3["🎫 Phase 3: 예매 처리"]
direction TB
P3A["ACTIVE 유저만 허용"]
P3B["Lua: 잔여좌석 원자 차감"]
P3C["MQ → Worker → RDB"]
P3A --> P3B --> P3C
end
P1 -->|"TPS 기반 유량 제어"| P2
P2 -->|"폴링 ACTIVE 감지"| P3
style P1 fill:#e3f2fd,stroke:#1565c0
style P2 fill:#fff3e0,stroke:#ef6c00
style P3 fill:#e8f5e9,stroke:#2e7d32Mermaid
복사
1.4 전체 시스템 다이어그램
flowchart TB
User["🖥️ 사용자<br/>(브라우저)"]
LB["⚖️ Load Balancer<br/>(Nginx / ALB)"]
subgraph APIPool["Express API Pool (Stateless)"]
API1["API #1"]
API2["API #2"]
APIN["API #N"]
end
subgraph RedisCluster["🔴 Redis Cluster (인메모리)"]
WQ["waiting_queue<br/>Sorted Set"]
AU["active_users<br/>Sorted Set"]
SEAT["seats:remaining<br/>String"]
end
SCH["⏰ Scheduler<br/>(1대, Redlock)"]
MQ["📨 MQ<br/>(RabbitMQ)"]
WRK["👷 Worker"]
RDB[("🗄️ PostgreSQL<br/>(RDB)")]
User -->|"HTTP 폴링"| LB
LB --> API1 & API2 & APIN
API1 & API2 & APIN --> WQ & AU & SEAT
SCH -->|"ZPOPMIN → ZADD"| WQ & AU
API1 -->|"Lua Script"| SEAT
API1 -->|"publish"| MQ
MQ --> WRK --> RDB
style RedisCluster fill:#ffebee,stroke:#c62828
style APIPool fill:#e3f2fd,stroke:#1565c0Mermaid
복사
1.5 왜 Redis인가? (MQ vs Sorted Set)
Q&A에서 강조한 내용:
기능 | MQ (Kafka/RabbitMQ) | Redis Sorted Set |
선착순 처리 | O (FIFO) | O (score 정렬) |
내 순번 조회 | X (오프셋 개념) | O (ZRANK) |
전체 대기 인원 | X | O (ZCARD) |
중복 클릭 방지 | 별도 로직 필요 | O (멱등성) |
중간 유저 삭제 | 어려움 | O (ZREM) |
결론: 대기열은 Redis Sorted Set, 비동기 후처리(DB 저장)는 MQ가 적합.
flowchart LR
subgraph Front["앞단: 상태 조회·관리"]
R["Redis Sorted Set<br/>ZRANK / ZCARD / ZREM"]
end
subgraph Back["뒷단: 비동기 처리"]
M["MQ<br/>버퍼링·재시도"]
D["RDB<br/>영구 저장"]
end
User["사용자"] -->|"순번 조회"| R
R -->|"입장 허용"| User
User -->|"예매 성공"| M --> D
style Front fill:#ffebee,stroke:#c62828
style Back fill:#f3e5f5,stroke:#6a1b9aMermaid
복사
1.6 왜 폴링인가? (WebSocket/SSE vs Polling)
방식 | 50만 동시 접속 시 | 적합성 |
WebSocket | 50만 연결 유지 → 메모리 폭발, 재접속 폭풍 | X |
SSE | 장시간 연결 유지 필요 | X |
폴링 | 요청-응답 후 연결 종료 (Stateless) | O |
flowchart TB
subgraph WS["❌ WebSocket (50만 연결)"]
WS1["50만 소켓 상주"]
WS2["메모리 폭발"]
WS3["재접속 폭풍 = DDoS"]
WS1 --> WS2 --> WS3
end
subgraph Poll["✅ Polling (50만 사용자)"]
P1["요청 → 응답 → 연결 종료"]
P2["Stateless → 무한 스케일 아웃"]
P3["Redis 숫자 1개 읽기 (초경량)"]
P1 --> P2 --> P3
end
style WS fill:#ffcdd2,stroke:#c62828
style Poll fill:#c8e6c9,stroke:#2e7d32Mermaid
복사
폴링 요청은 Redis에서 숫자 하나 읽는 초경량 요청이므로, 연결 유지 비용보다 훨씬 저렴합니다.
최적화 기법:
•
Adaptive Polling: 대기 순번이 멀면 5~10초, 가까우면 1~2초
•
Jitter: 사용자마다 랜덤 오프셋을 줘서 동시 폴링 분산
•
서버 주도 TTL: 응답에 nextPollMs를 내려서 클라이언트 폴링 주기 제어
1.7 TPS 기반 입장 처리량 산정
스케줄러가 한 번에 꺼내는 인원 수는 백엔드 RDB TPS에 맞춰 조절합니다.
예시:
- RDB 예매 처리 TPS = 100건/초
- 안전 마진 70% 적용 → 스케줄러 입장 처리 = 70명/초
- 스케줄러 주기 = 1초
- 매 tick마다 ZPOPMIN 70명 → active_users에 등록
Plain Text
복사
부하 테스트로 TPS를 측정한 뒤, 스케줄러 batchSize를 설정해야 합니다.
flowchart LR
TPS["RDB TPS<br/>(부하테스트)"] --> MARGIN["× 0.7<br/>안전 마진"]
MARGIN --> BATCH["batchSize<br/>(70명/초)"]
BATCH --> SCHED["스케줄러<br/>1초 tick"]
SCHED --> ACTIVE["active_users<br/>+70명/초"]
style TPS fill:#e3f2fd
style ACTIVE fill:#fff3e0Mermaid
복사
1.8 Active User TTL (3분 규칙)
KTX 실제 시스템처럼 입장 후 3분 안에 예매를 완료하지 않으면 퇴장 처리합니다.
# active_users는 Hash + TTL 또는 Sorted Set + score(만료시각)
# 3분 = 180초
EXPIRE active:user:{uuid} 180
Plain Text
복사
왜 허트비트를 안 쓰나?
•
대기열(Sorted Set)은 개별 TTL 불가 (전체 키에만 TTL)
•
폴링마다 TTL 갱신하면 쓰기 부하 급증
•
이탈한 유저가 active로 올라가도 TTL로 자연 소멸 → 비용 대비 효율적
sequenceDiagram
participant U as 사용자
participant S as Scheduler
participant R as Redis
participant P as 폴링 API
S->>R: ZADD active_users (expireAt = now+180s)
U->>P: GET /status/poll
P->>R: ZSCORE active_users
R-->>P: expireAt (미래)
P-->>U: status: ACTIVE ✅
Note over U: 3분간 예매 UI 사용
alt 3분 내 예매 완료
U->>R: POST /booking/reserve → 성공
else 3분 초과
P->>R: ZSCORE → now >= expireAt
P-->>U: status: EXPIRED ❌
endMermaid
복사
1.9 결제·좌석 지정 분리 (복잡도 제거)
flowchart LR
A["대기열<br/>(수백만)"] --> B["입장<br/>(수만)"]
B --> C["좌석 수 예매<br/>(수천)"]
C --> D["좌석 지정<br/>(수백)"]
D --> E["결제<br/>(수백)"]
style A fill:#ffcdd2
style B fill:#ffccbc
style C fill:#fff9c4
style D fill:#c8e6c9
style E fill:#b2dfdbMermaid
복사
1.11 설계 원칙 요약
1.
DB는 마지막에만 — 트래픽 흡수는 Redis가 담당
2.
원자성은 Lua Script — check-then-act를 하나의 원자 연산으로
3.
Stateless API — 수평 확장 가능
4.
복잡도 제거 — 결제/좌석 지정은 예매 이후 단계로 분리
5.
부하 테스트 필수 — TPS, batchSize, 폴링 주기는 측정값 기반
2. Express 프로젝트 구조 및 환경설정
2.1 기술 스택
구분 | 기술 | 버전 (권장) |
Runtime | Node.js | 20 LTS |
Framework | Express | 4.x |
Redis Client | ioredis | 5.x |
UUID | uuid | 9.x |
Validation | zod | 3.x |
Scheduler | node-cron | 3.x |
MQ (선택) | amqplib | 0.10.x |
DB (선택) | pg / mysql2 | - |
graph TB
subgraph Runtime["런타임"]
Node["Node.js 20 LTS"]
Express["Express 4.x"]
end
subgraph Data["데이터 계층"]
Redis["ioredis 5.x"]
PG["pg (PostgreSQL)"]
MQ["amqplib (RabbitMQ)"]
end
subgraph Util["유틸"]
UUID["uuid"]
Zod["zod"]
Cron["node-cron"]
end
Express --> Redis & PG & MQ
Express --> UUID & Zod
Cron --> Redis
style Runtime fill:#e3f2fd
style Data fill:#ffebee
style Util fill:#f3e5f5Mermaid
복사
2.2 프로젝트 디렉터리 구조
ticket-queue-system/
├── src/
│ ├── app.ts # Express 앱 진입점
│ ├── server.ts # HTTP 서버 시작
│ ├── config/
│ │ ├── index.ts # 환경변수 로드
│ │ └── redis.ts # Redis 연결
│ ├── routes/
│ │ ├── queue.routes.ts # 대기열 등록
│ │ ├── status.routes.ts # 폴링/상태 조회
│ │ └── booking.routes.ts # 예매 처리
│ ├── services/
│ │ ├── queue.service.ts # 대기열 비즈니스 로직
│ │ ├── entry.service.ts # 입장 처리
│ │ ├── booking.service.ts # 예매 처리
│ │ └── polling.service.ts # 폴링 응답 생성
│ ├── scheduler/
│ │ └── entry.scheduler.ts # 입장 스케줄러
│ ├── redis/
│ │ ├── keys.ts # Redis 키 상수
│ │ └── scripts/
│ │ ├── reserve-seats.lua # 좌석 차감 Lua
│ │ └── pop-queue.lua # 대기열 pop Lua
│ ├── mq/
│ │ └── publisher.ts # MQ 발행 (선택)
│ ├── db/
│ │ └── reservation.repo.ts # RDB 저장 (선택)
│ ├── middleware/
│ │ ├── errorHandler.ts
│ │ └── rateLimit.ts
│ └── types/
│ └── index.ts
├── docker-compose.yml # Redis, PostgreSQL, RabbitMQ
├── package.json
├── tsconfig.json
└── .env.example
Plain Text
복사
flowchart TB
subgraph Entry["진입점"]
server["server.ts"]
app["app.ts"]
end
subgraph Routes["routes/"]
QR["queue.routes.ts<br/>POST /join"]
SR["status.routes.ts<br/>GET /poll"]
BR["booking.routes.ts<br/>POST /reserve"]
end
subgraph Services["services/"]
QS["queue.service"]
PS["polling.service"]
ES["entry.service"]
BS["booking.service"]
end
subgraph Infra["인프라"]
redis["config/redis.ts"]
lua["redis/scripts/*.lua"]
sch["scheduler/"]
mq["mq/"]
db["db/"]
end
server --> app --> Routes
Routes --> Services
Services --> redis & lua & mq & db
sch --> ES
style Entry fill:#e3f2fd
style Routes fill:#fff3e0
style Services fill:#e8f5e9
style Infra fill:#f3e5f5Mermaid
복사
2.3 package.json
{
"name": "ticket-queue-system",
"version": "1.0.0",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"scheduler": "tsx src/scheduler/entry.scheduler.ts"
},
"dependencies": {
"express": "^4.21.0",
"ioredis": "^5.4.1",
"uuid": "^9.0.1",
"zod": "^3.23.8",
"node-cron": "^3.0.3",
"cors": "^2.8.5",
"helmet": "^7.1.0",
"amqplib": "^0.10.4",
"pg": "^8.12.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.14.0",
"@types/uuid": "^9.0.8",
"@types/cors": "^2.8.17",
"typescript": "^5.5.0",
"tsx": "^4.16.0"
}
}
JSON
복사
2.4 tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
JSON
복사
2.5 docker-compose.yml
version: '3.8'
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
command: redis-server --appendonly yes
volumes:
- redis_data:/data
# Redis Cluster (운영 환경)
# redis-node-1:
# image: redis:7-alpine
# command: redis-server --cluster-enabled yes ...
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: ticket
POSTGRES_USER: ticket
POSTGRES_PASSWORD: ticket123
ports:
- "5432:5432"
volumes:
- pg_data:/var/lib/postgresql/data
rabbitmq:
image: rabbitmq:3-management-alpine
ports:
- "5672:5672"
- "15672:15672"
volumes:
redis_data:
pg_data:
YAML
복사
flowchart LR
subgraph Docker["docker compose"]
Redis["Redis 7<br/>:6379"]
PG["PostgreSQL 16<br/>:5432"]
RMQ["RabbitMQ<br/>:5672 / :15672"]
end
API["Express API<br/>:3000"] --> Redis & PG & RMQ
SCH["Scheduler"] --> Redis
WRK["Worker"] --> RMQ & PG
style Docker fill:#e3f2fdMermaid
복사
2.6 .env.example
# Server
PORT=3000
NODE_ENV=development
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
# Scheduler
ENTRY_BATCH_SIZE=70 # 1초당 입장 인원 (RDB TPS * 0.7)
ENTRY_CRON=*/1 * * * * * # 1초마다 (node-cron 6필드)
ACTIVE_USER_TTL_SEC=180 # 3분
# Event
EVENT_ID=ktx-2026-chuseok
TOTAL_SEATS=100 # 7시 차次 총 좌석
# MQ (선택)
RABBITMQ_URL=amqp://localhost:5672
BOOKING_QUEUE=booking.completed
# DB (선택)
DATABASE_URL=postgresql://ticket:ticket123@localhost:5432/ticket
Plain Text
복사
2.7 config/index.ts
import { z } from 'zod';
const envSchema = z.object({
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
REDIS_HOST: z.string().default('localhost'),
REDIS_PORT: z.coerce.number().default(6379),
REDIS_PASSWORD: z.string().optional(),
ENTRY_BATCH_SIZE: z.coerce.number().default(70),
ACTIVE_USER_TTL_SEC: z.coerce.number().default(180),
EVENT_ID: z.string().default('default-event'),
TOTAL_SEATS: z.coerce.number().default(100),
RABBITMQ_URL: z.string().optional(),
BOOKING_QUEUE: z.string().default('booking.completed'),
DATABASE_URL: z.string().optional(),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('환경변수 검증 실패:', parsed.error.flatten());
process.exit(1);
}
export const config = parsed.data;
TypeScript
복사
2.8 config/redis.ts
import Redis from 'ioredis';
import { config } from './index';
import fs from 'fs';
import path from 'path';
// 단일 인스턴스 (개발)
export const redis = new Redis({
host: config.REDIS_HOST,
port: config.REDIS_PORT,
password: config.REDIS_PASSWORD || undefined,
maxRetriesPerRequest: 3,
enableReadyCheck: true,
lazyConnect: false,
});
// Lua 스크립트 SHA 캐시
const scriptsDir = path.join(__dirname, '../redis/scripts');
const scriptCache = new Map<string, string>();
export async function loadLuaScript(name: string): Promise<string> {
if (scriptCache.has(name)) {
return scriptCache.get(name)!;
}
const filePath = path.join(scriptsDir, `${name}.lua`);
const source = fs.readFileSync(filePath, 'utf-8');
const sha = (await redis.script('LOAD', source)) as string;
scriptCache.set(name, sha);
return sha;
}
export async function evalLua(
name: string,
numKeys: number,
...args: (string | number)[]
): Promise<unknown> {
const sha = await loadLuaScript(name);
try {
return await redis.evalsha(sha, numKeys, ...args);
} catch (err: unknown) {
// NOSCRIPT: 캐시 미스 시 재로드
if (err instanceof Error && err.message.includes('NOSCRIPT')) {
scriptCache.delete(name);
const newSha = await loadLuaScript(name);
return redis.evalsha(newSha, numKeys, ...args);
}
throw err;
}
}
redis.on('error', (err) => {
console.error('[Redis] 연결 오류:', err.message);
});
redis.on('connect', () => {
console.log('[Redis] 연결됨');
});
TypeScript
복사
2.9 src/app.ts
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { queueRouter } from './routes/queue.routes';
import { statusRouter } from './routes/status.routes';
import { bookingRouter } from './routes/booking.routes';
import { errorHandler } from './middleware/errorHandler';
export function createApp() {
const app = express();
app.use(helmet());
app.use(cors());
app.use(express.json({ limit: '10kb' }));
// Health check (로드밸런서용)
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: Date.now() });
});
app.use('/api/queue', queueRouter);
app.use('/api/status', statusRouter);
app.use('/api/booking', bookingRouter);
app.use(errorHandler);
return app;
}
TypeScript
복사
2.10 src/server.ts
import { createApp } from './app';
import { config } from './config';
import { redis } from './config/redis';
async function main() {
await redis.ping();
console.log('[Redis] PING OK');
const app = createApp();
const server = app.listen(config.PORT, () => {
console.log(`[Server] <http://localhost>:${config.PORT}`);
});
// Graceful shutdown
const shutdown = async (signal: string) => {
console.log(`[Server] ${signal} 수신, 종료 중...`);
server.close();
await redis.quit();
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
}
main().catch((err) => {
console.error('[Server] 시작 실패:', err);
process.exit(1);
});
TypeScript
복사
2.11 middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
export class AppError extends Error {
constructor(
public statusCode: number,
message: string,
public code?: string
) {
super(message);
this.name = 'AppError';
}
}
export function errorHandler(
err: Error,
_req: Request,
res: Response,
_next: NextFunction
) {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
success: false,
error: { code: err.code, message: err.message },
});
}
console.error('[Error]', err);
return res.status(500).json({
success: false,
error: { code: 'INTERNAL_ERROR', message: '서버 오류가 발생했습니다.' },
});
}
TypeScript
복사
2.12 middleware/rateLimit.ts (IP 기반 간단 제한)
import { Request, Response, NextFunction } from 'express';
import { redis } from '../config/redis';
/**
* IP당 초당 N회 제한 (대기열 등록 API용)
* 운영에서는 Nginx/ALB 레벨 rate limit 병행 권장
*/
export function rateLimit(maxPerSecond: number = 5) {
return async (req: Request, res: Response, next: NextFunction) => {
const ip = req.ip || 'unknown';
const key = `ratelimit:${ip}:${Math.floor(Date.now() / 1000)}`;
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, 2);
}
if (count > maxPerSecond) {
return res.status(429).json({
success: false,
error: { code: 'RATE_LIMIT', message: '요청이 너무 많습니다.' },
});
}
next();
};
}
TypeScript
복사
2.13 types/index.ts
export type QueueStatus = 'WAITING' | 'ACTIVE' | 'EXPIRED' | 'NOT_FOUND';
export interface QueueJoinResponse {
success: true;
data: {
token: string; // UUID (클라이언트 식별)
eventId: string;
joinedAt: number;
};
}
export interface StatusPollResponse {
success: true;
data: {
status: QueueStatus;
rank: number | null; // 대기 중일 때 내 순번 (1-based)
totalWaiting: number; // 전체 대기 인원
ahead: number | null; // 내 앞 대기 인원
behind: number | null; // 내 뒤 대기 인원
remainingSeats: number; // 잔여 좌석
nextPollMs: number; // 다음 폴링 권장 간격 (ms)
activeExpiresAt: number | null; // ACTIVE일 때 만료 시각
};
}
export interface BookingRequest {
token: string;
seatCount: number; // 예매할 좌석 수 (N장)
}
export interface BookingResponse {
success: true;
data: {
reservationId: string;
seatCount: number;
remainingSeats: number;
};
}
TypeScript
복사
2.14 실행 방법
# 1. 의존성 설치
npm install
# 2. 인프라 기동
docker compose up -d
# 3. 환경변수
cp .env.example .env
# 4. API 서버
npm run dev
# 5. 스케줄러 (별도 프로세스)
npm run scheduler
Bash
복사
flowchart TB
subgraph Processes["실행 프로세스 (3종)"]
P1["npm run dev<br/>Express API (N대)"]
P2["npm run scheduler<br/>입장 스케줄러 (1대)"]
P3["npm run worker<br/>MQ Worker (N대)"]
end
subgraph Infra2["docker compose"]
Redis2["Redis"]
PG2["PostgreSQL"]
RMQ2["RabbitMQ"]
end
P1 --> Redis2
P2 --> Redis2
P1 --> RMQ2
P3 --> RMQ2 & PG2
style P1 fill:#e3f2fd
style P2 fill:#fff3e0
style P3 fill:#e8f5e9Mermaid
복사
운영 팁: API 서버와 스케줄러는 별도 프로세스로 분리합니다. 스케줄러는 1대만 실행 (리더 선출 필요 시 Redlock 사용).
3. Redis 데이터 모델 설계
3.1 키 네이밍 규칙
{서비스}:{이벤트ID}:{엔티티}:{식별자}
Plain Text
복사
예: ticket:ktx-2026-chuseok:queue:waiting
3.2 전체 키 목록
키 | 타입 | 용도 | Phase |
ticket:{eventId}:queue:waiting | Sorted Set | 대기열 (score=timestamp) | 1 |
ticket:{eventId}:active:users | Sorted Set | 입장 허용 유저 (score=만료시각) | 2 |
ticket:{eventId}:seats:remaining | String (int) | 잔여 좌석 수 | 3 |
ticket:{eventId}:booking:done:{token} | String | 예매 완료 멱등 키 | 3 |
ticket:{eventId}:meta | Hash | 이벤트 메타 (총좌석, 오픈시각) | - |
erDiagram
WAITING_QUEUE ||--o{ ACTIVE_USERS : "ZPOPMIN → ZADD"
ACTIVE_USERS ||--o| BOOKING_DONE : "예매 성공 시"
SEATS_REMAINING ||--o{ BOOKING_DONE : "Lua 차감"
EVENT_META ||--|| SEATS_REMAINING : "초기화"
WAITING_QUEUE {
string key "ticket:{eventId}:queue:waiting"
string type "Sorted Set"
string member "UUID"
number score "timestamp(ms)"
}
ACTIVE_USERS {
string key "ticket:{eventId}:active:users"
string type "Sorted Set"
string member "UUID"
number score "expireAt(ms)"
}
SEATS_REMAINING {
string key "ticket:{eventId}:seats:remaining"
string type "String(int)"
number value "잔여 좌석"
}
BOOKING_DONE {
string key "ticket:{eventId}:booking:done:{token}"
string type "String"
string value "reservationId"
}
EVENT_META {
string key "ticket:{eventId}:meta"
string type "Hash"
string status "OPEN|CLOSED"
}Mermaid
복사
3.3 src/redis/keys.ts
import { config } from '../config';
const PREFIX = 'ticket';
export const keys = {
/** Phase 1: 대기열 Sorted Set */
waitingQueue: (eventId: string = config.EVENT_ID) =>
`${PREFIX}:${eventId}:queue:waiting`,
/** Phase 2: Active User Sorted Set (score = expireAt timestamp) */
activeUsers: (eventId: string = config.EVENT_ID) =>
`${PREFIX}:${eventId}:active:users`,
/** Phase 3: 잔여 좌석 */
remainingSeats: (eventId: string = config.EVENT_ID) =>
`${PREFIX}:${eventId}:seats:remaining`,
/** 예매 완료 멱등성 키 (중복 예매 방지) */
bookingDone: (token: string, eventId: string = config.EVENT_ID) =>
`${PREFIX}:${eventId}:booking:done:${token}`,
/** 이벤트 메타 정보 */
eventMeta: (eventId: string = config.EVENT_ID) =>
`${PREFIX}:${eventId}:meta`,
};
TypeScript
복사
3.4 Phase 1: 대기열 (Sorted Set)
구조
Key: ticket:ktx-2026-chuseok:queue:waiting
Type: ZSET
Member: UUID (사용자 토큰)
Score: 접수 시각 (Unix timestamp ms)
Plain Text
복사
Redis 명령 예시
# 대기열 등록 (멱등: 같은 UUID면 score만 갱신되지 않고 유지)
ZADD ticket:ktx-2026-chuseok:queue:waiting NX 1704067200000 "a1b2c3d4-..."
# 내 순번 조회 (0-based → +1 하면 1-based)
ZRANK ticket:ktx-2026-chuseok:queue:waiting "a1b2c3d4-..."
# 전체 대기 인원
ZCARD ticket:ktx-2026-chuseok:queue:waiting
# 앞에서 N명 꺼내기 (스케줄러)
ZPOPMIN ticket:ktx-2026-chuseok:queue:waiting 70
# 특정 유저 제거 (취소 기능 시)
ZREM ticket:ktx-2026-chuseok:queue:waiting "a1b2c3d4-..."
Plain Text
복사
NX 옵션의 의미 (중복 방지)
ZADD key NX score member
Plain Text
복사
•
NX: member가 이미 존재하면 추가하지 않음
•
사용자가 F5 연타, 버튼 연타해도 대기열에 1번만 등록
•
본 가이드에서 강조한 멱등성(idempotency)
Score = Timestamp 이유
사용자 A: score 1704067200000 (7:00:00.000)
사용자 B: score 1704067200001 (7:00:00.001)
사용자 C: score 1704067200002 (7:00:00.002)
Plain Text
복사
Sorted Set이 score 기준 자동 정렬 → 선착순 보장
flowchart LR
subgraph ZSET["waiting_queue (Sorted Set)"]
direction TB
U1["user-A<br/>score: 1704067200000<br/>rank: 0 (1번째)"]
U2["user-B<br/>score: 1704067200001<br/>rank: 1 (2번째)"]
U3["user-C<br/>score: 1704067200002<br/>rank: 2 (3번째)"]
U4["...<br/>score: ...<br/>rank: 502340"]
end
U1 --- U2 --- U3 --- U4Mermaid
복사
3.5 Phase 2: Active Users (Sorted Set)
구조
Key: ticket:ktx-2026-chuseok:active:users
Type: ZSET
Member: UUID
Score: 만료 시각 (Unix timestamp ms) = now + 180000
Plain Text
복사
왜 Sorted Set인가?
•
ZSCORE로 만료 시각 조회 가능
•
ZRANGEBYSCORE로 만료된 유저 일괄 제거 가능
•
ZSCORE로 active 여부 O(1) 확인
Redis 명령 예시
# 입장 처리 (스케줄러)
ZADD ticket:ktx-2026-chuseok:active:users 1704067380000 "a1b2c3d4-..."
# Active 여부 확인
ZSCORE ticket:ktx-2026-chuseok:active:users "a1b2c3d4-..."
# 만료된 유저 정리 (score < now)
ZREMRANGEBYSCORE ticket:ktx-2026-chuseok:active:users -inf 1704067200000
Plain Text
복사
대안: Hash + 개별 TTL
SET active:user:{uuid} 1 EX 180
Plain Text
복사
•
장점: 개별 TTL 자동 만료
•
단점: 수백만 키 생성, 메모리 오버헤드
•
본 가이드 결론: Sorted Set + 주기적 정리가 대용량에 유리
3.6 Phase 3: 잔여 좌석 (String)
구조
Key: ticket:ktx-2026-chuseok:seats:remaining
Type: String (integer)
Value: 100
Plain Text
복사
초기화 (이벤트 오픈 전 1회)
SET ticket:ktx-2026-chuseok:seats:remaining 100
Plain Text
복사
Lua Script로만 차감
DECRBY만 쓰면 안 되는 이유:
- 잔여 2석인데 3석 요청 → -1 반환 → 초과 예매 발생
Lua Script:
1. GET remaining
2. remaining >= requested 이면 DECRBY
3. 아니면 실패 반환
→ 원자적으로 check-then-act
Plain Text
복사
flowchart TB
subgraph Lua["reserve-seats.lua (원자적)"]
L1["1. EXISTS booking:done → 중복?"]
L2["2. GET remaining → 잔여 확인"]
L3{"remaining >= requested?"}
L4["3. DECRBY remaining"]
L5["4. SET booking:done NX"]
L6["return SOLD_OUT"]
L1 --> L2 --> L3
L3 -->|"Yes"| L4 --> L5
L3 -->|"No"| L6
end
style Lua fill:#fff3e0,stroke:#ef6c00Mermaid
복사
3.7 예매 완료 멱등 키
Key: ticket:ktx-2026-chuseok:booking:done:{uuid}
Type: String
Value: reservationId
TTL: 24시간 (이벤트 종료 후 정리)
Plain Text
복사
SET ticket:ktx-2026-chuseok:booking:done:a1b2c3d4 "res-12345" NX EX 86400
Plain Text
복사
•
NX: 이미 예매한 사용자의 중복 예매 차단
•
네트워크 재시도로 같은 요청이 2번 와도 안전
3.8 이벤트 초기화 스크립트
// scripts/init-event.ts
import { redis } from '../src/config/redis';
import { keys } from '../src/redis/keys';
import { config } from '../src/config';
async function initEvent() {
const eventId = config.EVENT_ID;
const totalSeats = config.TOTAL_SEATS;
// 잔여 좌석 초기화 (이미 있으면 덮어쓰지 않음)
const seatKey = keys.remainingSeats(eventId);
const exists = await redis.exists(seatKey);
if (!exists) {
await redis.set(seatKey, totalSeats);
console.log(`[Init] 잔여 좌석 설정: ${totalSeats}`);
}
// 메타 정보
await redis.hset(keys.eventMeta(eventId), {
totalSeats: String(totalSeats),
openedAt: String(Date.now()),
status: 'OPEN',
});
console.log(`[Init] 이벤트 ${eventId} 초기화 완료`);
await redis.quit();
}
initEvent();
TypeScript
복사
3.9 데이터 흐름 타임라인
sequenceDiagram
autonumber
participant U as 사용자
participant API as Express API
participant R as Redis
participant SCH as Scheduler
participant DB as PostgreSQL
U->>API: POST /queue/join
API->>R: ZADD waiting_queue NX
API-->>U: { token }
loop 폴링 (nextPollMs)
U->>API: GET /status/poll
API->>R: ZRANK / ZSCORE
API-->>U: { status: WAITING, rank }
end
SCH->>R: ZPOPMIN 70 → ZADD active_users
U->>API: GET /status/poll
API-->>U: { status: ACTIVE }
U->>API: POST /booking/reserve
API->>R: Lua Script (차감)
API->>DB: MQ → Worker → INSERT
API-->>U: { reservationId }Mermaid
복사
3.10 Redis Cluster 고려사항
운영 환경에서는 Redis Cluster를 사용합니다.
# Hash Tag로 관련 키를 같은 슬롯에 배치
ticket:{ktx-2026-chuseok}:queue:waiting
ticket:{ktx-2026-chuseok}:active:users
ticket:{ktx-2026-chuseok}:seats:remaining
Plain Text
복사
{eventId} Hash Tag를 사용하면 같은 슬롯에 배치되어 Lua Script에서 멀티 키 연산이 가능합니다.
flowchart TB
subgraph Cluster["Redis Cluster (3 Nodes)"]
subgraph Slot1["Slot #1234 (Hash Tag: {ktx-2026})"]
WQ3["waiting_queue"]
AU3["active_users"]
ST3["seats:remaining"]
end
subgraph Slot2["Slot #5678"]
OTHER["다른 이벤트 키"]
end
end
LUA2["Lua Script"] -->|"멀티키 원자 연산"| Slot1
style Slot1 fill:#ffebee,stroke:#c62828Mermaid
복사
// Hash Tag 적용
waitingQueue: (eventId: string) => `ticket:{${eventId}}:queue:waiting`,
TypeScript
복사
Cluster vs 단일 인스턴스
항목 | 단일 | Cluster |
개발/테스트 | O | X (복잡) |
수백만 QPS | X | O |
Lua 멀티키 | O | Hash Tag 필요 |
장애 복구 | 수동 | 자동 failover |
3.11 메모리 사용량 추정
대기열 200만 명 기준:
- UUID (36 bytes) + score (8 bytes) + 오버헤드 ≈ 64 bytes/entry
- 2,000,000 × 64 = ~128 MB
Active 10만 명 (누적 최대):
- 100,000 × 64 = ~6.4 MB
총 Redis 메모리: ~150 MB (대기열 단계)
→ 16GB Redis 인스턴스면 충분
Plain Text
복사
3.12 모니터링용 Redis 명령
# 대기 인원 실시간 확인
redis-cli ZCARD ticket:ktx-2026-chuseok:queue:waiting
# Active 유저 수
redis-cli ZCARD ticket:ktx-2026-chuseok:active:users
# 잔여 좌석
redis-cli GET ticket:ktx-2026-chuseok:seats:remaining
# 메모리 사용량
redis-cli INFO memory
Bash
복사
4. 1단계 — 대기열 등록 API
Phase 1: 수백만 동시 요청을 Redis Sorted Set에 등록. DB 접근 없음.
4.1 API 스펙
POST /api/queue/join
대기열에 진입합니다. 로그인 없이 UUID 토큰을 발급합니다 (KTX 방식).
Request
POST /api/queue/join HTTP/1.1
Content-Type: application/json
{
"eventId": "ktx-2026-chuseok" // optional, 기본값 config.EVENT_ID
}
Plain Text
복사
Response 200
{
"success": true,
"data": {
"token": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"eventId": "ktx-2026-chuseok",
"joinedAt": 1704067200123
}
}
JSON
복사
Response 409 (이미 예매 완료)
{
"success": false,
"error": {
"code": "ALREADY_BOOKED",
"message": "이미 예매를 완료했습니다."
}
}
JSON
복사
sequenceDiagram
participant U as 사용자 (브라우저)
participant API as Express API
participant R as Redis
U->>API: POST /api/queue/join
Note over API: UUID v4 생성
API->>R: ZADD waiting_queue NX {timestamp} {uuid}
Note over R: 이미 존재하면 무시 (멱등)
R-->>API: 1 (추가됨)
API-->>U: { token, eventId, joinedAt }
Note over U: localStorage에 token 저장Mermaid
복사
4.2 services/queue.service.ts
import { v4 as uuidv4 } from 'uuid';
import { redis } from '../config/redis';
import { keys } from '../redis/keys';
import { config } from '../config';
import { AppError } from '../middleware/errorHandler';
export class QueueService {
/**
* 대기열 등록
* - UUID 발급
* - ZADD NX로 멱등 등록
* - 이미 예매 완료한 토큰은 거부
*/
async join(eventId: string = config.EVENT_ID) {
const token = uuidv4();
const now = Date.now();
// 이미 예매 완료 여부 (재접속 시 새 토큰이 아닌 기존 토큰으로 조회하는 경우 대비)
// join 시점에는 새 토큰이므로 이 체크는 재등록 API에서 사용
const queueKey = keys.waitingQueue(eventId);
// NX: 이미 존재하는 member는 추가하지 않음
const added = await redis.zadd(queueKey, 'NX', now, token);
if (added === 0) {
// 이론상 새 UUID이므로 0이 나오면 안 됨
// 동일 토큰 재사용 API가 있다면 여기서 처리
throw new AppError(500, '대기열 등록 실패', 'QUEUE_ADD_FAILED');
}
return {
token,
eventId,
joinedAt: now,
};
}
/**
* 기존 토큰으로 대기열 재조회 (새로고침/F5 대응)
* 토큰이 대기열/Active/예매완료 중 어디에 있는지 확인
*/
async rejoin(token: string, eventId: string = config.EVENT_ID) {
// 예매 완료 확인
const booked = await redis.exists(keys.bookingDone(token, eventId));
if (booked) {
throw new AppError(409, '이미 예매를 완료했습니다.', 'ALREADY_BOOKED');
}
const queueKey = keys.waitingQueue(eventId);
const rank = await redis.zrank(queueKey, token);
if (rank !== null) {
// 이미 대기열에 있음 → 기존 순번 유지
return { token, eventId, joinedAt: null, rejoined: true };
}
// Active에 있는지 확인
const activeScore = await redis.zscore(keys.activeUsers(eventId), token);
if (activeScore !== null) {
return { token, eventId, joinedAt: null, rejoined: true, status: 'ACTIVE' };
}
// 어디에도 없으면 새로 등록
const now = Date.now();
await redis.zadd(queueKey, 'NX', now, token);
return { token, eventId, joinedAt: now, rejoined: false };
}
/**
* 대기열에서 특정 유저 제거 (취소 기능)
*/
async leave(token: string, eventId: string = config.EVENT_ID) {
const removed = await redis.zrem(keys.waitingQueue(eventId), token);
return { removed: removed > 0 };
}
}
export const queueService = new QueueService();
TypeScript
복사
4.3 routes/queue.routes.ts
import { Router } from 'express';
import { z } from 'zod';
import { queueService } from '../services/queue.service';
import { rateLimit } from '../middleware/rateLimit';
import { AppError } from '../middleware/errorHandler';
export const queueRouter = Router();
const joinSchema = z.object({
eventId: z.string().optional(),
});
/**
* POST /api/queue/join
* 대기열 최초 진입
*/
queueRouter.post('/join', rateLimit(10), async (req, res, next) => {
try {
const body = joinSchema.parse(req.body);
const result = await queueService.join(body.eventId);
res.status(200).json({ success: true, data: result });
} catch (err) {
next(err);
}
});
/**
* POST /api/queue/rejoin
* 기존 토큰으로 재진입 (F5/새로고침 대응)
*/
const rejoinSchema = z.object({
token: z.string().uuid(),
eventId: z.string().optional(),
});
queueRouter.post('/rejoin', rateLimit(10), async (req, res, next) => {
try {
const body = rejoinSchema.parse(req.body);
const result = await queueService.rejoin(body.token, body.eventId);
res.status(200).json({ success: true, data: result });
} catch (err) {
next(err);
}
});
/**
* DELETE /api/queue/leave
* 대기열 이탈 (취소)
*/
queueRouter.delete('/leave', rateLimit(5), async (req, res, next) => {
try {
const token = req.query.token as string;
if (!token) throw new AppError(400, 'token 필수', 'MISSING_TOKEN');
const result = await queueService.leave(token);
res.status(200).json({ success: true, data: result });
} catch (err) {
next(err);
}
});
TypeScript
복사
4.4 동시성 시나리오 검증
flowchart TB
subgraph Storm["200만 동시 클릭"]
U1["사용자 1"] & U2["사용자 2"] & UN["사용자 200만"]
end
subgraph APIPool["Express N대"]
A1["API #1"] & A2["API #2"] & AN["API #N"]
end
subgraph Redis2["Redis Cluster"]
ZADD["ZADD NX<br/>~300K ops/sec"]
end
U1 & U2 & UN --> APIPool
APIPool --> ZADD
DB2[("RDB")] -.-x|"접근 0건"| APIPool
style DB2 fill:#ffcdd2,stroke:#c62828
style ZADD fill:#c8e6c9,stroke:#2e7d32Mermaid
복사
시나리오 1: 200만 명 동시 클릭
200만 요청 → Express N대 → Redis ZADD NX
Redis ZADD 성능: ~100,000 ops/sec (단일 인스턴스)
Cluster 3노드: ~300,000 ops/sec
200만 / 300,000 = ~7초 내 전부 등록 가능
(DB는 0건 접근)
Plain Text
복사
시나리오 2: 같은 사용자 F5 연타
flowchart LR
F5["F5 연타"] --> RJ["POST /rejoin"]
RJ --> CHK{"ZRANK 존재?"}
CHK -->|"Yes"| KEEP["기존 순번 유지 ✅"]
CHK -->|"No"| NEW["ZADD NX 새 등록"]
BTN["버튼 연타"] --> JOIN["POST /join"]
JOIN --> NEW2["매번 새 UUID<br/>(별도 대기열 등록)"]
NEW2 -.-x|"❌ 잘못된 패턴"| WARN["rejoin API 사용 권장"]
style KEEP fill:#c8e6c9
style WARN fill:#ffcdd2Mermaid
복사
// 첫 요청
ZADD queue NX 1704067200000 "uuid-abc" → 1 (추가됨)
// F5 연타 (rejoin API)
ZRANK queue "uuid-abc" → 0 (이미 존재, 순번 유지)
// 새 토큰으로 join 연타해도 매번 새 UUID → 각각 다른 대기열 등록
// → rejoin API로 기존 토큰 유지가 정답
TypeScript
복사
시나리오 3: 이벤트 마감 후 접수
stateDiagram-v2
[*] --> PREPARE
PREPARE --> OPEN: init-event.ts
OPEN --> CLOSED: 매진 or 시간 마감
CLOSED --> [*]
state OPEN {
[*] --> AcceptingQueue
AcceptingQueue --> ProcessingEntry: Scheduler
ProcessingEntry --> AcceptingBooking: ACTIVE 예매
}Mermaid
복사
async join(eventId: string) {
const meta = await redis.hget(keys.eventMeta(eventId), 'status');
if (meta === 'CLOSED') {
throw new AppError(403, '예매가 마감되었습니다.', 'EVENT_CLOSED');
}
// ...
}
TypeScript
복사
4.5 curl 테스트
# 대기열 진입
curl -X POST <http://localhost:3000/api/queue/join> \
-H "Content-Type: application/json" \
-d '{}'
# 응답 예시
# {"success":true,"data":{"token":"f47ac10b-58cc-4372-a567-0e02b2c3d479","eventId":"ktx-2026-chuseok","joinedAt":1704067200123}}
# 재진입 (F5 대응)
curl -X POST <http://localhost:3000/api/queue/rejoin> \
-H "Content-Type: application/json" \
-d '{"token":"f47ac10b-58cc-4372-a567-0e02b2c3d479"}'
# 대기열 이탈
curl -X DELETE "<http://localhost:3000/api/queue/leave?token=f47ac10b-58cc-4372-a567-0e02b2c3d479>"
Bash
복사
4.6 부하 테스트 스크립트 (간단)
// scripts/load-test-join.ts
import { performance } from 'perf_hooks';
const CONCURRENCY = 1000;
const TOTAL = 10000;
const BASE_URL = '<http://localhost:3000>';
async function join() {
const res = await fetch(`${BASE_URL}/api/queue/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
return res.json();
}
async function main() {
const start = performance.now();
let completed = 0;
let errors = 0;
const batch = async () => {
const promises = Array.from({ length: CONCURRENCY }, () =>
join()
.then(() => { completed++; })
.catch(() => { errors++; })
);
await Promise.all(promises);
};
const batches = Math.ceil(TOTAL / CONCURRENCY);
for (let i = 0; i < batches; i++) {
await batch();
process.stdout.write(`\r${completed}/${TOTAL} (errors: ${errors})`);
}
const elapsed = (performance.now() - start) / 1000;
console.log(`\n완료: ${completed}건, ${elapsed.toFixed(2)}초, ${(completed / elapsed).toFixed(0)} req/s`);
}
main();
TypeScript
복사
4.7 클라이언트 토큰 저장 가이드
본 가이드에서 언급한 대로, 발급받은 UUID는 클라이언트에 저장합니다.
// 대기열 진입 후
const { data } = await response.json();
localStorage.setItem('queueToken', data.token);
// 이후 모든 요청에 사용
const token = localStorage.getItem('queueToken');
JavaScript
복사
저장소 | 장단점 |
localStorage | 탭 간 공유, 새로고침 유지 O |
sessionStorage | 탭 닫으면 삭제 |
HttpOnly Cookie | XSS 안전, 서버 설정 필요 |
4.8 주의사항
1.
join은 DB를 절대 호출하지 않는다 — 이것이 200만 트래픽을 버티는 핵심
2.
NX 옵션 필수 — 중복 등록 방지
3.
토큰은 클라이언트가 보관 — 로그인 전 단계이므로 UUID로 식별
4.
rate limit은 보조 — 메인 방어는 Redis 멱등성 + 인프라 레벨 제한
5. 폴링 API 및 대기 상태 조회
클라이언트가 주기적으로 호출하여 대기 순번, 입장 가능 여부를 확인합니다.
5.1 API 스펙
GET /api/status/poll?token={uuid}
Response 200 — 대기 중 (WAITING)
{
"success": true,
"data": {
"status": "WAITING",
"rank": 502341,
"totalWaiting": 1980000,
"ahead": 502340,
"behind": 1477659,
"remainingSeats": 87,
"nextPollMs": 5000,
"activeExpiresAt": null
}
}
JSON
복사
Response 200 — 입장 가능 (ACTIVE)
{
"success": true,
"data": {
"status": "ACTIVE",
"rank": null,
"totalWaiting": 1950000,
"ahead": null,
"behind": null,
"remainingSeats": 85,
"nextPollMs": 2000,
"activeExpiresAt": 1704067380000
}
}
JSON
복사
Response 200 — 만료 (EXPIRED)
{
"success": true,
"data": {
"status": "EXPIRED",
"rank": null,
"totalWaiting": 0,
"ahead": null,
"behind": null,
"remainingSeats": 0,
"nextPollMs": 0,
"activeExpiresAt": null
}
}
JSON
복사
stateDiagram-v2
[*] --> WAITING: POST /queue/join
WAITING --> WAITING: poll (rank > 0)
WAITING --> ACTIVE: poll (ZSCORE active 존재)
WAITING --> NOT_FOUND: poll (ZSET에 없음)
ACTIVE --> BOOKED: POST /booking/reserve 성공
ACTIVE --> EXPIRED: 3분 TTL 초과
NOT_FOUND --> WAITING: POST /queue/rejoin
EXPIRED --> [*]
BOOKED --> [*]
note right of WAITING
ZRANK로 순번 조회
ZCARD로 전체 인원
end note
note right of ACTIVE
예매 UI 표시
3분 카운트다운
end noteMermaid
복사
5.2 services/polling.service.ts
import { redis } from '../config/redis';
import { keys } from '../redis/keys';
import { config } from '../config';
import { QueueStatus, StatusPollResponse } from '../types';
export class PollingService {
/**
* 폴링 응답 생성
* Redis 읽기만 수행 → 초경량
*/
async poll(token: string, eventId: string = config.EVENT_ID) {
// 1. 예매 완료 확인
const booked = await redis.exists(keys.bookingDone(token, eventId));
if (booked) {
return this.buildResponse('ACTIVE', eventId, null, null);
}
// 2. Active User 확인
const activeKey = keys.activeUsers(eventId);
const expireAtStr = await redis.zscore(activeKey, token);
if (expireAtStr !== null) {
const expireAt = parseInt(expireAtStr, 10);
if (Date.now() < expireAt) {
const remaining = await this.getRemainingSeats(eventId);
const totalWaiting = await redis.zcard(keys.waitingQueue(eventId));
return this.buildResponse('ACTIVE', eventId, null, expireAt, remaining, totalWaiting);
}
// 만료됨
await redis.zrem(activeKey, token);
return this.buildResponse('EXPIRED', eventId, null, null);
}
// 3. 대기열 확인
const queueKey = keys.waitingQueue(eventId);
const rank = await redis.zrank(queueKey, token);
if (rank === null) {
return this.buildResponse('NOT_FOUND', eventId, null, null);
}
// 4. 순번 및 통계 (Pipeline으로 한 번에)
const pipeline = redis.pipeline();
pipeline.zcard(queueKey);
pipeline.get(keys.remainingSeats(eventId));
const results = await pipeline.exec();
const totalWaiting = (results?.[0]?.[1] as number) ?? 0;
const remainingSeats = parseInt((results?.[1]?.[1] as string) ?? '0', 10);
const rankOneBased = rank + 1; // 0-based → 1-based
const ahead = rank; // 내 앞 사람 수
const behind = totalWaiting - rankOneBased;
return this.buildResponse(
'WAITING',
eventId,
{ rank: rankOneBased, ahead, behind, totalWaiting },
null,
remainingSeats,
totalWaiting
);
}
private async getRemainingSeats(eventId: string): Promise<number> {
const val = await redis.get(keys.remainingSeats(eventId));
return parseInt(val ?? '0', 10);
}
/**
* Adaptive Polling: 대기 순번에 따라 폴링 주기 결정
*/
private calcNextPollMs(status: QueueStatus, rank: number | null): number {
if (status === 'ACTIVE') return 2000;
if (status === 'EXPIRED' || status === 'NOT_FOUND') return 0;
if (rank === null) return 5000;
// SRT/KTX처럼 서버가 폴링 주기를 제어
if (rank > 100000) return 10000; // 10초
if (rank > 10000) return 5000; // 5초
if (rank > 1000) return 3000; // 3초
if (rank > 100) return 2000; // 2초
return 1000; // 1초
}
private buildResponse(
status: QueueStatus,
eventId: string,
rankInfo: { rank: number; ahead: number; behind: number; totalWaiting: number } | null,
activeExpiresAt: number | null,
remainingSeats: number = 0,
totalWaiting: number = 0
) {
const nextPollMs = this.calcNextPollMs(status, rankInfo?.rank ?? null);
// Jitter: ±20% 랜덤 오프셋 (동시 폴링 분산)
const jitter = nextPollMs > 0
? Math.floor(nextPollMs * (0.8 + Math.random() * 0.4))
: 0;
return {
success: true as const,
data: {
status,
rank: rankInfo?.rank ?? null,
totalWaiting: rankInfo?.totalWaiting ?? totalWaiting,
ahead: rankInfo?.ahead ?? null,
behind: rankInfo?.behind ?? null,
remainingSeats,
nextPollMs: jitter,
activeExpiresAt,
},
};
}
}
export const pollingService = new PollingService();
TypeScript
복사
flowchart TD
START["GET /status/poll"] --> BOOKED{"booking:done<br/>존재?"}
BOOKED -->|"Yes"| DONE["status: BOOKED"]
BOOKED -->|"No"| ACTIVE{"ZSCORE<br/>active_users?"}
ACTIVE -->|"존재 & 미만료"| ACT["status: ACTIVE"]
ACTIVE -->|"만료"| EXP["status: EXPIRED"]
ACTIVE -->|"없음"| WAIT{"ZRANK<br/>waiting_queue?"}
WAIT -->|"존재"| WAITING["status: WAITING<br/>+ rank, ahead, behind"]
WAIT -->|"없음"| NF["status: NOT_FOUND"]
WAITING --> POLL["nextPollMs 후 재폴링"]
POLL --> START
style ACT fill:#c8e6c9
style EXP fill:#ffcdd2
style WAITING fill:#e3f2fdMermaid
복사
5.3 routes/status.routes.ts
import { Router } from 'express';
import { z } from 'zod';
import { pollingService } from '../services/polling.service';
import { AppError } from '../middleware/errorHandler';
export const statusRouter = Router();
const pollSchema = z.object({
token: z.string().uuid(),
eventId: z.string().optional(),
});
/**
* GET /api/status/poll?token=xxx
* 대기 상태 폴링
*/
statusRouter.get('/poll', async (req, res, next) => {
try {
const query = pollSchema.parse(req.query);
const result = await pollingService.poll(query.token, query.eventId);
res.status(200).json(result);
} catch (err) {
next(err);
}
});
TypeScript
복사
5.4 Redis Pipeline 최적화
sequenceDiagram
participant API as Express API
participant R as Redis
Note over API,R: ❌ Bad: 3번 왕복 (3 × RTT)
API->>R: ZRANK
R-->>API: rank
API->>R: ZCARD
R-->>API: total
API->>R: GET seats
R-->>API: remaining
Note over API,R: ✅ Good: Pipeline 1번 왕복
API->>R: Pipeline [ZRANK, ZCARD, GET]
R-->>API: [rank, total, remaining]Mermaid
복사
폴링은 초당 수십만 건 발생합니다. Pipeline으로 RTT를 줄입니다.
// Bad: 3번 왕복 (3 × RTT)
const rank = await redis.zrank(key, token);
const total = await redis.zcard(key);
const seats = await redis.get(seatKey);
// Good: 1번 왕복 (1 × RTT)
const pipeline = redis.pipeline();
pipeline.zrank(key, token);
pipeline.zcard(key);
pipeline.get(seatKey);
const results = await pipeline.exec();
TypeScript
복사
성능 비교
Redis RTT: ~0.5ms (같은 AZ)
Pipeline 없이 3명령: 1.5ms
Pipeline 1번: 0.5ms
폴링 100,000 req/s 기준:
- Pipeline 없음: 150ms 처리 지연
- Pipeline: 50ms 처리 지연
Plain Text
복사
5.5 ZRANK / ZCARD 동작 원리
# 200만 명 대기열
ZCARD ticket:ktx:queue:waiting
→ 2000000 (O(1) - 내부 카운터 유지)
ZRANK ticket:ktx:queue:waiting "my-uuid"
→ 502340 (O(log N) - skip list)
# 내 앞: 502340명
# 내 뒤: 2000000 - 502341 = 1477659명
Plain Text
복사
본 가이드에서 강조한 "G랭크로 내 위치, G카드로 전체 인원" 이 바로 이것입니다.
flowchart LR
subgraph ZSET2["waiting_queue (2,000,000명)"]
direction TB
HEAD["... 앞 502,340명 ..."]
ME["⭐ my-uuid<br/>rank: 502340"]
TAIL["... 뒤 1,477,659명 ..."]
end
ZCARD["ZCARD → 2,000,000"] --> ZSET2
ZRANK["ZRANK → 502340"] --> ME
AHEAD["ahead = 502340"] --> ME
BEHIND["behind = 1,477,659"] --> MEMermaid
복사
5.6 폴링 부하 계산
가정:
- 대기 중 사용자: 150만 명
- 평균 폴링 주기: 5초
- 폴링 QPS = 1,500,000 / 5 = 300,000 req/s
각 폴링 요청:
- Redis Pipeline 3~4 명령
- DB 접근: 0
- 응답 크기: ~200 bytes
→ API 서버 10대 (각 30,000 req/s)로 처리 가능
→ Redis Cluster 3노드 (각 100,000 ops/s)로 충분
Plain Text
복사
5.7 curl 테스트
TOKEN="f47ac10b-58cc-4372-a567-0e02b2c3d479"
# 폴링
curl "<http://localhost:3000/api/status/poll?token=$TOKEN>"
# 응답에서 nextPollMs 확인 후 그만큼 대기하고 재호출
Bash
복사
5.8 폴링 vs WebSocket 비교 (설계 근거)
항목 | 폴링 | WebSocket |
연결 | 요청-응답 후 종료 | 50만 연결 유지 |
메모리 | Stateless | 50만 소켓 객체 상주 |
재접속 | 자연스러움 | 50만 동시 재접속 = DDoS |
LB 확장 | 무한 스케일 아웃 | 연결 수 제한 |
구현 복잡도 | 낮음 | 높음 (sticky session) |
실시간성 | 1~5초 지연 | 즉시 |
결론: "내가 몇 번째인지" 정도의 단방향 상태 확인에는 폴링이 압도적으로 유리.
5.8 Adaptive Polling 결정 트리
flowchart TD
POLL["폴링 응답 생성"] --> STATUS{"status?"}
STATUS -->|"ACTIVE"| T2["nextPollMs = 2000"]
STATUS -->|"EXPIRED"| T0["nextPollMs = 0"]
STATUS -->|"WAITING"| RANK{"rank?"}
RANK -->|"> 100,000"| T10["10,000ms"]
RANK -->|"> 10,000"| T5["5,000ms"]
RANK -->|"> 1,000"| T3["3,000ms"]
RANK -->|"> 100"| T2B["2,000ms"]
RANK -->|"≤ 100"| T1["1,000ms"]
T10 & T5 & T3 & T2B & T1 --> JITTER["±20% Jitter 적용"]
T2 --> JITTER2["±20% Jitter"]
JITTER & JITTER2 --> OUT["nextPollMs 반환"]Mermaid
복사
5.9 SRT 실제 구현 참고 (실제 서비스 분석)
SRT 예매 시스템에서 관찰된 패턴:
응답 필드:
- ahead: 내 앞 대기 인원
- behind: 내 뒤 대기 인원
- TTL: 서버가 지정한 다음 폴링 주기 (1~2초)
- 키: 매 호출마다 갱신되는 서명 토큰 (재사용 방지)
- Content-Type: application/javascript (JSONP 방식)
Plain Text
복사
우리 구현에서는 JSON + nextPollMs로 동일한 효과를 냅니다.
5.10 에러 처리
// 토큰이 없는 경우
if (rank === null && activeScore === null && !booked) {
return { status: 'NOT_FOUND', nextPollMs: 0 };
// 클라이언트: 대기열 재등록 (/api/queue/rejoin) 유도
}
// 이벤트 종료
const eventStatus = await redis.hget(keys.eventMeta(eventId), 'status');
if (eventStatus === 'CLOSED') {
return { status: 'EXPIRED', nextPollMs: 0 };
}
TypeScript
복사
6. 2단계 — 입장 처리 스케줄러
Phase 2: 스케줄러가 대기열 앞에서 N명씩 꺼내 Active User로 이동. RDB TPS에 맞춰 유량 제어.
6.1 동작 원리
flowchart TB
SCH["⏰ Scheduler<br/>(1초 tick)"]
SCH --> POP["ZPOPMIN waiting_queue<br/>70명"]
POP --> ADD["ZADD active_users<br/>expireAt = now + 180s"]
ADD --> CLEAN["ZREMRANGEBYSCORE<br/>만료 유저 정리"]
CLEAN --> WAIT["다음 tick 대기"]
subgraph Redis3["Redis"]
WQ2["waiting_queue"]
AU2["active_users"]
end
POP --> WQ2
ADD --> AU2
CLEAN --> AU2
style SCH fill:#fff3e0,stroke:#ef6c00Mermaid
복사
6.2 batchSize 산정 공식
batchSize = RDB_TPS × safetyRatio × schedulerInterval
예시:
- RDB 예매 처리 TPS = 100건/초 (부하 테스트 결과)
- safetyRatio = 0.7 (70%만 입장 허용)
- schedulerInterval = 1초
batchSize = 100 × 0.7 × 1 = 70명/초
Plain Text
복사
RDB TPS | Safety 70% | batchSize (1초) |
50 | 35 | 35 |
100 | 70 | 70 |
200 | 140 | 140 |
500 | 350 | 350 |
flowchart LR
BT["부하 테스트"] --> TPS2["RDB TPS 측정<br/>(예: 100/s)"]
TPS2 --> MARGIN2["× 0.7 안전 마진"]
MARGIN2 --> BS["batchSize = 70"]
BS --> INTERVAL["스케줄러 주기<br/>(1초)"]
INTERVAL --> RESULT["70명/초 입장"]
style BT fill:#e3f2fd
style RESULT fill:#c8e6c9Mermaid
복사
6.3 redis/scripts/pop-queue.lua
-- pop-queue.lua
-- 대기열에서 N명을 원자적으로 꺼내 Active User로 이동
local waitingKey = KEYS[1] -- waiting queue
local activeKey = KEYS[2] -- active users
local batchSize = tonumber(ARGV[1])
local expireAt = tonumber(ARGV[2]) -- now + TTL
local results = {}
local popped = redis.call('ZPOPMIN', waitingKey, batchSize)
-- ZPOPMIN 결과: [member1, score1, member2, score2, ...]
for i = 1, #popped, 2 do
local member = popped[i]
local score = popped[i + 1]
redis.call('ZADD', activeKey, expireAt, member)
table.insert(results, member)
end
-- 만료된 active user 정리
local now = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', activeKey, '-inf', now)
return results
Lua
복사
6.4 services/entry.service.ts
import { redis } from '../config/redis';
import { evalLua } from '../config/redis';
import { keys } from '../redis/keys';
import { config } from '../config';
export class EntryService {
/**
* 대기열 → Active User 이동 (원자적)
*/
async processEntry(eventId: string = config.EVENT_ID) {
const now = Date.now();
const expireAt = now + config.ACTIVE_USER_TTL_SEC * 1000;
const batchSize = config.ENTRY_BATCH_SIZE;
const waitingKey = keys.waitingQueue(eventId);
const activeKey = keys.activeUsers(eventId);
// 대기열이 비었으면 스킵
const queueSize = await redis.zcard(waitingKey);
if (queueSize === 0) {
return { processed: 0, tokens: [] };
}
const result = await evalLua(
'pop-queue',
2,
waitingKey,
activeKey,
batchSize,
expireAt,
now
) as string[];
return {
processed: result.length,
tokens: result,
};
}
/**
* Active User 여부 확인
*/
async isActive(token: string, eventId: string = config.EVENT_ID): Promise<boolean> {
const score = await redis.zscore(keys.activeUsers(eventId), token);
if (score === null) return false;
return Date.now() < parseInt(score, 10);
}
/**
* 만료된 Active User 수 조회 (모니터링)
*/
async countExpired(eventId: string = config.EVENT_ID): Promise<number> {
const now = Date.now();
const activeKey = keys.activeUsers(eventId);
// score < now 인 멤버 수
const all = await redis.zrangebyscore(activeKey, '-inf', now);
return all.length;
}
}
export const entryService = new EntryService();
TypeScript
복사
6.5 scheduler/entry.scheduler.ts
import cron from 'node-cron';
import { entryService } from '../services/entry.service';
import { config } from '../config';
import { redis } from '../config/redis';
let isRunning = false;
async function tick() {
// 중복 실행 방지 (이전 tick이 아직 실행 중이면 스킵)
if (isRunning) {
console.warn('[Scheduler] 이전 tick 실행 중, 스킵');
return;
}
isRunning = true;
try {
const result = await entryService.processEntry();
if (result.processed > 0) {
console.log(`[Scheduler] ${result.processed}명 입장 처리`);
}
} catch (err) {
console.error('[Scheduler] 오류:', err);
} finally {
isRunning = false;
}
}
async function main() {
await redis.ping();
console.log('[Scheduler] Redis 연결 OK');
console.log(`[Scheduler] batchSize=${config.ENTRY_BATCH_SIZE}, TTL=${config.ACTIVE_USER_TTL_SEC}s`);
// 1초마다 실행 (node-cron 6필드: 초 분 시 일 월 요일)
cron.schedule('*/1 * * * * *', tick);
console.log('[Scheduler] 시작됨');
}
// Graceful shutdown
process.on('SIGTERM', async () => {
console.log('[Scheduler] 종료 중...');
await redis.quit();
process.exit(0);
});
main().catch(console.error);
TypeScript
복사
6.6 Active User TTL (3분) 상세
KTX 실제 동작
"3분 사이에 뭘 해도 3분이 지나가면 튕겨 버려요"
구현 방식
입장 시각: T
만료 시각: T + 180초
폴링 API에서:
ZSCORE active_users {token} → 1704067380000
now < 1704067380000 → ACTIVE
now >= 1704067380000 → EXPIRED (ZREM 후 응답)
Plain Text
복사
누적 문제와 해결
Q&A에서 지적된 "Active User가 계속 누적된다" 문제:
시나리오:
- 1초에 70명 입장
- 예매 완료까지 평균 30초 소요
- 동시 Active = 70 × 30 = 2,100명 (관리 가능)
최악:
- 70명/초 × 180초 TTL = 12,600명 동시 Active
- 이 중 실제 예매하는 건 소수
- TTL로 자동 정리되므로 문제 없음
Plain Text
복사
flowchart TB
subgraph Timeline["시간 흐름 (1초에 70명 입장)"]
T0["T+0s: 70명 입장"]
T1["T+1s: +70명 (누적 140)"]
T30["T+30s: 누적 ~2,100<br/>(70×30, 예매 중)"]
T180["T+180s: TTL 만료 → 자동 퇴장"]
end
T0 --> T1 --> T30 --> T180
subgraph Defense["2차 방어"]
LUA["Lua Script<br/>잔여 좌석 원자 차감"]
end
T30 --> DefenseMermaid
복사
6.7 스케줄러 다중 실행 방지
운영에서 스케줄러를 실수로 2대 띄우면 2배 입장이 발생합니다.
sequenceDiagram
participant S1 as Scheduler #1
participant S2 as Scheduler #2
participant R as Redis
S1->>R: SET lock NX (획득 ✅)
S2->>R: SET lock NX (실패 ❌)
S1->>R: ZPOPMIN 70 → ZADD active
Note over S2: 스킵 (lock 없음)
S1->>R: DEL lock (해제)Mermaid
복사
Redlock 패턴
import { redis } from '../config/redis';
const LOCK_KEY = 'scheduler:entry:lock';
const LOCK_TTL_MS = 900; // 1초 tick보다 짧게
async function acquireLock(): Promise<boolean> {
const result = await redis.set(LOCK_KEY, process.pid, 'PX', LOCK_TTL_MS, 'NX');
return result === 'OK';
}
async function tick() {
const acquired = await acquireLock();
if (!acquired) return;
// ... 입장 처리
}
TypeScript
복사
Redis Cluster 환경
// Redlock 라이브러리 사용 권장
import Redlock from 'redlock';
const redlock = new Redlock([redis], {
retryCount: 0, // 즉시 실패
});
async function tick() {
try {
const lock = await redlock.acquire(['scheduler:entry:lock'], 900);
try {
await entryService.processEntry();
} finally {
await lock.release();
}
} catch {
// 다른 인스턴스가 실행 중
}
}
TypeScript
복사
6.8 모니터링 메트릭
// scheduler/entry.scheduler.ts 에 추가
async function tick() {
const result = await entryService.processEntry();
// 메트릭 기록 (Prometheus, CloudWatch 등)
metrics.gauge('queue.waiting.total', await redis.zcard(keys.waitingQueue()));
metrics.gauge('queue.active.total', await redis.zcard(keys.activeUsers()));
metrics.counter('queue.entry.processed', result.processed);
const expired = await entryService.countExpired();
if (expired > 0) {
metrics.counter('queue.active.expired', expired);
}
}
TypeScript
복사
알림 기준
메트릭 | Warning | Critical |
waiting_queue 크기 | > 100만 | > 300만 |
active_users 크기 | > 5만 | > 20만 |
스케줄러 tick 지연 | > 2초 | > 5초 |
Redis 메모리 | > 70% | > 90% |
6.9 입장 처리 시퀀스 다이어그램
sequenceDiagram
participant SCH as Scheduler
participant R as Redis
participant U as 사용자
participant API as Polling API
loop 1초마다
SCH->>R: ZPOPMIN waiting_queue 70
R-->>SCH: [token1..token70]
SCH->>R: ZADD active_users (expireAt) × 70
SCH->>R: ZREMRANGEBYSCORE (만료 정리)
end
U->>API: GET /status/poll
API->>R: ZSCORE active_users {token}
R-->>API: expireAt (미래)
API-->>U: { status: ACTIVE }
Note over U: 예매 UI로 전환Mermaid
복사
6.10 동적 batchSize 조절 (고급)
RDB 부하에 따라 실시간으로 batchSize를 조절합니다.
async function getDynamicBatchSize(): Promise<number> {
const baseSize = config.ENTRY_BATCH_SIZE;
// RDB 응답 시간 기반 조절
const dbLatency = await measureDbLatency();
if (dbLatency > 500) return Math.floor(baseSize * 0.5); // 50% 감소
if (dbLatency > 200) return Math.floor(baseSize * 0.7); // 30% 감소
if (dbLatency < 50) return Math.min(baseSize * 1.2, 200); // 20% 증가
return baseSize;
}
TypeScript
복사
6.11 테스트
# 1. 대기열에 테스트 유저 100명 등록
for i in $(seq 1 100); do
curl -s -X POST <http://localhost:3000/api/queue/join> -H "Content-Type: application/json" -d '{}' &
done
wait
# 2. Redis에서 대기열 확인
redis-cli ZCARD ticket:default-event:queue:waiting
# → 100
# 3. 스케줄러 실행
npm run scheduler
# 4. 1~2초 후 Active 확인
redis-cli ZCARD ticket:default-event:active:users
# → 70 (batchSize=70이면)
redis-cli ZCARD ticket:default-event:queue:waiting
# → 30
Bash
복사
6.12 주의사항
1.
스케줄러는 반드시 1대만 (Redlock 사용)
2.
batchSize는 부하 테스트로 산정 — 추측 금지
3.
TTL 정리는 매 tick마다 — Active User 메모리 누수 방지
4.
스케줄러 장애 시 — 대기열은 계속 쌓이지만 입장이 멈춤 → 모니터링 필수
7. 3단계 — 예매 처리 Lua 동시성 제어
Phase 3: Active User만 예매 가능. Redis Lua Script로 잔여 좌석을 원자적으로 차감.
7.1 핵심 문제: Check-Then-Act
sequenceDiagram
participant A as Thread A
participant B as Thread B
participant R as Redis
Note over R: 잔여 좌석 = 2
A->>R: GET remaining → 2
B->>R: GET remaining → 2
A->>R: DECRBY 3 → -1 ❌
B->>R: DECRBY 3 → -4 ❌
Note over A,B: 초과 예매 발생!Mermaid
복사
DECRBY만으로는 안 됩니다. 조건 확인과 차감이 원자적이어야 합니다.
7.2 Lua Script가 필요한 이유
설계 Q&A에서 설명한 내용:
"DECRBY는 딱 한 장만 예매하는 상황에서 가능. N장을 한 번에 예매해야 하면, 2석 남았는데 3석 요청 시 -1이 반환됨."
-- 원자적: 확인 + 차감이 하나의 트랜잭션
local remaining = tonumber(redis.call('GET', seatKey))
if remaining >= requested then
redis.call('DECRBY', seatKey, requested)
return requested -- 성공: 차감된 수
else
return -1 -- 실패: 좌석 부족
end
Lua
복사
Redis는 Lua Script 실행 중 다른 명령이 끼어들 수 없음 → 원자성 보장.
sequenceDiagram
participant A as Thread A
participant B as Thread B
participant R as Redis (Lua)
Note over R: 잔여 좌석 = 5
A->>R: EVAL reserve-seats (3석)
Note over R: Lua 실행 중<br/>B 차단
B->>R: EVAL reserve-seats (3석)
Note over B: 대기...
R-->>A: OK (잔여 2)
R-->>B: SOLD_OUT (잔여 2 < 3)
Note over A,B: 초과 예매 없음 ✅Mermaid
복사
7.3 redis/scripts/reserve-seats.lua
-- reserve-seats.lua
-- 잔여 좌석 확인 + 차감 + 중복 예매 방지 (원자적)
local seatKey = KEYS[1] -- seats:remaining
local bookingKey = KEYS[2] -- booking:done:{token}
local requested = tonumber(ARGV[1])
local reservationId = ARGV[2]
local bookingTtl = tonumber(ARGV[3]) -- 예매 완료 키 TTL (초)
-- 1. 중복 예매 확인
local alreadyBooked = redis.call('EXISTS', bookingKey)
if alreadyBooked == 1 then
return {err = 'ALREADY_BOOKED', reservationId = redis.call('GET', bookingKey)}
end
-- 2. 잔여 좌석 확인
local remaining = tonumber(redis.call('GET', seatKey) or '0')
if remaining < requested then
return {err = 'SOLD_OUT', remaining = remaining}
end
-- 3. 좌석 차감
local newRemaining = redis.call('DECRBY', seatKey, requested)
-- 4. 예매 완료 기록 (멱등성)
redis.call('SET', bookingKey, reservationId, 'EX', bookingTtl)
return {
ok = true,
reserved = requested,
remaining = newRemaining,
reservationId = reservationId
}
Lua
복사
7.4 services/booking.service.ts
import { v4 as uuidv4 } from 'uuid';
import { redis } from '../config/redis';
import { evalLua } from '../config/redis';
import { keys } from '../redis/keys';
import { config } from '../config';
import { entryService } from './entry.service';
import { AppError } from '../middleware/errorHandler';
interface LuaReserveResult {
ok?: boolean;
err?: string;
reserved?: number;
remaining?: number;
reservationId?: string;
}
export class BookingService {
/**
* 예매 처리
* 1. Active User 확인
* 2. Lua Script로 좌석 원자적 차감
* 3. 성공 시 RDB/MQ 저장
*/
async reserve(
token: string,
seatCount: number,
eventId: string = config.EVENT_ID
) {
// 유효성 검증
if (seatCount < 1 || seatCount > 10) {
throw new AppError(400, '좌석 수는 1~10 사이여야 합니다.', 'INVALID_SEAT_COUNT');
}
// Active User 확인
const isActive = await entryService.isActive(token, eventId);
if (!isActive) {
throw new AppError(403, '입장 권한이 없거나 시간이 만료되었습니다.', 'NOT_ACTIVE');
}
const reservationId = uuidv4();
const seatKey = keys.remainingSeats(eventId);
const bookingKey = keys.bookingDone(token, eventId);
const result = await evalLua(
'reserve-seats',
2,
seatKey,
bookingKey,
seatCount,
reservationId,
86400 // 24시간 TTL
) as LuaReserveResult;
if (result.err === 'ALREADY_BOOKED') {
throw new AppError(409, '이미 예매를 완료했습니다.', 'ALREADY_BOOKED');
}
if (result.err === 'SOLD_OUT') {
throw new AppError(409, `좌석이 부족합니다. (잔여: ${result.remaining}석)`, 'SOLD_OUT');
}
// Active User에서 제거 (예매 완료)
await redis.zrem(keys.activeUsers(eventId), token);
// 비동기 저장 (8장 RDB/MQ 비동기 저장에서 상세)
await this.persistReservation({
reservationId: result.reservationId!,
token,
seatCount: result.reserved!,
eventId,
});
return {
reservationId: result.reservationId!,
seatCount: result.reserved!,
remainingSeats: result.remaining!,
};
}
/**
* RDB/MQ 비동기 저장 (스텁)
*/
private async persistReservation(data: {
reservationId: string;
token: string;
seatCount: number;
eventId: string;
}) {
// 8장에서 구현
console.log('[Booking] 저장 요청:', data.reservationId);
}
}
export const bookingService = new BookingService();
TypeScript
복사
7.5 routes/booking.routes.ts
import { Router } from 'express';
import { z } from 'zod';
import { bookingService } from '../services/booking.service';
import { AppError } from '../middleware/errorHandler';
export const bookingRouter = Router();
const reserveSchema = z.object({
token: z.string().uuid(),
seatCount: z.number().int().min(1).max(10),
eventId: z.string().optional(),
});
/**
* POST /api/booking/reserve
* 예매 처리 (Active User만 가능)
*/
bookingRouter.post('/reserve', async (req, res, next) => {
try {
const body = reserveSchema.parse(req.body);
const result = await bookingService.reserve(
body.token,
body.seatCount,
body.eventId
);
res.status(200).json({ success: true, data: result });
} catch (err) {
next(err);
}
});
/**
* GET /api/booking/status?token=xxx
* 예매 완료 여부 확인
*/
bookingRouter.get('/status', async (req, res, next) => {
try {
const token = req.query.token as string;
if (!token) throw new AppError(400, 'token 필수', 'MISSING_TOKEN');
const reservationId = await redis.get(keys.bookingDone(token));
res.status(200).json({
success: true,
data: {
booked: !!reservationId,
reservationId: reservationId ?? null,
},
});
} catch (err) {
next(err);
}
});
TypeScript
복사
7.6 동시성 테스트
// scripts/concurrency-test.ts
import { redis } from '../src/config/redis';
import { keys } from '../src/redis/keys';
import { evalLua } from '../src/config/redis';
async function main() {
const eventId = 'concurrency-test';
const seatKey = keys.remainingSeats(eventId);
// 10석으로 초기화
await redis.set(seatKey, 10);
const CONCURRENT = 50;
const SEATS_PER_REQUEST = 3;
const promises = Array.from({ length: CONCURRENT }, (_, i) => {
const token = `user-${i}`;
const bookingKey = keys.bookingDone(token, eventId);
return evalLua('reserve-seats', 2, seatKey, bookingKey, SEATS_PER_REQUEST, `res-${i}`, 3600)
.then((r) => ({ token, result: r }))
.catch((e) => ({ token, error: e.message }));
});
const results = await Promise.all(promises);
const success = results.filter((r) => (r.result as any)?.ok);
const soldOut = results.filter((r) => (r.result as any)?.err === 'SOLD_OUT');
const finalRemaining = await redis.get(seatKey);
console.log(`총 요청: ${CONCURRENT} (${SEATS_PER_REQUEST}석씩)`);
console.log(`성공: ${success.length}`);
console.log(`매진: ${soldOut.length}`);
console.log(`최종 잔여: ${finalRemaining}석`);
console.log(`예상 잔여: ${10 - success.length * SEATS_PER_REQUEST}석`);
// → 10석 / 3석 = 3명 성공(9석), 잔여 1석
await redis.quit();
}
main();
TypeScript
복사
예상 결과:
총 요청: 50 (3석씩)
성공: 3
매진: 47
최종 잔여: 1석
예상 잔여: 1석 ← 초과 예매 없음!
Plain Text
복사
7.7 왜 DECRBY가 안 되는지 — 상세 비교
Case 1: DECRBY (1석 예매)
# 100석, 100명이 동시에 1석씩 요청
DECRBY seats 1 → 99
DECRBY seats 1 → 98
...
DECRBY seats 1 → 0
# 100번째까지 정확. OK.
Plain Text
복사
Case 2: DECRBY (N석 예매) — 문제 발생
# 5석 남음, A가 3석, B가 3석 동시 요청
# A: GET → 5, B: GET → 5 (둘 다 5로 읽음)
# A: DECRBY 3 → 2
# B: DECRBY 3 → -1 ← 초과 예매!
Plain Text
복사
Case 3: Lua Script — 정확
sequenceDiagram
participant A as Thread A (3석)
participant B as Thread B (3석)
participant R as Redis Lua
Note over R: 잔여 = 5
A->>R: EVAL reserve-seats (3석)
Note over R: 원자 실행 중 B 대기
R-->>A: OK (잔여 2)
B->>R: EVAL reserve-seats (3석)
R-->>B: SOLD_OUT (2 < 3)
Note over A,B: 잔여 2, 초과 예매 없음 ✅Mermaid
복사
# A: remaining=5, 3<=5 → DECRBY → 2, return OK
# B: remaining=2, 3>2 → return SOLD_OUT
# 결과: 2석 남음, 초과 예매 없음
Lua
복사
7.8 좌석 선점 (특정 자리 지정) — 확장
Q&A: "특정 자리까지 선택해야 하면 SET NX로 선점"
sequenceDiagram
participant U as 사용자
participant API as API
participant R as Redis
U->>API: 좌석 5A 선택
API->>R: SET seat:5A {token} NX EX 180
alt 좌석 비어있음
R-->>API: OK
API-->>U: 선점 성공 ✅
else 이미 선점됨
R-->>API: nil
API-->>U: SEAT_TAKEN ❌
end
Note over U: 3분 내 예매 완료 or TTL 만료 → 자동 해제Mermaid
복사
-- hold-seat.lua
-- 특정 좌석 선점 (TTL 포함)
local seatId = KEYS[1] -- seat:{eventId}:{seatNumber}
local userToken = ARGV[1]
local ttl = tonumber(ARGV[2])
local result = redis.call('SET', seatId, userToken, 'NX', 'EX', ttl)
if result then
return {ok = true}
else
local holder = redis.call('GET', seatId)
return {err = 'SEAT_TAKEN', holder = holder}
end
Lua
복사
// 좌석 선점 API
async holdSeat(token: string, seatNumber: string, eventId: string) {
const seatKey = `ticket:{${eventId}}:seat:${seatNumber}`;
const result = await evalLua('hold-seat', 1, seatKey, token, 180); // 3분 TTL
// ...
}
TypeScript
복사
7.9 예매 실패 시나리오
상황 | HTTP | code | 클라이언트 동작 |
Active 아님 | 403 | NOT_ACTIVE | 대기열 화면으로 |
TTL 만료 | 403 | NOT_ACTIVE | "시간 초과" 안내 |
좌석 부족 | 409 | SOLD_OUT | "매진" 안내 |
중복 예매 | 409 | ALREADY_BOOKED | 예매 완료 화면 |
잘못된 좌석 수 | 400 | INVALID_SEAT_COUNT | 입력 수정 |
7.10 전체 예매 플로우
flowchart TB
START["POST /booking/reserve"] --> CHECK1{"isActive(token)?"}
CHECK1 -->|"No"| E403["403 NOT_ACTIVE"]
CHECK1 -->|"Yes"| LUA["Lua Script 실행"]
subgraph LuaBlock["reserve-seats.lua (원자적)"]
L1["EXISTS booking:done?"]
L2["GET remaining"]
L3{"remaining >= seatCount?"}
L4["DECRBY + SET booking:done"]
L5["return SOLD_OUT"]
L1 --> L2 --> L3
L3 -->|"Yes"| L4
L3 -->|"No"| L5
end
LUA --> LuaBlock
L4 --> ZREM["ZREM active_users"]
ZREM --> MQ["MQ publish"]
MQ --> WORKER["Worker → RDB INSERT"]
WORKER --> OK["200 OK"]
L5 --> E409["409 SOLD_OUT"]
style LuaBlock fill:#fff3e0,stroke:#ef6c00
style OK fill:#c8e6c9
style E403 fill:#ffcdd2
style E409 fill:#ffcdd2Mermaid
복사
7.11 curl 테스트
TOKEN="your-active-user-token"
# 예매 (5석)
curl -X POST <http://localhost:3000/api/booking/reserve> \
-H "Content-Type: application/json" \
-d "{\"token\":\"$TOKEN\",\"seatCount\":5}"
# 성공 응답
# {"success":true,"data":{"reservationId":"...","seatCount":5,"remainingSeats":95}}
# 중복 예매 시도
curl -X POST <http://localhost:3000/api/booking/reserve> \
-H "Content-Type: application/json" \
-d "{\"token\":\"$TOKEN\",\"seatCount\":3}"
# → 409 ALREADY_BOOKED
# 잔여 좌석 확인
redis-cli GET ticket:default-event:seats:remaining
Bash
복사
7.12 3가지 무결성 보장 정리
요구사항 | 구현 | 위치 |
순서 보장 | Sorted Set (timestamp score) | Phase 1 |
초과 예매 X | Lua Script (check + decr) | Phase 3 |
중복 예매 X | SET NX (booking:done) + ZADD NX (queue) | Phase 1, 3 |
8. RDB / MQ 비동기 저장
Redis에서 예매 성공 후, RDB에 영구 저장. MQ로 부하 분산 (선택).
8.1 왜 비동기인가?
아래와 같이 설명합니다:
"성공한 요청을 API가 MQ에 쓰고 바로 반환. MQ에서 백단 워커가 빼서 DB에 저장."
[동기] Redis 차감 → RDB INSERT → 응답 (RDB가 병목)
[비동기] Redis 차감 → MQ 발행 → 응답 (빠른 응답)
MQ Worker → RDB INSERT (백그라운드)
Plain Text
복사
Phase 1~3에서 이미 트래픽을 스케줄러로 조절했으므로, RDB 직접 저장도 가능합니다. 하지만 MQ를 추가하면 더 안전합니다.
8.2 아키텍처 선택
방식 | 장점 | 단점 | 추천 |
Redis → RDB 직접 | 단순 | RDB 장애 시 예매 실패 | 소규모 |
Redis → MQ → RDB | 완충, 재시도 | 복잡도 증가 | 대규모 |
Redis → MQ → Worker → RDB | 최대 안정성 | 인프라 비용 | 초대규모 |
flowchart TB
subgraph Sync["동기 방식"]
S1["Redis 차감"] --> S2["RDB INSERT"] --> S3["응답"]
S2 -.-x|"RDB 병목"| BOTTLENECK["느린 응답"]
end
subgraph Async["비동기 방식 (권장)"]
A1["Redis 차감"] --> A2["MQ publish"] --> A3["즉시 응답 ✅"]
A2 --> A4["Worker"] --> A5["RDB INSERT"]
end
style Sync fill:#ffcdd2
style Async fill:#c8e6c9Mermaid
복사
8.3 DB 스키마
-- PostgreSQL
CREATE TABLE reservations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
reservation_id VARCHAR(36) NOT NULL UNIQUE,
event_id VARCHAR(100) NOT NULL,
user_token VARCHAR(36) NOT NULL,
seat_count INTEGER NOT NULL CHECK (seat_count > 0),
status VARCHAR(20) NOT NULL DEFAULT 'CONFIRMED',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_event_token UNIQUE (event_id, user_token)
);
CREATE INDEX idx_reservations_event ON reservations(event_id);
CREATE INDEX idx_reservations_token ON reservations(user_token);
SQL
복사
UNIQUE (event_id, user_token): DB 레벨 중복 예매 방지 (최종 방어선).
8.4 db/reservation.repo.ts
import { Pool } from 'pg';
import { config } from '../config';
const pool = new Pool({
connectionString: config.DATABASE_URL,
max: 20, // 커넥션 풀 크기
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
export interface ReservationRecord {
reservationId: string;
eventId: string;
userToken: string;
seatCount: number;
}
export class ReservationRepo {
async insert(record: ReservationRecord): Promise<void> {
const query = `
INSERT INTO reservations (reservation_id, event_id, user_token, seat_count)
VALUES ($1, $2, $3, $4)
ON CONFLICT (event_id, user_token) DO NOTHING
`;
const result = await pool.query(query, [
record.reservationId,
record.eventId,
record.userToken,
record.seatCount,
]);
if (result.rowCount === 0) {
console.warn(`[DB] 중복 예매 무시: ${record.userToken}`);
}
}
async findByToken(eventId: string, token: string) {
const result = await pool.query(
'SELECT * FROM reservations WHERE event_id = $1 AND user_token = $2',
[eventId, token]
);
return result.rows[0] ?? null;
}
async countByEvent(eventId: string): Promise<number> {
const result = await pool.query(
'SELECT COALESCE(SUM(seat_count), 0) as total FROM reservations WHERE event_id = $1',
[eventId]
);
return parseInt(result.rows[0].total, 10);
}
}
export const reservationRepo = new ReservationRepo();
TypeScript
복사
8.5 mq/publisher.ts
import amqp, { Channel, Connection } from 'amqplib';
import { config } from '../config';
let connection: Connection | null = null;
let channel: Channel | null = null;
export async function getMqChannel(): Promise<Channel> {
if (channel) return channel;
connection = await amqp.connect(config.RABBITMQ_URL!);
channel = await connection.createChannel();
await channel.assertQueue(config.BOOKING_QUEUE, {
durable: true, // 서버 재시작 후에도 유지
arguments: {
'x-message-ttl': 3600000, // 1시간 TTL
},
});
return channel;
}
export interface BookingMessage {
reservationId: string;
eventId: string;
userToken: string;
seatCount: number;
timestamp: number;
}
export async function publishBooking(message: BookingMessage): Promise<boolean> {
const ch = await getMqChannel();
const sent = ch.sendToQueue(
config.BOOKING_QUEUE,
Buffer.from(JSON.stringify(message)),
{
persistent: true, // 디스크에 기록
contentType: 'application/json',
messageId: message.reservationId,
}
);
return sent;
}
export async function closeMq(): Promise<void> {
await channel?.close();
await connection?.close();
}
TypeScript
복사
8.6 mq/consumer.ts (Worker)
import { getMqChannel } from './publisher';
import { reservationRepo } from '../db/reservation.repo';
import { config } from '../config';
import { BookingMessage } from './publisher';
const MAX_RETRIES = 3;
export async function startBookingWorker() {
const ch = await getMqChannel();
// prefetch: Worker가 동시에 처리할 메시지 수
ch.prefetch(10);
console.log(`[Worker] ${config.BOOKING_QUEUE} 대기 중...`);
ch.consume(config.BOOKING_QUEUE, async (msg) => {
if (!msg) return;
const data: BookingMessage = JSON.parse(msg.content.toString());
const retryCount = (msg.properties.headers?.['x-retry'] as number) ?? 0;
try {
await reservationRepo.insert({
reservationId: data.reservationId,
eventId: data.eventId,
userToken: data.userToken,
seatCount: data.seatCount,
});
ch.ack(msg);
console.log(`[Worker] 저장 완료: ${data.reservationId}`);
} catch (err) {
console.error(`[Worker] 저장 실패 (${retryCount + 1}/${MAX_RETRIES}):`, err);
if (retryCount < MAX_RETRIES) {
// 재시도: nack + requeue
ch.nack(msg, false, true);
} else {
// 최대 재시도 초과 → Dead Letter Queue
ch.nack(msg, false, false);
console.error(`[Worker] DLQ 이동: ${data.reservationId}`);
}
}
});
}
// Worker 시작
if (require.main === module) {
startBookingWorker().catch(console.error);
}
TypeScript
복사
8.7 booking.service.ts 수정 (MQ 연동)
import { publishBooking } from '../mq/publisher';
import { reservationRepo } from '../db/reservation.repo';
import { config } from '../config';
// BookingService.persistReservation 수정
private async persistReservation(data: {
reservationId: string;
token: string;
seatCount: number;
eventId: string;
}) {
if (config.RABBITMQ_URL) {
// MQ 비동기 저장
const sent = await publishBooking({
reservationId: data.reservationId,
eventId: data.eventId,
userToken: data.token,
seatCount: data.seatCount,
timestamp: Date.now(),
});
if (!sent) {
// MQ 발행 실패 → 직접 저장 fallback
console.warn('[Booking] MQ 발행 실패, 직접 저장 시도');
await reservationRepo.insert({
reservationId: data.reservationId,
eventId: data.eventId,
userToken: data.token,
seatCount: data.seatCount,
});
}
} else if (config.DATABASE_URL) {
// MQ 없으면 직접 저장
await reservationRepo.insert({
reservationId: data.reservationId,
eventId: data.eventId,
userToken: data.token,
seatCount: data.seatCount,
});
}
}
TypeScript
복사
8.8 데이터 정합성 검증
Redis와 RDB 간 정합성을 주기적으로 확인합니다.
flowchart LR
REDIS["Redis<br/>seats:remaining"] --> COMPARE{"remaining ==<br/>TOTAL - DB합계?"}
DB["PostgreSQL<br/>SUM(seat_count)"] --> COMPARE
COMPARE -->|"OK"| PASS["정합성 ✅"]
COMPARE -->|"MISMATCH"| ALERT["알림 발송 🚨<br/>수동 조사"]
style PASS fill:#c8e6c9
style ALERT fill:#ffcdd2Mermaid
복사
// scripts/reconcile.ts
import { redis } from '../src/config/redis';
import { keys } from '../src/redis/keys';
import { reservationRepo } from '../src/db/reservation.repo';
import { config } from '../config';
async function reconcile() {
const eventId = config.EVENT_ID;
const redisRemaining = parseInt(
(await redis.get(keys.remainingSeats(eventId))) ?? '0',
10
);
const dbTotal = await reservationRepo.countByEvent(eventId);
const expected = config.TOTAL_SEATS - dbTotal;
console.log(`총 좌석: ${config.TOTAL_SEATS}`);
console.log(`DB 예매 수: ${dbTotal}`);
console.log(`Redis 잔여: ${redisRemaining}`);
console.log(`예상 잔여: ${expected}`);
console.log(`정합성: ${redisRemaining === expected ? 'OK' : 'MISMATCH!'}`);
if (redisRemaining !== expected) {
// 알림 발송
console.error('[Reconcile] 정합성 불일치 감지!');
}
await redis.quit();
}
reconcile();
TypeScript
복사
8.9 장애 시나리오와 복구
flowchart TD
subgraph S1["시나리오 1: Worker 장애"]
R1["Redis 차감 ✅"] --> MQ1["MQ 메시지 대기"]
MQ1 --> W1["Worker 다운 ❌"]
W1 --> FIX1["Worker 재시작 → 소비 ✅"]
end
subgraph S2["시나리오 2: MQ 발행 실패"]
R2["Redis 차감 ✅"] --> MQ2["MQ 발행 실패 ❌"]
MQ2 --> FB["fallback: 직접 RDB 저장"]
end
subgraph S3["시나리오 3: RDB INSERT 실패"]
R3["Redis ✅, MQ ✅"] --> W3["Worker INSERT 실패"]
W3 --> RETRY["재시도 3회"]
RETRY --> DLQ["DLQ 이동 → 수동 처리"]
end
style FIX1 fill:#c8e6c9
style DLQ fill:#ffcdd2Mermaid
복사
시나리오 1: MQ 발행 성공, Worker 장애
Redis: 차감 완료, booking:done 설정
MQ: 메시지 대기 중
Worker: 다운
→ Worker 재시작 시 메시지 소비 → RDB 저장
→ 문제 없음 (persistent message)
Plain Text
복사
시나리오 2: Redis 성공, MQ 발행 실패
→ fallback으로 직접 RDB 저장 (코드에 구현됨)
→ 또는 Redis booking:done은 있지만 RDB 없음
→ reconcile 스크립트로 감지
Plain Text
복사
시나리오 3: Redis 성공, MQ 성공, RDB INSERT 실패
→ Worker retry (최대 3회)
→ 3회 실패 → DLQ 이동
→ 운영자가 DLQ 메시지 수동 처리
Plain Text
복사
8.10 package.json 스크립트 추가
{
"scripts": {
"worker": "tsx src/mq/consumer.ts",
"reconcile": "tsx scripts/reconcile.ts"
}
}
JSON
복사
8.11 실행 순서 (전체)
# 1. 인프라
docker compose up -d
# 2. 이벤트 초기화
npx tsx scripts/init-event.ts
# 3. API 서버 (여러 인스턴스 가능)
npm run dev
# 4. 스케줄러 (1대)
npm run scheduler
# 5. MQ Worker (여러 인스턴스 가능)
npm run worker
Bash
복사
8.12 MQ vs 직접 저장 결정 가이드
예상 예매 TPS < 100 → RDB 직접 저장 OK
예상 예매 TPS 100~500 → MQ 권장
예상 예매 TPS > 500 → MQ + Worker 다수 필수
Plain Text
복사
핵심:
"이미 앞단에서 스케줄러로 뒤단에서 받을 수 있는 양만큼 조절해서 보내니까 RDB로내도 큰 무리가 없을 수도 있습니다."
즉, 스케줄러가 이미 유량 제어를 하므로 MQ는 추가 안전장치입니다.
9. 클라이언트 구현 가이드
브라우저에서 대기열 진입 → 폴링 → 입장 → 예매까지의 프론트엔드 구현.
9.1 전체 클라이언트 플로우
stateDiagram-v2
[*] --> idle: 페이지 로드
idle --> waiting: POST /queue/join
waiting --> waiting: poll (WAITING)
waiting --> booking: poll (ACTIVE)
waiting --> expired: poll (EXPIRED)
booking --> done: POST /reserve 성공
booking --> expired: 3분 TTL 초과
expired --> waiting: 다시 시도
done --> [*]
state waiting {
[*] --> showRank
showRank --> poll: nextPollMs 후
poll --> showRank: WAITING
}
state booking {
[*] --> selectSeats
selectSeats --> submit: 예매하기 클릭
}Mermaid
복사
9.2 API 클라이언트 (TypeScript)
// client/api.ts
const API_BASE = '<http://localhost:3000/api>';
export class TicketApi {
private token: string | null = null;
constructor() {
this.token = localStorage.getItem('queueToken');
}
/** 대기열 진입 */
async join(): Promise<string> {
const res = await fetch(`${API_BASE}/queue/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
const json = await res.json();
if (!json.success) throw new Error(json.error?.message);
this.token = json.data.token;
localStorage.setItem('queueToken', this.token!);
return this.token!;
}
/** 기존 토큰으로 재진입 */
async rejoin(): Promise<string> {
if (!this.token) return this.join();
const res = await fetch(`${API_BASE}/queue/rejoin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: this.token }),
});
const json = await res.json();
if (!json.success) throw new Error(json.error?.message);
return this.token;
}
/** 상태 폴링 */
async poll() {
if (!this.token) throw new Error('토큰 없음');
const res = await fetch(
`${API_BASE}/status/poll?token=${this.token}`
);
const json = await res.json();
if (!json.success) throw new Error(json.error?.message);
return json.data;
}
/** 예매 */
async reserve(seatCount: number) {
if (!this.token) throw new Error('토큰 없음');
const res = await fetch(`${API_BASE}/booking/reserve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: this.token, seatCount }),
});
const json = await res.json();
if (!json.success) throw new Error(json.error?.message);
return json.data;
}
}
TypeScript
복사
9.3 폴링 매니저 (Adaptive + Jitter)
flowchart TD
START["poll() 시작"] --> FETCH["GET /status/poll"]
FETCH --> PARSE{"status?"}
PARSE -->|"WAITING"| CB1["onWaiting(rank, ahead)"]
PARSE -->|"ACTIVE"| CB2["onActive() → stop()"]
PARSE -->|"EXPIRED"| CB3["onExpired() → stop()"]
PARSE -->|"NOT_FOUND"| REJOIN["rejoin() → 재폴링"]
CB1 --> DELAY["setTimeout(poll, nextPollMs)"]
REJOIN --> DELAY
DELAY --> START
FETCH -->|"에러"| ERR["onError()"]
ERR --> RETRY["setTimeout(poll, 5000)"]
RETRY --> START
style CB2 fill:#c8e6c9
style CB3 fill:#ffcdd2Mermaid
복사
// client/pollingManager.ts
import { TicketApi } from './api';
type Status = 'WAITING' | 'ACTIVE' | 'EXPIRED' | 'NOT_FOUND';
interface PollCallbacks {
onWaiting: (data: { rank: number; ahead: number; behind: number; remainingSeats: number }) => void;
onActive: (data: { remainingSeats: number; expiresAt: number }) => void;
onExpired: () => void;
onError: (error: Error) => void;
}
export class PollingManager {
private api: TicketApi;
private timerId: ReturnType<typeof setTimeout> | null = null;
private running = false;
private callbacks: PollCallbacks;
constructor(api: TicketApi, callbacks: PollCallbacks) {
this.api = api;
this.callbacks = callbacks;
}
start() {
this.running = true;
this.poll();
}
stop() {
this.running = false;
if (this.timerId) clearTimeout(this.timerId);
}
private async poll() {
if (!this.running) return;
try {
const data = await this.api.poll();
switch (data.status as Status) {
case 'WAITING':
this.callbacks.onWaiting({
rank: data.rank!,
ahead: data.ahead!,
behind: data.behind!,
remainingSeats: data.remainingSeats,
});
break;
case 'ACTIVE':
this.callbacks.onActive({
remainingSeats: data.remainingSeats,
expiresAt: data.activeExpiresAt!,
});
this.stop(); // 폴링 종료, 예매 화면으로
return;
case 'EXPIRED':
this.callbacks.onExpired();
this.stop();
return;
case 'NOT_FOUND':
// 토큰 만료 → 재등록
await this.api.rejoin();
break;
}
// 서버가 지정한 폴링 주기 (Adaptive + Jitter 포함)
const delay = data.nextPollMs || 3000;
this.timerId = setTimeout(() => this.poll(), delay);
} catch (err) {
this.callbacks.onError(err as Error);
// 에러 시 5초 후 재시도
this.timerId = setTimeout(() => this.poll(), 5000);
}
}
}
TypeScript
복사
9.4 React 컴포넌트 예시
// client/components/QueuePage.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { TicketApi } from '../api';
import { PollingManager } from '../pollingManager';
type Page = 'idle' | 'waiting' | 'booking' | 'done' | 'expired';
const api = new TicketApi();
export function QueuePage() {
const [page, setPage] = useState<Page>('idle');
const [rank, setRank] = useState(0);
const [ahead, setAhead] = useState(0);
const [remainingSeats, setRemainingSeats] = useState(0);
const [seatCount, setSeatCount] = useState(1);
const [reservationId, setReservationId] = useState('');
const [expiresAt, setExpiresAt] = useState(0);
const [timeLeft, setTimeLeft] = useState(0);
const startQueue = useCallback(async () => {
try {
await api.rejoin(); // 기존 토큰 있으면 재사용
setPage('waiting');
const manager = new PollingManager(api, {
onWaiting: (data) => {
setRank(data.rank);
setAhead(data.ahead);
setRemainingSeats(data.remainingSeats);
},
onActive: (data) => {
setRemainingSeats(data.remainingSeats);
setExpiresAt(data.expiresAt);
setPage('booking');
},
onExpired: () => setPage('expired'),
onError: (err) => console.error('폴링 오류:', err),
});
manager.start();
} catch (err) {
console.error('대기열 진입 실패:', err);
}
}, []);
// Active TTL 카운트다운
useEffect(() => {
if (page !== 'booking' || !expiresAt) return;
const interval = setInterval(() => {
const left = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
setTimeLeft(left);
if (left === 0) setPage('expired');
}, 1000);
return () => clearInterval(interval);
}, [page, expiresAt]);
const handleReserve = async () => {
try {
const result = await api.reserve(seatCount);
setReservationId(result.reservationId);
setPage('done');
} catch (err: any) {
alert(err.message);
}
};
return (
<div className="queue-page">
{page === 'idle' && (
<button onClick={startQueue} className="btn-primary">
예매 시작
</button>
)}
{page === 'waiting' && (
<div className="waiting-screen">
<h2>대기 중...</h2>
<p className="rank">{rank.toLocaleString()}번째</p>
<p>앞에 {ahead.toLocaleString()}명 대기 중</p>
<p>잔여 좌석: {remainingSeats}석</p>
<div className="spinner" />
</div>
)}
{page === 'booking' && (
<div className="booking-screen">
<h2>예매 가능!</h2>
<p className="timer">남은 시간: {timeLeft}초</p>
<p>잔여 좌석: {remainingSeats}석</p>
<label>
좌석 수:
<input
type="number"
min={1}
max={Math.min(10, remainingSeats)}
value={seatCount}
onChange={(e) => setSeatCount(Number(e.target.value))}
/>
</label>
<button onClick={handleReserve} className="btn-primary">
예매하기
</button>
</div>
)}
{page === 'done' && (
<div className="done-screen">
<h2>예매 완료!</h2>
<p>예매번호: {reservationId}</p>
<p>{seatCount}석 예매되었습니다.</p>
</div>
)}
{page === 'expired' && (
<div className="expired-screen">
<h2>시간 초과</h2>
<p>예매 시간이 만료되었습니다.</p>
<button onClick={startQueue}>다시 시도</button>
</div>
)}
</div>
);
}
TypeScript
복사
9.5 Vanilla JavaScript 버전
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>KTX 예매</title>
<style>
body { font-family: sans-serif; max-width: 480px; margin: 40px auto; text-align: center; }
.rank { font-size: 48px; font-weight: bold; color: #1a73e8; }
.timer { font-size: 24px; color: #d93025; }
button { padding: 12px 32px; font-size: 18px; cursor: pointer; }
.spinner { width: 40px; height: 40px; border: 4px solid #eee; border-top-color: #1a73e8;
border-radius: 50%; animation: spin 1s linear infinite; margin: 20px auto; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div id="app">
<button id="btnStart" onclick="startQueue()">예매 시작</button>
<div id="waiting" style="display:none">
<h2>대기 중...</h2>
<p class="rank" id="rank">-</p>
<p id="ahead"></p>
<p id="seats"></p>
<div class="spinner"></div>
</div>
<div id="booking" style="display:none">
<h2>예매 가능!</h2>
<p class="timer" id="timer"></p>
<label>좌석 수: <input type="number" id="seatCount" value="1" min="1" max="10"></label><br><br>
<button onclick="reserve()">예매하기</button>
</div>
<div id="done" style="display:none">
<h2>예매 완료!</h2>
<p id="result"></p>
</div>
</div>
<script>
const API = '<http://localhost:3000/api>';
let token = localStorage.getItem('queueToken');
let pollTimer = null;
let expireAt = 0;
function show(id) {
['btnStart','waiting','booking','done'].forEach(el => {
document.getElementById(el).style.display = el === id ? 'block' : 'none';
});
if (id === 'btnStart') document.getElementById('btnStart').style.display = 'inline-block';
}
async function startQueue() {
if (!token) {
const res = await fetch(`${API}/queue/join`, { method: 'POST', headers: {'Content-Type':'application/json'}, body: '{}' });
const json = await res.json();
token = json.data.token;
localStorage.setItem('queueToken', token);
}
show('waiting');
poll();
}
async function poll() {
try {
const res = await fetch(`${API}/status/poll?token=${token}`);
const json = await res.json();
const d = json.data;
if (d.status === 'WAITING') {
document.getElementById('rank').textContent = d.rank.toLocaleString() + '번째';
document.getElementById('ahead').textContent = `앞에 ${d.ahead.toLocaleString()}명`;
document.getElementById('seats').textContent = `잔여 ${d.remainingSeats}석`;
pollTimer = setTimeout(poll, d.nextPollMs);
} else if (d.status === 'ACTIVE') {
expireAt = d.activeExpiresAt;
show('booking');
startTimer();
} else if (d.status === 'EXPIRED') {
alert('시간 초과');
show('btnStart');
}
} catch (e) {
pollTimer = setTimeout(poll, 5000);
}
}
function startTimer() {
const el = document.getElementById('timer');
const iv = setInterval(() => {
const left = Math.max(0, Math.floor((expireAt - Date.now()) / 1000));
el.textContent = `남은 시간: ${left}초`;
if (left === 0) { clearInterval(iv); show('btnStart'); }
}, 1000);
}
async function reserve() {
const seatCount = parseInt(document.getElementById('seatCount').value);
const res = await fetch(`${API}/booking/reserve`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ token, seatCount }),
});
const json = await res.json();
if (json.success) {
document.getElementById('result').textContent =
`${json.data.seatCount}석 예매 완료! (${json.data.reservationId})`;
show('done');
} else {
alert(json.error?.message || '예매 실패');
}
}
</script>
</body>
</html>
HTML
복사
9.6 폴링 최적화 기법
Adaptive Polling (서버 주도)
// 서버가 nextPollMs를 내려줌
// 대기 순번 50만 → 10초
// 대기 순번 100 → 1초
// ACTIVE → 2초 (예매 화면 전환까지)
TypeScript
복사
Jitter (클라이언트 또는 서버)
// 서버에서 이미 Jitter 적용 (5장 폴링 API)
// 클라이언트에서 추가 Jitter가 필요하면:
const delay = data.nextPollMs + Math.random() * 1000;
TypeScript
복사
폴링 실패 처리
// Q&A: "비행기 모드 후 바로 예매되는 건 프론트 로직 오류"
// 폴링 실패 ≠ 입장 허용
catch (err) {
// 절대 예매 화면으로 보내지 않음
// 재시도만 수행
setTimeout(() => poll(), 5000);
}
TypeScript
복사
9.7 토큰 관리
// 페이지 로드 시
const savedToken = localStorage.getItem('queueToken');
if (savedToken) {
// 기존 토큰으로 상태 확인
const status = await api.poll();
if (status.status === 'WAITING') {
// 대기 화면 복원
startPolling();
} else if (status.status === 'ACTIVE') {
// 예매 화면으로
showBooking();
} else {
// 만료/없음 → 새로 등록
await api.join();
startPolling();
}
}
TypeScript
복사
9.8 UI/UX 권장사항
항목 | 권장 |
대기 순번 표시 | 502,341번째 (천 단위 콤마) |
잔여 좌석 | 실시간 업데이트 |
Active 타이머 | 3분 카운트다운 (빨간색) |
로딩 | 스피너 (순번 옆) |
에러 | "잠시 후 다시 시도" (재시도 버튼) |
완료 | 예매번호 표시 |
9.9 주의사항
1.
폴링 실패 시 예매 화면으로 보내지 않는다 (fail-open 금지)
2.
토큰은 localStorage에 저장 (새로고침 대응)
3.
서버의 nextPollMs를 따른다 (무한 루프 방지)
4.
Active TTL 카운트다운을 보여준다 (3분 압박감)
5.
예매 버튼은 1회만 클릭 (중복 요청 방지)
flowchart TD
POLL_FAIL["폴링 실패<br/>(네트워크 오류)"] --> DECISION{"프론트 판단"}
DECISION -->|"❌ fail-open"| BUG["예매 페이지 이동<br/>(버그!)"]
DECISION -->|"✅ fail-closed"| RETRY["재시도 / 오류 표시"]
SERVER_ACTIVE["서버: status=ACTIVE"] --> OK["예매 페이지 이동 ✅"]
style BUG fill:#ffcdd2,stroke:#c62828
style OK fill:#c8e6c9,stroke:#2e7d32
style RETRY fill:#e3f2fdMermaid
복사
10. 운영 및 부하테스트 체크리스트
오픈 전 반드시 수행해야 할 부하 테스트, 모니터링, 장애 대응 가이드.
10.1 오픈 전 체크리스트
flowchart TB
subgraph Infra["인프라 ✅"]
I1["Redis Cluster 3노드"]
I2["API 2대+ / LB"]
I3["Scheduler 1대 (Redlock)"]
I4["Worker 2대+"]
end
subgraph App["애플리케이션 ✅"]
A1["이벤트 초기화"]
A2["Lua Script 로드"]
A3["batchSize 산정"]
end
subgraph Test["부하 테스트 ✅"]
T1["join 10만/분"]
T2["poll 30만 req/s"]
T3["동시 예매 무결성"]
T4["전체 플로우 봇"]
end
subgraph Monitor["모니터링 ✅"]
M1["Redis 메모리 알림"]
M2["API p99 < 100ms"]
M3["reconcile cron"]
end
Infra --> App --> Test --> Monitor
Monitor --> GO["🚀 오픈"]
style GO fill:#4caf50,color:#fffMermaid
복사
인프라
Redis Cluster 구성 (최소 3노드)
API 서버 2대 이상 (로드밸런서 뒤)
스케줄러 1대 (Redlock 적용)
MQ Worker 2대 이상
RDB 커넥션 풀 크기 설정 (max: 20~50)
Health check 엔드포인트 (/health) LB 등록
애플리케이션
이벤트 초기화 스크립트 실행 (init-event.ts)
Lua Script 로드 확인 (SCRIPT LOAD)
batchSize 산정 (부하 테스트 기반)
ACTIVE_USER_TTL_SEC = 180 (3분)
환경변수 검증 (zod schema)
부하 테스트
대기열 등록 10만 건/분 테스트
폴링 30만 req/s 테스트
동시 예매 (좌석 초과 방지) 테스트
스케줄러 입장 처리 테스트
MQ → RDB 정합성 테스트
봇 시뮬레이션 (전체 플로우)
모니터링
Redis 메모리/CPU 알림
API 응답 시간 p99 < 100ms
대기열 크기 대시보드
잔여 좌석 실시간 표시
reconcile 스크립트 cron 등록
10.2 부하 테스트 시나리오
flowchart LR
subgraph Tests["k6 부하 테스트"]
T1["시나리오 1<br/>join 폭주<br/>10K VU"]
T2["시나리오 2<br/>폴링 부하<br/>5K VU"]
T3["시나리오 3<br/>동시 예매<br/>Race Condition"]
T4["시나리오 4<br/>전체 플로우<br/>봇"]
end
T1 --> PASS1["p99 < 200ms"]
T2 --> PASS2["p99 < 100ms"]
T3 --> PASS3["잔여좌석 정합성"]
T4 --> PASS4["E2E 성공률 > 99%"]
style PASS1 fill:#c8e6c9
style PASS2 fill:#c8e6c9
style PASS3 fill:#c8e6c9
style PASS4 fill:#c8e6c9Mermaid
복사
시나리오 1: 대기열 등록 폭주
# k6 부하 테스트
# load-test/join-storm.js
Bash
복사
// load-test/join-storm.js
import http from 'k6/http';
import { check } from 'k6';
export const options = {
scenarios: {
join_storm: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '10s', target: 1000 }, // 10초에 1000 VU
{ duration: '30s', target: 5000 }, // 30초에 5000 VU
{ duration: '60s', target: 10000 }, // 60초에 10000 VU
{ duration: '30s', target: 0 }, // ramp down
],
},
},
thresholds: {
http_req_duration: ['p(99)<200'], // p99 < 200ms
http_req_failed: ['rate<0.01'], // 에러율 < 1%
},
};
export default function () {
const res = http.post('<http://localhost:3000/api/queue/join>', '{}', {
headers: { 'Content-Type': 'application/json' },
});
check(res, {
'status 200': (r) => r.status === 200,
'has token': (r) => JSON.parse(r.body).data?.token,
});
}
JavaScript
복사
k6 run load-test/join-storm.js
Bash
복사
합격 기준:
•
p99 응답 시간 < 200ms
•
에러율 < 1%
•
Redis 메모리 < 80%
시나리오 2: 폴링 부하
// load-test/polling-load.js
import http from 'k6/http';
import { check, sleep } from 'k6';
const tokens = []; // 사전에 join으로 생성한 토큰 목록
export const options = {
scenarios: {
polling: {
executor: 'constant-vus',
vus: 5000,
duration: '5m',
},
},
thresholds: {
http_req_duration: ['p(99)<100'],
},
};
export function setup() {
// 5000개 토큰 사전 생성
const tokens = [];
for (let i = 0; i < 5000; i++) {
const res = http.post('<http://localhost:3000/api/queue/join>', '{}', {
headers: { 'Content-Type': 'application/json' },
});
tokens.push(JSON.parse(res.body).data.token);
}
return { tokens };
}
export default function (data) {
const token = data.tokens[Math.floor(Math.random() * data.tokens.length)];
const res = http.get(`http://localhost:3000/api/status/poll?token=${token}`);
check(res, { 'status 200': (r) => r.status === 200 });
const body = JSON.parse(res.body);
sleep(body.data.nextPollMs / 1000);
}
JavaScript
복사
합격 기준:
•
p99 < 100ms
•
Redis ops/s < 한계의 70%
시나리오 3: 동시 예매 (좌석 무결성)
// load-test/booking-race.js
import http from 'k6/http';
import { check } from 'k6';
export const options = {
scenarios: {
race: {
executor: 'shared-iterations',
vus: 100,
iterations: 100,
maxDuration: '30s',
},
},
};
export function setup() {
// 10석으로 초기화
// redis-cli SET ticket:test:seats:remaining 10
// 100명을 Active로 설정
const tokens = [];
for (let i = 0; i < 100; i++) {
const res = http.post('<http://localhost:3000/api/queue/join>', '{}', {
headers: { 'Content-Type': 'application/json' },
});
tokens.push(JSON.parse(res.body).data.token);
}
// 스케줄러로 active 처리 (또는 직접 ZADD)
return { tokens };
}
export default function (data) {
const token = data.tokens[__VU - 1];
const res = http.post('<http://localhost:3000/api/booking/reserve>',
JSON.stringify({ token, seatCount: 3 }),
{ headers: { 'Content-Type': 'application/json' } }
);
check(res, {
'200 or 409': (r) => r.status === 200 || r.status === 409,
});
}
export function teardown() {
// redis-cli GET ticket:test:seats:remaining
// → 1 (10석 / 3석 × 3명 = 9석, 잔여 1)
}
JavaScript
복사
합격 기준:
•
최종 잔여 좌석 = TOTAL - (성공 건수 × seatCount)
•
음수 잔여 좌석 없음
•
중복 예매 0건
시나리오 4: 전체 플로우 (봇)
// load-test/full-flow.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 100,
duration: '10m',
};
export default function () {
// 1. 대기열 진입
const joinRes = http.post('<http://localhost:3000/api/queue/join>', '{}', {
headers: { 'Content-Type': 'application/json' },
});
const token = JSON.parse(joinRes.body).data.token;
// 2. 폴링 (ACTIVE 될 때까지)
let status = 'WAITING';
let attempts = 0;
while (status === 'WAITING' && attempts < 120) {
const pollRes = http.get(`http://localhost:3000/api/status/poll?token=${token}`);
const data = JSON.parse(pollRes.body).data;
status = data.status;
sleep(data.nextPollMs / 1000);
attempts++;
}
// 3. ACTIVE이면 예매
if (status === 'ACTIVE') {
const reserveRes = http.post('<http://localhost:3000/api/booking/reserve>',
JSON.stringify({ token, seatCount: 1 }),
{ headers: { 'Content-Type': 'application/json' } }
);
check(reserveRes, {
'booked': (r) => r.status === 200 || r.status === 409,
});
}
}
JavaScript
복사
10.3 모니터링 대시보드
Redis 메트릭
# 실시간 모니터링 스크립트
# scripts/monitor.sh
#!/bin/bash
EVENT="ktx-2026-chuseok"
while true; do
WAITING=$(redis-cli ZCARD ticket:${EVENT}:queue:waiting)
ACTIVE=$(redis-cli ZCARD ticket:${EVENT}:active:users)
SEATS=$(redis-cli GET ticket:${EVENT}:seats:remaining)
MEMORY=$(redis-cli INFO memory | grep used_memory_human | cut -d: -f2 | tr -d '\r')
echo "$(date '+%H:%M:%S') | 대기: ${WAITING} | Active: ${ACTIVE} | 잔여: ${SEATS}석 | 메모리: ${MEMORY}"
sleep 5
done
Bash
복사
Prometheus 메트릭 (선택)
// middleware/metrics.ts
import client from 'prom-client';
export const queueWaitingGauge = new client.Gauge({
name: 'queue_waiting_total',
help: '대기열 인원 수',
});
export const queueActiveGauge = new client.Gauge({
name: 'queue_active_total',
help: 'Active User 수',
});
export const seatsRemainingGauge = new client.Gauge({
name: 'seats_remaining',
help: '잔여 좌석 수',
});
export const bookingCounter = new client.Counter({
name: 'booking_total',
help: '예매 완료 수',
labelNames: ['status'],
});
// /metrics 엔드포인트
app.get('/metrics', async (_req, res) => {
queueWaitingGauge.set(await redis.zcard(keys.waitingQueue()));
queueActiveGauge.set(await redis.zcard(keys.activeUsers()));
const seats = await redis.get(keys.remainingSeats());
seatsRemainingGauge.set(parseInt(seats ?? '0', 10));
res.set('Content-Type', client.register.contentType);
res.end(await client.register.metrics());
});
TypeScript
복사
10.4 장애 대응 플레이북
flowchart TD
ALERT["🚨 알림 발생"] --> TYPE{"장애 유형?"}
TYPE -->|"Redis"| R1["Cluster failover 확인"]
R1 --> R2{"복구?"}
R2 -->|"No"| R3["이벤트 일시 중단"]
R2 -->|"Yes"| OK["정상 복구"]
TYPE -->|"Scheduler"| S1["프로세스 재시작"]
S1 --> S2["Redlock 확인"]
TYPE -->|"RDB"| D1["MQ 메시지 확인"]
D1 --> D2["Worker 재시작"]
D2 --> D3["reconcile 실행"]
TYPE -->|"API 과부하"| A1["스케일 아웃"]
A1 --> A2["rate limit 강화"]
style R3 fill:#ffcdd2
style OK fill:#c8e6c9Mermaid
복사
Redis 장애
증상: API 500 에러, 폴링 실패
영향: 대기열 등록/조회 불가, 예매 불가
대응:
1. Redis Cluster failover 확인
2. Sentinel/Cluster 자동 복구 대기
3. 복구 불가 시 → 이벤트 일시 중단 공지
4. AOF/RDB 백업으로 복구
Plain Text
복사
스케줄러 장애
증상: 대기열은 쌓이지만 입장 안 됨
영향: 사용자 무한 대기
대응:
1. 스케줄러 프로세스 재시작
2. Redlock 확인 (다른 인스턴스가 실행 중?)
3. batchSize 임시 증가 (RDB 여유 있을 때)
Plain Text
복사
RDB 장애
증상: 예매 API 500, Worker 에러
영향: Redis 차감은 됐지만 RDB 미저장
대응:
1. MQ에 메시지 남아있으면 → Worker 재시작 후 소비
2. reconcile 스크립트로 정합성 확인
3. booking:done 키는 있지만 RDB 없는 건 → 수동 INSERT
Plain Text
복사
API 서버 과부하
증상: 응답 지연, 503
영향: 폴링/등록 지연
대응:
1. API 서버 스케일 아웃 (Stateless이므로 즉시 가능)
2. LB에서 unhealthy 인스턴스 제거
3. rate limit 강화
Plain Text
복사
10.5 오픈 당일 타임라인
gantt
title 오픈 당일 타임라인
dateFormat HH:mm
axisFormat %H:%M
section 준비
서비스 기동 확인 :done, prep1, 06:50, 10m
모니터링 대시보드 :done, prep2, 06:55, 5m
section 오픈
이벤트 OPEN :crit, open, 07:00, 1m
트래픽 모니터링 :active, monitor, 07:00, 30m
section 운영
1차 정합성 확인 :reconcile, 07:30, 5m
매진 시 CLOSE :milestone, soldout, 07:45, 0mMermaid
복사
D-7 부하 테스트 최종
D-3 모니터링 대시보드 확인
D-1 이벤트 초기화 (잔여 좌석, 메타)
스케줄러/Worker/API 기동 확인
봇 테스트 (전체 플로우)
D-Day 06:50 전체 서비스 기동 확인
06:55 모니터링 대시보드 열기
07:00 이벤트 OPEN (status → OPEN)
07:00~ 트래픽 모니터링
- waiting_queue 증가 추이
- API p99 응답 시간
- Redis 메모리
07:30 1차 정합성 확인 (reconcile)
매진 이벤트 CLOSE (status → CLOSED)
Plain Text
복사
10.6 batchSize 튜닝 가이드
1. RDB 단독 TPS 측정
→ k6로 POST /booking/reserve 직접 호출
→ p99 < 200ms 유지되는 최대 TPS = X
2. batchSize = X × 0.7
3. 전체 플로우 부하 테스트
→ RDB CPU < 80% 확인
4. 운영 중 동적 조절 (선택)
→ RDB latency > 200ms → batchSize 50% 감소
→ RDB latency < 50ms → batchSize 20% 증가
Plain Text
복사
10.7 정합성 검증 cron
# crontab
# 매 5분마다 Redis ↔ RDB 정합성 확인
*/5 * * * * cd /app && npx tsx scripts/reconcile.ts >> /var/log/reconcile.log 2>&1
Bash
복사
10.8 용량 계획
항목 | 100만 대기 | 300만 대기 |
Redis 메모리 | ~64 MB | ~192 MB |
API 서버 | 5대 | 15대 |
Redis 노드 | 3 (Cluster) | 6 (Cluster) |
폴링 QPS | ~200K | ~600K |
RDB (예매) | TPS 100 | TPS 300 |
10.9 보안 체크리스트
토큰(UUID) 추측 불가 (v4 random)
rate limit 적용 (IP당 초당 10회)
CORS 설정 (허용 도메인만)
Helmet 미들웨어 적용
Redis 비밀번호 설정
API 서버 외부 직접 노출 X (LB 뒤)
DB 커넥션 암호화
10.10 전체 문서 요약
flowchart LR
subgraph P1["Phase 1"]
A1["POST /join"]
A2["ZADD NX"]
end
subgraph P2["Phase 2"]
B1["Scheduler"]
B2["ZPOPMIN"]
end
subgraph P3["Phase 3"]
C1["POST /reserve"]
C2["Lua Script"]
end
P1 -->|"순서 보장 ✅"| P2
P2 -->|"ACTIVE만"| P3
P3 -->|"초과X 중복X ✅"| DB[("RDB")]
style P1 fill:#e3f2fd
style P2 fill:#fff3e0
style P3 fill:#e8f5e9Mermaid
복사
Phase | 구현 | Redis 자료구조 | 핵심 명령 |
1. 대기열 | POST /queue/join | Sorted Set | ZADD NX, ZRANK, ZCARD |
2. 입장 | Scheduler | Sorted Set | ZPOPMIN, ZADD, ZREMRANGEBYSCORE |
3. 예매 | POST /booking/reserve | String + Lua | GET, DECRBY (Lua 내) |
폴링 | GET /status/poll | 읽기 전용 | ZRANK, ZSCORE, ZCARD |
저장 | MQ Worker | - | INSERT (RDB) |
3대 무결성 최종 정리
순서 보장 → Sorted Set (timestamp score) → Phase 1
초과 예매X → Lua Script (check + decr) → Phase 3
중복 예매X → ZADD NX + SET NX (booking:done) → Phase 1, 3
Plain Text
복사
부하 테스트를 충분히 수행한 뒤 오픈하는 것을 권장합니다.
이상으로 대용량 선착순 예매 시스템 구현 가이드를 마칩니다.
11. 부록 — 인프라 및 확장 패턴
핵심 구현에 분산된 보조 주제를 모았습니다.
11.1 결제 분리 전략
결제는 뒤로 미루면 시스템이 훨씬 가벼워집니다.
flowchart LR
subgraph Heavy["🔥 대용량 구간 (수백만)"]
Q["대기열"]
E["입장"]
B["좌석 예매<br/>(수량만)"]
end
subgraph Light["✅ 소량 구간 (좌석수만큼)"]
S["좌석 지정"]
P["결제"]
C["발권"]
end
Q --> E --> B --> S --> P --> C
style Heavy fill:#ffebee,stroke:#c62828
style Light fill:#e8f5e9,stroke:#2e7d32Mermaid
복사
단계 | 트래픽 규모 | 복잡도 |
대기열 + 입장 | 수백만 | 낮음 (Redis만) |
좌석 수 예매 | 수만 (스케줄러 제어) | 중간 (Lua) |
좌석 지정 | 수천 | 중간 (SET NX) |
결제 | 수백~수천 | 높음 (PG 연동, 실패 복구) |
KTX 실제: 출발 시간 + 좌석 수량만 예매 → 자리 지정/결제는 이후 단계.
11.2 Redis 분리 구성 옵션
Redis를 따로 두 대 둘지, 큰 클러스터 하나로 둘지, active를 어디에 둘지는 상황에 따라 다릅니다.
graph TB
subgraph OptionA["옵션 A: 단일 Cluster (권장-중규모)"]
RC1["Redis Cluster"]
RC1 --- WQ1["waiting_queue"]
RC1 --- AU1["active_users"]
RC1 --- ST1["seats"]
end
subgraph OptionB["옵션 B: 용도별 분리 (대규모)"]
RC2["Redis #1<br/>대기열 전용"]
RC3["Redis #2<br/>예매/좌석 전용"]
RC2 --- WQ2["waiting_queue"]
RC2 --- AU2["active_users"]
RC3 --- ST2["seats + booking"]
end
style OptionA fill:#e3f2fd
style OptionB fill:#fff3e0Mermaid
복사
옵션 | 장점 | 단점 |
단일 Cluster | Lua 멀티키 간단, 운영 단순 | 대기열+예매 부하 혼재 |
용도별 분리 | 대기열 IO와 예매 IO 격리 | 크로스-Redis 트랜잭션 불가 |
Active 별도 | 입장 처리 독립 스케일 | 복잡도 증가 |
11.3 SRT/KTX 실제 폴링 구현 분석
개발자 도구로 분석한 SRT 대기열 통신:
sequenceDiagram
participant B as 브라우저
participant Q as 대기열 서버<br/>(별도 호스트)
participant S as 예매 서버<br/>(SRT 본 서버)
B->>Q: GET /poll?key=서명토큰&ttl=2
Q-->>B: application/javascript<br/>callback({ahead, behind, ttl:2})
Note over B: 서버가 준 JS 실행<br/>ttl=2초 후 재요청
B->>Q: GET /poll (2초 후)
Q-->>B: callback({status: 입장가능})
B->>S: 리다이렉트 → 예매 페이지Mermaid
복사
SRT에서 관찰된 5가지 포인트
# | 특징 | 우리 구현 대응 |
1 | 상용 대기열 솔루션 (Netfunnel 등) | 직접 Redis 구현 (4~6장) |
2 | HTTP Polling (WebSocket 아님) | GET /status/poll |
3 | 서버가 폴링 주기 제어 (TTL) | nextPollMs 응답 필드 |
4 | 서명된 토큰 (재사용 방지) | UUID + 향후 HMAC 확장 |
5 | JSONP (크로스 도메인) | CORS 또는 JSONP 선택 |
크로스 도메인 + JSONP
flowchart LR
subgraph Domain1["queue.example.com"]
QS["대기열 API"]
end
subgraph Domain2["booking.example.com"]
BS["예매 사이트"]
end
BS -->|"JSONP script tag"| QS
QS -->|"callback(data)"| BS
style Domain1 fill:#e3f2fd
style Domain2 fill:#fff3e0Mermaid
복사
대기열 서버와 예매 서버가 다른 호스트인 경우:
•
JSONP: <script src="queue.example.com/poll?callback=cb">
•
또는 CORS 헤더: Access-Control-Allow-Origin: <https://booking.example.com>
11.4 Sticky Session
SRT에서 관찰: 한 번 연결된 서버에 계속 붙는 패턴.
flowchart TB
LB["Load Balancer<br/>(Sticky Session)"]
LB -->|"Cookie: SERVERID=1"| S1["API #1"]
LB -->|"Cookie: SERVERID=2"| S2["API #2"]
S1 & S2 --> Redis["Redis Cluster<br/>(상태는 Redis에)"]Mermaid
복사
우리 아키텍처에서는 Sticky Session 불필요 — 모든 상태가 Redis에 있으므로 API는 완전 Stateless.
11.5 WebFlux / Netty 논블로킹 IO (확장)
WebFlux + Netty를 사용하면 Redis IO 효율을 높일 수 있습니다.
flowchart TB
subgraph Blocking["Express (Blocking)"]
T1["Thread 1"] -->|"대기"| R1["Redis IO"]
T2["Thread 2"] -->|"대기"| R2["Redis IO"]
T3["Thread N"] -->|"대기"| R3["Redis IO"]
end
subgraph NonBlocking["WebFlux (Non-Blocking)"]
EL["Event Loop<br/>(소수 스레드)"] -->|"동시 다발"| R4["Redis IO"]
EL --> R5["Redis IO"]
EL --> R6["Redis IO"]
end
style Blocking fill:#ffebee
style NonBlocking fill:#e8f5e9Mermaid
복사
항목 | Express (Blocking) | WebFlux (Non-Blocking) |
스레드 모델 | 요청당 스레드 | 이벤트 루프 |
Redis IO 대기 | 스레드 블로킹 | 논블로킹 |
적합 규모 | ~10K concurrent | ~100K+ concurrent |
복잡도 | 낮음 | 높음 |
대기열 시스템은 CPU 연산보다 IO 대기가 병목 → 논블로킹이 유리하지만, Express + Redis Pipeline으로도 충분한 경우가 많음.
11.6 Nginx / OS 커널 튜닝
NIC 버퍼, backlog, Nginx connection 수 튜닝이 필요할 수 있습니다.
flowchart TB
Client["수백만 클라이언트"] --> NIC["NIC / 커널<br/>somaxconn, tcp_backlog"]
NIC --> NGX["Nginx<br/>worker_connections<br/>keepalive"]
NGX --> LB["Load Balancer"]
LB --> API["Express API Pool"]
style NIC fill:#fff3e0
style NGX fill:#e3f2fdMermaid
복사
Nginx 기본 설정 예시
# /etc/nginx/nginx.conf
worker_processes auto;
worker_rlimit_nofile 65535;
events {
worker_connections 65535;
use epoll;
multi_accept on;
}
http {
# Keep-Alive (폴링에 유리)
keepalive_timeout 65;
keepalive_requests 1000;
upstream api_backend {
least_conn;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
server 127.0.0.1:3003;
keepalive 256;
}
# Rate Limit (IP당)
limit_req_zone $binary_remote_addr zone=queue:100m rate=10r/s;
server {
listen 80;
location /api/queue/join {
limit_req zone=queue burst=20 nodelay;
proxy_pass http://api_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
location /api/ {
proxy_pass http://api_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
}
Plain Text
복사
OS 커널 튜닝
# /etc/sysctl.conf
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_tw_reuse = 1
net.core.netdev_max_backlog = 65535
fs.file-max = 2097152
Bash
복사
11.7 Chunked Response (대용량 SSR 완화)
대용량 SSR 환경에서는 0.8KB 단위로 응답을 끊어 전송하는 방식도 사용됩니다.
sequenceDiagram
participant B as 브라우저
participant S as 서버
S->>B: HTTP 200 Transfer-Encoding: chunked
S->>B: chunk 1 (0.8KB) — HTML 헤더
Note over B: 연결 유지 (타임아웃 방지)
S->>B: chunk 2 (0.8KB) — 대기 UI
S->>B: chunk 3 (0.8KB) — 스크립트
S->>B: chunk 0 (종료)Mermaid
복사
서버 사이드 렌더링(SSR) 시 수십만 HTML을 한 번에 만들면 메모리 폭발 → HTTP Chunked Transfer로 잘게 나눠 전송.
우리 아키텍처는 API(JSON) + SPA이므로 해당 없음. SSR 사용 시에만 적용.
11.8 상용 대기열 솔루션 vs 직접 구현
quadrantChart
title 솔루션 선택 기준
x-axis "낮은 커스터마이징" --> "높은 커스터마이징"
y-axis "낮은 비용" --> "높은 비용"
quadrant-1 "직접 구현 (본 문서)"
quadrant-2 "하이브리드"
quadrant-3 "오픈소스"
quadrant-4 "Netfunnel 등 상용"Mermaid
복사
방식 | 예시 | 장점 | 단점 |
상용 | Netfunnel, Queue-it | 검증됨, 빠른 도입 | 비용, 커스터마이징 제한 |
직접 구현 | 본 문서 (Redis) | 완전 제어 | 구축/운영 부담 |
하이브리드 | 상용 대기열 + 자체 예매 | 앞단 부담 위임 | 벤더 종속 |
SRT/KTX는 상용 솔루션(Netfunnel 추정) + 자체 예매 시스템 조합.
11.9 이벤트 라이프사이클
stateDiagram-v2
[*] --> PREPARE: 이벤트 생성
PREPARE --> OPEN: 오픈 (07:00)
OPEN --> SOLD_OUT: 좌석 소진
OPEN --> CLOSED: 시간 마감
SOLD_OUT --> CLOSED
CLOSED --> [*]
state OPEN {
[*] --> AcceptingQueue
AcceptingQueue --> ProcessingEntry: 스케줄러 동작
ProcessingEntry --> AcceptingBooking: ACTIVE 유저 예매
}Mermaid
복사
// 이벤트 상태 전환 API (운영용)
async function openEvent(eventId: string) {
await redis.hset(keys.eventMeta(eventId), 'status', 'OPEN');
await redis.set(keys.remainingSeats(eventId), config.TOTAL_SEATS);
}
async function closeEvent(eventId: string) {
await redis.hset(keys.eventMeta(eventId), 'status', 'CLOSED');
}
TypeScript
복사
11.10 fail-open 방지 (비행기 모드 버그)
flowchart TD
A["폴링 요청"] --> B{"네트워크 OK?"}
B -->|"Yes"| C["서버 응답 처리"]
B -->|"No (비행기 모드)"| D{"프론트 로직"}
D -->|"❌ fail-open"| E["예매 페이지로 이동<br/>(버그!)"]
D -->|"✅ fail-closed"| F["재시도 / 오류 표시<br/>(정상)"]
C --> G{"status?"}
G -->|"ACTIVE"| H["예매 페이지"]
G -->|"WAITING"| I["대기 화면 유지"]
style E fill:#ffcdd2,stroke:#c62828
style F fill:#c8e6c9,stroke:#2e7d32
style H fill:#c8e6c9,stroke:#2e7d32Mermaid
복사
비행기 모드 등 네트워크 오류 후 바로 예매 화면으로 이동하는 것은 프론트엔드 로직 오류일 가능성이 큽니다.
절대 규칙: 폴링 실패 ≠ 입장 허용. 서버가 status: ACTIVE를 명시적으로 반환해야만 예매 화면으로 이동.
11.11 Sorted Set 개별 TTL 불가 (기술적 제약)
flowchart LR
subgraph ZSET["Sorted Set (waiting_queue)"]
M1["user-A"]
M2["user-B"]
M3["user-C"]
end
TTL["EXPIRE key 300"] -.->|"전체 키에만 적용"| ZSET
M1 -.-x|"개별 TTL 불가"| IND["EXPIRE member"]
style IND fill:#ffcdd2Mermaid
복사
대안 비교:
방식 | 구현 | 대용량 적합 |
ZSET 전체 TTL | EXPIRE key | X (전체 삭제) |
member별 별도 키 | SET user:{id} EX | X (수백만 키) |
이탈 무시 + TTL 정리 | Active에서 3분 TTL | O |
Heartbeat | 폴링마다 ZADD 갱신 | X (쓰기 부하) |
이상으로 부록을 마칩니다.
안녕하세요
•
관련 기술 문의와 R&D 공동 연구 사업 관련 문의는 “glory@keti.re.kr”로 연락 부탁드립니다.
Hello 
•
For technical and business inquiries, please contact me at “glory@keti.re.kr”

