본문 바로가기
개발/스터디

[WIL]대기열 관련 공부

by 글쓰는 개발자 2026. 4. 3.

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 NXmember가 없을 때만 추가하고, 이미 있으면 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를 호출한다.

대응책:

  1. 배치 크기 제한 — 커넥션 풀이 감당할 수 있는 수준으로만 발급
  2. 순차 발급(Staggered Release) — 10명씩 1초 간격으로 나눠서 발급
  3. 토큰 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}

각 단계마다 "왜 이 선택인가"를 설명할 수 있게 됐다.

반응형