TL;DR
Redis 대기열은 ZSET 하나로 시작했는데, 고민 할 때마다 키가 늘어났습니다.. 최종적으로 4개

 

주문API 앞단에 대기열을 붙이는 과정에서, 처음에 생각한 과정은 아래와 같았습니다.

User -> ZADD waiting-queue -> Scheduler가 ZPOPMIN -> 토큰 발급 -> POST /orders

 

Sorted Set에 넣고, 앞에서부터 꺼내고, 토큰 주고, 주문하면 끝. Redis 하나면 되는 거 아닌가?

근데 설계 고민을 하나씩 하기 시작하면서, 이 단순한 그림에 구멍이 뚫리기 시작했습니다. 이 글은 그 구멍들을 하나씩 메워나가는 과정을 정리한 글입니다.

이 글은 아래 흐름을 따라갑니다.

1. score 하나 정하는 데 이렇게 깊어진다고?

2. "대기열에도 없고 토큰도 없는 유저"의 정체

3. 토큰 하나에 이렇게 많은 결정이

4. 스케줄러, 한 대만 돌려야 한다

5. 운영을 생각하면 또 다른 세계

6. 최종 설계와 회고


1. score 하나 정하는 데 이렇게 깊어진다고?

Q. ZADD waiting-queue {score} {userId} 에서 score를 뭘로 잡을 건가요?

 

선착순이니까 시간이 맞겠죠. 그래서 처음에는 나노초 기반 Instant를 쓰려고 했습니다.

같은 밀리초에 두 유저가 들어오면 나노초로 구분하면 되니까요.

근데 함정이 있었습니다. Redis Sorted Set의 score는 double(64비트 부동소수점)입니다.

유효 정밀도가 약 15~16자리인데, 나노초 epoch는 19자리입니다. double에 넣는 순간 뒷자리가 잘려서 서로 다른 나노초 값이 같은 score로 저장될 수 있습니다.

그럼 밀리초는? 밀리초는 13자리라 double 정밀도 안에 들어갑니다.

 

Q. "근데 같은 밀리초에 두 유저가 들어오면 순서가 뒤바뀔 수 있지 않나요? 선착순인데 불공정한 거 아닌가요?"

 

이 질문에 대한 답을 고민하다가, 대기열의 역할을 다시 생각하게 됐습니다. 대기열은 주문 API 앞단의 관문이지, 대기열 자체가 재고를 차감하는 게 아닙니다. 같은 밀리초에 진입한 두 유저가 512번, 513번으로 배정되든 반대로 배정되든, 어차피 둘 다 비슷한 시점에 주문 API에 진입합니다. 1ms 이내의 순서 역전이 유저에게 체감되는 불공정이 될 수 없습니다.(사용자는 어차피 실제 순서를 모름)

결론: score는 밀리초 단위 timestamp로 충분합니다.

 

여기서 하나 더. 유저가 대기 중에 새로고침을 누르면? ZADD는 기본적으로 이미 존재하는 member의 score를 덮어씁니다. 원래 100번째였던 유저가 새로고침 한 번에 맨 뒤로 밀릴 수 있습니다.

해결은 간단합니다. ZADD NX 옵션을 쓰면 member가 이미 존재할 때 score를 갱신하지 않습니다. 이미 대기열에 있는 유저가 다시 요청하면 에러 대신 기존 순번을 그대로 돌려주면 됩니다.

 


2. "대기열에도 없고 토큰도 없는 유저"의 정체

스케줄러가 ZPOPMIN으로 N명을 꺼내 입장 토큰을 발급합니다. ZPOPMIN은 꺼내는 순간 Sorted Set에서 해당 member를 삭제합니다.

 

유저는 2초마다 GET /queue/position을 polling하고 있는데, 대기열에서 빠졌으니 ZRANK가 null을 반환합니다. 그러면 토큰이 발급된 건지, 뭔가 잘못된 건지 유저는 알 수가 없습니다.

 

그래서 ZRANK가 null이면 entry-token:{userId} 키에서 토큰을 조회하는 로직을 추가했습니다. 토큰이 있으면 "입장 가능", 토큰이 없으면...?

여기서 진짜 문제가 터졌습니다. 대기열에도 없고 토큰도 없는 유저는 누구인가?

처음에는 "서버 에러거나 부정 접근이겠지"라고 생각했습니다. 근데 아닙니다. 토큰에는 TTL이 있거든요. 토큰을 발급받고 5분 동안 주문을 안 하면 TTL로 자동 만료됩니다. 이 경우에도 대기열에 없고 + 토큰도 없는 상태가 됩니다. 정상적인 시나리오인 겁니다.

가능한 상황이 최소 세 가지:

  1. 토큰 TTL 만료
  2. 주문 완료 후 토큰 삭제됨
  3. 애초에 대기열에 진입한 적 없음

이 셋을 구분하려면 "토큰이 발급된 적 있다"는 흔적이 필요합니다.

SET queue-status:{userId} TOKEN_ISSUED EX 420

토큰 발급 시 별도 키에 상태를 기록합니다. TTL은 토큰 TTL(5분)보다 약간 길게 7분으로 잡았습니다.

왜 토큰과 같은 TTL이면 안 되냐고요? 이 키의 존재 이유가 "토큰 만료 뒤에도 흔적을 남기는 것"인데, 토큰이랑 동시에 만료시키면 원래 문제가 그대로 남습니다.

 

polling 응답 로직은 이렇게 됩니다:

 

ZRANK → 순번 있음 → "N번째, 약 M분 소요"
     → null → GET entry-token → 토큰 있음 → "입장 가능"
                              → null → GET queue-status → TOKEN_ISSUED → "토큰이 만료되었습니다"
                                                        → null → "대기열에 없습니다. 진입해 주세요"

 

Redis 키 1개로 시작했는데, 이 문제 하나로 3개가 됐습니다.


3. 토큰 하나에 결정할게 이렇게 많다니요....

토큰을 가진 유저가 POST /orders로 주문 API에 진입합니다. 토큰 검증은 Interceptor에서 처리하기로 했습니다. @EntryTokenRequired 어노테이션 기반으로 다른 API에도 확장 가능하도록요.

여기서 핵심적인 결정이 두 개 있었습니다.

이중 주문 방지

토큰을 "검증 → 주문 처리 → 완료 후 삭제" 순서로 하면 주문 실패 시 토큰을 살릴 필요가 없어서 편합니다. 근데 문제가 있습니다. 토큰이 아직 살아있는 틈에 유저가 같은 토큰으로 POST /orders를 한 번 더 호출하면, Interceptor에서 토큰이 아직 있으니 통과 → 같은 주문이 두 번 처리될 수 있습니다.

그래서 검증과 동시에 삭제를 원자적으로 해야 합니다. Redis 6.2+의 GETDEL 명령어가 정확히 이 용도입니다. 값을 가져오면서 동시에 삭제합니다. 첫 번째 요청만 토큰을 받고, 이후 요청은 null을 받아 거부됩니다.

주문 실패 시에는? catch 블록에서 같은 키에 토큰을 다시 SET합니다. 재발급이 아니라 복원인 거죠. TTL은 5분으로 다시 주더라도, 주문 단계에서 실패한 유저라 금방 재시도하고 나갈 겁니다.

Thundering Herd

스케줄러가 한 번에 100명에게 토큰을 발급하면, 이 100명이 거의 동시에 주문 API를 호출합니다. 서버에 순간적으로 부하가 집중되는 Thundering Herd 현상입니다.

발급 시점 자체를 분산시키는 방법도 있지만, 그러면 스케줄러에 루프 + delay가 들어가면서 분산 락 점유 시간이 길어집니다.

더 나은 방법은 토큰에 activateAt을 분산 부여하는 겁니다. 100명에게 한 번에 발급하되, 각 토큰의 활성화 시각에 랜덤 Jitter를 넣습니다. Interceptor에서 activateAt 이전 요청은 거부하면 됩니다. 스케줄러는 빠르게 발급하고 락을 놓고, 주문 API 호출 시점이 자연스럽게 분산됩니다.

 


4. 스케줄러, 한 대만 돌려야 한다

서버가 3대면 스케줄러도 3개가 돕니다. 각각 N명씩 꺼내면 매 주기 3N명에게 토큰이 발급됩니다. TPS 175 기준인데 525명이 동시에 몰리는 거죠.

"3대면 175/3만큼씩 꺼내면 되지 않나요?" 서버가 5대로 늘거나 1대가 죽으면? 각 서버가 "지금 몇 대가 살아있는지"를 알아야 해서 복잡해집니다.

더 단순한 접근은 1대에서만 스케줄러를 실행하는 겁니다. 이걸 보장하는 방법이 분산 락입니다.

SET lock:scheduler NX EX {TTL}

NX로 키가 없을 때만 SET → 먼저 도착한 서버만 성공합니다. EX로 TTL을 주면 락을 잡은 서버가 죽었을 때 TTL 만료 후 자동으로 락이 해제됩니다. 다른 서버가 다음 주기에 락을 획득하면 별도 failover 로직 없이 자동 복구됩니다. 대기열 상태는 Redis에 있으니까 서버가 바뀌어도 데이터 유실이 없습니다.

여기서 락 TTL 설정이 미묘합니다. 스케줄러 주기가 100ms인데, 락 TTL도 100ms로 잡으면? 작업이 150ms 걸리는 경우 락이 먼저 만료돼서 다른 서버도 실행됩니다.

 

원칙: 락 TTL > 작업 최대 실행 시간.

 

빈 슬롯 보충도 깔끔하게 풀렸습니다. maxSlot(동시 주문 가능한 최대 인원)에서 현재 활성 토큰 수를 빼면, 그게 이번 주기에 발급할 수입니다. 토큰이 TTL로 만료되면 자연스럽게 활성 토큰 수가 줄어들고, 다음 주기에 빈 슬롯만큼 보충합니다.


 

5. 운영을 생각하면 또 다른 세계

설계의 뼈대가 잡힌 뒤에도 질문은 계속됐습니다.

대기열 최대 인원

무제한으로 받으면 안 됩니다. 유저가 기다릴 의사가 있는 시간으로 역산하면 됩니다.

최대 허용 대기 시간 × TPS = 최대 대기열 인원
예: 5분 × 175 TPS = 52,500명

ZADD 전에 ZCARD로 현재 인원을 확인하고, 초과하면 거부 응답을 내립니다.

어뷰징

같은 계정으로 여러 브라우저에서 진입하는 건 Sorted Set의 ZADD NX 특성상 이미 방어됩니다. userId가 member니까 중복 저장이 안 되거든요.

다중 계정 어뷰징은 완벽한 방어가 불가능합니다. IP 제한은 VPN으로 우회 가능하고, 디바이스 fingerprint는 시크릿 모드로 무력화됩니다. 100% 방어보다는 비용 대비 효과적인 억제 전략을 선택하는 게 현실적입니다.

Polling 부하

대기 인원 1만 명에 polling 주기 2초면 초당 5,000건의 요청이 발생합니다. SSE로 바꾸면 polling 자체는 없어지지만 1만 개의 커넥션을 유지해야 합니다.

polling을 유지하면서 부하를 줄이는 방법도 있습니다. 대기 순번이 10,000번인 사람과 10번인 사람이 같은 주기로 polling할 필요가 없으니까요. 서버가 polling 응답에 retryAfter를 함께 내려주면, 순번이 뒤일수록 긴 주기, 앞일수록 짧은 주기로 동적 조절이 가능합니다.

 


6. 최종 설계와 회고

Redis 키 구조

타입 용도 TTL
waiting-queue Sorted Set 대기열 (score=timestamp, member=userId) 없음
entry-token:{userId} String 입장 토큰 + activateAt 5분
queue-status:{userId} String 토큰 발급 이력 (TOKEN_ISSUED) 7분
lock:scheduler String 스케줄러 분산 락 100ms

API

API 설명
POST /queue/enter 대기열 진입. ZADD NX + ZRANK
GET /queue/position 순번 조회. ZRANK → 토큰 확인 → 상태 확인
POST /orders 주문. Interceptor에서 GETDEL로 토큰 검증

설정값

항목 근거
스케줄러 주기 100ms Thundering Herd 완화
배치 크기 ~18명/100ms TPS 175 기준 (p99)
토큰 TTL 5분 유저 행동 시간 고려
상태 기록 TTL 7분 토큰 TTL + α
Polling 주기 2초 서버 부하와 응답성 균형

회고

솔직히 대기열 설계를 시작할 때는 "Sorted Set에 넣고 빼면 끝이지 뭐가 어렵겠어"라고 생각했습니다.

근데 "score를 뭘로 잡을 건가요?" 라는 첫 질문부터 double 정밀도라는 생각지 못한 제약에 부딪혔고, "새로고침하면?" 이라는 단순한 질문이 ZADD NX라는 설계 결정으로 이어졌고, "대기열에도 없고 토큰도 없으면?" 이라는 질문이 queue-status라는 새로운 키를 탄생시켰습니다.

 

Redis 키 1개로 시작한 시스템이 4개가 된 건, 제가 처음부터 복잡하게 설계한 게 아닙니다. 질문 하나가 엣지 케이스 하나를 드러냈고, 그 엣지 케이스가 새로운 설계 결정을 요구한 것입니다.

분산 락이라는 개념도 이번에 처음 접했습니다. "스케줄러를 1대에서만 돌리고 싶은데 어떻게 하죠?" 라고 물었을 때 "요청을 서버 한 대로만 흘리면 되지 않나요?" 라고 답했던 게 좀 부끄럽습니다. 스케줄러는 유저 요청이 아니라 서버 내부에서 자동 실행되는 작업이라 요청 라우팅과는 다른 문제였거든요.

 

그리고 토큰 삭제 시점의 결정이 가장 인상 깊었습니다. "검증 후 바로 삭제 → 주문 처리"와 "검증 → 주문 처리 → 완료 후 삭제"는 순서 하나의 차이인데, 전자는 주문 실패 시 유저 구제 문제가 생기고, 후자는 이중 주문 문제가 생깁니다. 어느 쪽을 택하든 새로운 위험이 따라오고, 그 위험을 어떻게 메울 것인가가 설계의 본질이었습니다.

결국 GETDEL로 원자적 검증+삭제를 하고, 실패 시 토큰을 복원하는 방향으로 결론이 났는데, 이건 두 가지 선택지의 단점을 모두 메우는 조합이었습니다.

 

단순해 보이는 시스템일수록 엣지 케이스가 설계를 만든다. 큰 거 하나 배웠다...ㅜ

+ Recent posts