폭증하는 트래픽을 어느정도 나는 고려해서 설계를 할 수 있는가?
TL;DR: 선착순 주문에 10만 명이 몰리는 상황을 Redis 대기열로 풀었다. 처음에는 “Redis니까 빠르겠지”로 시작했는데, 동시성 구멍이 줄줄이 터졌다. Lua 스크립트로 원자성을 확보하고, 100ms 단위 분산 발급으로 Thundering Herd를 완화하고, 토큰 소비를 원자 연산으로 바꾸기까지의 과정을 정리했다.
1. 문제: “선착순 주문”이라는 폭탄
이커머스에서 선착순 주문은 늘 문제다. 평소 TPS가 50인 서비스에 갑자기 10만 명이 동시에 POST /orders를 누른다. DB 커넥션 풀은 40개다. 나머지 99,960명의 요청은 어디로 가는가?
답은 간단하다. 커넥션 풀이 고갈되고, 타임아웃이 연쇄하고, 서비스 전체가 멈춘다.
선착순 주문뿐만이 아니다. 타임세일, 한정판 드랍, 쿠폰 발급 — 트래픽이 순간적으로 폭증하는 시나리오는 실무에서 반복적으로 나타난다. 이 문제를 풀기 위해 대기열 시스템을 설계했다.
2. 핵심 아이디어: “줄 세우기”
대기열의 본질은 단순하다. 서버가 처리할 수 있는 속도로만 사용자를 들여보내는 것.
[사용자 10만명] → [대기열 (FIFO)] → [토큰 발급 (18명/100ms)] → [주문 API]
- 사용자가
POST /queue/enter로 대기열에 진입한다. - 스케줄러가 100ms마다 앞에서 18명씩 꺼내서 입장 토큰을 발급한다.
- 토큰을 받은 사용자만
POST /orders를 호출할 수 있다. - 토큰이 없으면 인터셉터에서 403으로 차단한다.
이렇게 하면 주문 API에 도달하는 트래픽이 초당 ~180명으로 제한된다. DB 커넥션 풀(40개)이 감당할 수 있는 수준이다.
3. 숫자를 먼저 정했다
설계 전에 시스템이 감당할 수 있는 한계를 먼저 계산했다. 감(感)이 아니라 숫자로 시작해야 한다.
하류 시스템 처리량 역산
DB 커넥션 풀 (HikariCP max) = 40개
주문 1건 평균 처리 시간 = ~200ms (비관적 락 + 쿠폰 검증 + 저장 + 이벤트)
이론적 최대 TPS = 40 / 0.2 = 200 TPS
안전 마진 적용 (70%) = 200 × 0.7 = 140 TPS
실제 설정에서는 175 TPS로 잡았다. Tomcat max threads(200)는 커넥션 풀(40)보다 넉넉하므로 병목은 DB 커넥션이다. 커넥션 풀이 병목이라는 사실을 먼저 파악한 것이 나머지 설계의 출발점이 되었다.
Thundering Herd 완화
여기서 중요한 판단이 있었다. 175명을 1초에 한 번 발급할 것인가, 100ms마다 나눠서 발급할 것인가?
AS-IS: 1초마다 175명 동시 발급 → 175명이 동시에 POST /orders → 커넥션 풀 순간 고갈
TO-BE: 100ms마다 ~18명씩 발급 → 10회에 걸쳐 분산 → 순간 부하 10배 감소
| 설정 | 값 | 산정 근거 |
|---|---|---|
| 스케줄러 주기 | 100ms | 1초를 10구간으로 분할 |
| 배치 크기 | 18명 | 175 / 10 = 17.5 → 올림 |
| 토큰 TTL | 300초 (5분) | 주문 작성(~1분) + 결제(~1분) + 여유(3분) |
| 최대 대기열 | 100,000명 | 100,000 / 175 ≈ 571초 ≈ ~9.5분 대기 |
9.5분이 넘으면 사용자 이탈률이 급격히 올라가므로, 10만 명을 넘으면 QUEUE_FULL로 거절하기로 했다. “모두 받아주겠다”는 것보다 “솔직하게 거절하겠다”는 것이 더 나은 UX라고 판단했다.
4. Redis를 선택한 이유 — 그리고 처음에 잘못 쓴 이유
대기열 저장소로 Redis Sorted Set을 선택했다. 이유는 명확하다.
ZADD: O(log N) 삽입 + FIFO 보장 (score 기반 정렬)ZPOPMIN: O(log N) + O(M) 으로 앞에서 N명 추출ZRANK: O(log N) 으로 내 순번 조회- 인메모리이므로 10만 명 수준은 메모리 수 MB로 처리 가능
하지만 “Redis니까 빠르고 안전하겠지”는 착각이었다.
처음 구현은 이랬다.
// 처음 구현 — 원자성이 없다
public long enter(Long userId) {
if (entryTokenRepository.exists(userId)) throw ALREADY_HAS_TOKEN; // ① 토큰 확인
if (waitingQueueRepository.getTotalSize() >= maxSize) throw FULL; // ② 크기 확인
double score = System.currentTimeMillis(); // ③ score 생성
waitingQueueRepository.add(userId, score); // ④ ZADD
return waitingQueueRepository.getRank(userId); // ⑤ 순번 반환
}
이 코드에는 세 가지 동시성 구멍이 있었다.
구멍 1: 크기 검사와 삽입 사이의 틈
Thread A: ② getTotalSize() → 99,999 (OK)
Thread B: ② getTotalSize() → 99,999 (OK)
Thread A: ④ ZADD → 100,000번째
Thread B: ④ ZADD → 100,001번째 ← maxQueueSize 초과!
크기 확인과 삽입이 별도 명령이므로, 두 스레드가 동시에 통과할 수 있다.
구멍 2: System.currentTimeMillis() 충돌
같은 밀리초에 두 명이 진입하면 score가 동일하다. Redis Sorted Set은 동일 score일 때 member를 사전순으로 정렬하므로, 먼저 요청한 사람이 뒤로 밀릴 수 있다. FIFO가 깨진다.
구멍 3: 토큰 확인과 진입 사이의 틈
Thread A: ① exists(userId) → false
Scheduler: 토큰 발급 (userId에게 토큰 발급)
Thread A: ④ ZADD → 대기열에도 있고 토큰도 있는 상태!
5. Lua 스크립트로 원자성 확보
위 세 가지 구멍을 모두 막으려면, 토큰 확인 → 크기 검사 → score 생성 → 삽입 → 순번 반환을 하나의 원자 연산으로 묶어야 한다. Redis의 Lua 스크립트가 정확히 이 용도다.
-- ENTER_SCRIPT: 대기열 진입 (원자적)
-- KEYS: [waiting-queue, waiting-queue:seq]
-- ARGV: [userId, maxQueueSize, tokenKeyPrefix]
-- 1. 토큰 보유 여부 확인
if redis.call('EXISTS', ARGV[3] .. ARGV[1]) == 1 then
return -2 -- ALREADY_HAS_TOKEN
end
-- 2. 이미 대기열에 있는지 확인 (멱등성)
local rank = redis.call('ZRANK', KEYS[1], ARGV[1])
if rank then return rank end
-- 3. 대기열 크기 확인
if tonumber(redis.call('ZCARD', KEYS[1])) >= tonumber(ARGV[2]) then
return -1 -- QUEUE_FULL
end
-- 4. 단조 증가 score 생성 (INCR로 충돌 불가)
local seq = redis.call('INCR', KEYS[2])
-- 5. 삽입 + 순번 반환
redis.call('ZADD', KEYS[1], 'NX', seq, ARGV[1])
return redis.call('ZRANK', KEYS[1], ARGV[1])
Redis는 Lua 스크립트를 단일 스레드에서 원자적으로 실행한다. 스크립트 실행 중에는 다른 명령이 끼어들 수 없다. 이 한 가지 특성이 위의 세 가지 구멍을 모두 막는다.
System.currentTimeMillis() 대신 INCR로 단조 증가하는 시퀀스를 score로 사용한 것도 핵심이다. 같은 밀리초에 100명이 진입해도 score가 전부 다르므로 FIFO가 보장된다.
같은 원리로 토큰 발급(pop + save)과 토큰 소비(검증 + 삭제)도 Lua로 원자화했다.
-- CONSUME_IF_MATCHES_SCRIPT: 토큰 검증 + 삭제를 한 번에
local stored = redis.call('GET', KEYS[1])
if not stored then return -1 end -- 토큰 없음 (만료)
if stored == ARGV[1] then
redis.call('DEL', KEYS[1])
return 1 -- 소비 성공
end
return 0 -- 토큰 불일치
이 스크립트 하나로 동일 토큰으로 동시에 두 번 주문하면 정확히 한 번만 성공하는 것을 보장한다.
6. 토큰 생명주기 — 실패해도 괜찮은 구조
토큰의 생명주기를 설계할 때 가장 고민한 부분은 “주문이 실패하면 토큰은 어떻게 되는가?”였다.
[진입] → [대기] → [토큰 발급 (TTL 5분)] → [토큰 소비] → [주문 생성]
↓ (주문 실패 시)
[토큰 복원]
처음에는 OrderService 안에서 @Transactional 경계 내에 토큰 삭제를 넣었다. 하지만 Redis 삭제와 DB 트랜잭션은 서로 다른 시스템이므로 원자성이 보장되지 않는다.
- Redis 삭제 성공 → DB 커밋 실패 → 토큰은 사라졌는데 주문은 없는 상태
- Redis 삭제 실패 → DB 커밋 성공 → 주문은 됐는데 토큰이 남아서 중복 주문 가능
이 문제를 인터셉터 패턴으로 풀었다.
public class EntryTokenInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(...) {
String token = request.getHeader("X-Entry-Token");
// Lua 스크립트로 검증 + 삭제를 원자적으로 수행
validateEntryTokenUseCase.consume(userId, token);
// 복원용으로 저장
request.setAttribute("consumed-token", token);
return true;
}
@Override
public void afterCompletion(..., Exception ex) {
if (ex != null) {
// 주문 실패 시 토큰 복원 (TTL 유지)
String token = (String) request.getAttribute("consumed-token");
entryTokenRepository.save(userId, token, remainingTtl);
}
}
}
preHandle: 주문 API 진입 전에 토큰을 원자적으로 소비(Lua로 검증+삭제 동시 수행)afterCompletion: 주문이 실패하면 토큰을 복원
이렇게 하면 토큰 소비가 OrderService의 트랜잭션 경계 밖에서 일어나므로, DB 트랜잭션 실패와 Redis 상태 불일치 문제가 해소된다. 최악의 경우에도 토큰 TTL(5분)이 안전망 역할을 한다.
7. 동시성 테스트 — “되겠지”는 테스트가 아니다
대기열 시스템에서 단위 테스트만으로는 부족하다. 동시에 50명이 진입하고, 동시에 같은 토큰으로 주문하고, 스케줄러가 동시에 돌아가는 상황을 테스트해야 한다.
@Test
void 동일_토큰으로_동시_주문시_정확히_1건만_성공한다() {
// 50 threads가 동시에 같은 토큰으로 consume 시도
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(50);
ConcurrentLinkedQueue<Throwable> errors = new ConcurrentLinkedQueue<>();
AtomicInteger successCount = new AtomicInteger(0);
for (int i = 0; i < 50; i++) {
executor.submit(() -> {
startLatch.await(); // 모든 스레드가 동시에 시작
try {
queueService.consume(userId, token);
successCount.incrementAndGet();
} catch (Exception e) {
errors.add(e);
} finally {
doneLatch.countDown();
}
});
}
startLatch.countDown(); // 동시 출발
assertThat(doneLatch.await(10, SECONDS)).isTrue();
assertThat(successCount.get()).isEqualTo(1); // 정확히 1건
assertThat(errors).hasSize(49); // 나머지는 전부 실패
}
여기서 중요한 건 ConcurrentLinkedQueue로 실패를 수집하는 것이다. 처음에는 예외를 그냥 삼키고 doneLatch만 확인했는데, 이러면 스레드 일부가 예상과 다른 이유로 실패해도 테스트가 녹색이 된다. 거짓 양성(false positive)은 동시성 테스트에서 가장 위험한 결과다.
8. 코드 리뷰에서 배운 것
이 시스템은 처음 PR을 올린 뒤 코드 리뷰에서 상당한 지적을 받았다. 그 과정에서 배운 것들을 정리한다.
배운 것 1: “나중에 Lua로 바꿔야지”는 없다
처음에는 Java에서 여러 Redis 명령을 순차 호출하고, “성능 이슈가 생기면 Lua로 바꾸자”고 생각했다. 하지만 성능이 아니라 정합성이 문제였다. 동시성 버그는 “나중에”가 아니라 트래픽이 폭증하는 바로 그 순간에 터진다. 대기열 시스템은 트래픽 폭증을 전제로 만드는 것이므로, 원자성은 최초 설계부터 확보해야 했다.
배운 것 2: validate()와 consume()은 분리하면 안 된다
처음에는 인터셉터에서 validate(token) → OrderService에서 delete(token)으로 분리했다. “검증은 검증, 삭제는 삭제”라는 깔끔한 분리처럼 보였다. 하지만 그 사이에 같은 토큰으로 두 번째 요청이 들어오면 둘 다 검증을 통과한다. 검증과 소비는 반드시 원자 연산이어야 한다. consumeIfMatches Lua 스크립트가 이 교훈의 결과물이다.
배운 것 3: Master/Replica 분리는 대기열에서 독이 될 수 있다
읽기 성능을 위해 Redis Master/Replica를 분리했는데, 토큰 발급(Master 쓰기) 직후 토큰 검증(Replica 읽기)을 하면 복제 지연 때문에 “토큰 없음”이 나올 수 있다. Read-your-writes가 필요한 경로에서는 Replica가 아니라 Master에서 읽어야 한다. “읽기는 무조건 Replica”라는 규칙은 대기열에서는 틀렸다.
9. 이 경험이 실무에서 어떻게 쓰일까
이번에 배운 것의 핵심은 기술이 아니라 사고방식이다.
“이 시스템에 10만 명이 동시에 몰리면 어디가 먼저 터지는가?”
이 질문을 먼저 하고, 숫자를 먼저 계산하고, 병목을 먼저 파악한 뒤에 코드를 짜야 한다. DB 커넥션 풀 40개라는 제약을 모른 채 대기열을 설계하면, 대기열을 통과한 뒤에 결국 같은 문제가 터진다.
그리고 동시성 문제는 “일어날 수 있다”는 건 “반드시 일어난다”와 같다. 특히 트래픽이 몰리는 순간에. “확률이 낮으니까 괜찮겠지”는 선착순 주문에서 가장 위험한 생각이다.
10. 남은 과제
현재 구현에는 인지하고 있는 한계가 있다.
- 토큰 복원의 불완전함: 인터셉터의
afterCompletion에서 토큰을 복원하지만, 서버 프로세스 자체가 죽으면 복원이 불가능하다. 최종 안전망은 TTL(5분)이지만, 그 사이 해당 슬롯은 사실상 낭비된다. - 단일 Redis 의존: Redis 장애 시 대기열 전체가 멈춘다. Redis Sentinel이나 Cluster로 HA를 확보하거나, 대기열 장애 시 직접 주문을 허용하는 폴백 정책이 필요하다.
- 대기 시간 추정의 한계:
순번 / 초당 처리량은 순수 추정치다. 토큰 미사용(만료)이 많으면 실제 대기 시간은 더 짧고, 하류 시스템 장애가 나면 더 길어진다.
이런 한계를 인식하고 있다는 것 자체가, 10주 전의 나와 다른 점이라고 생각한다. 예전에는 “동작하면 끝”이었다면, 지금은 “이게 어떤 상황에서 깨지는가”를 먼저 본다.
참고
- 우아한형제들 - 선착순 이벤트 서버 생존기 — 대규모 트래픽 처리를 위한 대기열 설계와 정산 배치
- 우아한형제들 - Spring Batch와 Querydsl — 대규모 데이터 처리 성능 최적화
- 카카오 - Redis, 잘못 쓰면 망한다 — Redis 안티패턴과 주의사항
- Redis Sorted Set을 활용한 랭킹 시스템 개발하기 — ZSET 기반 실시간 랭킹 구현 사례