블랙 프라이데이급 트래픽에도 시스템을 보호하면서, 유저에게 공정한 대기 경험을 제공할 것.
주문 API 앞단에 Redis 기반 대기열을 세우는 과제였다.
막상 만들기 시작해보니 이 한 줄 안에 답해야 할 질문이 다섯 개나 들어 있었다.
질문마다 선택지가 있었고, 뭘 고르든 아쉬운 점이 하나씩 남았다.
이 글은 그 다섯 개의 질문에 어떻게 답했는지의 기록이다.
TL;DR
주문은 유저에게 "기회"라서, 거부(429) 대신 줄을 세웠다.
순번은 Redis INCR로 겹치지 않게 매기고, 입장 속도는 커넥션 풀에서 거꾸로 계산해 초당 100명 — 몰아넣지 않고 100ms마다 10명씩 나눠 들여보낸다.
입장 토큰은 실패한 유저가 다시 시도할 수 있도록 주문이 성공했을 때만 지우고, 기다리는 유저에게는 예상 시간과 조회 주기(pollAfter)를 알려준다.
거부하는 대신 기다리게 하기로 했다면, "기다리는 경험"까지가 설계의 절반이다.
다섯 개의 질문
- 넘치는 요청 — 돌려보낼까, 기다리게 할까
- 공정한 줄은 어떻게 만들까
- 한 번에 몇 명씩 들여보낼까
- 입장권은 몇 분짜리로 할까
- 기다리는 유저에게 뭘 보여줄까
본격적으로 들어가기 전에, 완성된 시스템의 전체 그림을 먼저 놓고 시작한다.
유저의 진입 → 폴링 → 스케줄러의 입장 처리 → 토큰 발급 → 주문 → 검증까지, 다섯 질문이 어디에 대응하는지 이 흐름 위에서 하나씩 풀어간다.

질문 1. 넘치는 요청, 돌려보낼까 기다리게 할까
블랙 프라이데이 자정, 세일이 열리자 수십만 명이 동시에 주문 버튼을 눌렀다. 서버가 감당할 수 있는 건 초당 100건 남짓인데, 요청은 그보다 훨씬 많이 쏟아진다.
그대로 다 받으면 서버가 마비되고, 이미 결제 중이던 유저의 주문마저 실패한다.
그러니 "다 받는다"는 애초에 선택지가 아니다. 남는 건 둘이다.
돌려보내거나, 기다리게 하거나.
--
선택지 A — Rate Limiting. 초당 받을 수 있는 양을 정해두고, 넘치는 요청은 429로 바로 돌려보낸다. 시스템 부하가 확실하게 제한되고 만들기도 쉽다.
문제는 거절당한 유저다. 먼저 클릭했어도 429를 받으면 끝 — 다시 시도하다 지치거나 떠나면서 주문 기회 자체를 잃는다. 늦게 클릭한 유저가 운 좋게 통과할 수도 있으니, 시스템은 지켜지는데 공정함이 무너진다.
선택지 B — 대기열. 돌려보내지 않고 줄을 세운 뒤, 시스템이 감당할 수 있는 속도로만 통과시킨다. 들어온 순서가 지켜지고, 순번과 예상 시간을 보여주면 이탈도 줄어든다.
대신 만들기가 꽤 복잡해진다.
| 아무것도 없음 | Rate Limiting | 대기열 | |
|---|---|---|---|
| 초과 요청 처리 | 그대로 받다가 터짐 | 즉시 거부 (429) | 줄 세움 |
| 공정성 | 낮음 (커넥션 운) | 낮음 (속도 운) | 높음 (순서 보장) |
| 유저 경험 | 에러/성공 양극단 | 거부 → 이탈 | 기다리지만 예측 가능 |
| 구현 복잡도 | 낮음 | 낮음 | 높음 |
결국 판단 기준은 이거였다. 이 요청이 유저에게 얼마나 중요한가?
봇 트래픽이나 단순 조회라면 잘라내도 된다. 하지만 주문은 유저에게 "기회"고, 기회라면 운이 아니라 온 순서대로 나눠주는 게 맞다.
그래서 대기열이다.
다만 만들고 나서 알게 된 게 하나 있다. 대기열도 무한히 받을 수는 없다 — Redis 메모리는 유한하니 상한(10만 명)을 두고, 넘치면 429로 돌려보낸다.
어라, 이건 Rate Limiting이다.
그러니까 둘 중 하나를 고르는 문제가 아니었다.
기회를 지키는 층(대기열) 위에 시스템을 지키는 층(상한 429)을 겹쳐 쌓는 것이 실제 답이었다.
질문 2. 공정한 줄은 어떻게 만들까
Redis Sorted Set에 userId를 member로, 순번을 score로 넣으면 줄이 된다.
간단해 보이지만 "공정한" 줄이 되려면 챙길 게 세 가지 있었다.
번호가 절대 겹치지 않아야 하고, 새로고침해도 내 자리가 그대로여야 하고, 끼어들기가 안 돼야 한다.
번호가 겹치면 안 된다 — timestamp 대신 카운터
score의 첫 후보는 자연스럽게 System.currentTimeMillis()였다. 들어온 시각이 곧 순서니까.
그런데 구멍이 두 개 있다.
- ms 정밀도 충돌 — 동시에 우르르 들어오면 같은 밀리초에 여러 명이 겹친다. score가 같으면 누가 먼저인지 애매해진다.
- 서버 시계 차이 — 서버가 여러 대면 시계가 미묘하게 다르다. A 서버로 먼저 들어온 유저가 B 서버로 늦게 들어온 유저보다 뒤 번호를 받을 수 있다.
Redis INCR 카운터는 둘 다 없다. Redis가 한 곳에서 원자적으로 숫자를 올려주니 절대 겹치지 않고, 어느 서버를 거쳐 왔는지도 상관없다.
"순서"의 기준을 각 서버의 시계가 아니라 Redis에 도착한 순서 하나로 통일한 것이다.
새로고침해도 내 자리는 그대로
대기 중에 불안해서 새로고침을 누르는 건 자연스러운 행동이다. 그런데 티켓팅 서비스의 대기열 화면을 떠올려보면, 하단에 이런 안내가 붙어 있곤 하다 — "새로고침하시면 대기 순번이 뒤로 밀릴 수 있습니다."

재접속을 새로 들어온 것으로 취급해서 줄 맨 뒤로 보내는 정책이다.
새로고침 연타를 막아 극단적인 트래픽을 줄이는 효과는 있지만, 불안해서 누른 유저에게 벌을 주는 방식이기도 하다.
이커머스 주문에서는 트래픽을 줄이는 것보다 유저의 신뢰가 더 중요하다고 판단해서 반대로 갔다. 다시 들어와도 원래 번호를 그대로 돌려준다(ZADD NX). 네트워크 재시도에 안전해지는 건 덤.
끼어들 틈 없애기 — 네 단계를 Lua 하나로
진입 한 번에 필요한 일은 넷이다: 이미 줄에 있는지 확인 → 줄이 꽉 찼는지 확인 → 번호 발급 → 줄 세우기.
이걸 명령 네 개로 따로따로 보내면 명령 사이사이에 다른 유저의 요청이 끼어든다. 상한 확인과 등록 사이를 비집고 들어와서 상한이 뚫리는 식이다.
이 틈을 막는 도구가 Lua다. Redis에 내장된 스크립트 언어로, 여러 명령을 한 덩어리로 묶어 원자적으로 실행해준다 — 스크립트가 도는 동안 다른 요청은 잠시 기다린다.
if redis.call('ZSCORE', KEYS[1], ARGV[1]) then
return redis.call('ZRANK', KEYS[1], ARGV[1]) + 1 -- 이미 줄에 있음 → 기존 순번
end
if redis.call('ZCARD', KEYS[1]) >= tonumber(ARGV[2]) then
return -1 -- 줄이 꽉 참 → 429로 변환
end
local score = redis.call('INCR', KEYS[2]) -- 새 번호 발급
redis.call('ZADD', KEYS[1], score, ARGV[1])
return redis.call('ZRANK', KEYS[1], ARGV[1]) + 1
Redis는 Lua 스크립트 하나를 통째로 실행한다. 네 단계가 한 덩어리가 되는 순간 끼어들 틈 자체가 사라진다.

질문 3. 한 번에 몇 명씩 들여보낼까
다섯 질문 중 가장 오래 고민한 질문이다.
스케줄러가 대기열에서 유저를 꺼내 입장시키는데, 한 번에 몇 명씩, 얼마나 자주?
"적당히 10명씩?"이라고 하기엔 근거가 없다.
이런 숫자는 시스템에서 제일 먼저 막히는 지점, 그러니까 병목에서 거꾸로 계산해야 한다.
이 시스템에서 대기열이 최종적으로 지키려는 자원은 DB 커넥션 풀이다. 거기서 출발하면 계산 자체는 어렵지 않다.
DB 커넥션 풀: 40개 (HikariCP maximum-pool-size)
주문 API 몫: 50% → 20개 (상품 조회 등 다른 API와 풀 공유)
주문 평균 처리 시간 가정: 200ms (실측 전 보수적 가정)
커넥션 1개가 1초에 처리 가능한 주문 = 1000ms / 200ms = 5건
초당 입장 가능 = 20개 × 5건 = 초당 100명
초당 100명이 나왔다.
그런데 여기서 하나 더 정해야 한다. 이 100명을 1초에 한 번 몰아서 들여보낼까, 나눠서 들여보낼까.
총량은 같아도 결과가 다르다. 1초에 100명을 한꺼번에 들여보내면 그 100명이 거의 동시에 주문 API를 두드린다.
커넥션 20개에 요청 100개가 한 번에 도착하면 80개는 자리가 날 때까지 쌓여서 기다린다 — 이렇게 무리가 우르르 몰려드는 현상을 Thundering Herd라고 부른다.
100ms마다 10명씩 나누면 같은 초당 100명이라도 시간축으로 퍼져서 몰림이 훨씬 완만해진다.

이 "나눠 들여보내기"의 효과는 이론에 그치지 않았다.
나중에 배치 안의 동시성까지 Jitter로 한 번 더 흩뿌리고 k6로 측정해봤더니 주문 API p95 지연이 6.10s → 2.42s, 60% 줄었다.
동시에 도착하는 요청 수를 줄이는 것만으로 요청 하나하나도 빨라진다는 게 숫자로 확인된 셈이다.
두 가지만 덧붙여둔다.
- 위 계산은 "토큰 받은 100명이 전부 곧바로 주문한다"는 최악의 경우 기준이다. 실제로는 토큰을 받았다고 바로 주문하는 건 아니라서 DB로 가는 유입은 더 완만하다. 일단 보수적으로 잡고, 실측이 쌓이면 고쳐나가면 된다.
- 꺼내는 동작도 원자적이어야 한다. "앞에서 10명 조회 → 삭제"를 두 명령으로 하면 그 사이에 또 끼어들기가 생긴다.
ZPOPMIN은 조회와 삭제가 명령 하나다.
질문 4. 입장권은 몇 분짜리로 할까
스케줄러가 꺼낸 유저에게는 입장 토큰이 발급된다. queue:token:{userId} = UUID.
여기서 정할 건 두 가지다. 얼마나 오래 유효하게 둘지, 그리고 언제 지울지.
얼마나 — TTL 5분
토큰의 수명은 "들어온 유저가 주문을 마치는 데 필요한 시간"이면 된다. 주문서 확인하고 결제 수단 고르기에는 넉넉해야 하고, 그렇다고 너무 길면 곤란하다 — 토큰만 받아놓고 떠난 유저가 자리를 오래 차지하면 그만큼 뒷사람 입장이 늦어지니까.
그 사이 어딘가로 잡은 게 5분이다.
만료 처리 코드는 따로 없다.
Redis TTL이 알아서 지워주면 "키 없음 = 발급 안 됐거나 만료"로 통일되고, 만료된 유저는 처음부터 다시 줄을 선다.
언제 — 주문이 "성공"했을 때만 삭제
처음엔 검증하면서 바로 지우는 쪽이 깔끔해 보였다.GETDEL이면 검증과 삭제가 원자적이라 같은 토큰을 동시에 쓰는 것도 원천 차단된다.
그런데 이러면 주문에 실패한 유저가 다시 시도할 수 없다.
재고 부족, 결제 실패 — 유저 잘못이 아닌 실패에서도 토큰이 사라져서 처음부터 다시 줄을 서야 한다.
5분짜리 기회를 서버 사정으로 날려버리는 셈이다.
그래서 성공했을 때만 지우기로 했고, 이 정책은 코드 세 줄에 그대로 보인다.
entryTokenValidator.validate(userId, entryToken); // ① 검증 (실패 → 403)
OrderInfo info = createOrder(...); // ② 주문 (여기서 예외 → 토큰 유지)
entryTokenValidator.consume(userId); // ③ 성공했을 때만 삭제

물론 공짜는 아니다. 같은 토큰으로 동시에 요청이 들어올 가능성이 이론상 생긴다.
재고 차감 로직이 마지막 방어선이 된다는 걸 확인하고 받아들였다 — 깔끔한 원자적 삭제를 포기하는 대신, 실패한 유저가 다시 시도할 수 있는 쪽을 고른 것이다.
여담 하나. 검증에 실패하면 401이 아니라 403을 돌려준다. 로그인은 이미 된 유저고, 없는 건 인증이 아니라 "입장 권한"이니까.
질문 5. 기다리는 유저에게 뭘 보여줄까
여기까지로 줄 세우기와 들여보내기는 끝났다. 하지만 대기열이 잘 만들어졌는지는 결국 기다리는 경험이 정한다.
아무 정보 없이 기다리게 하면 유저는 떠나거나 새로고침을 연타한다. 은행 창구의 "대기 12명, 예상 15분" 전광판 같은 게 필요하다.
예상 대기 시간 — 그냥 나눗셈이다
처리 속도는 우리가 설정으로 정하는 값이다: 초당 100명. 그러면 예상 대기 시간은 나눗셈 하나로 나온다.
estimatedWaitTime(초) = ceil(내 순번 / 100)
예: 342번째 → ceil(3.42) = 4초
단, 순번 ≤ 배치 크기(10)이면 0초 — "곧 입장"
"최근 N분간 실제로 몇 명이 통과했는지"를 재서 동적으로 계산하는 방법도 생각했지만, 스케줄러 처리량은 잘 안 변하는 값이라 이 정도 계산으로 충분했다.
스케줄러가 멈추면 예상 시간이 어긋난다는 한계는 있다 — 실제 처리량이 설정값과 많이 달라지는 게 보이면 그때 실측 기반으로 바꾸면 된다.
pollAfter — 폴링이 서버를 공격하지 않게
계산 하나를 해보면 아찔해진다. 기다리는 10만 명이 2초마다 순번을 조회하면 초당 5만 요청.
재미있는 건 어디가 먼저 막히느냐다.
Redis ZRANK는 O(log N)이라 이 정도는 버틴다.
먼저 무너지는 건 톰캣 워커 스레드(기본 200개)다.
주문 API를 지키려고 세운 대기열인데, 순번 조회 API가 먼저 죽는 웃지 못할 그림이 나온다.
그래서 응답에 다음 조회 권장 간격 pollAfter를 담았다.
아이디어는 단순하다 — 멀리 있는 유저일수록 자주 확인할 필요가 없다.
| 예상 대기 시간 | pollAfter |
|---|---|
| 60초 이상 | 10초 |
| 10~60초 | 5초 |
| 10초 미만 | 2초 |
| READY / NOT_IN_QUEUE | null (조회 종료) |
구간 기준을 순번이 아니라 예상 대기 시간으로 잡은 것도 의도한 부분이다. 배치 설정이 바뀌어 처리 속도가 달라지면 pollAfter 구간도 알아서 따라온다.
한계도 있다. 서버가 강제할 수 없는 "권장" 값이라서, 마음먹은 클라이언트나 봇은 무시할 수 있다. 그래도 대부분의 정상 유저가 조회를 줄여주는 것만으로 충분히 의미가 있다.
다시 대기열을 만들게 된다면
이번에 만들면서 정리해둔, 다음에 또 대기열을 만들 때 스스로에게 물어볼 목록으로 글을 마친다.
- ☐ 이 요청, 거절당하면 유저가 잃는 게 있나? — 잃는 게 있으면 줄을 세우고, 없으면 잘라내도 된다. 그리고 줄에도 상한은 필요하다.
- ☐ 순번을 매기는 기준이 하나인가? — 서버 시계처럼 여러 개일 수 있는 걸 기준으로 잡고 있지 않은지.
- ☐ 들여보내는 숫자, 계산해서 나온 건가 감인가? — 감으로 정한 숫자는 나중에 바꿀 때도 감으로 바꾸게 된다.
- ☐ 확인하고 나서 행동하는 코드가 있다면, 그 사이에 끼어들 틈은 없나?
- ☐ 유저 잘못이 아닌 실패인데, 처음부터 다시 하게 만들고 있지 않나?
- ☐ 기다리는 유저가 앞으로 얼마나 남았는지 알 수 있나? — 순번, 예상 시간, 조회 주기. 기다리는 경험까지가 대기열 설계다.
'Tech' 카테고리의 다른 글
| 다시 느려진 쿼리를 마주했을 때__레거시 프로시저 튜닝기 (0) | 2026.07.08 |
|---|---|
| Kafka는 이벤트를 잃지 않는다, 도달했다면 (0) | 2026.07.01 |
| Resilience4j CB, 기본 설정만 믿으면 안 된다 (0) | 2026.06.26 |
| 거짓말 안 하는 챗봇 만들기 (0) | 2026.06.25 |
| 테스트가 의도한 코드를 실제로 검증하고 있는가 (0) | 2026.05.14 |