서론
우리 타임딜 서비스 프로젝트에서의 대기열(Waiting Queue)의 목적은 "여러 사용자들이 몰릴 경우, 사용자의 주문 요청을 튕겨내지 않고 줄을 세워서 붙잡아두는 것"이다.
또한, 서비스 지속성 면에서의 목적으로는 대규모의 사용자가 주문을 요청할 경우, 주문(Order) 서버의 DB에 엄청난 부하가 가해지기에 서버 쪽에서 장애가 나거나 죽을 수도 있다. 따라서 대기열(Queue) 서버에서는 이러한 대규모 트래픽 상황에서의 DB 부하를 최소화하기 위해 Redis를 기반으로 대기열 데이터 구조를 설계하게 되었다.
본론
대기열 서비스에서 쓰일 Redis 큐를 다음과 같이 정의했다.
Waiting Queue(대기열) : 주문 페이지에 입장하기까지 대기하는 상황
Active Queue(활성열) : 주문 페이지에 입장한 상황
동작 개념
동작 방식은 다음과 같이 정하였다. (이후에 기술하겠지만 FAST TRACK 정책이 존재한다.)
예를 들면 다음과 같다.
진입 요청 시(클라이언트가 주문 요청 버튼을 막 눌렀을 경우) : 현재 Active Queue의 토큰 개수가 100개 이상이면 Waiting Queue(대기열)에 먼저 등록한다. (100개 미만이면 토큰을 발급받자마자 Active Queue에 곧바로 등록한다.)
스케줄러 : queueGap(타임딜 정책의 확인 주기) 초마다 Active Queue 열에 들어갈 자리가 남았는지 확인하고, 남은 자리가 있으면 Waiting Queue에 먼저 등록된 사람부터 Active Queue(활성열)로 이동시킨다.
대기열 서비스의 엔티티 & 데이터 구조 구성 (초기 설계)
대기열 그 자체는 속도가 생명이므로 RDB의 엔티티(@Entity)를 쓰지 않고, 일반적으로 Redis 자료구조를 사용한다. 우리 프로젝트에서도 이와 같이 각 Queue의 Redis 데이터 구조를 다음과 같이 설정하였다.
- 대기열 (Waiting Queue): Sorted Set (ZSet)
- Key: waiting_queue:product:{productId}
- Value: UUID (발급된 토큰 UUID)
- Score: 요청 시간 (Timestamp) (먼저 온 사람이 점수가 낮음 -> 먼저 나감)
- 활성열 (Active Queue): Set (순서 필요 없음, 존재 여부만 중요) => 추후에 ZSet 구조로 변경함
- Key: active_queue:product:{productId}
- Value: UUID (발급된 토큰 UUID)
- 유저 인덱스 (USER_INDEX): String
- Key: queue:user:product:{productId}:{userId}
- Value: UUID (발급된 토큰 UUID)
- TTL: 타임딜 이벤트 종료 시간과 동일
유저 인덱스(USER INDEX)가 필요한 이유
저 유저 인덱스 키가 필요한 이유와 목적들은 다음과 같다.
중복 진입 차단 목적
- 대기열(ZSet)에는 토큰(UUID)만 저장되고, 유저 ID는 따로 저장하고 있지 않다
- 한 명의 유저(userId)는 하나의 상품에 대해 단 하나의 대기열 토큰만 가져야 한다
- 만약 유저가 브라우저를 새로고침해서 새로운 토큰 UUID를 발급받으면, ZSet은 서로 다른 사람인 줄 알고 또 줄을 세우게 된다
- 이를 막기 위해 유저 ID를 Key로 하는 USER_INDEX_KEY에 SETNX(Set If Not Exists) 명령어를 사용하여 중복 진입을 원천 차단할 수 있다
토큰 도용 방지 목적
- userId만으로 즉시 토큰을 찾을 수 있는 인덱스(Index) 역할을 한다
- 다른 사람의 토큰 UUID만 알아내서 주문을 시도하는 공격을 막기 위해, "이 토큰의 주인이 요청한 userId와 일치하는가?"를 검증하는 역할을 한다
- 대기열/활성열(ZSet)에서 토큰이 사라졌을 때, 이 키가 남아있다면 "좀비 키"로 판단하여 삭제하고 대기열에 재진입을 허용해주는 기준점 역할을 한다
- 토큰 UUID가 일치하지 않을 경우, "토큰 소유자가 일치하지 않습니다." 라는 메시지와 에러 코드를 표시해준다
왜 대기열은 ZSet이고, 활성열은 Set으로 했는지에 대한 이유
위 내용을 보면 알다시피 같은 열인데 Redis 데이터 구조가 좀 다르다. 이 차이에 대한 이유는 "순서가 중요한지?" 아니면 "검색 속도가 중요한지?"에 있다.
대기열 (Sorted Set/ZSet)
- 목적: 선착순 진입 순서 보장 (공정성)
- 이유: 일반 Set이나 List는 데이터가 들어온 순서를 시간순으로 정렬해서 관리하기 어렵거나(List는 중간 삭제가 느림), 순서 개념이 없다.(Set)
- 작동 원리: Sorted Set은 Value(토큰)와 Score(가중치)를 쌍으로 저장한다.
- Score의 값으로 System.currentTimeMillis() (입장 시간)을 넣는다.
- Redis는 내부적으로 이 Score를 기준으로 오름차순(먼저 온 사람이 위로) 자동 정렬을 해준다.
- 따라서 스케줄러가 "상위 100명 가져와" 할 때 "ZRANGE 0 99" 명령어 하나로 가장 오래 기다린 사람을 즉시 꺼낼 수 있다
- Redis ZRANK 명령어를 통하여 대기 순번 조회 성능을 O(log N)으로 최적화할 수 있다
활성열 (Set)
- 목적: 입장권 검사 (속도)
- 이유: 여기 들어온 토큰들은 이미 순서대로 다 들어온 상태이다. 이제부터 중요한 건 "이 토큰이 유효한가?"의 여부를 빠르게 확인하는 것이다.
- 작동 원리: Set은 순서가 없는 대신, 특정 데이터가 있는지 확인하는 SISMEMBER 명령어가 "O(1)"의 속도를 가진다. 데이터가 100만 개가 있어도 조회 속도가 똑같이 빠릅니다.
활성열의 Redis 데이터 구조를 ZSet으로 변경해야 했던 이유
그러나 개발을 하면서 활성열에 있는 토큰에 대해 TTL을 설정해줘야 하는 필요성이 생겼다. 이유는 아래와 같다.
주문 페이지에 진입한 사용자가 주문을 완료하지 않고 머무르는 '유령 유저'가 생기면, 활성열의 자리가 차지된 상태로 남아 대기열 토큰이 그만큼 활성열로 이동하지 못하게 된다.
기존 Set 구조에서는 단순히 "입장 가능한 토큰인가?"라는 기준으로만 빠르게 확인했었다. 그러나 "이 사람이 언제 나가야 하는가?"를 알 수 없다는 문제점이 발견되었다. 아마도 특정 시간이 지나면 쫓아내야 하는데 Set 구조로는 힘든 상황이었다.
즉, 대기열 -> 활성열로 이동시키는 스케줄러가 계속 동작하고 있는 상황에서 활성된 토큰이 계속 활성열에 있을 경우, 원활한 토큰 활성화 처리가 불가하기 때문에 활성 토큰에 대해 TTL을 설정해야 한다는 것이다.
그렇기에 활성 토큰의 TTL과 관련된 SHADOW_KEY를 레디스 구조에 추가적으로 생성했으나, 이미 3개의 레디스 데이터가 구조가 존재하고 토큰 삭제 처리와 관련된 관리 면에서의 복잡성이 커진다는 문제점이 발견되었다. 따라서 활성열의 데이터 구조도 대기열과 같은 ZSet으로 변경하게 되었다.
ZSet의 Score를 활용하여 만료 시간(Timestamp)를 저장하고, 만료된 활성 토큰들을 일괄적으로 삭제하여 관리하기 위해서이다. Score로 TTL 관리를 하는 것이다.
따라서 활성열의 redis 데이터 구조를 ZSet으로 변경해야 했던 이유는 아래와 같다.
- TTL 관리 : ZSCORE 조회 한 번으로 "입장 여부"와 "만료 여부(현재 시간 vs Score)"를 동시에 확인할 수 있다
- Lazy Cleanup: ZREMRANGEBYSCORE 명령어를 통해 만료된 토큰들을 주기적으로 혹은 조회 시점에 한방에 삭제할 수 있다
이를 통해 접근 제어와 수명 관리를 하나의 데이터 구조로 해결할 수 있다.
대기열 서비스 엔티티 & Redis 데이터 구조 (최종 설계)
- 대기열 (Waiting Queue): Sorted Set (ZSet)
- Key: waiting_queue:product:{productId}
- Value: UUID (발급된 토큰 UUID)
- Score: 요청 시간 (Timestamp) (먼저 온 사람이 점수가 낮음 -> 먼저 나감)
- 활성열 (Active Queue): ZSet
- Key: active_queue:product:{productId}
- Value: UUID (발급된 토큰 UUID)
- Score: 만료 시간 (Timestamp) (만료 시점이 이를수록 점수가 낮음)
- 검증 로직 : Score가 현재 시간보다 미래인지 확인
- 유저 인덱스 (USER_INDEX): String
- Key: queue:user:product:{productId}:{userId}
- Value: UUID (발급된 토큰 UUID)
- TTL: 타임딜 이벤트 종료 시간과 동일
토큰 유효성 검증은 어디에서?
사용자가 대기열 진입 요청을 하며 토큰을 발급 받고, 그 발급받은 토큰은 스케줄러에 따라 본인의 순서가 돌아왔을 경우, 활성열로 이동하게 된다. 그러면 이 활성열에 있는 토큰은 어디에서 검증하게 되는걸까?
대기열 시스템을 콘서트장 줄이라고 비유한다면 다음과 같다.
- Queue-Service(대기열 서버 = 매표소) : 줄을 세우고, 순서가 되면 입장권(Token)을 발급해 주는 곳
- Order-Service(주문 서버 = 콘서트장) : 실제 서비스를 이용하는 곳
만약 Order-Service에서 토큰 검사를 안하게 된다면, 사용자들은 매표소에서 줄을 서지 않고, 뒷문(API URL 직접 호출)으로 몰래 들어와서 콘서트장에 진입하려고 할 것이다.
따라서, Order-Service에서 활성 토큰 유효성 검사를 하는 이유는 아래와 같다.
우회 공격 차단
- 악성 사용자가 대기열 API를 무시하고, POST /orders 주소만 알아내서 바로 주문 요청을 보낼 수 있으므로 이를 방지할 수 있다 (새치기) Order-Service에서 토큰 유효성 검사를 하지 않으면, 대기열 시스템은 사실상 무용지물이 되는 것이다
트래픽 제어
- 대기열의 존재 목적은 DB 보호이다
- Order Service 앞단에서 토큰을 검사하여 유효하지 않은 요청을 비즈니스 로직(DB 트랜잭션) 진입 전에 튕겨내야 한다
- 만약 검증 없이 주문 로직을 수행하면, 트래픽 폭주 시 Order DB가 락(Lock) 대기로 인해 다운될 수 있다
데이터 정합성 보장
- "활성 상태(Active)"인 유저만 주문을 생성할 수 있다는 규칙을 비즈니스 로직의 전제 조건으로 설정했다
- 시나리오: 사용자가 Active 상태가 되어 주문 페이지에 들어왔다. 그런데 그 사이 만료 시간이 지나 토큰이 만료되었다
- 검증이 없다면: 만료된 토큰으로도 주문이 성공하게 됩니다. 이는 대기열 공정성에 위배된다
- 결론: 주문 버튼을 누르는 "순간"에 토큰이 여전히 유효한지 확인해야 데이터 정합성을 보장할 수 있다
WAIT 상태의 토큰: 아직 자기 차례가 아님 → 주문 불가
EXPIRED 상태의 토큰: 입장 시간(5분)이 지남 → 주문 불가
ACTIVE 상태의 토큰: 주문 가능
결론
플로우 & 동작 흐름 정리

- 진입 시: User Index 생성 -> Waiting Queue 추가.
- 본인 확인 시: User Index 조회해서 토큰 값 비교
- 활성화 시: 스케줄러가 Waiting Queue에서 Pop -> Active Queue에 Add. (이때 User Index는 그대로 둠)
- 주문 요청 시: 주문 서비스(Order-Service)에서 대기열 서비스로 토큰 유효성 여부 확인 요청. 활성 토큰이 ACTIVE일 경우에만 주문 페이지 입장 허용
- 최종 만료/주문 완료 시: Active Queue에서 삭제 AND User Index도 삭제(해야 재구매/대기열 재진입 가능)
'Project' 카테고리의 다른 글
| [RUSH_CREW] 2. DDD에서의 VO(Value Object) (0) | 2025.12.02 |
|---|---|
| [RUSH_CREW] 1. 타임딜 서비스 개요 (feat. DDD Bounded Context) (0) | 2025.11.26 |
| [DELIVERY_SIGNAL] Circuit Breaker 구현(Resilience4j) (0) | 2025.11.21 |
| [DELIVERY_SIGNAL] 배송 담당자 순번 할당 설계/구현 (0) | 2025.11.07 |
| [DELIVERY_SIGNAL] DDD 계층 구조 적용 (0) | 2025.11.06 |