본문 바로가기

Programming/Java & Spring 관련 내용 정리

대규모 트래픽 티켓팅 시스템 설계 해보기

 

 

이번 글에서는 대규모 트래픽이 발생할 수 있는

티켓팅 시스템을 어떻게 설계할지 고민해보고

최종적으로 정리한 아키텍처 구성을 단계적으로 살펴보려고 한다.

 

 

💡 Redis는 대기열과 임시 좌석 점유를 빠르게 처리하고,
최종 구매 확정은 DB 트랜잭션과 제약 조건으로 보장

 

 


📋 요구사항 정의

 

1️⃣ 계정당 R석과 S석을 포함하여 총 5장까지만 구매 가능

2️⃣ 보유 티켓보다 더 많은 티켓이 판매되지 않도록 관리

3️⃣ 티켓팅 오픈 시 초당 5,000건 이상의 요청이 유입되는 상황을 고려

 

초당 5,000건이라는 요구사항은 대기열 진입인지, 좌석 선점인지, 결제 확정인지에 따라 난이도가 크게 달라진다.

이 글에서는 대기열 진입과 좌석 선점 구간을 Redis로 빠르게 처리하고,

최종 구매 확정은 DB 트랜잭션으로 정합성을 보장하는 방향으로 설계한다.

 

 


📐 전체 설계 요약

 

🖥️ Client
📋 Queue API
대기열 진입 / 순번
⚡ Redis Sorted Set
대기열 + 토큰 발급
▼ 입장 토큰 발급 후
🪑 Seat API
토큰 검증 필수
🔒 Redis SET NX EX
임시 점유
💳 Order → Payment Gateway
PENDING_PAYMENT → PG 결제 (idempotency key)
▼ PG Callback
🏦 DB Transaction
멱등성 • 구매 제한 • 좌석 SOLD • CONFIRMED
🗄️ DB
seat_id UNIQUE • payment_id UNIQUE
최종 확정 지점

 

 

 

계층 역할 성격
Redis 대기열 • 임시 좌석 Lock • 임시 수량 제한 빠르지만 유실 가능 — 임시 상태
DB 주문 • 결제 • 좌석 판매 확정 • 구매 이력 최종 확정 데이터 저장 — 정합성 보장

 

 


1) 대기열: Sorted Set + 입장 토큰

 

사용자가 접속하면 Redis Sorted Set에 추가된다.

score는 접속 시각을 기본으로 쓸 수 있지만,

동일 시각 충돌을 줄이기 위해 Redis INCR 기반 sequencetimestamp + sequence 조합을 사용할 수 있다.

 

1
대기열 진입ZADD queue:{eventId} {score} {userId}
2
대략적 순번 조회ZRANK queue:{eventId} {userId}
3
배치로 입장 대상 선정 — 예: ZPOPMIN queue:{eventId} {batchSize}
※ ZPOPMIN은 조회와 동시에 대기열에서 제거하므로, 토큰 발급 실패 시 사용자가 대기열에서 사라질 수 있다.
실제 구현에서는 입장 대상 선정, 토큰 발급, 대기열 제거를 Lua Script나 별도 상태값으로 함께 관리해야 한다.
4
입장 토큰 발급 (10분) — SET entry_token:{eventId}:{userId} {token} EX 600
5
토큰 검증 — 토큰 없이 좌석 API 호출 → 403 Forbidden

 

이전 설계에서는 "순번이 변경될 때마다 모든 사용자에게 WebSocket 푸시"로 했지만,

100만 명 대기 시 1명 처리마다 100만 ZRANK + 100만 메시지가 발생한다.

현실적으로는 정확한 실시간 순번 대신 "대략 순번 / 예상 대기 시간"을 제공한다.

Polling이라면 1~3초 간격으로 자기 순번을 조회하고,

SSE라면 서버가 일정 주기 또는 상태 변경 시점에 이벤트를 내려준다.

 

 


2) 좌석 선점: SET NX EX

 

좌석 선택은 "조회 후 등록"이 아니라 원자적 선점이어야 한다.

 

❌ 이전 설계 (Race Condition)

 

👤 사용자 B
ZRANK → null
ZADD 성공 ✅
👤 사용자 C
ZRANK → null 😱
ZADD 성공 ✅ 😱
같은 좌석을 두 명이 잡음 — ZRANK와 ZADD 사이에 다른 요청이 끼어든다

 

✅ 수정된 설계 (SET NX로 원자적 선점)

 

👤 사용자 B
SET seat:lock:{eventId}:F103-25 user_B NX EX 300
OK ✅
5분 후 자동 만료
👤 사용자 C
SET seat:lock:{eventId}:F103-25 user_C NX EX 300
nil ❌
이미 선택된 좌석

 

SET NX는 "키가 없을 때만 생성"을 하나의 원자적 명령으로 처리한다.

두 요청이 동시에 와도 Redis가 하나만 성공시킨다.

EX 300으로 5분 후 자동 만료되므로 별도의 만료 처리가 필요 없다.

 

여러 좌석을 동시에 잡아야 할 때는 Lua Script 안에서

모든 좌석의 존재 여부를 확인한 뒤, 모두 비어 있을 때만 각 좌석 Lock을 설정한다.

하나라도 점유되어 있으면 전체 실패한다. Lua는 Redis에서 원자적으로 실행되므로 중간에 다른 명령이 끼어들 수 없다.

 

⚠️ Redis Lock은 좌석을 일정 시간 임시로 점유하는 용도다. 결제 완료 후 DB에서 최종 확정해야 한다.

 

⚠️ 이전 설계의 EXPIRE pending_seats G101-10 300
Redis EXPIRE는 키 단위 TTL이므로 Sorted Set 개별 멤버에는 쓸 수 없다.
개별 키 방식(SET NX EX)으로 전환하면 이 문제는 자동으로 해결된다.

 

 


3) 결제 확정: DB 트랜잭션 + unique constraint + 멱등성

 

지금까지의 Redis 처리는 모두 임시 상태였다.

결제 완료 콜백을 검증한 뒤, DB 트랜잭션으로 최종 확정해야 한다.

 

결제 흐름

 

1
Redis SET NX EX — 임시 좌석 점유 (5분)
2
Order 생성 (DB) — status: PENDING_PAYMENT
3
Payment Gateway 호출 — idempotency key = orderId
4
PG Callback 수신
5
🏦 DB Transaction (하나의 트랜잭션)
a. 멱등성 payment_id UNIQUE b. 구매 제한 확정 < 5 c. 좌석 seat_id UNIQUE d. → CONFIRMED
6
Redis Lock 해제DEL seat:lock:{eventId}:{seatId}

 

⚠️ 결제 전에 SOLD를 확정하면 안 된다. PG Callback 후 DB 트랜잭션에서 한 번에 확정해야 한다.

 

DB 최종 확정 SQL

 

지정석에서는 sold_seat 테이블에 seat_id UNIQUE를 두고,

결제 완료 시 INSERT로 판매 확정을 기록하는 방식이 단순하다.

 

INSERT INTO sold_seat(seat_id, order_id, user_id)
VALUES (?, ?, ?);
-- seat_id UNIQUE constraint로 같은 좌석이 두 번 확정되는 것을 막는다

 

Redis Lock과 별개로, DB에서 최종 판매 여부를 다시 검증하는 구조다.

 

구매 제한

 

이전 설계의 HGET → HINCRBY는 두 요청이 동시에 "4장"을 읽으면 6장이 될 수 있다.

좌석을 임시 점유할 때는 Redis Lua Script로 사용자별 임시 점유 수량을 원자적으로 증가시켜 5장 초과를 1차로 막는다.

다만 이미 확정된 구매 수량과 최종 제한 검증은 DB 트랜잭션에서 다시 수행한다.

 

결제 멱등성

 

PG사의 결제 콜백은 네트워크 타임아웃 등으로 중복으로 올 수 있다.

 

멱등성이 없으면 같은 콜백이 2번 올 때 주문이 2번 확정되고, 재고도 2번 차감된다.

멱등성이 있으면 이미 처리된 payment_id를 확인하고, 기존 처리 결과를 그대로 반환한다.

 

payment_id에 UNIQUE constraint를 건다.

같은 주문이 중복 확정되는 것도 막으려면 order_id 기준의 상태 조건부 UPDATE도 함께 고려한다.

 

💡 DB 확정 후 알림/통계 이벤트를 발행할 때는 이벤트 유실을 막기 위해 Outbox Pattern을 고려할 수 있다.

 

 


4) 실시간 알림: SSE/Polling/WebSocket 선택 기준

 

모든 알림을 WebSocket으로 처리할 필요는 없다.

기능마다 요구하는 실시간성, 연결 유지 비용, 확장 방식이 다르다.

 

기능 통신 방식 이유
대기열 순번 SSE / Polling 단방향. 초 단위면 충분
좌석 선택/Lock HTTP API 원자성/공정성이 더 중요
결제/확정 HTTP + 비동기 트랜잭션 보장 필요

 

대기열 순번은 서버→클라이언트 단방향이므로 SSE가 WebSocket보다 가볍다.

WebSocket을 쓰더라도 Sticky Session, WebSocket Gateway, 클라이언트 재연결 전략 등을 함께 고려해야 한다.

 

 


📝 정리

 

구간 처리 방식
대기열 Redis Sorted Set + 배치 ZPOPMIN + 입장 토큰
좌석 선점 SET seat:lock:{eventId}:{seatId} NX EX로 원자적 선점. 다중 좌석은 Lua
구매 제한 Redis Lua로 임시 제한 + DB 트랜잭션으로 최종 확정
결제 확정 PG Callback 후 DB 트랜잭션. seat_id + payment_id UNIQUE
실시간 알림 용도별 SSE/Polling/HTTP 분리. WebSocket은 필수가 아님