tldr
주문 API로 요청을 바로 밀어 넣는 대신 Redis Sorted Set에 사용자를 먼저 줄 세웠다.
스케줄러는 1초마다 최대 50명을 대기열에서 꺼내 5분 동안 유효한 입장 토큰을 발급한다. 사용자는 토큰을 받기 전까지 자신의 순번을 polling하고, 주문 API는 유효한 X-Entry-Token이 있는 요청만 처리한다.
주문이 정상적으로 commit되면 OrderEvent.Created를 받은 local event listener가 토큰을 삭제한다. 주문이 실패한 경우에는 토큰을 유지해 사용자가 다시 시도할 수 있게 했다.
다만 이 구조는 완성된 운영용 대기열이라기보다 입장률을 제한하는 첫 번째 설계에 가깝다. 원자적 진입, 토큰 발급 중 장애 복구, 다중 인스턴스의 전역 처리량 제어, polling 부하 같은 문제는 추가로 해결해야 한다.
Overview
목표:
- 순간적으로 주문 요청이 몰려도 모든 요청이 주문 DB까지 한 번에 도달하지 않게 한다.
- 사용자가 대기열에서 자신의 순번과 예상 대기 시간을 확인할 수 있게 한다.
- 일정한 수의 사용자에게만 주문 API를 호출할 수 있는 입장 토큰을 발급한다.
- 주문 트랜잭션이 성공한 경우에만 입장 토큰을 제거한다.
- 대기열과 주문 도메인이 Redis 구현 세부사항에 직접 의존하지 않게 한다.
이번 구현의 기준값:
- scheduler fixed delay: 1초
- scheduler batch size: 50명
- entry token TTL: 300초
- rank: Redis
ZRANK결과를 그대로 사용하는 0-based
왜 주문 API 앞에 대기열이 필요한가
주문 요청이 평소보다 조금 늘어나는 상황이라면 WAS를 scale-out하거나 DB connection pool을 조정하는 것으로 대응할 수 있다.
하지만 이벤트 시작 시점처럼 짧은 시간에 요청이 폭발하면 이야기가 달라진다. 애플리케이션 인스턴스를 늘려도 결국 모든 요청은 재고와 쿠폰 row를 잠그고 주문을 저장하기 위해 같은 DB로 모인다. WAS가 더 많은 요청을 받아줄수록 DB connection과 lock 대기는 오히려 빠르게 증가할 수 있다.
대기열의 목적은 요청을 더 빨리 처리하는 것이 아니다.
하위 시스템이 감당할 수 있는 속도로만 요청을 통과시키고, 나머지 사용자를 시스템 바깥에서 기다리게 만드는 것이 목적이다.
이번에는 주문 요청 payload 자체를 서버 큐에 저장하지 않았다. 대기열에는 memberId만 등록하고, 입장 허가를 받은 사용자가 원래 주문 요청을 다시 보내는 방식을 선택했다.
이렇게 하면 대기 중인 주문 payload의 상태와 재시도 정책까지 서버가 관리하지 않아도 된다. 대신 클라이언트가 순번을 조회하고 READY 상태가 된 뒤 주문 API를 호출해야 한다.
전체 아키텍처

전체 흐름은 세 부분으로 나뉜다.
- 사용자를 Redis ZSET 대기열에 등록하고 현재 상태를 조회한다.
- 스케줄러가 선두 사용자를 꺼내 입장 토큰을 발급한다.
- 주문 API가 토큰을 검증하고, 주문 commit 이후 local event로 토큰을 삭제한다.
계층도 역할에 맞게 분리했다.
| 계층 | 책임 |
|---|---|
| Interface | 대기열과 주문 API, 주문 완료 event listener 제공 |
| Application | 사용자 인증, 대기열 상태 조립, 토큰 발급, 주문 진입 검증 수행 |
| Domain / Port | WaitingQueuePosition 상태 모델과 Redis를 추상화한 repository interface 제공 |
| Infrastructure | Spring Data Redis를 이용해 repository port 구현 |
| Redis | 대기 순서 ZSET과 사용자별 입장 토큰 String 저장 |
1. Redis Sorted Set으로 순서를 표현한다
대기열에서 필요한 값은 크게 세 가지였다.
- 사용자가 대기열에 있는가
- 현재 몇 번째 순서인가
- 전체 대기 인원은 몇 명인가
Redis Sorted Set은 하나의 자료구조로 이 요구를 표현할 수 있다.
key : queue:waiting
member : memberId
score : enteredAt
memberId는 ZSET 안에서 유일하기 때문에 같은 사용자를 여러 번 저장해도 member가 중복으로 늘어나지 않는다. enteredAt을 score로 사용하면 먼저 진입한 사용자가 작은 score를 가지므로 ZRANK로 순번을 구하고 ZPOPMIN으로 선두 사용자를 꺼낼 수 있다.
대기 순번은 Redis가 반환하는 ZRANK를 그대로 사용했다. 따라서 첫 번째 사용자의 rank는 0이다. 내부 모델과 API 응답에서 서로 다른 기준을 사용하면 매 경계에서 +1, -1 변환이 생기기 때문에 이번 구현에서는 모두 0-based로 통일했다.
반복 진입 시 기존 순번을 유지한다
대기 중인 사용자가 화면을 새로고침하거나 진입 API를 다시 호출할 수 있다. 이때 score를 현재 시간으로 덮어쓰면 사용자의 순번이 뒤로 밀린다.
현재 구현은 기존 score를 먼저 확인하고, 아직 등록되지 않은 사용자만 추가한다.
override fun enterIfAbsent(memberId: Long, score: Double) {
val member = memberId.toString()
if (redisTemplate.opsForZSet().score(WAITING_QUEUE_KEY, member) == null) {
redisTemplate.opsForZSet().add(WAITING_QUEUE_KEY, member, score)
}
}
이 방식으로 순차적인 반복 호출에서는 기존 score와 rank를 유지할 수 있었다.
다만 ZSCORE와 ZADD가 두 개의 명령이라는 점은 중요한 한계다. 같은 사용자의 첫 진입 요청이 정확히 동시에 들어오면 두 요청 모두 score가 없다고 판단한 뒤 서로 다른 score를 저장할 수 있다. 엄밀한 멱등성을 보장하려면 ZADD NX 또는 addIfAbsent처럼 확인과 추가를 Redis의 단일 원자 연산으로 처리해야 한다.
2. 대기 상태를 하나의 응답 모델로 표현한다
클라이언트는 대기열 진입 이후 같은 position API를 polling한다. 응답 상태는 세 가지다.
WAITING: ZSET에 존재하며 아직 입장 토큰이 없다.READY: 입장 토큰이 발급되어 주문 API를 호출할 수 있다.NOT_ENTERED: 대기열에도 없고 유효한 입장 토큰도 없다.
상태를 판정할 때는 입장 토큰을 먼저 조회한다. 스케줄러가 사용자를 ZSET에서 꺼낸 뒤 토큰을 발급하기 때문에 READY 사용자는 더 이상 ZSET에 존재하지 않는다. rank부터 확인하면 토큰이 발급된 사용자를 NOT_ENTERED로 잘못 판단할 수 있다.
WaitingQueuePosition은 조회된 rank와 전체 대기 인원을 받아 다음 값을 계산한다.
statusrankcurrentTotalWaitingCountestimatedWaitSecondspollingIntervalSecondsentryToken
예상 대기 시간은 현재 rank를 초당 예상 처리량 50명으로 나누어 계산했다. polling 주기는 앞에 100명 미만이면 1초, 1,000명 미만이면 3초, 그 이상이면 5초로 늘린다.
사용자가 입장에 가까울수록 빠르게 상태를 갱신하고, 멀리 있을수록 Redis 조회 횟수를 줄이려는 선택이다. 다만 예상 처리량이 고정값이므로 실제 주문 처리 속도를 반영한 정교한 예측은 아니다.
3. 스케줄러가 입장률을 제어한다
대기열에 등록된 사용자를 모두 동시에 주문 API로 보내면 대기열을 둔 의미가 없다.
WaitingQueueScheduler는 1초마다 issueNextEntries()를 호출한다. 서비스는 ZPOPMIN으로 score가 가장 낮은 사용자부터 최대 50명을 꺼내고, 각 사용자에게 입장 토큰을 발급한다.
fun issueNextEntries(batchSize: Long): List<String> {
return waitingQueueRepository.popNext(batchSize)
.mapNotNull { memberId ->
val token = generateToken()
entryTokenRepository.issue(memberId, token, properties.entryTokenTtl)
entryTokenRepository.find(memberId)
}
}
토큰은 SecureRandom으로 생성한 32byte 값을 URL-safe Base64로 인코딩한다.
key : queue:entry-token:{memberId}
value : generated token
TTL : 300 seconds
application에서 생성한 token을 그대로 반환하지 않고 Redis에 저장한 뒤 다시 조회한 값을 사용했다. 저장되지 않은 token을 입장 가능한 값처럼 다루지 않기 위해서다.
TTL은 두 가지 역할을 한다.
첫 번째는 토큰이 무기한 남아 주문 API 접근 권한처럼 사용되는 것을 막는 것이다.
두 번째는 주문 완료 후 token 삭제가 실패해도 최종적으로 Redis에서 사라지게 만드는 안전망이다.
반대로 TTL이 너무 짧으면 토큰을 받은 사용자가 주문을 작성하는 중 만료될 수 있고, 너무 길면 주문하지 않는 사용자가 입장 가능 상태를 오래 점유한다. 현재 300초는 과제의 기본 정책이며 실제 운영에서는 사용자 행동 시간과 목표 처리량을 측정해 조정해야 한다.
4. 주문 API는 입장 토큰이 있어야 통과한다
주문 요청은 X-Entry-Token header를 함께 보낸다.
OrderFacade.placeOrder()는 사용자 인증 이후 Redis에 저장된 token과 요청 token을 비교한다. token이 없거나, 다르거나, TTL이 지나 사라졌다면 주문 로직을 시작하기 전에 401 Unauthorized로 종료한다.
검증을 controller가 아니라 application layer에 둔 이유는 입장 가능 여부가 단순한 HTTP header 형식 검사가 아니라 주문 use case의 선행 조건이기 때문이다.
이 구조는 Redis가 응답하지 않으면 주문을 막는 fail-closed 방식이다. 대기열이 우회되어 하위 시스템으로 트래픽이 쏟아지는 것보다 주문을 받지 않는 편을 선택했다. 서버 보호에는 유리하지만 Redis 장애가 곧 주문 불가로 이어지는 비용이 있다.
5. 토큰 삭제는 주문 트랜잭션 밖에서 처리한다
처음에는 주문 생성 메서드에서 token을 바로 삭제하는 방식도 생각했다.
하지만 token 삭제가 주문 트랜잭션 안에 있으면 외부 Redis 작업이 주문의 DB transaction 흐름에 섞인다. 반대로 주문이 commit되기 전에 token을 지웠다가 주문이 rollback되면 사용자는 주문에 실패하고 입장 권한까지 잃는다.
현재 주문 흐름은 이미 성공 시 OrderEvent.Created를 발행한다. 그래서 token 삭제는 이 local event를 AFTER_COMMIT 시점에 받는 listener로 분리했다.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handle(event: OrderEvent.Created) {
waitingQueueService.deleteEntryToken(event.memberId)
}
이 방식으로 얻은 것은 두 가지다.
- 주문 transaction이 rollback되면 token을 유지해 다시 주문할 수 있다.
- token 정리 실패가 이미 성공한 주문을 rollback시키지 않는다.
대신 local event는 durable message가 아니다. DB commit 직후 프로세스가 종료되거나 Redis 삭제가 실패하면 listener가 복구되지 않는다. 현재는 TTL이 최종 정리 역할을 하지만, 즉시 정리가 반드시 필요하다면 retry 가능한 event record나 별도 cleanup job이 필요하다.
또한 AFTER_COMMIT은 자동으로 비동기 실행되거나 예외가 격리된다는 뜻이 아니다. 현재 listener는 주문 요청과 같은 호출 흐름에서 실행되므로 Redis 삭제 예외가 그대로 전파되면 DB 주문은 이미 commit되었지만 HTTP 응답은 실패할 수 있다. 운영 환경에서는 예외를 기록하고 알림을 보내는 정책, retry 가능한 cleanup 작업을 함께 두어야 한다.
Technical Decisions
| 설계 항목 | 선택 | 이유 |
|---|---|---|
| 대기 순서 | Redis Sorted Set | member 중복을 막으면서 score 순서, rank, 전체 인원을 함께 조회하기 위해 |
| ZSET member | memberId | 한 사용자당 하나의 대기열 항목만 유지하기 위해 |
| ZSET score | 진입 시각 millisecond | 먼저 진입한 사용자를 우선 처리하기 위해 |
| 입장 제어 | fixed delay scheduler + batch size | 하위 시스템에 전달되는 사용자 수를 일정한 속도로 제한하기 위해 |
| 입장 권한 | 사용자별 Redis String token + TTL | 대기 상태와 주문 가능 상태를 분리하고 오래된 권한을 자동 만료하기 위해 |
| 상태 조회 | client polling | 별도 push channel 없이 단순한 HTTP API로 순번을 제공하기 위해 |
| 주문 검증 | OrderFacade 시작 지점 | 비싼 주문 처리 전에 입장 조건을 검사하기 위해 |
| token 정리 | OrderEvent.Created의 AFTER_COMMIT listener | 주문 rollback과 token 삭제를 분리하고 성공한 주문만 정리하기 위해 |
이 설계의 트레이드오프
입장률은 제어하지만 동시 처리량을 정확히 제한하지는 않는다
스케줄러가 초당 50개의 token을 발급한다고 해서 동시에 실행되는 주문이 항상 50개 이하라는 뜻은 아니다.
token은 300초 동안 유효하다. 먼저 token을 받은 사용자들이 기다렸다가 같은 순간 주문하면 여러 초 동안 발급된 사용자가 한꺼번에 들어올 수 있다. 현재 구조가 제어하는 것은 token 발급률이며 실제 주문의 in-flight concurrency가 아니다.
하위 시스템을 정확히 보호하려면 active slot, token claim, semaphore 같은 별도 동시성 제어가 필요하다. 또는 실제 주문 처리 완료율을 기준으로 다음 입장자를 발급해야 한다.
polling은 단순하지만 조회 트래픽을 만든다
HTTP polling은 클라이언트와 서버 모두 구현하기 쉽고 연결 상태를 오래 유지하지 않는다.
대신 대기자가 많아질수록 GET token, ZRANK, ZCARD 요청이 반복된다. rank별 polling interval을 다르게 둔 이유도 이 부하를 줄이기 위해서다.
규모가 커지면 client별 jitter를 추가해 요청 시점을 분산하거나, 긴 polling 간격과 CDN이 아닌 Redis read replica 활용, SSE 기반 push를 검토할 수 있다.
주문과 token 정리는 분리되지만 즉시 정리를 보장하지 않는다
AFTER_COMMIT listener를 사용하면 주문 transaction의 관심사를 줄일 수 있다.
반면 listener 실패는 주문 성공 이후에 발생하므로 주문을 되돌릴 수 없다. local event는 재시도 기록도 남기지 않는다. 현재 설계에서는 TTL이 이 불일치를 최종적으로 해소하지만 만료 전까지는 token이 남을 수 있다.
Redis 장애 시 주문도 중단된다
fail-closed는 대기열을 우회한 요청으로부터 DB를 보호한다.
하지만 Redis가 전체 주문 흐름의 필수 의존성이 된다. 운영 환경에서는 replication, Sentinel 또는 Cluster, persistence와 장애 전환 정책을 함께 설계해야 한다.
과제 범위 밖이거나 현재 남아 있는 한계
1. 같은 사용자의 동시 진입은 원자적으로 막아야 한다
현재 구현의 ZSCORE 후 ZADD는 check-then-act 구조다. 서로 다른 사용자 8명의 동시 진입과 같은 사용자의 순차 반복 진입은 검증했지만, 같은 사용자의 최초 요청이 동시에 들어오는 경우까지 원자적으로 보장하지는 않는다.
이 부분은 ZADD NX 단일 명령으로 바꾸는 것이 맞다.
2. ZPOPMIN과 token 저장 사이에 유실 구간이 있다
스케줄러는 사용자를 ZSET에서 먼저 제거한 뒤 token을 저장한다.
두 작업 사이에서 프로세스가 종료되거나 Redis token 저장이 실패하면 사용자는 대기열에도 없고 token도 없는 NOT_ENTERED 상태가 된다. 저장 후 token을 다시 조회하는 것만으로는 이미 pop된 사용자를 복구하지 못한다.
운영용 구조라면 대기 사용자를 바로 제거하기보다 processing ZSET으로 원자적으로 이동한 뒤 token 발급 성공 시 ack하는 방식이 필요하다. 일정 시간 동안 ack되지 않은 사용자를 다시 waiting ZSET으로 돌리는 recovery job도 함께 둘 수 있다.
3. 여러 WAS 인스턴스에서 batch size는 전역 제한이 아니다
모든 인스턴스에서 scheduler가 실행되면 각 인스턴스가 매초 50명씩 pop한다. ZPOPMIN 덕분에 같은 사용자를 중복으로 꺼내지는 않지만, 인스턴스가 4대라면 전체 발급량은 최대 초당 200명으로 증가한다.
batch size를 전역 처리량으로 사용하려면 scheduler leader 선출, distributed lock, 전용 worker 분리 또는 Redis 기반 global rate limiter가 필요하다.
4. 현재 token은 일회용 주문권이 아니라 유효 시간 동안의 입장권이다
token 삭제는 주문 commit 이후에 일어난다. 같은 사용자가 동일 token으로 주문 요청을 동시에 보내면 두 요청 모두 token 검증을 통과할 수 있다.
한 token으로 주문 한 건만 허용해야 한다면 검증과 사용 처리를 원자적으로 묶어야 한다. 단순히 주문 시작 시 GETDEL을 사용하면 주문 rollback에도 token이 사라지므로 READY → CLAIMED → CONSUMED 상태와 rollback 시 release 정책까지 필요하다. 주문 자체의 idempotency key도 별도로 고려해야 한다.
5. 예상 대기 시간은 실제 처리량을 반영하지 않는다
현재는 rank / 50으로 예상 대기 시간을 계산한다. scheduler 설정과는 맞지만 DB latency, 주문 성공률, token만 받고 주문하지 않는 사용자 비율, 장애 상황은 반영하지 않는다.
실제 환경에서는 최근 token 발급률과 주문 완료율의 moving average를 사용하고, 운영 지표에 따라 batch size를 조절하는 방식이 더 정확하다.
6. 같은 millisecond의 진입 순서는 완전한 FIFO가 아니다
score가 application의 millisecond 시각이므로 여러 사용자가 같은 millisecond에 진입할 수 있고, 여러 WAS의 clock이 어긋나면 실제 도착 순서와 score 순서가 달라질 수도 있다. Redis ZSET은 score가 같으면 member의 사전식 순서를 사용하기 때문에 동일 score 사용자 사이의 순서도 실제 도착 순서와 다를 수 있다.
엄격한 FIFO가 필요하다면 Redis TIME과 sequence를 조합하거나 별도의 증가값을 score에 포함해야 한다.
검증한 범위
이번 구현에서는 Testcontainers Redis와 API E2E 테스트로 다음 흐름을 확인했다.
- 대기열 첫 진입 시 rank 0과 전체 대기 인원 반환
- 같은 사용자의 순차 반복 진입 시 기존 rank 유지
- 서로 다른 여러 사용자의 동시 진입 시 고유한 rank 부여
WAITING,READY,NOT_ENTERED상태 응답- token TTL 만료 후 조회 불가
- 대기 인원이 batch size보다 많아도 한 번에 batch size만큼만 발급
- 입장 token이 없으면 주문 거절
- 주문 commit 성공 후 entry token 삭제
이 테스트는 기능 계약을 검증한다. 최대 수용량과 적절한 batch size까지 알려주지는 않는다. 운영값을 결정하려면 k6 같은 도구로 진입 burst와 position polling을 동시에 발생시키고 WAS latency, Redis command 처리량, CPU, memory, connection 수를 함께 관찰해야 한다.
Alternatives Considered
| 옵션 | 장점 | 단점 |
|---|---|---|
| 주문 transaction 안에서 token 삭제 | 주문 코드 안에서 전체 흐름을 바로 확인할 수 있음 | Redis 후처리가 주문 transaction 관심사에 섞이고 rollback 시 token 처리 기준이 복잡해짐 |
선택: AFTER_COMMIT local event로 삭제 | 성공한 주문만 token을 정리하고 주문 transaction과 후처리를 분리할 수 있음 | listener 실패를 자동 복구하지 못하며 token이 TTL까지 남을 수 있음 |
이번 선택에서 중요했던 것은 token 삭제가 주문 성공의 조건은 아니라는 점이다.
주문이 commit되었다면 token 삭제 실패 때문에 이미 반영된 주문을 실패처럼 응답하는 것보다, 주문 성공은 유지하고 token을 별도로 정리하는 편이 자연스럽다고 봤다.
다만 local event만으로 신뢰성까지 얻은 것은 아니다. 책임은 분리했지만 실패 복구는 TTL에 의존한다. 즉 구조가 깔끔해진 것과 운영 신뢰성이 높아진 것은 별개의 문제다.
마무리
처음에는 Redis ZSET에 사용자를 넣고 순번을 반환하면 대기열 구현이 끝날 것이라고 생각했다.
실제로 고민이 많았던 부분은 줄을 세우는 방법보다 사용자가 언제 주문할 자격을 얻고, 그 자격이 언제 사라지며, 중간에 실패하면 어느 상태로 돌아가야 하는지였다.
이번 구현에서는 ZSET을 waiting 상태, TTL token을 ready 상태로 사용했다. 주문 API는 token으로 진입을 제한하고, 주문 commit 이후 event를 기준으로 token을 정리했다. 이를 통해 대기열과 주문 transaction의 책임을 비교적 명확하게 나눌 수 있었다.
동시에 현재 구조의 빈틈도 확인할 수 있었다.
원자적인 대기열 등록, pop 이후 장애 복구, 다중 인스턴스에서의 전역 처리량, token의 일회성, 실제 처리량 기반 예상 시간은 과제 요구만으로는 드러나지 않지만 운영 환경에서는 먼저 해결해야 할 문제다.
대기열은 단순히 사용자를 줄 세우는 자료구조가 아니었다.
시스템이 감당할 수 있는 속도로 사용자를 이동시키고, 각 사용자의 상태 전이를 실패 상황에서도 잃지 않게 만드는 하나의 admission control system에 가까웠다.
