Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
53 changes: 53 additions & 0 deletions scripts/after.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import http from 'k6/http';
import { sleep, check } from 'k6';
import { uuidv4 } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js';

export const options = {
stages: [
{ duration: '30s', target: 3000 },
{ duration: '2m30s', target: 3000 },
{ duration: '10s', target: 0 },
],
};

const PROGRAM_ID = '550e8400-e29b-41d4-a716-446655440000';
const BASE_URL = 'http://localhost:8085';

const userIdsByVU = {};

export default function () {
if (!userIdsByVU[__VU]) {
userIdsByVU[__VU] = uuidv4();

http.post(
`${BASE_URL}/api/v1/queues/programs/${PROGRAM_ID}`,
null,
{ headers: { 'X-User-Id': userIdsByVU[__VU] }, tags: { name: 'enqueue' } }
);
}

const userId = userIdsByVU[__VU];

const res = http.get(
`${BASE_URL}/api/v1/queues/programs/${PROGRAM_ID}`,
{ headers: { 'X-User-Id': userId }, tags: { name: 'polling' } }
);

check(res, { 'polling 200': (r) => r.status === 200 });

// After: retryAfterMs 따라 동적 sleep
let sleepSec = 3;
if (res.status === 200) {
try {
const body = JSON.parse(res.body);
const retryAfterMs = body.data?.retryAfterMs;
if (retryAfterMs != null) {
sleepSec = retryAfterMs / 1000;
}
} catch (e) {
// 파싱 실패 시 기본값
}
}

sleep(sleepSec);
}
43 changes: 43 additions & 0 deletions scripts/before.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import http from 'k6/http';
import { sleep, check } from 'k6';
import { uuidv4 } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js';

export const options = {
stages: [
{ duration: '30s', target: 3000 },
{ duration: '2m30s', target: 3000 },
{ duration: '10s', target: 0 },
],
};

const PROGRAM_ID = '550e8400-e29b-41d4-a716-446655440000';
const BASE_URL = 'http://localhost:8085';

// 각 VU 마다 고유 userId 유지
const userIdsByVU = {};

export default function () {
// 이 VU 의 userId (없으면 새로 만들고 enqueue)
if (!userIdsByVU[__VU]) {
userIdsByVU[__VU] = uuidv4();

// enqueue (한 번만)
http.post(
`${BASE_URL}/api/v1/queues/programs/${PROGRAM_ID}`,
null,
{ headers: { 'X-User-Id': userIdsByVU[__VU] }, tags: { name: 'enqueue' } }
);
}

const userId = userIdsByVU[__VU];

// Polling
const res = http.get(
`${BASE_URL}/api/v1/queues/programs/${PROGRAM_ID}`,
{ headers: { 'X-User-Id': userId }, tags: { name: 'polling' } }
);

check(res, { 'polling 200': (r) => r.status === 200 });

sleep(3); // Before: 3초 고정
}
34 changes: 32 additions & 2 deletions src/docs/asciidoc/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,41 @@
"tokenId": "...",
"status": "WAITING",
"issuedAt": "...",
"position": 50
"position": 50,
"retryAfterMs": 1000
}
}
----

=== Adaptive Polling (`retryAfterMs`)

대기 정보 응답에 포함되는 `retryAfterMs` 필드는 클라이언트가 다음 폴링까지 대기해야 할 시간(ms)을 서버가 권장하는 값이다.

순번에 따라 차등 적용된다:

[cols="1,1,2", options="header"]
|===
| 순번 구간 | 권장 간격 | 비고

| ≤ 100 (임박)
| 1,000ms
| Jitter 없음 (빠른 알림 우선)

| ≤ 1,000 (중간)
| 5,000ms ± 300ms
| Jitter 적용

| ≤ 10,000 (멀리)
| 15,000ms ± 500ms
| Jitter 적용

| > 10,000 (매우 멀리)
| 30,000ms ± 4,000ms
| Jitter 적용
|===

ADMITTED 등 큐에서 빠진 상태의 토큰 조회 시 `retryAfterMs` 는 응답에서 제외된다 (폴링 종료 신호).

== 공통 에러 응답

=== 인증 실패 (401)
Expand Down Expand Up @@ -58,7 +88,7 @@ operation::queue-token-get[snippets='http-request,path-parameters,request-header

=== ADMITTED 상태 응답

큐에서 빠진 후 (입장 가능) 응답.
큐에서 빠진 후 (입장 가능) 응답. `retryAfterMs` 는 포함되지 않으며 클라이언트는 폴링을 중단하고 `entryToken` 으로 입장한다.

operation::queue-token-get-admitted[snippets='http-response,response-fields']

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.firstticket.queueservice.queuetoken.presentation;

import org.springframework.stereotype.Component;
import java.util.concurrent.ThreadLocalRandom;

/**
* 사용자 순번에 따른 폴링 간격 결정 정책.
*
* <p>Server-driven Adaptive Polling 의 핵심 컴포넌트.
* 응답에 포함될 {@code retryAfterMs} 값을 계산한다.
*
* <h3>정책</h3>
* <ul>
* <li>임박 (≤100명): 1초 (jitter 없음, 빠른 알림 우선)</li>
* <li>중간 (≤1000명): 5초 ± 300ms</li>
* <li>멀리 (≤10000명): 15초 ± 500ms</li>
* <li>매우 멀리 (그 이상): 30초 ± 4초</li>
* </ul>
*
* <p>Jitter 적용으로 같은 구간 사용자들의 동시 요청 분산 (Thundering Herd 방지).
*/
@Component
public class PollingIntervalPolicy {

private static final int IMMINENT_THRESHOLD = 100;
private static final int NEAR_THRESHOLD = 1000;
private static final int FAR_THRESHOLD = 10000;

private static final int IMMINENT_INTERVAL_MS = 1000;
private static final int NEAR_INTERVAL_MS = 5000;
private static final int FAR_INTERVAL_MS = 15000;
private static final int VERY_FAR_INTERVAL_MS = 30000;

private static final int NEAR_JITTER_MS = 300;
private static final int FAR_JITTER_MS = 500;
private static final int VERY_FAR_JITTER_MS = 4000;

/**
* 사용자 순번에 따른 다음 폴링 간격을 계산한다.
*
* @param position 1-based 순번. {@code null} 이면 큐에서 빠진 상태 (ADMITTED 등).
* @return 다음 폴링까지 대기 시간 (ms). {@code null} 이면 폴링 불필요.
*/
public Integer nextRetryAfterMs(Long position) {
if (position == null) {
return null;
}

int interval;
int jitter;

if (position <= IMMINENT_THRESHOLD) {
interval = IMMINENT_INTERVAL_MS;
jitter = 0;
} else if (position <= NEAR_THRESHOLD) {
interval = NEAR_INTERVAL_MS;
jitter = NEAR_JITTER_MS;
} else if (position <= FAR_THRESHOLD) {
interval = FAR_INTERVAL_MS;
jitter = FAR_JITTER_MS;
} else {
interval = VERY_FAR_INTERVAL_MS;
jitter = VERY_FAR_JITTER_MS;
}

int jitterValue = jitter > 0
? ThreadLocalRandom.current().nextInt(-jitter, jitter + 1)
: 0;

return interval + jitterValue;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
public class QueueTokenController {

private final QueueTokenService queueTokenService;
private final PollingIntervalPolicy pollingIntervalPolicy;

/**
* 대기열 진입.
Expand All @@ -38,9 +39,10 @@ public ResponseEntity<ApiResponse<QueueTokenResponse>> issueToken(
programId
);
QueueTokenResult result = queueTokenService.issueToken(command);
Integer retryAfterMs = pollingIntervalPolicy.nextRetryAfterMs(result.position());
return ApiResponse.success(
QueueSuccessCode.QUEUE_TOKEN_ISSUED,
QueueTokenResponse.from(result)
QueueTokenResponse.from(result, retryAfterMs)
);
}

Expand All @@ -55,9 +57,11 @@ public ResponseEntity<ApiResponse<QueueTokenResponse>> getToken(
) {
GetQueueTokenQuery query = GetQueueTokenQuery.of(AuthContext.getUserId(), programId);
QueueTokenResult result = queueTokenService.getToken(query);
Integer retryAfterMs = pollingIntervalPolicy.nextRetryAfterMs(result.position());
return ApiResponse.success(
QueueSuccessCode.QUEUE_TOKEN_FOUND,
QueueTokenResponse.from(result));
QueueTokenResponse.from(result, retryAfterMs)
);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,17 @@ public record QueueTokenResponse(
String status,
LocalDateTime issuedAt,
Long position,
String entryToken
String entryToken,
Integer retryAfterMs
) {
public static QueueTokenResponse from(QueueTokenResult result) {
public static QueueTokenResponse from(QueueTokenResult result, Integer retryAfterMs) {
return new QueueTokenResponse(
result.tokenId().asString(),
result.status().name(),
result.issuedAt().value(),
result.position(),
result.entryToken()
result.entryToken(),
retryAfterMs
);
}
}
Loading