
이번 글에서는 대규모 트래픽이 발생할 수 있는
티켓팅 시스템을 어떻게 설계할지 고민해보고
최종적으로 정리한 아키텍처 구성을 단계적으로 살펴보려고 한다.
최종 구매 확정은 DB 트랜잭션과 제약 조건으로 보장
📋 요구사항 정의
1️⃣ 계정당 R석과 S석을 포함하여 총 5장까지만 구매 가능
2️⃣ 보유 티켓보다 더 많은 티켓이 판매되지 않도록 관리
3️⃣ 티켓팅 오픈 시 초당 5,000건 이상의 요청이 유입되는 상황을 고려
초당 5,000건이라는 요구사항은 대기열 진입인지, 좌석 선점인지, 결제 확정인지에 따라 난이도가 크게 달라진다.
이 글에서는 대기열 진입과 좌석 선점 구간을 Redis로 빠르게 처리하고,
최종 구매 확정은 DB 트랜잭션으로 정합성을 보장하는 방향으로 설계한다.
📐 전체 설계 요약
대기열 진입 / 순번
대기열 + 토큰 발급
토큰 검증 필수
임시 점유
PENDING_PAYMENT → PG 결제 (idempotency key)
멱등성 • 구매 제한 • 좌석 SOLD • CONFIRMED
seat_id UNIQUE • payment_id UNIQUE
최종 확정 지점
1) 대기열: Sorted Set + 입장 토큰
사용자가 접속하면 Redis Sorted Set에 추가된다.
score는 접속 시각을 기본으로 쓸 수 있지만,
동일 시각 충돌을 줄이기 위해 Redis INCR 기반 sequence나 timestamp + sequence 조합을 사용할 수 있다.
ZADD queue:{eventId} {score} {userId}ZRANK queue:{eventId} {userId}ZPOPMIN queue:{eventId} {batchSize}※ ZPOPMIN은 조회와 동시에 대기열에서 제거하므로, 토큰 발급 실패 시 사용자가 대기열에서 사라질 수 있다.
실제 구현에서는 입장 대상 선정, 토큰 발급, 대기열 제거를 Lua Script나 별도 상태값으로 함께 관리해야 한다.
SET entry_token:{eventId}:{userId} {token} EX 600
이전 설계에서는 "순번이 변경될 때마다 모든 사용자에게 WebSocket 푸시"로 했지만,
100만 명 대기 시 1명 처리마다 100만 ZRANK + 100만 메시지가 발생한다.
현실적으로는 정확한 실시간 순번 대신 "대략 순번 / 예상 대기 시간"을 제공한다.
Polling이라면 1~3초 간격으로 자기 순번을 조회하고,
SSE라면 서버가 일정 주기 또는 상태 변경 시점에 이벤트를 내려준다.
2) 좌석 선점: SET NX EX
좌석 선택은 "조회 후 등록"이 아니라 원자적 선점이어야 한다.
❌ 이전 설계 (Race Condition)
ZRANK → nullZADD 성공 ✅ZRANK → null 😱ZADD 성공 ✅ 😱
✅ 수정된 설계 (SET NX로 원자적 선점)
SET seat:lock:{eventId}:F103-25 user_B NX EX 3005분 후 자동 만료
SET seat:lock:{eventId}:F103-25 user_C NX EX 300이미 선택된 좌석
SET NX는 "키가 없을 때만 생성"을 하나의 원자적 명령으로 처리한다.
두 요청이 동시에 와도 Redis가 하나만 성공시킨다.
EX 300으로 5분 후 자동 만료되므로 별도의 만료 처리가 필요 없다.
여러 좌석을 동시에 잡아야 할 때는 Lua Script 안에서
모든 좌석의 존재 여부를 확인한 뒤, 모두 비어 있을 때만 각 좌석 Lock을 설정한다.
하나라도 점유되어 있으면 전체 실패한다. Lua는 Redis에서 원자적으로 실행되므로 중간에 다른 명령이 끼어들 수 없다.
EXPIRE pending_seats G101-10 300은Redis EXPIRE는 키 단위 TTL이므로 Sorted Set 개별 멤버에는 쓸 수 없다.
개별 키 방식(SET NX EX)으로 전환하면 이 문제는 자동으로 해결된다.
3) 결제 확정: DB 트랜잭션 + unique constraint + 멱등성
지금까지의 Redis 처리는 모두 임시 상태였다.
결제 완료 콜백을 검증한 뒤, DB 트랜잭션으로 최종 확정해야 한다.
결제 흐름
PENDING_PAYMENTDEL seat:lock:{eventId}:{seatId}
DB 최종 확정 SQL
지정석에서는 sold_seat 테이블에 seat_id UNIQUE를 두고,
결제 완료 시 INSERT로 판매 확정을 기록하는 방식이 단순하다.
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도 함께 고려한다.
4) 실시간 알림: SSE/Polling/WebSocket 선택 기준
모든 알림을 WebSocket으로 처리할 필요는 없다.
기능마다 요구하는 실시간성, 연결 유지 비용, 확장 방식이 다르다.
대기열 순번은 서버→클라이언트 단방향이므로 SSE가 WebSocket보다 가볍다.
WebSocket을 쓰더라도 Sticky Session, WebSocket Gateway, 클라이언트 재연결 전략 등을 함께 고려해야 한다.
📝 정리
'Programming > Java & Spring 관련 내용 정리' 카테고리의 다른 글
| "주문이 사라졌다!" Oracle → Kafka → DynamoDB 파이프라인 캐시 불일치 (0) | 2026.03.23 |
|---|---|
| Push 서버 재구성: 유연한 이벤트성 푸시 발송 시스템 구현 (1) | 2024.08.27 |
| MDC를 활용해 쓰레드 전환을 이해해보다. (4) | 2024.08.27 |
| JPA에서 @BatchSize 를 쓸 것인가 vs Fetch Join을 쓸 것인가 (1) | 2024.08.06 |
| [Java] 인터페이스를 통해 if문의 향연을 고쳐보았다. (0) | 2023.09.11 |