주문 대기열 시스템 설계: Redis Sorted Set의 내부 구조가 실시간 순번 조회를 가능하게 하는 원리
TL;DR — 트래픽 폭증 시 하류 시스템을 보호하면서 유저에게 공정한 순서를 보장하기 위해 Redis Sorted Set 기반의 주문 대기열을 구현했다. 처음에는 "Redis가 빠르니까"라는 이유만으로 충분하다고 생각했지만, 순번 조회(ZRANK)가 왜 O(log N)인지를 제대로 설명하지 못하는 자신을 발견했다. 그래서 Sorted Set의 내부 구현체인 스킵 리스트(Skip List)와 스팬(Span) 메커니즘까지 파고들었고, 그 과정에서 얻은 이해를 이 글에 정리한다.

1. 트래픽 제어의 필요성: Back-pressure
평소 초당 100건이던 주문 요청이 블랙 프라이데이 시작 직후 초당 10,000건으로 폭증하는 상황을 가정해 보았다. 이 트래픽이 주문 API로 직접 유입되면 어떤 일이 벌어질지를 먼저 그려보는 것이 출발점이었다.
[10,000 req/s] → POST /orders
├── 재고 확인 & 차감 (DB)
├── 결제 처리 (PG)
└── 주문 저장 (DB)
→ DB 커넥션 풀 고갈
→ 응답 지연 → 타임아웃
→ 전체 시스템 장애
서버를 10배 스케일 아웃해도 DB와 PG는 스케일이 제한적이라는 점이 핵심이라고 생각했다. 오토스케일링이 반응하기 전에 피크가 지나가는 경우가 대부분이므로, 결국 하류 시스템이 감당할 수 있는 속도만큼만 상류 요청을 흘려보내는 것이 근본적인 방향이 아닐까 싶었다. 이 개념이 Back-pressure라는 것을 나중에 알게 되었다.
Rate Limiting이 아닌 Queuing을 선택한 이유
트래픽 초과 요청을 처리하는 전략으로 두 가지를 검토했다.
구분 Rate Limiting Queuing
| 초과 요청 처리 | 거부 (429 Too Many Requests) | 보관 (대기열에 적재) |
| 유저 반응 | 새로고침 → 재시도 폭풍 | 순번 확인 → 순서대로 대기 |
| 적합한 상황 | API 보호, 봇 차단, 일상적 부하 제어 | 유저가 기다릴 의사가 있는 경우 |
블랙 프라이데이에 "나중에 다시 시도하세요"를 반환하면 유저가 이탈하거나 더 세게 새로고침할 것이라고 생각했다. 이건 트래픽을 악화시키는 피드백 루프로 이어질 가능성이 높다. 주문은 유저가 기다려서라도 완료하고 싶은 행위이므로, 거부보다는 보관 전략이 더 적합하지 않을까 하는 판단이었다.
2. 구조적 고민: Kafka 버퍼링과 대기열의 차이
이전 주차(Round 7)에서 Kafka를 버퍼로 활용해 선착순 쿠폰을 처리한 경험이 있었다. 처음에는 "주문도 Kafka로 버퍼링하면 되지 않나?"라는 생각이 들었다. 하지만 두 시나리오를 나란히 놓고 비교해 보니 본질적으로 다르다는 걸 느꼈다.
구분 Kafka 버퍼링 (쿠폰) 대기열 (주문)
| 유저 경험 | 요청 후 나중에 결과 확인 | 화면에서 순번을 보며 대기 |
| 제어 대상 | 처리 순서 | 처리 속도 (throughput) |
| 핵심 관심사 | 메시지 유실 방지, 멱등 처리 | 공정한 순서, 실시간 피드백, 토큰 만료 |
| 유저 인지 | "신청 완료, 결과는 나중에" | "현재 512번째, 예상 대기 3분" |
쿠폰은 Fire & Forget이었다. 요청을 넣고 나중에 결과를 확인하면 그만이었다. 하지만 주문은 다르다고 느꼈다. 유저는 주문이 처리될 때까지 화면 앞에서 기다리고, "내 순서가 언제인지"를 실시간으로 알 수 있어야 이탈하지 않을 것이라고 생각했다.
이 차이에서 한 가지 기술적 질문이 떠올랐다. 대기열에 10,000명이 적재된 상태에서, 특정 유저의 순번을 어떻게 빠르게 계산할 수 있을까? 이 질문이 이후의 자료구조 선택을 결정짓는 출발점이 되었다.
3. 해결 방안 탐구: Redis Sorted Set의 이면
3.1 처음 떠올린 RDB 기반 순번 조회, 그리고 그 한계
대기열을 어디에 둘지 고민할 때, 가장 먼저 떠오른 것은 익숙한 RDB 테이블이었다.
SELECT COUNT(*) FROM waiting_queue
WHERE entered_at < (SELECT entered_at FROM waiting_queue WHERE user_id = ?)
특정 유저보다 먼저 진입한 행의 수를 세는 쿼리다. 단순해 보이지만, 곰곰이 생각해 보니 문제가 있었다. entered_at에 B-Tree 인덱스가 있더라도 COUNT(*)는 인덱스를 따라가며 조건을 만족하는 행 수를 하나씩 세야 하므로 O(N)에 가까운 비용이 발생한다. (MySQL InnoDB의 경우 secondary index에 대한 정확한 COUNT는 실제로 행을 하나씩 세는 방식으로 동작한다.)
10,000명이 2초마다 Polling하면 초당 5,000건의 범위 카운트 쿼리가 DB를 타격하게 된다. 대기열이 DB를 보호하기 위해 존재하는 건데, 대기열 자체가 DB에 부하를 주는 모순이 생기는 셈이었다. 이 지점에서 RDB는 적합하지 않겠다는 판단을 내렸다.
인메모리로 옮기면 디스크 I/O 문제는 해결되겠지만, 단순 리스트(Array, LinkedList)를 사용하면 순번 계산에 여전히 O(N) 탐색이 필요하다는 점이 걸렸다. 그래서 Redis Sorted Set을 살펴보기 시작했다.
3.2 Sorted Set의 활용: ZADD, ZRANK, ZPOPMIN
구현에서 사용한 Redis 명령어와 그 역할을 정리하면 다음과 같다.
ZADD waiting-queue {timestamp} {userId} -- 대기열 진입
ZRANK waiting-queue {userId} -- 내 순번 조회 (0-based)ZCARD waiting-queue -- 전체 대기 인원
ZPOPMIN waiting-queue {N} -- 앞에서 N명 꺼내기
- score = 진입 시각(timestamp): 먼저 들어온 유저가 낮은 score를 갖게 되므로 자연스럽게 앞 순번이 된다.
- member = userId: Set의 특성상 동일 member의 중복 삽입이 무시된다. 별도 로직 없이 userId 기반 중복 진입 방지가 가능해지는 점이 깔끔하다고 느꼈다.
여기서 가장 눈에 띈 것은 ZRANK의 평균 시간 복잡도가 O(log N)이라는 점이었다. 대기열에 10,000명이 있어도, 100,000명이 있어도 순번 조회가 로그 스케일로 처리된다니, 처음에는 솔직히 어떻게 가능한 건지 잘 와닿지 않았다. (스킵 리스트는 확률적 자료구조이므로 최악의 경우 O(N)이 될 수 있지만, 실제 운영 환경에서 그런 확률은 무시할 수 있을 만큼 낮다고 한다.)
"왜 O(log N)인가?"를 제대로 이해하고 싶어서 Sorted Set의 내부 구현을 파고들었다.
3.3 내부 구현: 스킵 리스트(Skip List) + 해시 테이블(Hash Table)
Redis Sorted Set은 두 개의 자료구조를 결합해서 구현되어 있었다.
- 해시 테이블(Hash Table): member → score 매핑. 특정 member의 score를 O(1)에 조회할 수 있다.
- 스킵 리스트(Skip List): score 기준으로 정렬된 연결 리스트의 계층적 확장. 랭킹 계산과 범위 검색을 O(log N)에 수행할 수 있다.
해시 테이블은 직관적이어서 금방 이해가 되었는데, 스킵 리스트 쪽은 좀 더 깊이 들여다볼 필요가 있었다.
스킵 리스트의 계층적 구조
일반 정렬 연결 리스트에서 특정 원소의 위치를 찾으려면 헤드부터 순차 탐색해야 하므로 O(N)이 걸린다. 스킵 리스트는 이 문제를 확률적 계층화(probabilistic layering)로 풀어낸다.
Level 3: HEAD ───────────────────────────────────── 50 ───── NIL
Level 2: HEAD ──────── 10 ──────────────────────── 50 ───── NIL
Level 1: HEAD ──── 5 ── 10 ──── 20 ──── 30 ───── 50 ── 70 NIL
Level 0: HEAD ─ 3 ─ 5 ─ 10 ─ 15 ─ 20 ─ 25 ─ 30 ─ 50 ─ 70 NIL
- Level 0은 모든 원소를 포함하는 정렬 연결 리스트다.
- 상위 레벨은 하위 레벨의 원소 중 일부만 포함하며, 이진 탐색에서의 "중간값"과 비슷한 역할을 한다고 이해했다.
- 새 원소 삽입 시 레벨은 확률적으로 결정된다. Redis는 p = 0.25, 최대 레벨 32를 사용한다고 한다(t_zset.c의 ZSKIPLIST_P와 ZSKIPLIST_MAXLEVEL). 각 노드가 레벨 k에 존재할 확률은 (1/4)^k이므로, 평균 레벨 수는 log₄(N)이 되고, N = 10,000일 때 약 6~7레벨이 만들어지는 셈이다.
탐색은 최상위 레벨에서 시작해서, 다음 노드의 score가 목표보다 크면 하위 레벨로 내려가는 방식이다. 이진 탐색과 비슷한 원리로 평균 O(log N) 탐색이 가능하다는 게 이해가 되기 시작했다.
스팬(Span): O(log N) 랭킹 계산의 핵심
그런데 여기서 한 가지 의문이 남았다. 스킵 리스트로 특정 score를 가진 노드를 찾는 것은 O(log N)으로 가능하다고 쳐도, "이 원소가 전체에서 몇 번째인가?"를 알려면 결국 앞에 몇 개의 원소가 있는지를 세야 하지 않나? 그러면 다시 O(N)으로 돌아가는 거 아닌가?
이 의문을 해결해 준 것이 스팬(span) 필드였다. Redis는 William Pugh의 원래 스킵 리스트 논문(1990)에는 없는 span이라는 필드를 각 레벨의 forward 포인터에 추가해 두었다. 이것이 일반적인 스킵 리스트와 Redis 구현 사이의 가장 큰 차이라고 생각한다.
Redis 소스 코드(server.h)에서 스킵 리스트 노드는 다음과 같이 정의되어 있다.
typedef struct zskiplistNode {
sds ele; // member (userId) double score; // score (timestamp) struct zskiplistNode *backward; // 이전 노드 (Level 0에서만)
struct zskiplistLevel { struct zskiplistNode *forward; // 다음 노드
unsigned long span; // 이 포인터가 건너뛰는 노드 수
} level[];} zskiplistNode;
각 레벨의 forward 포인터에 span 값이 함께 저장되어 있다. span은 해당 포인터가 Level 0 기준으로 몇 개의 노드를 건너뛰는지를 나타낸다.
Level 2: HEAD ─────[span=2]───── 10 ─────[span=3]───── 50 ──── NIL
Level 1: HEAD ──[span=1]── 5 ─[span=1]─ 10 ─[span=2]─ 20 ─... NIL
Level 0: HEAD ─[1]─ 3 ─[1]─ 5 ─[1]─ 10 ─[1]─ 15 ─[1]─ 20 ─... NIL
ZRANK 연산은 헤드에서 목표 노드까지 탐색하면서 경유하는 포인터들의 span을 누적하는 것이었다. 최상위 레벨에서 시작해 하위로 내려가며 span을 더하기만 하면 되므로, 탐색 경로의 길이인 O(log N)만에 정확한 랭킹이 계산되는 구조였다.
구체적으로 예시를 들어보면, 대기열에 10,000명이 있을 때 userId=7777의 순번을 조회하는 경우:
- 해시 테이블에서 userId=7777의 score를 O(1)에 조회한다.
- 스킵 리스트의 헤드에서, 최상위 레벨(약 log₄(10000) ≈ 6~7)부터 탐색을 시작한다.
- 각 레벨에서 forward 노드의 score가 목표 score 이하이면 해당 포인터의 span을 누적하고 이동한다. 초과하면 한 레벨 아래로 내려간다.
- 목표 score에 도달했을 때의 누적 span이 곧 해당 유저의 0-based 랭킹이 된다.
전체 경로 길이는 평균 O(log N)이므로, 10,000명 기준으로 약 6~7번의 포인터 이동만으로 순번이 확정된다. 10,000명이 2초마다 Polling하면 초당 5,000건의 ZRANK 연산이 발생하겠지만, 각 연산이 μs 단위로 완료되므로 Redis 단일 인스턴스로 충분히 처리할 수 있을 것이라고 판단했다.
한 가지 더 확인한 것은, span이 삽입/삭제 시에도 갱신된다는 점이다. 새 노드가 삽입되면 삽입 경로에 포함된 레벨들의 span 값만 +1 하면 되므로, 삽입 연산의 시간 복잡도가 O(log N)에서 변하지 않는다. 이 부분이 확인되고 나서야 "대기열 진입과 순번 조회 모두 O(log N)으로 처리 가능하겠다"는 확신이 생겼다.
왜 균형 이진 트리(B-Tree, Red-Black Tree)가 아닌 스킵 리스트인가
순번 조회에 적합한 자료구조를 찾다 보니, Order-Statistic Tree(각 노드에 서브트리 크기를 저장한 균형 이진 트리)도 같은 목적을 달성할 수 있다는 것을 알게 되었다. 그래서 Redis가 왜 스킵 리스트를 선택했는지가 궁금해졌다.
비교 항목 스킵 리스트 균형 이진 트리
| 범위 검색 | 연결 리스트 순회로 자연스럽게 지원 | 중위 순회 필요 |
| 삽입/삭제 | 확률적 레벨 결정, 리밸런싱 없음 | 회전(rotation) 연산 필요 |
| 구현 복잡도 | 상대적으로 단순 | 복잡한 리밸런싱 로직 |
| 동시성 | 부분 잠금이 용이한 구조 | 리밸런싱 시 넓은 범위 잠금 필요 |
Redis 제작자 Salvatore Sanfilippo(antirez)가 스킵 리스트를 선택한 이유로 "구현이 단순하면서도 균형 트리와 동일한 평균 시간 복잡도를 제공하고, ZRANGE 같은 범위 연산에서 자연스럽게 효율적"이라는 점을 밝힌 바 있다고 한다. 대기열 시스템에서 ZPOPMIN(앞에서 N명 꺼내기)이 효율적으로 동작하는 것도 연결 리스트 기반 구조의 이점이라고 생각한다.
다만, Redis가 사용하는 것은 원래의 스킵 리스트가 아니라 span 필드를 추가한 변형(augmented skip list)이라는 점은 짚고 넘어갈 필요가 있을 것 같다. 일반적인 스킵 리스트 설명과 Redis의 실제 구현 사이에 이 차이가 있다는 것을 처음에는 인지하지 못했었다.
3.4 소결: 대기열 순번 조회에 Sorted Set이 적합하다고 판단한 이유
여기까지 조사한 결과를 정리하면, Redis Sorted Set이 대기열 순번 조회에 적합하다고 판단한 근거는 다음과 같다.
- 해시 테이블: member → score 매핑으로 특정 유저의 존재 여부와 score를 O(1)에 확인할 수 있다. 중복 진입 방지가 자료구조 수준에서 보장되는 점이 깔끔했다.
- 스킵 리스트 + 스팬: 탐색 경로상의 span 누적으로 O(log N) 랭킹 계산이 가능하다. 대기 인원이 10만 명이어도 약 8~9번의 포인터 이동으로 순번이 확정되는 셈이다.
- 원자성: ZADD, ZRANK, ZPOPMIN 등 모든 연산이 Redis의 싱글 스레드 이벤트 루프에서 원자적으로 실행되므로, 동시에 수천 명이 진입해도 순서가 뒤섞이지 않을 것이라고 기대할 수 있었다.
- 인메모리: 디스크 I/O가 없으므로 순번 조회 응답이 μs 단위일 것으로 예상했다.
4. 아키텍처 고도화: 실시간 피드백과 Thundering Herd
4.1 실시간 순번 조회: Polling 방식 채택
유저에게 순번을 어떻게 전달할지 고민하면서 Polling, SSE, WebSocket 세 가지를 검토했다.
방식 장점 단점
| Polling | 구현 단순, 인프라 변경 없음 | 불필요한 요청 발생, 주기 사이 지연 |
| SSE | 변경 시점에만 전송, HTTP 기반 | 대기 인원 × 1 커넥션 유지 필요 |
| WebSocket | 양방향 실시간 | 대기열 용도에는 과도한 기능 |
결론적으로 Polling을 선택했는데, 그 판단 과정은 이랬다.
- 대기열 순번 조회는 서버 → 클라이언트 단방향이면 충분하다고 생각했으므로 WebSocket은 먼저 제외했다.
- SSE가 가장 이상적으로 보였지만, 대기 인원 수만큼의 TCP 커넥션을 유지해야 한다는 점이 부담이었다. 10,000명이 대기 중이면 10,000개의 커넥션이 열려 있어야 하고, 로드밸런서의 커넥션 유지 설정도 필요해진다. 현재 인프라에서 이걸 감당할 수 있을지 확신이 없었다.
- Polling은 비효율적으로 보이지만, Redis ZRANK의 응답 시간이 μs 단위라는 점을 고려하면 생각보다 부하가 적을 것 같았다. 10,000명이 2초마다 Polling해도 초당 5,000건 수준인데, Redis 단일 인스턴스의 처리 용량(초당 수십만 건)에 비하면 충분히 감당 가능한 범위라고 판단했다.
"우선 Polling으로 시작하고, 실제로 부하가 문제가 되면 SSE로 전환하자"는 것이 현실적인 접근이라고 생각했다.
순번과 함께 예상 대기 시간도 응답에 포함하기로 했다. 계산식은 단순하게 잡았다.
예상 대기 시간(초) = 현재 순번 / 초당 처리량(175 TPS)
순번 300이면 300 / 175 ≈ 1.7초가 된다. 다만 이 값은 정상 상태(steady state)에서의 추정치에 불과하다. 실제 대기 시간은 토큰 미사용(만료)에 의한 슬롯 회수, 주문 처리 시간의 변동(결제 PG 응답 지연 등), 스케줄러 실행 간격의 지터(jitter) 등에 따라 달라질 수 있어서, "약 N초"로 표현하는 게 맞을 것 같았다.
토큰이 발급된 유저가 순번 조회를 하면, position = 0과 함께 토큰이 응답에 포함되도록 했다. 클라이언트는 이 응답을 받으면 Polling을 중단하고 주문 API를 호출하는 흐름이다.
4.2 Thundering Herd 완화: 100ms 단위 분할 발급
대기열로 Back-pressure를 구현했다고 해서 문제가 끝나는 게 아니었다. 스케줄러가 1초마다 175명에게 토큰을 한꺼번에 발급하면, 175명이 동시에 주문 API를 호출하게 된다. 이러면 DB 커넥션 풀(50)이 순간적으로 고갈되는 Thundering Herd 문제가 발생할 수 있다는 걸 뒤늦게 깨달았다.
이 문제를 완화하기 위해 토큰 발급을 100ms 단위로 분할하기로 했다.
처리량 산정 근거:
DB 커넥션 풀: 50
주문 1건 평균 처리 시간: 200ms (재고 차감 + 결제 + 저장)
이론적 최대 TPS: 50 / 0.2 = 250 TPS안전 마진 70% 적용: 175 TPS
스케줄러 배치 크기 결정:
목표 TPS: 175스케줄러 실행 주기: 100ms (초당 10회)
배치 크기: 175 / 10 = 17.5 ≈ 18명
방식 동시 주문 요청 DB 커넥션 점유
| 1초 단위 발급 | 175명 동시 | 175개 동시 점유 → 풀 고갈 |
| 100ms 단위 발급 | ~18명씩 | ~18개 점유 → 안정적 |
100ms마다 약 18명씩 발급하면, 순간 최대 DB 커넥션 점유가 18개 수준으로 유지될 것이라고 예상했다. 커넥션 풀 50개 대비 36%만 점유하므로, 다른 API 트래픽과 공존할 여유가 있을 것 같았다.
다만 이 산정에는 토큰을 받은 유저가 즉시 주문 API를 호출한다는 가정이 깔려 있다. 실제로는 유저마다 주문 페이지 체류 시간이 다르므로, 주문 요청이 100ms 윈도우 안에 정확히 18건 들어오지는 않을 것이다. 오히려 자연스러운 분산이 발생해서 실제 부하는 산정치보다 완만해지지 않을까 싶다.
스케줄러는 ZPOPMIN waiting-queue 18 명령으로 대기열 앞쪽에서 18명을 원자적으로 꺼낸 뒤, 각 유저에게 UUID 기반 토큰을 SET entry-token:{userId} {token} EX 300으로 발급하도록 구현했다.
5. 주문 API 연동: 토큰 검증과 소비
대기열은 주문 API 앞단의 관문 역할을 하도록 설계했다. 전체 흐름을 정리하면 다음과 같다.
[유저] → POST /queue/enter
→ Redis ZADD (대기열 진입)
→ 순번 응답
[유저] → GET /queue/position (2초마다 Polling) → Redis ZRANK (순번 조회)
→ 순번 + 예상 대기 시간 응답
[스케줄러] → 100ms마다 실행
→ ZPOPMIN으로 18명 꺼내기
→ SET entry-token:{userId} (TTL 300초)
[유저] → position = 0, token 수신
→ POST /orders → 토큰 검증 (GET entry-token:{userId}) → 주문 처리
→ 토큰 삭제 (DEL entry-token:{userId})
[주문 이후] → Round 7 이벤트 파이프라인 (ApplicationEvent → Kafka → collector)
토큰 검증은 OrderFacade.createOrder()의 첫 번째 단계에 넣었다. 토큰이 없거나 만료된 유저는 주문 API에 진입할 수 없도록 했다. 주문이 정상 완료되면 토큰을 삭제해서, 해당 슬롯이 다음 대기자에게 돌아가도록 하는 구조다.
주문 완료 이후의 흐름(이벤트 발행, Kafka 파이프라인, Metrics 집계)은 Round 7에서 구축한 Outbox Pattern 기반 이벤트 파이프라인을 그대로 활용하기로 했다. 대기열 시스템이 주문 API의 앞단에만 관여하고, 후속 파이프라인과는 결합이 없도록 경계를 나누는 것이 깔끔하다고 생각했다.
6. 토큰 TTL과 리스크 관리
6.1 토큰 미사용과 TTL 회수
토큰의 TTL을 몇 분으로 설정할지 고민이 있었다. 너무 짧으면 유저가 주문 페이지에서 결제 정보를 입력하다가 만료될 수 있고, 너무 길면 미사용 토큰이 슬롯을 오래 차지하게 된다.
결국 300초(5분)로 설정했다. 근거는 이렇다:
- 주문 페이지 진입 → 결제 완료까지 일반적으로 2~3분 정도 소요된다고 가정했다.
- 5분이면 충분한 여유를 두면서도, 미사용 토큰이 비교적 빠르게 회수되는 균형점이 아닐까 싶었다.
- Redis의 TTL 메커니즘이 만료된 토큰을 자동 삭제해 주므로, 별도의 회수 로직을 구현할 필요가 없다는 점도 매력적이었다.
물론 이 수치가 최적인지는 실제 운영 데이터를 보기 전까지는 알 수 없을 것 같다. Token Expiry Rate를 모니터링하면서 조정해 나가야 할 부분이라고 생각한다.
6.2 어뷰징 방지
한 유저가 여러 브라우저나 디바이스로 중복 진입을 시도하는 경우를 고려했다. Redis Sorted Set에 userId를 member로 사용하면 자동으로 중복이 방지되는 구조여서, 구현에서는 ZADD NX(addIfAbsent)를 사용해 이미 존재하는 member에 대한 삽입을 거부하도록 했다. 입장 토큰이 이미 발급된 유저가 대기열에 재진입하는 것도 서비스 레벨에서 차단하는 방어 로직을 추가했다.
6.3 Redis 장애 시 Graceful Degradation
구현 중 계속 마음에 걸렸던 점은, 대기열 전체가 Redis에 의존하고 있다는 것이었다. Redis가 장애를 일으키면 어떻게 해야 할까? 세 가지 전략을 정리해 보았다.
전략 동작 트레이드오프
| 전면 차단 | 대기열 진입 자체를 거부하고 안내 | 서비스 중단, 그러나 시스템 보호 |
| 대기열 우회 | 토큰 없이 주문 API 직접 접근 허용 | 서비스 유지, 그러나 과부하 위험 |
| Fallback 큐 | 로컬 메모리 큐 또는 Kafka로 임시 전환 | 순번 정확성 저하, 그러나 서비스 유지 |
어느 것이 정답인지는 비즈니스 맥락에 따라 다를 것 같다. 이번 구현에서는 여기까지 깊이 다루지 못했지만, 중요한 것은 "Redis 장애 시 우리 서비스가 어떻게 동작해야 하는가"를 사전에 정의해 두는 것 자체가 아닐까 생각한다. 장애가 터진 뒤에 판단하면 늦을 수밖에 없다.
7. 운영 지표 정의
대기열 시스템을 만들고 나니, "이게 잘 동작하고 있는지 어떻게 알 수 있을까?"라는 질문이 자연스럽게 따라왔다. 장애가 발생하기 전에 이상 징후를 감지할 수 있어야 한다고 생각했고, 다음 지표들을 모니터링 대상으로 정의해 보았다.
지표 측정 방법 의미
| Queue Depth | ZCARD waiting-queue | 급격히 증가하면 유입 > 처리량이라는 신호 |
| Avg Wait Time | 진입 → 토큰 발급까지의 평균 시간 | 유저 체감 품질의 핵심 지표 |
| P99 Wait Time | 상위 1% 유저의 대기 시간 | 평균이 정상이어도 P99가 높으면 특정 시점 병목 |
| Token Conversion Rate | 토큰 발급 → 주문 완료 비율 | < 50%이면 TTL이 짧거나 주문 UX에 문제가 있을 수 있음 |
| Token Expiry Rate | 토큰 만료(이탈) 비율 | > 30%이면 유저가 대기 중 포기하고 있다는 신호일 수 있음 |
| Scheduler Health | 스케줄러 마지막 실행 시각 | 1분 이상 미실행 시 대기열 전체가 멈추게 됨 |
특히 Token Conversion Rate와 Token Expiry Rate는 단순한 시스템 지표가 아니라 비즈니스 지표에 가깝다고 생각한다. 유저가 토큰을 받고도 주문하지 않는다면, 대기 시간이 너무 길거나 토큰 TTL 설정이 맞지 않다는 뜻일 수 있다. 이 비율의 추이를 보면서 TTL 조정이나 UX 개선의 근거를 확보할 수 있지 않을까 기대하고 있다.
마무리
이번 대기열 시스템을 구현하면서 가장 많이 배운 것은, 자료구조 선택의 근거를 "빠르니까" 수준에서 끝내면 안 된다는 것이었다. 처음에는 "Redis Sorted Set이 O(log N)이라고 하니까 쓰자"로 시작했지만, 왜 O(log N)인지를 설명하지 못하면 설계에 확신이 생기지 않았다.
스킵 리스트의 계층적 탐색 구조, 거기에 Redis가 추가한 스팬(span) 필드, 그리고 해시 테이블과의 결합 — 이 세 가지가 맞물려서 "진입도 O(log N), 순번 조회도 O(log N)"이 가능해진다는 것을 이해하고 나서야 비로소 "이 자료구조가 대기열에 적합하겠다"는 판단에 확신을 가질 수 있었다.
대기열의 본질은 트래픽을 없애는 게 아니라 피크를 평탄화(smoothing)하는 것이라고 이해했다. 하류 시스템의 한계를 정량적으로 파악하고(DB 커넥션 풀 50, 처리 시간 200ms), 그 한계를 기준으로 스케줄러의 배치 크기(18명/100ms)와 토큰 TTL(300초)을 산정하는 것이 설계의 핵심이라고 생각한다. 그리고 이 설계가 운영 환경에서 올바르게 동작하는지를 Token Conversion Rate, Queue Depth 같은 지표로 지속적으로 검증해 나가야 할 것 같다.
마지막으로, 이 설계에는 단일 Redis 인스턴스에 대한 의존이라는 구조적 한계가 남아 있다. Redis Sorted Set의 모든 연산은 싱글 스레드 이벤트 루프에서 실행되므로, 단일 인스턴스의 처리량 상한(초당 수십만 연산)을 초과하는 규모에서는 대기열 키를 샤딩하거나 Redis Cluster를 도입하는 등의 추가 설계가 필요할 것이다. 현재 설계는 대기 인원 수만~수십만 명 규모에서 단일 인스턴스로 충분할 것이라고 보고 있으며, 이를 넘어서는 규모는 별도의 아키텍처 판단이 필요하다고 생각한다.
참고 링크
Redis Sorted Set: https://www.youtube.com/watch?v=-titFXvd5xE&t=68s