들어가며
매년 명절 시즌이 되면 기차 예매를 위해 아침 7시 코레일 대기열에 올라탄다.
1만번대의 대기표를 받고 기다리면서 항상 궁금했다.
어떻게 시스템을 만들었을까??
큐를 이용해 줄을 세우고 한 명씩 예매 페이지로 라우팅 시키는 방식일거라 추측만하고
내부적으로 어떤 기술을 사용하고 어떻게 구현되었는지 자세하게 알지는 못했다.
그래서 이번에 그 궁금증을 풀어가는 시간을 가져보려고 한다.
돌려보낼 것인가(RateLimiting), 줄 세울 것인가(Queueing)
평소 100건의 주문 트래픽이 발생하던 시스템에 갑자기 10000건의 트래픽이 발생하면 어떻게 될까??
서버는 정신을 못차리고 터져버릴 것이다.
서버는 동시에 처리할 수 있는 요청의 수가 정해져 있다. DB 커넥션 풀, 스레드 수, CPU 코어 등 모든 자원에는 한계가 있다.
평소 100건을 처리하도록 설계된 시스템에 10,000건이 한꺼번에 들이닥치면,
자원을 전부 소모한 뒤 메모리 부족, 타임아웃, 데드락이 연쇄적으로 발생한다. 결국 서버 전체가 응답 불능 상태에 빠진다.
이걸 막기 위한 전략으로 크게 두 가지가 있다.
RateLimiting(돌려보내기)과 Queueing(줄 세우기) 이다.
RateLimiting - 정중히, 그러나 단호하게 돌려보낸다.
Rate Limiting은 "우리 서버는 초당 N건만 받겠습니다" 라고 선언하는 것이다.
그 이상 들어오는 요청은 처리도 해보지 않고 즉시 429 Too Many Requests를 반환한다.
사용하는 여러가지 방식이 있다.
- Fixed Window: 1분 단위로 카운터를 리셋. 단순하지만 윈도우 경계에서 버스트가 가능하다.
- Sliding Window: 현재 시점 기준으로 N초 전부터 지금까지의 요청을 카운팅. 더 정밀하다.
- Token Bucket: 일정 속도로 토큰을 충전하고, 요청마다 토큰을 소비. 일시적인 버스트를 허용하면서도 평균 속도를 제한한다.
- Leaky Bucket: 요청을 일정한 속도로 흘려보내는 방식. 출력 속도가 일정해야 할 때 유리하다.
그러면 어떤 상황에 쓰일 수 있을까??
- 외부에 공개된 API에 사용하여 DDos 공격을 방어하거나 스팸을 걸러낼 수 있다.
- 특정 사용자나 IP가 독점하는 것을 막고 싶을 때 사용할 수 있다.
Queueing - 일단 받아놓고, 천천히 처리한다.
Queueing은 "일단 다 받아줄게. 근데 줄서서 기다리면 순서대로 처리할거야!"
요청을 거절하지 않는다.
대신 대기열(Queue) 에 순서대로 쌓아두고, 서버가 여유가 생길 때마다 앞에서부터 하나씩 꺼내 처리한다.
클라이언트 입장에서는 응답이 즉시 오지 않지만, 요청이 유실되지 않는다는 게 핵심이다.
대기열은 어떤 상황에 쓰일까??
- 주문 처리, 결제, 이메일 발송 등 모든 요청이 반드시 처리되어야 할 때
- 처리 시간이 길어져도 괜찮은 비동기 요청 처리 시
- 트래픽이 폭주할 때
그렇다면 이 둘을 구분해서 사용해야할까??
아니다. 실제 운영 환경에서는 Rate Limiting과 Queueing을 함께 조합해서 사용한다.
둘은 경쟁 관계가 아니라 역할이 다른 보완재다.
Rate Limiting이 문지기라면, Queueing은 대기실이다.
문지기가 명백히 이상한 사람을 걸러내고, 나머지는 대기실에서 순서를 기다린다.
조합해서 사용하면 아래와 같이 사용이 가능하다.
[Client]
↓
[Rate Limiter] ← ① 비정상 트래픽·어뷰징 1차 차단 (즉시 429 반환)
↓
[Message Queue] ← ② 정상 요청이지만 처리량 초과분은 대기열에 줄세우기
↓
[Server] ← ③ 서버는 자신의 속도로 소비
Rate Limiting 혼자였다면 정상 사용자의 요청도 그냥 거절해버린다.
Queueing 혼자였다면 악의적인 트래픽까지 전부 Queue에 쌓여 메모리가 터진다.
둘을 조합해서 사용하면 서로의 단점을 보완할 수 있다.
그렇다면 주문 요청일 경우 어떤 전략을 선택해야할까??
주문은 거절하면 안된다. 물건이나 서비스를 구매하러 들어온 사용자를 거절해버리면 이 사용자는 영영 돌아오지 않는다.
이탈한 사용자는 곧 매출 감소로 이어진다.
트래픽이 폭증하는 순간일수록 가장 많은 돈이 오가는 순간이기도 하다.
블랙프라이데이, 수량 한정 특가, 공연 티켓 오픈. 이 타이밍에 요청을 튕겨내는 건 서버를 지키기 위해 매출을 포기하는 것이다.
대기열을 만들기 위해 사용할 수 있는 도구는 하나가 아니다. 상황과 규모에 따라 선택할 수 있는 도구가 다양하다.
Redis, RabbitMQ, Kafka 등...
그중 Redis를 이용한 대기열을 집중해서 알아보려고 한다.
Redis로 대기열 만들기 - Sorted Set
Redis로 대기열을 구현하는 방법은 여러 가지가 있다.
그 중 실무에서 가장 많이 활용되는 자료구조가 바로 Sorted Set(ZSet) 이다.
Sorted Set은 member와 score로 이루어져있고 각 요소에 점수(score)를 부여하고, 그 점수 기준으로 자동 정렬되는 자료구조다.
- member — 각 요소를 식별하는 고유키. Set과 마찬가지로 중복을 허용하지 않는다. 같은 member를 다시 ZADD하면 추가가 아니라 score만 업데이트된다.
- score — 정렬 기준이 되는 숫자값. 64비트 부동소수점(double)으로 저장된다. Sorted Set은 항상 이 score를 기준으로 오름차순 정렬된 상태를 유지한다.
핵심 명령어
ZADD - 요소 추가
ZADD queue 1 "user:A"
ZADD queue 2 "user:B"
ZADD queue 3 "user:C"
이미 존재하는 member를 ZADD하면 값이 추가되는 게 아니라 score만 갱신된다.
ZADD queue 99 "user:A" # user:A의 score가 99로 변경됨
대기열에서 이 특성을 잘 활용하면 중복 요청을 막을 수 있다.
같은 사용자가 두 번 요청해도 대기열에 두 번 들어가지 않고, score(시간)만 갱신된다.
ZRANGE - 범위 조회
score 순서대로 범위를 꺼낸다. 인덱스는 0부터 시작하며 -1은 마지막 요소를 의미한다.
ZRANGE queue 0 -1 # 전체 조회 (score 오름차순)
ZRANGE queue 0 2 # 앞에서 3개만 조회
ZRANGE queue 0 -1 WITHSCORES # score 값도 함께 조회
내림차순으로 조회하고 싶다면 ZREVRANGE를 쓴다. score가 높은 순서대로 꺼내야 할 때 사용한다.
ZRANK - 순서 조회
특정 member가 몇 번째 순서인지 반환한다. 0이 가장 앞이다.
ZRANK queue "user:B" # 1 (0번째가 user:A이므로)
ZREM - 요소 삭제
처리가 완료된 member를 대기열에서 제거한다.
ZREM queue "user:A"
ZCARD - 전체 대기 인원 조회
대기 인원이 몇명인지 조회한다.
ZCARD queue # 현재 대기열에 있는 전체 인원 수
score를 어떻게 활용하냐에 따라 대기열의 성격이 달라진다.
- timestamp - 선착순 대기열
가장 기본적인 형태. 먼저 온 요청이 낮은 score를 가지므로 앞에 배치된다. - 우선순위 값 - 우선순위 대기열
우선적으로 처리되어야할 사용자(VIP)나 긴급 요청을 앞으로 당길 수 있다. - 만료시간 - TTL 대기열
score에 만료 시간을 넣으면 특정 시간이 지난 요청을 한 번에 정리할 수 있다.
오래 기다린 요청을 자동으로 제거하거나, 일정 시간 안에 처리되지 않은 요청을 정리할 때 유용하다.
스케줄러 - 몇 명씩 입장시켜야 하나?
대기열에 사용자를 쌓아두는건 알아보았다.
이제 필요한 건 주기적으로 대기열에서 사용자를 꺼내 입장권을 부여하는 스케줄러다.
여기서 입장권으로 토큰을 사용한다.
대기열을 통과한 사용자에게 토큰을 발급하고, 이후 요청에 토큰을 가지고 있어야 실제 주문이 가능하도록 하는 구조다.
[대기열] → [스케줄러] → [토큰 발급] → [주문 처리]
↑
N초마다 실행
한 번에 M명 처리
한 번에 몇 명씩 입장시킬 수 있을까?
스케줄러를 만들기 전에 반드시 고려해야할 부분이 있다. 한 번의 배치에서 몇 명한테 입장권(토큰)을 줄 것인가?
너무 많이 뽑으면 서버가 감당하지 못하고, 너무 적게 뽑으면 대기열이 줄어들지 않는다.
이 숫자는 감이 아니라 서버 성능을 통해 역산해야 한다.
처리 가능 인원 계산
- DB 커넥션 풀 크기
서버가 동시에 처리할 수 있는 DB 작업의 최대치.
대부분 DB를 거치기 때문에 커넥션 풀이 사실상 처리량의 상한선이 된다.
▶ 커넥션 풀 크기 = 50 - 요청 평균 처리 시간
하나의 주문을 처리하는 데 걸리는 평균 시간.
DB 조회, 재고 확인, 결제 요청 등을 모두 포함한 end-to-end 시간이다.
▶ 평균 처리 시간 = 200ms - 스케줄러 실행 주기
스케줄러가 몇 초마다 한 번씩 배치를 실행하는지.
▶ 실행 주기 = 1000ms (1초)
이 세가지로 이론상 처리 가능 인원을 계산할 수 있다.
최대 처리 인원 = 커넥션 풀 × (실행 주기 / 평균 처리 시간)
= 50 × (1000ms / 200ms)
= 50 × 5
= 250명
- 안전 마진 적용
이론상 최대치로 운영하면 DB 응답 지연, GC pause, 네트워크 지연 등 예측 불가능한 변수에 취약해진다.
통상 20~30%를 여유분으로 남겨두는 것이 안전하다.
실제 배치 처리 인원 = 최대 처리 인원 × (1 - 안전 마진)
= 250 × 0.7 (안전 마진 30%)
= 175명
입장 토큰 - 대기하여 얻어낸 입장권
대기열에서 순서대로 사용자를 입장을 시킨다.
이 때 사용자에게 "이제 주문하실 수 있습니다~" 하고 권한을 주는 역할을 하는 것이 입장 토큰이다.
토큰이 없는 사용자가 그냥 주문을 할 수 있다면 어떻게 될까??
대기열을 거치지 않은 사용자도 URL을 직접 입력해 주문 페이지에 접근할 수 있다면 대기열의 의미가 사라진다.
토큰 없는 구조 ← 누구나 주문 API 직접 호출 가능
토큰 있는 구조 ← 대기열을 통과한 사용자만 주문 API 호출 가능
토큰 구조
입장 토큰은 UUID 형태의 랜덤 문자열로 발급하고, Redis에 저장해 관리한다.
key: "order-token:user:A"
value: "550e8400-e29b-41d4-a716-446655440000"
TTL: 300s (5분)
입장 토큰은 수명이 짧고(5분), 즉시 무효화가 필요한 경우가 많기 때문에 UUID + Redis 조합이 적합하다.
TTL이 지나면 Redis가 알아서 삭제해주고, 주문 완료 시 즉시 무효화도 DELETE 한 번으로 끝난다.
토큰 생명주기
[대기열 진입]
↓
[순서 도달 → 토큰 발급] ── TTL 5분
↓
[사용자가 주문 API 호출 → 토큰 검증]
↓
┌─────────┴──────────┐
성공 실패
↓ ↓
[토큰 삭제] [토큰 만료 → 대기열 재진입 안내]
[주문 처리]
토큰이 살아있는 동안 사용자는 주문을 완료해야 한다.
5분 안에 주문하지 않으면 토큰이 자동 만료되고, 다시 대기열에 진입해야 한다.
이 구조 덕분에 토큰만 받아두고 자리를 차지하는 사용자 문제도 자연스럽게 해결된다.
Thundering Herd - 대기열이 만든 새로운 문제
토큰을 발급받은 사용자들이 동시에 주문 API를 호출하는 순간, 문제가 다시 발생한다.
스케줄러가 1초마다 175명에게 토큰을 발급한다고 하자.
토큰을 받은 175명은 거의 동시에 주문 API를 호출한다.
대기열로 트래픽을 고르게 분산시켜 놨더니, 토큰 발급 시점에 다시 트래픽이 몰리는 것이다.
이 현상을 Thundering Herd 라고 부른다.
우리 안에 갇혀 있던 동물들이 문이 열리는 순간 한꺼번에 쏟아져 나오는 모습에서 따온 이름이다.
대기열이 없을 때는 트래픽이 지속적으로 몰렸다면, Thundering Herd는 트래픽이 주기적으로 몰린다.
해결 방법
- 토큰 발급 간격 분산
1초에 175명에게 토큰을 발급하는 것 보다 주기를 더 잘게 쪼개서 100ms 마다 토큰을 발급하는 방법이다.
스케줄링 주기 = 100ms
발급 개수 = 175 / 10 = 17.5 (18명) - Random Jitter 추가
발급을 분산시키는 것 만으로는 부족할 수 있다. 서버 인스턴스가 여러 대라면 각 인스턴스가 동시에 스케줄러를 실행하면서 결국 같은 시점에 토큰이 다시 몰릴 수 있다.
이 때는 Jitter를 랜덤하게 추가해 토큰을 발급 시킬 수 있다.
0 ~ 1초 혹은 2초 사이의 시간을 추가해서 분산해서 발급하는 것이다.
대기열 플로우

지금까지의 내용을 가지고 전체적인 대기열 흐름을 그려보면 다음과 같다.
- 요청에 Token이 있는지 확인하고 없으면 대기열 진입
- 스케줄러를 통해 대기열의 요청을 순차적으로 배치 처리하여 입장 토큰 발급
- 토큰이 있을 경우 주문 API를 통해 검증 시작
- 검증에 성공한 경우 주문 실행, 실패 시 대기열 진입
결론
대기열 시스템은 겉으로 보면 단순하다. "요청을 쌓아두고 순서대로 처리한다."
그런데 막상 설계를 시작하면 고민할 부분이 많다.
악의적인 트래픽은 대기열에 들어오기 전에 걸러야 한다. 그래서 Rate Limiting이 앞단에 필요하다.
Sorted Set의 score를 무엇으로 설정하느냐에 따라 선착순이 될 수도, 우선순위 대기열이 될 수도 있다.
스케줄러가 한 번에 너무 많이 뽑으면 서버가 흔들리고, 너무 적게 뽑으면 대기열이 줄지 않는다.
토큰을 동시에 발급하면 사용자들이 일제히 몰려드는 Thundering Herd가 발생한다.
결국 대기열 시스템의 본질은 트래픽을 거절하지 않으면서 서버를 지키는 것이다.
사용자를 돌려보내지 않고, 줄을 세우고, 순서가 됐을 때 안전하게 통과시킨다.
그 과정에서 서버는 자신이 감당할 수 있는 만큼의 트래픽을 유지한다.
단순해 보이는 "줄 세우기" 뒤에 이 많은 고민이 녹아 있다.
댓글