Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
fe00ad2
feat: Redis Sorted Set 기반 대기열 진입 API 구현
leeedohyun Mar 30, 2026
15d197c
feat: 대기열 순번 조회 API 구현
leeedohyun Mar 30, 2026
72bae15
docs: 대기열 스케줄러 배치 크기 산정 근거 ADR 추가
leeedohyun Mar 31, 2026
79e05cf
feat: 대기열 입장 허용 스케줄러 및 진입 토큰 발급 구현
leeedohyun Apr 1, 2026
58eafeb
feat: 주문 생성 시 입장 토큰 검증 인터셉터 구현
leeedohyun Apr 1, 2026
3b6978a
feat: 대기열 예상 대기 시간을 실측 처리량 기반으로 개선
leeedohyun Apr 1, 2026
3a88bd5
feat: 대기열 순번 구간별 동적 폴링 주기 산정 및 통합 테스트 보강
leeedohyun Apr 1, 2026
1b4e090
feat: TTL 내 누적 토큰 동시 주문 리스크를 고려하여 입장 속도 조정
leeedohyun Apr 1, 2026
e2d3f56
refactor: 대기열 입장 처리를 Lua 스크립트에서 개별 Redis 명령어 기반으로 전환
leeedohyun Apr 2, 2026
b9bf6c7
feat: 대기열 등록 API 멱등성 보장 및 순번 반환
leeedohyun Apr 2, 2026
4cb7ee5
refactor: 대기열 설정을 프로퍼티 기반으로 통합하고 토큰 검증 강화
leeedohyun Apr 2, 2026
3322178
feat: 주문 성공 후 AFTER_COMMIT 시점에 입장 토큰 삭제
leeedohyun Apr 3, 2026
291c36a
fix: 대기열 코드 리뷰 반영 — 인터셉터 방어 로직, 스케줄러 조건부 등록, Javadoc 수정
leeedohyun Apr 3, 2026
178a8e6
refactor: EntryTokenInterceptor에서 불필요한 userId null 체크 제거 및 테스트 추가
leeedohyun Apr 3, 2026
be5f1c2
docs: ADR-04에 동적 폴링 주기 구간 설계 근거 추가
leeedohyun Apr 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions .docs/adr/04-queue-scheduler-batch-sizing.md
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);
}
Comment thread
leeedohyun marked this conversation as resolved.
}
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

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

queue.enabled 프로퍼티가 존재하지만 스케줄러는 enabled 여부와 무관하게 주기 실행됩니다. 로컬 프로파일에서 queue.enabled=false여도 스케줄러가 계속 Redis를 두드리는 불필요한 부하/노이즈가 생길 수 있으니, 스케줄러를 queue.enabled=true에서만 동작하도록 @ConditionalOnProperty(name=\"queue.enabled\", havingValue=\"true\") 적용 또는 메서드 초기에 enabled=false면 즉시 return 하도록 처리하는 것을 권장합니다.

Copilot uses AI. Check for mistakes.

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);
Comment thread
leeedohyun marked this conversation as resolved.
}
}
Loading