TroubleShooting & Study/SpringBoot

[Waiting Queue] 당신의 대기열은 안녕하신가?

DH_0518 2026. 4. 1. 22:54
TL;DR

대기열에서는 실시간 순번, 부하 분산, 중복 진입 등의 문제가 발생한다. 각각의 문제를 해결하기 위한 보편적인 방법으로 SSE, Jitter, NX와 같은 방법이 있지만, 데이터 특성과 흐름을 생각해보면 예상외의 결과가 발생한다.

- 순번은 1:N fan-out이라 SSE보다 Polling이 자연스럽다.
- 대기열을 통과한 유저를 거부하는 Jitter/Rate Limit은 대기열의 약속을 깨뜨린다.
- ZADD NX를 끄면 코드 한 줄 없이 트래픽을 1/5로 줄일 수 있다.
- 순번 정밀도를 한 단계 낮추면 Redis 장애에도 서비스가 유지된다.

즉, '좁은 범위의 특정 문제'만을 바라보지 말고, 전체적인 서비스의 흐름과 유저 행동을 먼저 봐야 한다.

 

 

티켓팅을 해본적이 있는가?

아주 한정적인 짧은 시간동안 수십/수백만명의 사람들이 몰리고, 내가 할 수 있는건 오롯이 기도밖에 없음을 절실히 느낄 수 있다.

 

당하는 사람 입장에서도 그렇게 힘든 시간인데.. 과연 이걸 설계하는 입장은 얼마나 괴로울까?

그래서 이런 기분이 궁금한 당신을 위해 준비했다.

 

이번 글에서는, 우리가 '블랙 프라이데이'를 하루 앞둔 이커머스 백엔드 개발자라 생각하고, 성과금을 위해 트래픽 버스트에서 우리의 DB를 안전하게 지켜내면서, 동시에 UX를 고려하여 대기 순번까지 보여주는 설계를 진행해보겠다.

 

쉬운일은 아니겠지만, 이번 글을 빌어 고뇌하는 시간을 통해 '블랙 프라이데이' 하루 전날 개발자의 기분을 느껴보자.


상황

먼저 우리 서버의 처리량과 트래픽을 가정하고 진행해보겠다.

DB 커넥션 풀 50개, 주문 처리 시간 평균 200ms. 이론적 최대 TPS는 250이다. 여기에 100만 유저가 동시에 몰리면 DB가 견딜 수 없다. (100만명인 이유는.. 롯데 티켓팅 할때 보니까 대기열이 100만이 넘어가길래 그걸 기준으로 잡아봤다. 현실에서도 이정도 트래픽이 발생하기에 충분히 해볼만한 수치라 생각했기 때문)

 

대기열이 필요한 이유는 단순하다. 한 번에 처리할 수 없으니 줄을 세우는 것이다. 다만 줄을 세우는 것 자체는 쉬운데, 그 줄에서 유저가 이탈하지 않고 기다리게 만드는 것이 어렵다.

 

대기열이 일반적인 서비스와 다른 이유는 세 가지다.

 

첫째, 1명이 빠지면 전원이 영향받는다.
일반 API는 유저 A의 요청이 유저 B에게 영향을 주지 않는다. 하지만 대기열에서 18명을 꺼내면, 남은 100만 명 전원의 순번이 동시에 바뀐다. 데이터의 영향 범위가 1:1이 아니라 1:N이다.

 

둘째, 유저가 기다리고 있다.
일반 API는 요청-응답이 끝이지만, 대기열 유저는 화면 앞에서 몇 분~수십 분을 보낸다. 그 시간 동안 새로고침을 누르고, 탭을 닫았다 열고, 재접속을 시도한다. 이 행동 하나하나가 시스템 부하로 돌아온다.

 

셋째, "기다리면 된다"는 약속이 전제다.
대기열은 거부(reject)가 아니라 지연(defer)이다. 유저는 "순서만 기다리면 반드시 들어갈 수 있다"고 믿고 있다. 이 약속이 깨지면 대기열의 존재 이유 자체가 사라진다.

 

이 세 가지 특성 때문에, 다른 곳에서 잘 작동하는 기법이 대기열에서는 다르게 작동하거나 역효과를 냈다. 지금부터 보는 네 가지 사례가 전부 이 맥락이다.


설계 초안

먼저 처리량을 산정했다.

 

항목계산결과
이론적 최대 TPS DB 커넥션 50 / 처리시간 0.2s 250 TPS
목표 TPS 250 × 0.7 (안전 마진) 175 TPS
1회 배치 크기 175 / 10 (100ms 주기) 18명

 

안전 마진을 70%로 잡은 이유는, DB 커넥션을 주문만 쓰는 게 아니고, 처리 시간도 평균이지 최대가 아니기 때문이다. 여유를 두고 시작한 뒤 모니터링으로 조절하는 전략이다.

 

이 수치를 기반으로 큰 그림을 그렸다.

  1. 대기열 진입: 유저가 진입 요청 → Kafka produce → Consumer가 Redis Sorted Set에 ZADD
  2. 순번 조회: 유저가 주기적으로 Polling → ZRANK로 현재 순번 응답
  3. 토큰 발급: 스케줄러가 100ms마다 ZPOPMIN으로 18명씩 꺼내 입장 토큰 발급
  4. 주문: 입장 토큰을 가진 유저만 주문 API 진입

안전 마진을 70%로 잡은 이유는, DB 커넥션을 주문만 쓰는 게 아니고, 처리 시간도 평균이지 최대가 아니기 때문이다. 여유를 두고 시작한 뒤 모니터링으로 조절하는 전략이다.

 

여기까지는 순조로웠다. 문제는 세부 설계에서 터졌다.


문제

 

1. 실시간 순번 — SSE로 100만 명에게 Push할 수 있는가?

유저에게 순번을 실시간으로 보여주려면 SSE(Server-Sent Events)가 당연한 선택처럼 보였다. 서버가 데이터를 Push해주니 클라이언트가 Polling할 필요가 없고, 실시간성도 보장된다. 적어도 다른 서비스에서는 그랬다.

 

그런데 적용하려 하니 다음의 순환 논증에 빠졌다.

 

SSE가 실시간이 맞아?
→ Polling이면 아닌데?
→ 트리거가 뭔데?
→ 대기열에서 N개 꺼내는 거
→ 그때마다 전체에 보내? 100만 명인데?
→ Jitter 쓰자
→ Jitter가 실시간이 맞냐?
→ 아니지…
→ 그럼 SSE 왜 쓰지?
→ SSE는 실시간이잖아
→ (처음으로 돌아감)

 

이 순환에서 한참을 빠져나오지 못했다. 원인은 "SSE = 실시간"이라는 전제가 반만 맞기 때문이었다.

 

SSE가 효과적인 경우를 떠올려보면, 채팅 메시지나 알림처럼 특정 유저에게만 데이터가 가는 1:1 Push다. fan-out이 1이다. 하지만 대기열 순번은 구조가 완전히 다르다. ZPOPMIN 1회로 18명을 꺼내면, 남은 100만 명 전원의 순번이 동시에 바뀐다. 100ms마다 100만 건을 send해야 하는 1:N fan-out이다.

 

결국 SSE라는 전달 수단을 바꿔도 데이터의 fan-out 비용 자체는 줄지 않는다. "SSE로 순번을 보내면 실시간"이 아니라, "서버 측 Polling을 SSE 채널로 쏘는 것"이었다.

 

 

2. 동시 주문 부하 — Thundering Herd를 어떻게 완화할 것인가?

스케줄러가 18명에게 토큰을 발급하면, 18명이 거의 동시에 주문 API를 호출할 것이다. 이른바 Thundering Herd 문제다. 이걸 완화하는 데 Jitter(랜덤 딜레이)와 Rate Limit은 정석 중의 정석이다. 당연히 적용하려 했다.

 

그런데 두 기법 모두 대기열의 맥락에서는 UX를 붕괴시켰다.

  • 토큰 Jitter
    토큰을 발급하되 0~2초 뒤에 활성화하는 방식이다. 그런데 유저는 토큰을 받는 즉시 주문을 시도한다. 비활성 토큰이니 주문이 실패한다. 유저는 당연히 새로고침을 누른다. 그런데 NX를 미사용하는 구조(뒤에서 다룬다)라서, 새로고침하면 대기열 뒤로 밀린다. 10분을 기다린 유저가 다시 처음부터 줄을 서는 상황이 된다.
  • 주문 API Rate Limit
    토큰이 있어도 초당 N건까지만 처리하는 방식인데, 10분 이상 대기한 뒤 429 거부를 받으면 어떨까. 토큰 TTL 5분 안에 재시도해야 하는데, Rate Limit에 계속 걸리다 토큰이 만료되면 대기열에 다시 진입해야 한다.

두 기법의 문제가 같은 지점에서 발생하고 있었다. 대기열을 통과한 유저가 주문 API에서 거부당한다는 것. 부하 완화 기법이 downstream rejection으로 나타나면, 대기열의 "기다리면 된다"는 약속을 부정하는 셈이다.

 

 

3. 중복 진입 — 새로고침하면 순번을 유지해야 하는가?

Redis Sorted Set의 ZADD NX는 "이미 존재하면 무시"하는 옵션이다. 대기열에서 NX를 쓰면 새로고침해도 순번이 유지되어 공정하다. 직관적으로 당연히 써야 할 것 같았다.

 

세 가지 선택지를 비교했다.

  • A. NX 사용: 순번 유지, 공정. 하지만 새로고침해도 부담이 없어서 불필요한 요청이 줄지 않는다.
  • B. NX 미사용 + 서버에서 ZRANK 확인: 이미 있으면 기존 순번 반환.
  • C. NX 미사용 + 서버 방어 없음: 새로고침하면 무조건 뒤로 밀린다.

처음에는 B안이 균형 잡힌 타협처럼 보였다. NX는 안 쓰되, 서버에서 방어하면 의도치 않은 재진입도 막을 수 있으니까. 그런데 검토하다 보니 깨달았다. "이거 NX 쓰는 거랑 뭐가 다르지?"

 

NX를 안 쓰는 이유 자체가 "새로고침 시 뒤로 밀리게 하려고"인데, 서버에서 ZRANK를 확인해서 기존 순번을 돌려주면 결과적으로 NX를 켠 것과 동일하다. 새로고침 억제 효과가 사라지면서 NX 미사용의 핵심 가치가 없어진다. 게다가 B안은 ZRANK → ZADD가 비원자적이라 NX보다 오히려 비싸고 불안정한 구현이 된다.

 

 

4. Redis 장애 — 대기열이 멈추면 어떻게 할 것인가?

Redis가 죽으면 대기열 전체가 멈춘다. ZADD도, ZRANK도, ZPOPMIN도 전부 Redis에 의존하기 때문이다. 처음에는 "전면 차단"이 가장 안전하다고 결론 냈다.

 

대안을 하나씩 검토했지만 전부 문제가 있었다.

  • 로컬 Fallback 큐: 서버 3대로 스케일아웃하면, 각 서버에 독립적인 "1번"이 3명 생긴다. 전역 순번이 깨진다.
  • 우회(bypass): 대기열을 건너뛰면 DB 보호 목적 자체가 사라진다.
  • Kafka 전환: 기존 Redis 대기열에 이미 들어있는 유저와의 병합이 불가능하다.

전면 차단이 정답인 것 같았다. 그런데 여기서 핵심 질문이 하나 나왔다. "대기열이 정말로 정확한 실시간 순번을 보장해야 하는가?"

 

ZPOPMIN으로 18명씩 꺼내는 구조에서, 유저 순번은 이미 18씩 점프한다. 1씩 깎이는 게 아니다. 유저도 이 정밀도를 기대하지 않는다. "정확한 순번"이 아니라 "보수적 근사 순번"이면 충분하다는 전제 전환이 열쇠였다.


해결

 

1. 실시간 순번 — Polling 메인 + 구간별 차등 주기

SSE의 가치가 있는 곳과 없는 곳을 나눴다.

  • 순번 전체(1:N fan-out) → Polling. 클라이언트가 필요할 때 GET /queue/position으로 현재 순번을 가져온다.
  • 입장 임박 상위 100명의 토큰 전달(1:1 Push) → SSE. ZPOPMIN 트리거로 해당 유저에게만 즉시 Push한다.

Queue를 분리하지 않고 Push 대상만 좁혔다. 그리고 Polling 주기는 순번 구간별로 차등 적용했다. 곧 입장할 유저에게는 짧은 주기로 체감을 개선하고, 뒤쪽 유저에게는 긴 주기로 서버 부하를 억제한다.

 

// position 기준 Polling 주기 (ms)
int getInterval(long position) {
    if (position <= 100) return 1000;     // 곧 입장 — 1초
    if (position <= 1000) return 2000;    // 2초
    if (position <= 10000) return 5000;   // 5초
    if (position <= 100000) return 10000; // 10초
    return 30000;                          // 9.5분+ 대기 — 30초
}

// Jitter 범위 (ms) — 동시 Polling 분산
int getJitter(long position) {
    if (position <= 100) return 0;        // 곧 입장은 Jitter 없음
    if (position <= 1000) return 300;
    if (position <= 10000) return 500;
    if (position <= 100000) return 1000;
    return 4000;
}

// 서버가 retryAfterMs에 Jitter를 포함하여 응답
int retryAfterMs = interval
    + ThreadLocalRandom.current().nextInt(-jitter, jitter + 1);

 

Jitter 로직이 서버에만 있으므로 클라이언트는 서버가 내려준 retryAfterMs를 그대로 쓰면 된다. 클라이언트 변경 없이 분산 정책을 자유롭게 조절할 수 있다.

 

정리하면, 전달 수단(SSE/Polling)이 아니라 데이터의 fan-out 특성이 방식을 결정했다.

 

 

2. 동시 주문 부하 — 발급 간격 분산 (100ms × 18명)

유저 행동에 영향을 주지 않는 방식으로 부하를 분산했다. 1초에 175명을 한 번에 발급하는 대신, 100ms마다 18명씩 발급하여 부하를 10배 평탄화했다.

 

// 100ms마다 실행, ShedLock으로 분산 환경에서 단일 리더 보장
@Scheduled(fixedRate = 100)
@SchedulerLock(name = "queue-token-issuer",
    lockAtLeastFor = "PT0.05S", lockAtMostFor = "PT5S")
public void issueEntryTokens() {
    List<String> issuedUserIds = waitingQueueRedisPort
        .issueEntryTokens(BATCH_SIZE, tokenTtlSeconds);  // 18명, 300초
}

 

실제 토큰 발급은 Redis Lua script로 ZPOPMIN과 SET EX를 원자적으로 처리한다. 두 연산 사이에 서버가 죽으면, 유저가 대기열에서는 빠졌는데 토큰은 못 받는 영구 유실이 발생하기 때문이다.

 

-- Redis Lua script: ZPOPMIN + SET EX 원자적 토큰 발급
-- ARGV: [1]=batchSize, [2]=TTL, [3..N]=Java에서 사전 생성한 entry-{UUID} 토큰
local users = redis.call('ZPOPMIN', KEYS[1], ARGV[1])  -- waiting-queue, 18
local results = {}
local idx = 0
for i = 1, #users, 2 do
  local userId = users[i]
  idx = idx + 1
  local token = ARGV[2 + idx]  -- entry-{UUID} (Java에서 사전 생성, 예측 불가)
  redis.call('SET', 'entry-token:' .. userId, token, 'EX', ARGV[2])  -- TTL 300초
  table.insert(results, userId)
end
return results

 

18명 동시 호출은 DB 커넥션 50개 기준으로 충분히 처리 가능하다. 결국 원칙은 단순했다. "정상 부하 상황에서, 대기열을 통과한 유저는 주문에서 실패하면 안 된다."

 

참고로 Jitter 자체가 나쁜 기법은 아니다. 서버 내부(Polling 응답의 retryAfterMs)에서는 적극적으로 적용했다. 핵심은 같은 기법이라도 유저 행동에 직접 영향을 주는 곳에 쓸 때와 서버 내부에서 쓸 때의 결과가 전혀 다르다는 것이다.

 

 

3. 중복 진입 — NX 미사용, 새로고침 억제

C안을 택했다. "새로고침하면 뒤로 밀린다"는 사실 자체가 유저의 새로고침을 억제한다.

 

100만 유저가 평균 5번 새로고침하면 500만 enter 요청이다. 억제가 작동하면 100만 건으로 줄어든다. 코드 한 줄 안 바꾸고 트래픽을 1/5로 줄이는 방어 수단이다.

 

// Kafka Consumer: 메시지를 읽은 순서대로 Redis에 적재
public long addToQueue(Long userId) {
    // 전역 단조 증가 순번 (timestamp 대신 INCR → FIFO 엄밀 보장)
    Long score = redisTemplate.opsForValue().increment("waiting-queue-seq");

    // NX 미사용: 재진입 시 score 갱신 → 뒤로 밀림 (새로고침 억제)
    redisTemplate.opsForZSet().add("waiting-queue", userId.toString(), score);
    return score;
}

 

score를 timestamp 대신 Redis INCR로 생성한 이유도 있다. 동일 밀리초에 여러 유저가 들어오면 timestamp가 같아지는데, 이 경우 Redis는 member 사전순으로 정렬한다. 멀티 인스턴스 환경에서는 clock skew까지 더해져 순서가 역전될 수 있다. INCR은 원자적이고 전역 단조 증가라 이런 문제가 없다.

 

트레이드오프는 분명하다. 네트워크 끊김이나 브라우저 재전송 같은 의도치 않은 재요청에도 뒤로 밀린다. 이건 POST /queue/enter에 자동 재시도를 금지(LB/클라이언트 설정)하는 것이 전제다.

 

결국 "공정함"이라는 직관보다 "새로고침 억제"라는 대기열 트래픽 특성이 더 큰 가치를 가졌다.

 

 

4. Redis 장애 — Kafka-first 아키텍처

정밀도 전제를 전환하면, 처음부터 Kafka에 진입 데이터를 넣고 Redis는 순번 조회용으로만 쓰는 아키텍처가 가능해진다.

 

// 정상 운영
User → Kafka produce (acks=all) → "접수 완료" 응답
Consumer → Kafka 읽기 → Redis ZADD (순번 조회용)
Polling → Redis ZRANK → 순번 응답

// Redis 장애 시
User → Kafka produce → "접수 완료" (Kafka가 살아있으니 진입 가능)
Polling → Kafka offset 기반 근사 순번 (보수적 120명/초로 계산)
토큰 발급 → Consumer가 Kafka에서 읽어 직접 발급

// Redis 복구 시
Consumer가 밀린 Kafka 메시지 → Redis ZADD 재적재 → 정상 복귀

 

Kafka에 원본이 있으니 Redis가 죽어도 데이터 유실이 없다. 인증은 HMAC 서명 기반이라 Redis/DB 없이도 동작한다. 근사 순번은 실제 처리 속도(175명/초)보다 보수적으로(120명/초) 계산해서, "예상보다 빨리 입장"하는 경험을 유도했다.

 

예상보다 빨리 들어가는 건 좋은 경험이지만, 늦게 들어가는 건 나쁜 경험이다. 보수적 계산이 유저 경험을 지키는 장치가 된 셈이다.

 

결국 요구사항의 정밀도를 한 단계 낮추는 것이 시스템 탄력성을 크게 높인 사례였다. "정확한 순번"을 포기한 게 아니라, "이 시스템이 실제로 요구하는 정밀도가 뭔지"를 다시 정의한 것이다.

 


정리

네 가지 판단이 뒤집힌 이유를 정리하면 이렇다.

 

기법 일반적 판단 대기열에서의 판단 뒤집힌 이유
SSE 실시간이니 당연히 사용 Polling이 자연스럽다 순번은 1:N fan-out
Jitter / Rate Limit Thundering Herd 정석 적용하면 안 된다 통과 유저 거부 = 약속 파괴
ZADD NX 공정하니 당연히 사용 끄는 게 낫다 새로고침 억제 효과가 더 크다
정확한 순번 당연한 요구사항 근사치면 충분하다 이미 18씩 점프하는 구조

 

네 경우 모두 같은 실수를 반복하고 있었다. 기법의 일반적인 효과를 보고 "이건 좋은 기법이니까 여기서도 맞겠지"라고 판단했다. 하지만 대기열이라는 맥락에서는 데이터 특성(1:N fan-out), 유저 행동(새로고침, 즉시 주문), 시스템 정밀도(이미 근사치)를 먼저 보지 않으면 오히려 역효과가 났다.

 

기법은 도구일 뿐이다. 도구가 좋은지 나쁜지는 어디에 쓰느냐에 따라 달라진다.

 

"좁은 문제에 집착하지 말고, 그 문제가 놓인 맥락부터 보자. 그래야 당신의 대기열이 안녕할 수 있다."