티스토리 뷰
기본편에서 대기열을 만들고 효과를 검증했다. 하지만 50명이 동시에 입장하는 Thundering Herd, polling이 만드는 부하, Redis 장애 시 주문 불가라는 문제가 남아 있었다. 배치를 쪼개고, polling 주기를 차등 적용하고, Kafka로 우회하는 Graceful Degradation을 설계하면서 "대기열을 만드는 것"과 "대기열을 운영하는 것"은 다른 문제라는 걸 알게 됐다.
대기열 기본편에서는 Redis 기반의 기본적인 대기열을 구축해봤다. 여기서는 대기열을 운영하면서 발생할 수 있는 추가적인 문제를 해결해보고자 한다.
1. Thundering Herd — 50명이 동시에 문을 열면
기본편에서 대기인원을 입장시키는 배치 사이즈는 50, 스케줄 주기를 1,000ms로 설정했다. 초당 50건의 처리량을 흘려보내는 설정이다. 그런데 한가지 주목해야하는 점은 50명의 입장 토큰이 한꺼번에 생성된다는 점이다.
1초마다 50개의 토큰이 한 번에 발급되면, 50명이 거의 동시에 실제 주문 API를 호출한다. 50건이 한 시점에 몰린다. 굳이 50명을 동시에 입장시킬 이유가 없다. 두 가지 방법으로 입장 시점을 분산시켰다.
1) 배치 쪼개기 — 배치 사이즈를 줄이고 주기를 짧게
| 방식 | 배치 사이즈 | 주기 | 초당 처리량 | 피크 부하 |
|---|---|---|---|---|
| 변경 전 | 50명 | 1,000ms | 50명/초 | 50명 동시 |
| 변경 후 | 10명 | 200ms | 50명/초 | 10명 동시 |
초당 처리량은 동일하게 유지하면서 피크 부하만 1/5로 줄였다.
2) Jitter — 같은 배치 내에서도 입장 시점을 흩뿌리기
배치를 쪼개도 같은 배치에 속한 10명은 여전히 동시에 입장권을 받는다. 여기에 Jitter(랜덤 지연)를 추가했다. 입장권을 발급할 때 사용자마다 "주문 가능 시각"에 랜덤한 지연을 더해 주문 시점을 흩뿌리는 것이다.
// 유저별 주문 가능 시각 계산 — 랜덤 지연 추가
long now = System.currentTimeMillis();
List<Long> orderableAts = IntStream.range(0, batchSize)
.mapToLong(i -> now + ThreadLocalRandom.current().nextLong(jitterMaxMs))
.boxed()
.toList();
10명이 동시에 입장권을 받아도, 각자의 주문 가능 시각이 랜덤하게 달라지므로 주문 요청이 한 시점에 몰리지 않는다.
부하 테스트 결과 (500명 × 10,000건):
| 주문 API 응답 시간 | 1,000ms / 50명 | 200ms / 10명 |
|---|---|---|
| p95 | 129ms | 79ms |
| 최대 | 6,184ms | 6,840ms |
에러율은 양쪽 모두 0%로 동일하지만, p95 응답 시간이 129ms → 79ms로 39% 개선되었다. 최대 응답 시간은 비슷한데, 이건 스케줄러 전환 시점 등의 일시적 지연이지 구조적 문제는 아니다. 핵심은 p95, 즉 대부분의 요청이 더 빠르게 처리된다는 것이다.
2. 대기열이 만든 또 다른 부하
대기열을 도입하면서 새로운 부하가 생겼다. 클라이언트는 자신의 대기 순번 및 입장권을 확인하기 위해 주기적으로 확인 요청을 한다. 대기자가 1,000명이고 5초 주기로 확인하면 초당 200건이다. 부하를 안전하게 흘려보내기 위한 대기열이 또 다른 부하를 만들었다.
한 가지 생각해 볼게 있다. 곧 입장할 사용자와 1,000 번째 순번에 있는 사용자의 polling 주기가 동일해야 할까? 곧 입장할 사람은 자주 확인하고 아직 차례가 많이 남은 사람은 조금 널널하게 확인을 해도 된다. 순번에 따라 polling 주기를 차등 적용했다.
| 대기 순번 | 예상 대기 시간 | 폴링 주기 | 근거 |
|---|---|---|---|
| 1~100번 | 0~2초 | 2초 | 곧 입장이므로 빠르게 확인 |
| 101~500번 | 2~10초 | 5초 | 수 초 내 입장, 적당한 빈도 |
| 501번~ | 10초 이상 | 10초 | 자주 조회해도 순번 변화가 체감되지 않음 |
대기자 수에 따른 부하 예측:
| 대기자 수 | 일괄 5초 | 구간별 | 절감률 |
|---|---|---|---|
| 1,000명 | 200건/초 | 180건/초 | 10% |
| 5,000명 | 1,000건/초 | 580건/초 | 42% |
구간별 polling 주기를 차등 적용 했을 때 서버가 받는 TPS를 비교해보면 다음과 같다. 구간별 방식은 후 순위에 있는 불필요한 요청을 줄이고, 입장이 임박한 사용자의 응답성을 높인다. 대기자가 많아질수록 뒤쪽 구간에 인원이 집중되므로, 개선 효과는 점점 커진다.
3. Redis가 죽으면 주문도 죽는가
대기열이 Redis에 의존하는 이상, Redis 장애는 곧 주문 불가를 의미한다. 블랙프라이데이에 주문이 막히면 매출 타격이 크다. 우리는 Redis 장애에 대비하여 3가지 선택지를 고려해볼 수 있다.
- 주문 전면 차단
- 주문 API 직접 접근 허용
- Fallback (Kafka로 임시 전환)
정답은 없다. 사전에 협의한 방향으로 진행하면 된다. 중요한 건 장애가 발생한 뒤 판단하면 늦다는 것이다.
블랙프라이데이라는 맥락에서 주문 불가는 선택지가 아니었다. 그렇다고 대기열 없이 전부 허용하면 대기열을 둔 이유가 사라진다. 유입 제어의 주체를 Redis에서 Kafka로 전환하기로 했다.
Redis 장애 감지
QueueService에 volatile boolean redisAvailable 플래그를 둔다. Redis 호출 성공 시 true, 실패 시 false로 갱신한다. OrderController는 이 플래그만 읽어 분기를 판단한다. Redis를 매번 체크하지 않으므로 장애 상황에서 추가 부하가 없다.
정상 흐름 vs Degraded 흐름
정상 흐름
sequenceDiagram
participant C as Client
participant S as Server
participant R as Redis
participant DB as DB
C->>S: POST /queue/enter
S->>R: ZADD (대기열 진입)
loop polling
C->>S: GET /queue/position
S->>R: ZRANK (순번 조회)
S-->>C: 순번 응답
end
Note over S,R: 스케줄러 → 입장권 발급
C->>S: POST /orders (입장권)
S->>R: 입장권 검증
S->>DB: 주문 처리
S-->>C: 200 OK
Degraded 흐름 (Redis 장애 시)
sequenceDiagram
participant C as Client
participant S as Server
participant DB as DB
participant K as Kafka
C->>S: POST /queue/enter
S-->>C: status: BYPASSED
C->>S: POST /orders
S->>DB: Outbox INSERT (PENDING)
S-->>C: 200 (status: ACCEPTED)
Note over DB,K: TX COMMIT 후
DB->>K: Kafka 발행
Note over K,DB: Consumer 배치 처리
K->>DB: OrderFacade.placeOrder()
Outbox 패턴으로 유실 방지
주문 요청을 Kafka에 직접 발행하면 DB에 기록이 남지 않아 유실 가능성이 있다. Outbox 패턴을 적용하여 outbox_events 테이블에 먼저 INSERT한 뒤, 트랜잭션 커밋 후 Kafka로 발행한다.
OrderRequestProducer.send(command)
→ @Transactional
→ outbox_events INSERT (status=PENDING)
→ TX COMMIT
→ AFTER_COMMIT: Kafka 발행 → status=PUBLISHED
→ 실패 시: OutboxRelayScheduler(10초)가 재시도
200 응답 시점에 DB 레코드가 존재하므로, Kafka 발행이 실패해도 릴레이 스케줄러가 재시도를 보장한다.
(1)편에서는 대기열의 기본 구조를 만들었다면, 이번 편에서는 대기열 운영 시 발생할 수 있는 문제들을 다뤘다. 피크 부하 분산, polling 최적화, 장애 대응까지. 특히 Graceful Degradation과 같이 Redis 장애 시 대체안을 미리 마련해둬야 한다. 장애가 발생한 뒤에 판단하면 늦다. 항상 장애 상황 시 대체안을 마련해 둬야 한다.
'데이터베이스' 카테고리의 다른 글
| 대기열, 시스템을 보호하라 (1) (0) | 2026.04.12 |
|---|---|
| Redis Sorted Set 명령어 정리 (0) | 2026.04.11 |
| 일간 랭킹 시스템을 만들면서 마주친 것들 (0) | 2026.04.10 |
| 캐싱, 간단할 줄 알았다. 그런데... (0) | 2026.03.29 |
| 서브쿼리 이해하기: 인라인뷰, 스칼라 서브쿼리, 상관·비상관 쿼리 (1) | 2025.12.01 |
