Search
☑️

260720_1720_대용량 선착순 예매 시스템 — Redis + Express 완전 구현 가이드

참고 영상

대용량 선착순 예매 시스템 — 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 RDB
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:#6a1b9a
Mermaid
복사

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:#c8e6c9
Mermaid
복사

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:#fce4ec
Mermaid
복사

작성 예시

```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:#fff
Mermaid
복사
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:#fff
Mermaid
복사
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:#2e7d32
Mermaid
복사

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:#1565c0
Mermaid
복사

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:#6a1b9a
Mermaid
복사

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:#2e7d32
Mermaid
복사
폴링 요청은 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:#fff3e0
Mermaid
복사

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 ❌
    end
Mermaid
복사

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:#b2dfdb
Mermaid
복사
본 가이드는 C단계(좌석 수 예매) 까지 구현합니다. D/E는 트래픽이 적어 별도 시스템으로 분리합니다. 상세: 11장 부록 — 인프라 및 확장 패턴

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:#f3e5f5
Mermaid
복사

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:#f3e5f5
Mermaid
복사

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:#e3f2fd
Mermaid
복사

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:#e8f5e9
Mermaid
복사
운영 팁: 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 --- U4
Mermaid
복사

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:#ef6c00
Mermaid
복사

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:#c62828
Mermaid
복사
// 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:#2e7d32
Mermaid
복사

시나리오 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:#ffcdd2
Mermaid
복사
// 첫 요청 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 note
Mermaid
복사

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:#e3f2fd
Mermaid
복사

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"] --> ME
Mermaid
복사

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:#ef6c00
Mermaid
복사

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:#c8e6c9
Mermaid
복사

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 --> Defense
Mermaid
복사

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>2return 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:#ffcdd2
Mermaid
복사

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:#c8e6c9
Mermaid
복사

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:#ffcdd2
Mermaid
복사
// 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:#ffcdd2
Mermaid
복사

시나리오 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:#ffcdd2
Mermaid
복사
// 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:#e3f2fd
Mermaid
복사
상세: 부록 — 인프라 및 확장 패턴 — fail-open 방지 섹션 참고

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:#fff
Mermaid
복사

인프라

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:#c8e6c9
Mermaid
복사

시나리오 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:#c8e6c9
Mermaid
복사

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, 0m
Mermaid
복사
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:#e8f5e9
Mermaid
복사
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:#2e7d32
Mermaid
복사
단계
트래픽 규모
복잡도
대기열 + 입장
수백만
낮음 (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:#fff3e0
Mermaid
복사
옵션
장점
단점
단일 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:#fff3e0
Mermaid
복사
대기열 서버와 예매 서버가 다른 호스트인 경우:
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:#e8f5e9
Mermaid
복사
항목
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:#e3f2fd
Mermaid
복사

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:#2e7d32
Mermaid
복사
비행기 모드 등 네트워크 오류 후 바로 예매 화면으로 이동하는 것은 프론트엔드 로직 오류일 가능성이 큽니다.
절대 규칙: 폴링 실패 ≠ 입장 허용. 서버가 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:#ffcdd2
Mermaid
복사
대안 비교:
방식
구현
대용량 적합
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”