-
Notifications
You must be signed in to change notification settings - Fork 43
[volume-8] Redis 기반 대기열 시스템 구현 #320
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fe00ad2
15d197c
72bae15
79e05cf
58eafeb
3b6978a
3a88bd5
1b4e090
e2d3f56
b9bf6c7
4cb7ee5
3322178
291c36a
178a8e6
be5f1c2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,200 @@ | ||
| # ADR: 대기열 스케줄러 배치 크기 산정 | ||
|
|
||
| STATUS: Accepted | ||
| DATE: 2026-03-31 | ||
| LAST UPDATED: 2026-04-05 | ||
|
|
||
| ## 개요 | ||
|
|
||
| 대기열 스케줄러가 한 번에 몇 명을 입장시킬지(배치 크기)와 얼마나 자주 실행할지(주기)를 부하테스트 수치 기반으로 산정한 근거를 기록한다. | ||
|
|
||
| --- | ||
|
|
||
| ## 배경 | ||
|
|
||
| ### 문제 | ||
|
|
||
| 스케줄러가 대기열에서 N명을 꺼내 입장 토큰을 발급하면, | ||
| N명이 거의 동시에 주문 API를 호출한다. | ||
|
|
||
| 평균 투입률이 한계 TPS 이하여도, **순간 burst가 한계를 초과하면 장애가 발생한다** (Thundering Herd). | ||
|
|
||
| 따라서 배치 크기는 평균 처리량이 아닌 **순간 burst 허용량** 기준으로 산정해야 한다. | ||
|
|
||
| ### 한계 TPS 측정 | ||
|
|
||
| k6로 주문 생성 API(`POST /api/v1/orders`)의 한계 TPS를 측정했다. | ||
| 100명의 가상 유저(VU)가 동일 상품 1개에 동시 주문하는 시나리오로, | ||
| ramp-up → sustain → ramp-down 총 100초간 실행했다. | ||
| 인증(DB 조회 + bcrypt)은 병목이 아닌 변수를 제거하기 위해 제외했다. | ||
|
|
||
| | VU | TPS | avg | p95 | p99 | | ||
| |----|-----|-----|-----|-----| | ||
| | 100 | 82 | 127ms | 641ms | 1s | | ||
| | 200 | 91 | 508ms | 1s | 1s | | ||
|
|
||
| VU를 2배로 늘려도 TPS가 10% 증가에 그쳤다. **한계 TPS ≈ 90**으로 판단했다. | ||
|
|
||
| 병목 원인은 비관적 락(`SELECT FOR UPDATE`) 기반 재고 차감의 직렬화다. | ||
| 동시 요청이 늘어날수록 락 대기가 직렬로 누적되어 tail latency가 급격히 악화된다. | ||
|
|
||
| --- | ||
|
|
||
| ## 결정 | ||
|
|
||
| **스케줄러 주기 300ms, 배치 크기 18명** | ||
|
|
||
| ``` | ||
| 배치 크기 = 한계 TPS(90) × 주기(0.3초) × 안전 계수(0.67) = 18명 | ||
| 평균 투입률 = 18 / 0.3 = 60명/s (한계 TPS의 67%) | ||
| ``` | ||
|
|
||
| ### 안전 계수 0.67의 근거 | ||
|
|
||
| - 주문 외 트래픽(상품 조회, 결제 콜백 등)이 DB 커넥션 풀을 공유한다 | ||
| - 피크 시 처리 시간이 급격히 증가한다 (avg 127ms → p95 641ms) | ||
|
|
||
| --- | ||
|
|
||
| ## 대안 검토 | ||
|
|
||
| 주기를 바꿔도 평균 투입률(60명/s)은 동일하게 유지하되, burst 크기만 달라지는 조합을 비교했다. | ||
|
|
||
| ``` | ||
| 평균 투입률 = 배치 크기 / 주기 (일정) | ||
| 순간 최대 = 배치 크기 (줄어듦) | ||
| ``` | ||
|
|
||
| ### burst 부하테스트 결과 | ||
|
|
||
| k6로 N명이 동시에 주문 API를 호출하는 burst 패턴을 반복 실행했다. | ||
| 토큰 검증은 미구현 상태이므로 제외하고, 순수 주문 처리 성능만 측정했다. | ||
|
|
||
| | 주기 | 배치 | avg | med | p95 | p99 | max | | ||
| |------|------|-----|-----|-----|-----|-----| | ||
| | 1초 | 60명 | 56ms | 11ms | 302ms | 717ms | 974ms | | ||
| | 500ms | 30명 | 29ms | 11ms | 135ms | 267ms | 419ms | | ||
| | 300ms | 18명 | 21ms | 12ms | 74ms | 174ms | 284ms | | ||
| | 200ms | 12명 | 35ms | 15ms | 161ms | 202ms | 282ms | | ||
|
|
||
| median은 모든 조합에서 11~15ms로 유사하다. 대부분의 요청은 빠르게 처리되며, | ||
| **tail latency(p95, p99, max)에서 burst 크기에 비례하여 차이가 벌어진다.** | ||
|
|
||
| ### 1초 / 60명 — 탈락 | ||
|
|
||
| - p99 717ms, max 974ms로 tail latency가 위험 수준 | ||
| - 60명이 동시에 비관적 락을 잡으면 마지막 요청은 락 대기만 ~1초 | ||
|
|
||
| ### 500ms / 30명 — 탈락 | ||
|
|
||
| - p99 267ms, max 419ms — 처리는 되지만 max가 300ms/18명 대비 1.5배 | ||
| - 300ms/18명과 평균 투입률은 동일한데 tail latency만 더 나쁨 | ||
|
|
||
| ### 200ms / 12명 — 탈락 | ||
|
|
||
| - p99 202ms, max 282ms로 300ms/18명과 유사하지만 | ||
| - 스케줄러 호출 빈도가 초당 5회로 300ms(초당 3.3회)의 1.5배 | ||
| - 추가적인 latency 개선 폭이 미미 (p99 기준 28ms 차이) | ||
|
|
||
| ### 300ms / 18명 — 채택 | ||
|
|
||
| - p95 74ms, p99 174ms로 전 구간에서 안정적 | ||
| - 500ms/30명 대비 p99가 93ms 개선되면서 max도 284ms로 수렴 | ||
| - 스케줄러 호출 빈도 초당 3.3회로 과도하지 않음 | ||
|
|
||
| --- | ||
|
|
||
| ## 최종 수치 | ||
|
|
||
| | 항목 | 값 | 산정 근거 | | ||
| |------|-----|----------| | ||
| | 스케줄러 주기 | 300ms | burst 크기와 호출 빈도의 균형 | | ||
| | 배치 크기 | 18명 | 한계 TPS 90 × 0.3초 × 안전계수 0.67 | | ||
| | 평균 투입률 | 60명/s | 한계 TPS의 67% | | ||
|
|
||
| --- | ||
|
|
||
| ## 한계 및 전제 조건 | ||
|
|
||
| - 로컬 Docker 환경에서 측정한 수치이므로 운영 환경에서는 재측정 필요 | ||
| - 단일 상품에 대한 주문만 테스트 — 상품이 분산되면 락 경합이 줄어 한계 TPS 상승 가능 | ||
| - 토큰 검증 미구현 상태에서 테스트 — 토큰 검증(Redis GET) 오버헤드는 1ms 미만으로 무시 가능한 수준 | ||
|
|
||
| --- | ||
|
|
||
| ## 개정 이력 | ||
|
|
||
| ### 2026-04-02: 누적 토큰 동시 주문 리스크 반영 | ||
|
|
||
| #### 변경 사유 | ||
|
|
||
| 기존 산정은 "배치 크기 = 한계 TPS × 주기 × 안전계수"로 한 배치 내 burst만 고려했다. | ||
| 그러나 입장 토큰 TTL이 120초이므로 토큰을 받은 뒤 즉시 주문하지 않는 유저가 누적된다. | ||
| 특정 시점에 다수의 활성 토큰 보유자가 동시에 주문하면 | ||
| **단일 배치 burst가 아닌, TTL 내 누적 토큰 보유자의 동시 주문이 실제 병목**이 된다. | ||
|
|
||
| #### 새로운 산정 공식 | ||
|
|
||
| ``` | ||
| 입장 속도 ≤ 안전 TPS / 주문 소요 시간(W) | ||
| ``` | ||
|
|
||
| 1. 주문 API 한계 TPS: 90 (SELECT FOR UPDATE 병목, k6 부하테스트 실측) | ||
| 2. 안전 마진 85% 적용: 90 × 0.85 = 76 TPS | ||
| 3. 주문 소요 시간(W) 추정: 15초 (페이지 로딩 + 정보 입력/확인 + 제출) | ||
| 4. 최대 입장 속도 = 76 / 15 ≈ 5명/s | ||
| 5. 배치 크기 = 5명/s × 0.4초(주기) = 2명 | ||
|
|
||
| #### 변경 전후 비교 | ||
|
|
||
| | 항목 | 변경 전 | 변경 후 | | ||
| |------|--------|--------| | ||
| | 배치 크기 | 18명 | 2명 | | ||
| | 스케줄러 주기 | 300ms | 400ms | | ||
| | 입장 속도 | 60명/s | 5명/s | | ||
| | W=15초 동시 주문 가능 인원 | 900명 | 75명 | | ||
| | 한계 TPS(76) 대비 | 초과 위험 | 안전 (75 ≤ 76) | | ||
|
|
||
| --- | ||
|
|
||
| ## 부록: 동적 폴링 주기 구간 설계 | ||
|
|
||
| ### 배경 | ||
|
|
||
| 순번 조회 응답에 `pollingIntervalMs`를 포함하여 클라이언트가 구간별로 폴링 주기를 조절한다. | ||
| 입장 임박 사용자는 빠르게, 먼 사용자는 느리게 폴링하여 서버 부하와 UX를 균형 맞춘다. | ||
|
|
||
| 폴링 엔드포인트는 Redis ZRANK(O(logN)) + ZCARD(O(1))로 주문 TPS와 경합하지 않는다. | ||
|
|
||
| ### 구간 경계값 산정 근거 | ||
|
|
||
| 입장 속도 5명/s(배치 2명 / 400ms) 기준, 예상 대기 시간에 비례하여 4구간으로 분류했다. | ||
|
|
||
| | 구간 | 순번 범위 | 예상 대기 | 폴링 주기 | 근거 | | ||
| |------|----------|----------|----------|------| | ||
| | IMMINENT | 1–60 | ≤12초 | 1초 | 입장 임박, UX 응답성 우선 | | ||
| | SOON | 61–300 | 12–60초 | 3초 | 1분 이내 대기, 적당한 피드백 | | ||
| | MODERATE | 301–3,000 | 1–10분 | 10초 | 장기 대기, 부하 절감 | | ||
| | FAR | 3,001+ | 10분+ | 20초 | 30초 이상은 피드백 끊김 느낌 | | ||
| | ADMITTED | 입장 완료 | 0 | 0 | 폴링 불필요 | | ||
|
|
||
| ### 논의 과정 | ||
|
|
||
| **초기 안**: IMMINENT 2초, SOON 5초, MODERATE 10초, FAR 30초, DISTANT 60초 (5구간) | ||
|
|
||
| - IMMINENT 2초 → 1초: 입장 임박 사용자의 토큰 발견 레이턴시를 줄이기 위해 단축 | ||
| - SOON 5초 → 3초: 예상 대기 시간 대비 더 직관적인 비례 | ||
| - MODERATE 상한 1,800 → 3,000: 구간 단순화 | ||
| - FAR(30초) / DISTANT(60초) → 20초 통합: 30초 이상은 대기 중 순번 변화가 보이지 않아 UX 불안감 유발 | ||
|
|
||
| ### 부하 추정 (1만 명 대기 시) | ||
|
|
||
| | 구간 | 인원 | req/s | | ||
| |------|------|-------| | ||
| | IMMINENT (1–60) | 60명 | 60 | | ||
| | SOON (61–300) | 240명 | 80 | | ||
| | MODERATE (301–3,000) | 2,700명 | 270 | | ||
| | FAR (3,001+) | 7,000명 | 350 | | ||
| | **합계** | **10,000명** | **~760** | | ||
|
|
||
| Redis 기반 조회이므로 760 req/s는 충분히 처리 가능한 수준이다. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| package com.loopers.application.queue; | ||
|
|
||
| import com.loopers.application.shared.annotation.UseCase; | ||
| import com.loopers.support.queue.WaitingQueue; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
|
|
||
| /** | ||
| * 사용자가 대기열에 진입합니다. | ||
| * | ||
| * <p>멱등 연산으로, 이미 대기열에 존재하는 사용자가 재진입을 시도해도 정상 처리됩니다.</p> | ||
| */ | ||
| @UseCase | ||
| @RequiredArgsConstructor | ||
| public class EnterQueueUseCase { | ||
|
|
||
| private final WaitingQueue waitingQueue; | ||
| private final QueuePositionCalculator queuePositionCalculator; | ||
|
|
||
| /** | ||
| * @param userId 대기열에 진입할 사용자 ID | ||
| * @return 현재 대기 순번 정보 | ||
| */ | ||
| public QueuePositionResult execute(Long userId) { | ||
| waitingQueue.enter(userId); | ||
| Long rank = waitingQueue.getPosition(userId); | ||
| long totalWaiting = waitingQueue.getTotalCount(); | ||
| return queuePositionCalculator.calculate(rank, totalWaiting); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| package com.loopers.application.queue; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; | ||
| import org.springframework.scheduling.annotation.Scheduled; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import com.loopers.support.queue.QueueProperties; | ||
| import com.loopers.support.queue.WaitingQueueAdmitter; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
| /** | ||
| * 대기열 입장 허용 스케줄러. | ||
| * | ||
| * <p>주기적으로 대기열에서 일정 인원을 입장열로 이동시킨다. | ||
| * 배치 크기 및 주기 산정 근거는 ADR-04 참고.</p> | ||
| */ | ||
| @Slf4j | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| @ConditionalOnProperty(name = "queue.enabled", havingValue = "true") | ||
| public class QueueAdmissionScheduler { | ||
|
|
||
| private final WaitingQueueAdmitter waitingQueueAdmitter; | ||
| private final QueueProperties queueProperties; | ||
|
|
||
| @Scheduled(fixedRateString = "${queue.interval-ms}") | ||
| public void admit() { | ||
| List<Long> admitted = waitingQueueAdmitter.admit(queueProperties.batchSize()); | ||
|
Comment on lines
+30
to
+32
|
||
|
|
||
| if (!admitted.isEmpty()) { | ||
| log.debug("입장 허용 [count={}]", admitted.size()); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| package com.loopers.application.queue; | ||
|
|
||
| /** | ||
| * 대기열 순번 기반 클라이언트 폴링 주기 정책. | ||
| * | ||
| * <p>순번 구간별로 권장 폴링 주기(ms)를 반환한다.</p> | ||
| * <ul> | ||
| * <li>1–60: 1,000ms (IMMINENT)</li> | ||
| * <li>61–300: 3,000ms (SOON)</li> | ||
| * <li>301–3,000: 10,000ms (MODERATE)</li> | ||
| * <li>3,001+: 20,000ms (FAR)</li> | ||
| * </ul> | ||
| */ | ||
| public final class QueuePollingPolicy { | ||
|
|
||
| static final long TIER_IMMINENT_MAX = 60; | ||
| static final long TIER_SOON_MAX = 300; | ||
| static final long TIER_MODERATE_MAX = 3_000; | ||
|
|
||
| static final long POLLING_IMMINENT_MS = 1_000; | ||
| static final long POLLING_SOON_MS = 3_000; | ||
| static final long POLLING_MODERATE_MS = 10_000; | ||
| static final long POLLING_FAR_MS = 20_000; | ||
|
|
||
| private QueuePollingPolicy() { | ||
| } | ||
|
|
||
| /** | ||
| * 대기 순번에 따른 권장 폴링 주기(ms)를 반환한다. | ||
| * | ||
| * @param position 1-based 대기 순번 | ||
| * @return 권장 폴링 주기(밀리초) | ||
| */ | ||
| public static long calculateIntervalMs(long position) { | ||
| if (position <= TIER_IMMINENT_MAX) { | ||
| return POLLING_IMMINENT_MS; | ||
| } | ||
| if (position <= TIER_SOON_MAX) { | ||
| return POLLING_SOON_MS; | ||
| } | ||
| if (position <= TIER_MODERATE_MAX) { | ||
| return POLLING_MODERATE_MS; | ||
| } | ||
| return POLLING_FAR_MS; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| package com.loopers.application.queue; | ||
|
|
||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import com.loopers.support.queue.QueueProperties; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
|
|
||
| /** | ||
| * 대기열 순번(rank) 기반으로 {@link QueuePositionResult}를 생성한다. | ||
| * | ||
| * <p>예상 대기 시간과 권장 폴링 주기를 계산하여 결과에 포함한다. | ||
| * 처리량은 {@link QueueProperties}에서 주입받아 스케줄러 설정과 단일 소스로 관리한다.</p> | ||
| */ | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class QueuePositionCalculator { | ||
|
|
||
| private final QueueProperties queueProperties; | ||
|
|
||
| /** | ||
| * 0-based rank와 전체 대기 인원으로부터 {@link QueuePositionResult}를 생성한다. | ||
| * | ||
| * @param rank 0-based 대기 순번 | ||
| * @param totalWaiting 전체 대기 인원 | ||
| * @return 1-based 순번, 예상 대기 시간, 폴링 주기가 포함된 결과 | ||
| */ | ||
| public QueuePositionResult calculate(long rank, long totalWaiting) { | ||
| long position = rank + 1; | ||
| long estimatedWaitSeconds = (long) Math.ceil(position / queueProperties.throughputPerSecond()); | ||
| long pollingIntervalMs = QueuePollingPolicy.calculateIntervalMs(position); | ||
| return new QueuePositionResult(position, totalWaiting, estimatedWaitSeconds, pollingIntervalMs, null); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| package com.loopers.application.queue; | ||
|
|
||
| /** | ||
| * 대기열 순번 조회 결과. | ||
| * | ||
| * @param position 1-based 대기 순번 (입장 완료 시 0) | ||
| * @param totalWaiting 전체 대기 인원 | ||
| * @param estimatedWaitSeconds 예상 대기 시간(초) | ||
| * @param pollingIntervalMs 클라이언트 권장 폴링 주기(밀리초, 입장 완료 시 0) | ||
| * @param token 입장 토큰 (대기 중이면 null) | ||
| */ | ||
| public record QueuePositionResult( | ||
| long position, | ||
| long totalWaiting, | ||
| long estimatedWaitSeconds, | ||
| long pollingIntervalMs, | ||
| String token | ||
| ) { | ||
|
|
||
| /** | ||
| * 입장 허용된 사용자용 결과를 생성한다. 폴링이 불필요하므로 {@code pollingIntervalMs}는 0이다. | ||
| */ | ||
| public static QueuePositionResult admitted(String token) { | ||
| return new QueuePositionResult(0, 0, 0, 0, token); | ||
|
leeedohyun marked this conversation as resolved.
|
||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.