WIL — 대기열 시스템 설계 (Redis 기반)
이번 주에 대기열 시스템을 직접 설계하면서 학습한 내용을 정리합니다.
1. Redis Sorted Set — 대기열의 핵심 자료구조
List가 아니라 Sorted Set을 쓰는 이유가 명확했다.
| 이슈 | List | Sorted Set |
|---|---|---|
| 중복 방지 | 직접 구현 필요 | member 유일성 보장 |
| 순번 조회 | O(N) 스캔 | O(log N) |
| 순서 보장 | push 방향에 따라 위험 | score 기준 항상 보장 |
ZADD queue {timestamp} {userId} 한 줄로 "진입 순서 기반 자동 정렬 + 중복 방지"를 동시에 해결한다. ZRANK는 0-indexed라서 반환값에 +1을 해야 사용자에게 보여줄 순번이 된다.
2. ZADD NX — 멱등성을 명령어 하나로
ZADD NX는 member가 없을 때만 추가하고, 이미 있으면 score도 바꾸지 않는다.
ZADD queue NX 1000 userA → 추가됨 (반환: 1)
ZADD queue NX 9999 userA → 무시됨 (반환: 0)반환값으로 "첫 진입인지 중복 진입인지" 즉시 구분할 수 있어서, 별도의 존재 여부 확인 없이 한 번의 명령어로 처리가 끝난다.
3. TOCTOU — "확인 후 추가"의 함정
처음엔 ZSCORE로 존재 여부 확인 → ZADD로 추가하는 2단계 구조를 생각했다. 그게 더 직관적으로 느껴졌으니까.
하지만 두 명령어 사이에 다른 스레드가 끼어들면 중복 등록이 발생한다. 이게 TOCTOU(Time-of-Check-Time-of-Use) 문제다.
Redis는 싱글 스레드로 명령어를 처리하므로, ZADD NX 하나로 확인과 추가를 원자적으로 처리하면 이 문제가 완전히 사라진다. 2단계 → 1단계로 줄이는 것 자체가 해결책이었다.
4. Back-pressure — 대기열의 존재 이유
처리 가능: 초당 10건
들어오는 요청: 초당 100건
→ 거절(Rate Limiting): "나중에 다시 시도하세요"
→ 대기(Back-pressure): "번호표 드릴게요"대기열은 Back-pressure의 구현체다. 시스템이 감당할 수 있는 속도(N명/주기)만큼만 진입시키고 나머지는 큐에서 흡수한다. 대기열 없이 바로 DB에 접근하면 트래픽 급증 시 커넥션 풀 고갈 → 서버 다운으로 이어진다.
5. 대기열 vs Rate Limiting — 용도가 다르다
Rate Limiting을 쓰면 서버를 보호할 수 있다고 생각했지만, 거절 후 재시도를 막지는 못한다.
100명 요청 → 90명 429 거절 → 90명 즉시 재시도 → 또 거절 → 또 재시도 ...대기열은 모든 사람에게 번호표를 발급하는 순간 경쟁이 끝난다. 재시도 폭탄이 없다.
- Rate Limiting: 악의적 요청 차단, API quota 관리
- 대기열: 한정 자원 공정 분배 (Black Friday, 한정판 판매)
두 가지는 경쟁 관계가 아니라 상황에 따라 선택하는 별개의 도구다.
6. Thundering Herd — 대기열 안에서 터지는 폭발
대기열을 만들었는데 토큰 발급 순간에 또 문제가 생긴다. N명에게 동시에 토큰을 발급하면 N명이 동시에 주문 API를 호출한다.
대응책:
- 배치 크기 제한 — 커넥션 풀이 감당할 수 있는 수준으로만 발급
- 순차 발급(Staggered Release) — 10명씩 1초 간격으로 나눠서 발급
- 토큰 TTL — 받은 즉시 주문하는 사람과 잠시 후 주문하는 사람이 자연 분산
7. 배치 크기 N 산정 공식
N = 커넥션 수 × (스케줄 주기 / 평균 처리 시간)커넥션 10개, 처리 시간 1초, 주기 5초라면 N = 50. 이 수를 초과하면 커넥션이 부족해져 대기 타임아웃이 발생한다.
실전에서는 계산값의 80% 수준으로 여유를 두고, 평균 처리 시간은 실측 P95 기준으로 측정하는 게 좋다.
8. 입장 토큰 & TTL 설계
토큰을 Redis String으로 저장하고 TTL로 자동 만료시키는 구조:
SET token:{userId} 1 EX {TTL초}TTL의 균형이 중요하다:
| TTL | 문제 |
|---|---|
| 없음 | 토큰 영구 유효 → 재사용 가능 |
| 너무 짧음 | 결제 중 만료 → 사용자 이탈 |
| 너무 길음 | 자리만 점유하는 유저 증가 |
DB에 저장하면 안 되냐는 의문이 들었는데, 조회 빈도가 높고(모든 주문 API 호출마다) TTL을 직접 관리해야 하는 부담이 생긴다. Redis는 TTL 내장 + 인메모리 조회로 이 두 가지를 한 번에 해결한다.
9. TTL 기준 산정 — P95 기반
TTL = 상품 확인 + 수량 선택 + 결제 정보 입력 + 결제 완료기존 서비스라면 실제 데이터의 P95 주문 완료 시간, 신규라면 UX 예상 시간 + 여유 20~30%.
예: P95가 4분이면 TTL은 5~6분.
10. 토큰 검증 위치 — Filter vs Interceptor vs AOP
HTTP 요청 → [Filter] → DispatcherServlet → [Interceptor] → [AOP] → Controller대기열 토큰 검증을 Interceptor에 구현하는 이유:
| Filter | Interceptor | AOP | |
|---|---|---|---|
| Spring Bean 주입 | 불편 | 가능 | 가능 |
| ControllerAdvice 예외 처리 | 직접 작성 | 자동 | 자동 |
| URL 패턴 적용 | 복잡 | 간단 | 어노테이션 필요 |
Redis 조회(Spring Bean) + CoreException(ControllerAdvice) + /order/** 패턴 → 세 조건 모두 Interceptor가 가장 적합하다.
JWT 파싱은 Filter를 쓰는 이유가 이제 명확하다. Spring Bean이 불필요한 단순 문자열 디코딩이고, 모든 요청에 적용되기 때문이다.
11. Polling vs SSE — 순번 실시간 표시
| Polling | SSE | |
|---|---|---|
| 방식 | 클라이언트가 주기적으로 요청 | 서버가 이벤트 푸시 |
| 서버 부하 | 요청 수 = 주기 × 사용자 수 | 연결 수 = 동시 접속자 수 |
| 구현 복잡도 | 단순 | 연결 관리, 재연결 처리 필요 |
10만 명이 대기 중이면 SSE는 10만 개의 연결을 유지해야 한다. 서버 배포 시 전부 끊기고 재연결 로직도 필요하다. Polling은 주기만 조정하면 부하를 직접 제어할 수 있다는 점에서 실무에서 더 선호된다.
12. Polling 부하 완화 — Redis only + Adaptive Polling
GET /queue/position 은 DB가 아닌 Redis ZRANK만 조회해야 한다. DB 커넥션을 소모하면 주문 API와 커넥션을 경쟁하게 된다.
Adaptive Polling으로 추가 최적화:
순번 1~10번 → 3초마다 (곧 진입)
순번 11~100번 → 10초마다
순번 100번~ → 30초마다 (한참 기다려야 함)서버 응답에 nextPollAfter 를 포함시켜 클라이언트가 그에 맞춰 조정하게 하는 방식도 깔끔하다.
13. 예상 대기 시간 계산
예상 대기 시간 = 내 순번 / 배치 크기 N × 스케줄 주기순번 100번, N=10, 주기 5초 → 50초.
이 값은 정확한 예측이 아니라 "대략적인 안내" 용도다. 앞 사람의 이탈, 가변적인 처리 시간에 따라 실제와 달라진다. UX 목적으로만 사용해야 한다.
14. HikariCP — 커넥션 풀 고갈의 연쇄
기본 maximum-pool-size = 10. 동시 요청이 50개 들어오면:
10개 즉시 처리 → 40개 커넥션 대기
→ 대기 스레드 점유 → 스레드 풀 고갈
→ 새 요청도 처리 불가 → 서버 다운이 에러가 보이면 커넥션 풀 고갈이다:
HikariPool-1 - Connection is not available, request timed out after 30000ms배치 크기 N 공식이 결국 "HikariCP가 감당 가능한 동시 요청 수"를 계산하는 공식이었다는 걸 이 개념을 배우고 나서야 완전히 연결됐다.
15. Redis 장애 시나리오 — 503 vs Fallback
Redis가 죽으면 대기열 전체(진입, 토큰 발급/검증, 순번 조회)가 동시에 불가해진다.
| 대응 | 장점 | 단점 |
|---|---|---|
| 503 반환 | 안전, 정합성 보장 | 매출 손실 |
| 직접 진입 허용 | 매출 유지 | DB 커넥션 고갈 위험 |
정답이 없다. 비즈니스 임팩트 vs 시스템 안정성을 팀이 함께 결정하고, 그 근거를 PR에 남기는 게 중요하다는 점이 인상 깊었다. 기술적 선택만큼이나 의사결정 과정의 기록이 자산이 된다.
이 프로젝트는 Redis Master-Replica 구성(6379 + 6380)으로 단일 장애점을 줄이고 있다.
핵심 설계 흐름 요약
사용자 요청
↓
ZADD NX (원자적 등록, 중복 방지)
↓
Redis Sorted Set (score = timestamp, 자동 정렬)
↓
스케줄러 N명 배치 토큰 발급 (HikariCP 기준 산정)
↓
SET token:{userId} 1 EX {TTL} + ZREM queue userId
↓
사용자 Polling으로 순번 확인 (Redis only, Adaptive)
↓
토큰 보유자만 주문 API 통과 (Interceptor 검증)
↓
주문 완료 → DEL token:{userId}각 단계마다 "왜 이 선택인가"를 설명할 수 있게 됐다.
'개발 > 스터디' 카테고리의 다른 글
| WIL - 배치 기반 주간·월간 랭킹 설계: 핵심 정리 (0) | 2026.04.17 |
|---|---|
| [WIL] Redis Hot Key 를 공부하면서 배운 것들 (0) | 2026.04.10 |
| [WIL] DB 조회가 느릴 때, 인덱스와 캐시 중 뭘 먼저 봐야 할까? (0) | 2026.03.13 |
| [WIL] TDD로 회원가입/내정보조회/비밀번호 변경 구현 (0) | 2026.02.06 |
| 글쓰기 스터디 3차 모집 (0) | 2025.12.22 |