선착순 시스템 완전 정리: 6가지 구현 방식과 선택 가이드
서론
이전 글에서 FOR UPDATE의 한계를 다뤘다. 동시 100명이 몰리면 99명이 대기하고, 커넥션 풀이 고갈되고, 데드락 위험까지 따라온다. DB 락만으로는 트래픽이 높은 선착순 시스템을 감당할 수 없다.
이번 글에서는 한 발짝 물러서서 전체 그림을 본다 — 선착순 시스템이 해결해야 하는 문제가 뭐고, 어떤 방식들이 있고, 각각 언제 쓰는 게 맞는지.
1. 선착순 시스템이란?
콘서트 티켓 예매, 한정판 스니커즈 구매, 쿠팡 로켓딜 — 모두 정해진 수량을 먼저 신청한 사람에게 배정하는 시스템이다. 단순해 보이지만, 수천~수만 명이 동시에 몰리는 순간 3가지 핵심 문제가 발생한다.
핵심 3대 문제
| 문제 | 설명 | 안 풀면 어떻게 되나? |
|---|---|---|
| 동시성 제어 | 수천 명이 같은 순간에 같은 재고를 차감하려 한다 | 재고 100개인데 150명이 구매 성공 (초과 판매) |
| 정확한 재고 차감 | 차감 연산이 원자적이지 않으면 숫자가 꼬인다 | 재고가 음수가 되거나, 두 요청이 동시에 같은 값을 읽어서 하나만 차감됨 |
| 중복 구매 방지 | 같은 사용자가 여러 번 요청하면 중복 당첨될 수 있다 | 한 명이 10개를 가져감 |
비유: 빵집에서 한정 100개 빵을 판다고 생각해보자. 줄을 안 세우면(동시성 제어 없음) 사람들이 뒤엉켜서 빵을 집어간다. 카운터 숫자를 안 세면(재고 차감 오류) 101번째 사람도 빵을 받는다. 번호표 검사를 안 하면(중복 방지 없음) 한 사람이 줄을 여러 번 선다.
2. 6가지 구현 방식
2.1 DB 비관적 락 (SELECT FOR UPDATE)
핵심 원리: 재고 행을 먼저 잠그고, 차감하고, 잠금을 풀어서 한 번에 하나의 요청만 처리한다.
| 단계 | 클라이언트 | 서버 (DB) | 상태 |
|---|---|---|---|
| 1 | 구매 요청 | SELECT stock FOR UPDATE → 행 잠금 | 🔒 |
| 2 | stock > 0 확인 → UPDATE stock = stock - 1 | ✅ 차감 | |
| 3 | COMMIT → 잠금 해제 | 🔓 | |
| 4 | 다음 요청 | 이제야 락 획득 가능 → 반복 | ⏳ |
| 장점 | 단점 |
|---|---|
| 추가 인프라 불필요 (DB만 있으면 됨) | 동시 요청이 직렬화됨 (99명 대기) |
| 구현이 단순함 | 데드락 위험 |
| 데이터 정합성 확실 | DB 커넥션 풀 고갈 위험 |
적합한 상황: 동시 접속 수십 명 이하, 추가 인프라를 도입하기 어려운 초기 서비스
2.2 Redis 원자 연산 (DECR)
핵심 원리: Redis의 DECR 명령어는 싱글 스레드에서 원자적으로 실행되므로, 락 없이도 안전하게 재고를 차감한다.
| 단계 | 클라이언트 | 서버 (Redis → DB) | 상태 |
|---|---|---|---|
| 1 | 구매 요청 | DECR stock_key → 결과값 확인 | |
| 2 | 결과 ≥ 0이면 → 구매 성공, DB에 주문 저장 | ✅ | |
| 3 | 결과 < 0이면 → INCR stock_key (복구) → 품절 응답 | ❌ |
| 장점 | 단점 |
|---|---|
| 초당 수만 TPS 처리 가능 | Redis 장애 시 데이터 유실 가능 |
| 락 없이 원자적 처리 | Redis와 DB 간 데이터 정합성 관리 필요 |
| 구현이 비교적 단순 | 재고 복구 로직 필요 (결과 < 0일 때) |
적합한 상황: 수백~수천 명 동시 접속, 빠른 응답이 중요한 서비스
2.3 Redis + Lua 스크립트
핵심 원리: 재고 확인과 차감을 하나의 Lua 스크립트로 묶어서 Redis에서 원자적으로 실행한다. 단순 DECR과 달리 “확인 → 차감”이 한 덩어리로 실행되어 음수 재고가 원천 차단된다.
| 단계 | 클라이언트 | 서버 (Redis → DB) | 상태 |
|---|---|---|---|
| 1 | 구매 요청 | Lua 스크립트 실행 시작 (원자적) | 🔒 |
| 2 | stock > 0 확인 + 중복 구매 확인 + DECR → 한 번에 실행 | ||
| 3 | 결과: 성공이면 DB에 주문 저장, 실패면 즉시 응답 | ✅ / ❌ |
| 장점 | 단점 |
|---|---|
| 재고 확인 + 차감 + 중복 체크를 원자적으로 | Lua 스크립트 디버깅이 어려움 |
| 음수 재고 원천 차단 | Redis 장애 시 데이터 유실 가능 |
| 복구 로직 불필요 (확인 후 차감이니까) | 스크립트가 길어지면 Redis 블로킹 위험 |
적합한 상황: 높은 트래픽 + 중복 구매 방지까지 Redis 레벨에서 처리하고 싶을 때
2.4 메시지 큐 (Kafka / RabbitMQ)
핵심 원리: 구매 요청을 큐에 넣고, 컨슈머가 순서대로 하나씩 꺼내서 처리한다. 요청 자체를 직렬화하는 방식이다.
| 단계 | 클라이언트 | 서버 (큐 → 컨슈머 → DB) | 상태 |
|---|---|---|---|
| 1 | 구매 요청 | 큐에 메시지 발행 → 즉시 “접수 완료” 응답 | 📨 |
| 2 | 컨슈머가 순서대로 메시지 소비 | ⏳ | |
| 3 | 재고 확인 → 차감 → 주문 생성 | ✅ / ❌ | |
| 4 | 결과 확인 (폴링 / 웹소켓) | 처리 결과 전달 | 📬 |
| 장점 | 단점 |
|---|---|
| 트래픽 폭주에 강함 (버퍼 역할) | 응답이 비동기 (즉시 결과를 모름) |
| 서버 부하 분산 | 구현 복잡도 높음 (큐 + 컨슈머 + 결과 전달) |
| 컨슈머를 늘려 처리량 조절 가능 | 추가 인프라 필요 (Kafka / RabbitMQ) |
적합한 상황: 대규모 트래픽, 즉시 응답이 필수가 아닌 경우 (쿠폰 발급, 이벤트 응모)
2.5 대기열 (Waiting Queue)
핵심 원리: 사용자를 대기열에 넣고 순번을 부여한 뒤, 순서가 되면 구매 페이지로 입장시킨다. 네이버 예매, 인터파크 티켓팅에서 흔히 보는 “앞에 N명 대기 중” 방식이다.
| 단계 | 클라이언트 | 서버 (Redis Sorted Set) | 상태 |
|---|---|---|---|
| 1 | 접속 | ZADD queue timestamp userId → 대기열 진입 | 🕐 대기 |
| 2 | ”앞에 342명” 표시 | ZRANK queue userId → 현재 순번 조회 | ⏳ |
| 3 | 순번 도달 | 대기열에서 제거 → 구매 페이지 입장 허용 | 🎫 입장 |
| 4 | 구매 진행 | 재고 차감 (Redis 또는 DB) | ✅ / ❌ |
| 장점 | 단점 |
|---|---|
| 서버 부하를 일정하게 유지 | 사용자 대기 경험 (UX 비용) |
| 공정한 순서 보장 | 구현 복잡도 높음 (대기열 + 입장 제어 + 만료 처리) |
| 트래픽을 두 단계로 분산 | 대기 이탈 시 슬롯 낭비 처리 필요 |
적합한 상황: 수만 명 이상 동시 접속, 공정한 순서가 중요한 티켓팅/예매 시스템
2.6 토큰 발급 방식
핵심 원리: 먼저 입장 토큰을 발급하고, 토큰을 가진 사용자만 구매할 수 있게 한다. 트래픽을 “토큰 발급”과 “실제 구매” 두 단계로 완전히 분리한다.
| 단계 | 클라이언트 | 서버 | 상태 |
|---|---|---|---|
| 1 | 토큰 요청 | 토큰 발급 서버: 재고 수량만큼 토큰 발급 → 초과 시 거절 | 🎟️ / ❌ |
| 2 | 토큰으로 구매 요청 | 구매 서버: 토큰 유효성 검증 → 재고 차감 → 주문 생성 | ✅ |
| 3 | 토큰 만료/사용 처리 | 🔒 |
| 장점 | 단점 |
|---|---|
| 구매 서버에 트래픽이 집중되지 않음 | 토큰 발급 서버 + 구매 서버 분리 필요 |
| 토큰 수 = 재고 수 → 초과 판매 원천 차단 | 토큰 만료/도용 방지 로직 필요 |
| 서버 분리로 부하 분산 | 사용자 경험이 2단계로 나뉨 |
적합한 상황: 한정판 판매, 사전 예약, 트래픽을 물리적으로 분리해야 하는 대규모 시스템
3. 한눈에 비교
| 방식 | 처리량 (TPS) | 구현 복잡도 | 추가 인프라 | 데이터 정합성 | 적합 규모 |
|---|---|---|---|---|---|
| DB 비관적 락 | 낮음 (수십~수백) | ⭐ | 없음 | 높음 | 소규모 |
| Redis DECR | 높음 (수만) | ⭐⭐ | Redis | 중간 | 중규모 |
| Redis + Lua | 높음 (수만) | ⭐⭐⭐ | Redis | 높음 | 중~대규모 |
| 메시지 큐 | 높음 (확장 가능) | ⭐⭐⭐⭐ | Kafka/RabbitMQ | 높음 | 대규모 |
| 대기열 | 높음 (제어 가능) | ⭐⭐⭐⭐ | Redis | 높음 | 대규모 |
| 토큰 발급 | 높음 (분산) | ⭐⭐⭐⭐ | 토큰 서버 | 높음 | 대규모 |
처리량은 단순 수치 비교가 아니다. DB 락은 “순서대로 하나씩”이라 TPS가 낮고, Redis 방식은 “락 없이 원자적”이라 TPS가 높다. 메시지 큐와 대기열은 컨슈머 수를 늘려서 처리량을 조절할 수 있다는 점에서 “확장 가능”이다.
4. 어떤 방식을 선택할까?
의사결정 흐름
| 단계 | 질문 | 답변 → 방향 |
|---|---|---|
| 1 | 동시 접속자가 몇 명인가? | 수십 명 → DB 비관적 락으로 충분 |
| 2 | 추가 인프라(Redis) 도입이 가능한가? | 불가 → DB 비관적 락 |
| 3 | 즉시 응답이 필수인가? | 필수 → Redis DECR 또는 Redis + Lua |
| 4 | 중복 구매 방지를 Redis에서 처리하고 싶은가? | 예 → Redis + Lua |
| 5 | 트래픽이 수만 명 이상이고, 비동기 응답이 괜찮은가? | 예 → 메시지 큐 |
| 6 | 공정한 대기 순서가 중요한가? | 예 → 대기열 |
| 7 | 구매 트래픽을 물리적으로 분리해야 하는가? | 예 → 토큰 발급 |
실전에서는 조합한다
실제 대규모 시스템은 하나의 방식만 쓰지 않는다. 예시:
| 시스템 | 조합 |
|---|---|
| 쿠팡 로켓딜 | 대기열 (입장 제어) + Redis (재고 차감) |
| 콘서트 티켓팅 | 대기열 (순번 관리) + 토큰 (입장 권한) + DB 락 (최종 결제) |
| 한정판 스니커즈 | 토큰 발급 (봇 방지) + Redis + Lua (재고 차감) |
| 소규모 이벤트 쿠폰 | Redis DECR 하나로 충분 |
핵심은 **“어떤 방식이 최고인가”가 아니라 “우리 상황에 뭐가 맞는가”**다. 동시 접속 50명인 서비스에 Kafka를 도입하면 과잉 설계이고, 10만 명이 몰리는 티켓팅에 DB 락만 쓰면 서버가 다운된다.
정리
| 핵심 포인트 | 내용 |
|---|---|
| 선착순의 3대 문제 | 동시성 제어, 정확한 재고 차감, 중복 구매 방지 |
| 6가지 방식 | DB 락, Redis DECR, Redis + Lua, 메시지 큐, 대기열, 토큰 발급 |
| 규모에 맞게 선택 | 소규모 → DB 락, 중규모 → Redis, 대규모 → 큐/대기열/토큰 |
| 실전은 조합 | 대기열 + Redis, 토큰 + DB 락 등 여러 방식을 섞어 쓴다 |
| 과잉 설계 주의 | 트래픽 규모와 인프라 여건에 맞는 가장 단순한 방식부터 |
다음 글에서는 4편: DB 락으로 선착순 시스템 구현을 다룬다. 가장 단순한 방식부터 직접 코드로 구현하고, 동시성 테스트로 한계를 확인해본다.