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
2,217 changes: 2,217 additions & 0 deletions grafana/provisioning/dashboards/catcheat-loadtest-dashboard.json

Large diffs are not rendered by default.

522 changes: 522 additions & 0 deletions grafana/provisioning/dashboards/catcheat-monitoring-dashboard.json

Large diffs are not rendered by default.

21 changes: 17 additions & 4 deletions k6/02-reservation-concurrency.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,17 @@
import http from 'k6/http';
import { sleep, check, group } from 'k6';
import { Counter, Rate, Trend } from 'k6/metrics';
import { BASE_URL, HEADERS_AUTH, REMAIN_ID, THRESHOLDS } from './config.js';
import { BASE_URL, HEADERS_JSON, HEADERS_AUTH, AUTH_TOKEN, REMAIN_ID, THRESHOLDS, requireAuthToken } from './config.js';

// 다중 토큰 풀 (선택). 운영 환경과 더 비슷한 검증을 원하면 TOKENS CSV 주입.
const TOKENS = (__ENV.TOKENS ? __ENV.TOKENS.split(',') : []).filter(Boolean);
const USE_MULTI_TOKENS = TOKENS.length > 1;

export function setup() {
if (!AUTH_TOKEN && !USE_MULTI_TOKENS) {
requireAuthToken('02');
}
}

const successCount = new Counter('reservation_success');
const exhaustedCount = new Counter('reservation_exhausted');
Expand Down Expand Up @@ -44,15 +54,18 @@ export const options = {
};

export default function () {
const headers = USE_MULTI_TOKENS
? { ...HEADERS_JSON, Authorization: `Bearer ${TOKENS[(__VU - 1) % TOKENS.length]}` }
: HEADERS_AUTH;

group('예약 생성 (동시성)', () => {
const payload = JSON.stringify({
remainId: parseInt(REMAIN_ID),
member: 2,
couponId: null,
});

const res = http.post(`${BASE_URL}/api/v1/reservations`, payload, {
headers: HEADERS_AUTH,
});
const res = http.post(`${BASE_URL}/api/v1/reservations`, payload, { headers });

reserveDuration.add(res.timings.duration);

Expand Down
5 changes: 3 additions & 2 deletions k6/03-full-flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import http from 'k6/http';
import { sleep, check, group } from 'k6';
import exec from 'k6/execution';
import { Trend, Rate } from 'k6/metrics';
import { BASE_URL, HEADERS_JSON, HEADERS_AUTH, THRESHOLDS } from './config.js';
import { BASE_URL, HEADERS_JSON, HEADERS_AUTH, AUTH_TOKEN, THRESHOLDS } from './config.js';

const flowDuration = new Trend('full_flow_duration', true);

Expand Down Expand Up @@ -178,7 +178,8 @@ export default function () {
sleep(0.5);
});

if (HEADERS_AUTH.Authorization !== 'Bearer ') {
// AUTH_TOKEN 있을 때만 로그인 step 실행 (config.js v1.4: 빈 토큰이면 Authorization 헤더 자체 제거)
if (AUTH_TOKEN) {
group('7. 북마크 폴더 조회 (로그인)', () => {
const res = http.get(`${BASE_URL}/api/v1/bookmark-folders`, { headers: HEADERS_AUTH });
const ok = check(res, { '북마크 200': (r) => r.status === 200 });
Expand Down
53 changes: 46 additions & 7 deletions k6/04-circuit-breaker.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@
* 서킷브레이커 부하테스트 — 점진적 증가로 OPEN 전환 시점 탐색
* 실행: .\k6\run.ps1 -Test 04 -AuthToken "eyJ..." -BaseUrl https://api.catcheat.kro.kr
*
* ⚠️ ⚠️ ⚠️ 비용 경고 ⚠️ ⚠️ ⚠️
* 이 시나리오는 실제 Gemini API를 호출합니다. 전체 5분 30초 동안 약 2,000~5,000 요청 발생.
* 서킷이 빨리 열리지 않으면 그만큼 외부 API 비용이 발생합니다.
*
* 안전 가드:
* 1. MAX_ITER_PER_VU 환경변수로 VU당 요청 상한 (기본 250 → 글로벌 추정 5000)
* ⚠️ k6 VU는 isolated VM이라 이 가드는 "VU별" 상한임
* 글로벌 최대 = MAX_ITER_PER_VU × 시나리오 max VU 수(20)
* 예: MAX_ITER_PER_VU=25 ./k6/run.sh -t 04 (글로벌 ~500건)
* 2. 일일 메시지 제한(100회/사용자) — 동일 AUTH_TOKEN 사용 시 100건 후 자동 차단됨
* 3. mock LLM 사용 권장 — application.yml에서 spring.ai.* 를 mock provider로 변경 후 테스트
*
* 목적:
* - LLM API(Spring AI)에 점진적으로 부하를 올려 서킷브레이커가 열리는 VU 수 확인
* - 서킷 CLOSED → OPEN → HALF-OPEN → CLOSED 전체 상태 전환 사이클 관찰
Expand Down Expand Up @@ -39,17 +51,32 @@
import http from 'k6/http';
import { sleep, check, group } from 'k6';
import { Counter, Rate, Trend } from 'k6/metrics';
import { BASE_URL, HEADERS_AUTH } from './config.js';
import { BASE_URL, HEADERS_AUTH, requireAuthToken } from './config.js';

// 빈 AUTH_TOKEN 즉시 fail-fast — 401 사일런트 실패 시 Gemini 비용은 안 들지만 측정 무의미
export function setup() {
requireAuthToken('04');
}

const circuitOpenCount = new Counter('circuit_open_responses'); // 빠른 실패 (서킷 OPEN)
const circuitClosedCount = new Counter('circuit_closed_responses'); // 정상 응답 (서킷 CLOSED)
const llmErrorRate = new Rate('llm_error_rate');
const chatDuration = new Trend('chat_duration', true);
const skippedSafeguard = new Counter('skipped_by_safeguard'); // 안전 가드로 차단된 요청

// 서킷 OPEN 판별 기준: 500ms 미만이면서 에러 → 서킷이 즉시 차단한 것
// LLM 정상 처리는 최소 수백ms~수초이므로 500ms 이하 빠른 실패 = OPEN 확실
const CIRCUIT_OPEN_THRESHOLD_MS = 500;

// 안전 가드: VU당 최대 요청 수 — Gemini API 비용 폭증 방지
// k6는 각 VU가 isolated VM이라 module 변수가 VU별로 분리됨.
// 따라서 이 값은 "VU당 상한"이며, 글로벌 최대 ≈ 값 × 최대 VU 수.
// 시나리오 04는 최대 20 VU 사용 → 글로벌 최대 = MAX_ITER_PER_VU × 20
// 기본 250 × 20 = 5000건 (Gemini 무료 티어 일일 한도 보호)
const MAX_ITER_PER_VU = Number(__ENV.MAX_ITER_PER_VU || 250);
const MAX_VUS_IN_SCENARIO = 20; // 시나리오 옵션 변경 시 함께 갱신
let vuIterCount = 0;

export const options = {
scenarios: {
// ── 워밍업: 서킷 CLOSED 기준선 측정 ─────────────────────────────────────
Expand Down Expand Up @@ -117,6 +144,13 @@ const TEST_MESSAGES = [
];

export default function () {
// 안전 가드 — 이 VU가 MAX_ITER_PER_VU 도달 시 추가 호출 중단 (Gemini 비용 차단)
vuIterCount++;
if (MAX_ITER_PER_VU > 0 && vuIterCount > MAX_ITER_PER_VU) {
skippedSafeguard.add(1);
return;
}
Comment on lines +147 to +152

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

MAX_ITER_PER_VU 도달 시 return을 통해 조기 반환하고 있으나, 반환하기 전에 sleep을 호출하지 않습니다. k6의 constant-vus 또는 ramping-vus 실행기는 테스트 기간 동안 default 함수를 루프 내에서 계속 실행하므로, sleep 없이 즉시 반환하게 되면 해당 VU는 아무런 대기 시간 없이 무한 루프를 돌게 됩니다.

이로 인해 k6 러너의 CPU 사용량이 100%로 치솟고, skipped_by_safeguard 메트릭이 순식간에 수백만 건 이상 생성되어 시스템 메모리 고갈이나 k6 프로세스 다운을 유발할 수 있습니다.

안전 가드가 작동한 후에는 CPU 과점유를 방지하기 위해 sleep을 호출한 후 반환하는 것이 안전합니다.

Suggested change
// 안전 가드 — 이 VU가 MAX_ITER_PER_VU 도달 시 추가 호출 중단 (Gemini 비용 차단)
vuIterCount++;
if (MAX_ITER_PER_VU > 0 && vuIterCount > MAX_ITER_PER_VU) {
skippedSafeguard.add(1);
return;
}
// 안전 가드 — 이 VU가 MAX_ITER_PER_VU 도달 시 추가 호출 중단 (Gemini 비용 차단)
vuIterCount++;
if (MAX_ITER_PER_VU > 0 && vuIterCount > MAX_ITER_PER_VU) {
skippedSafeguard.add(1);
sleep(1); // CPU 100% 점유 및 메트릭 폭증 방지를 위해 sleep 추가
return;
}


const msg = TEST_MESSAGES[Math.floor(Math.random() * TEST_MESSAGES.length)];
const payload = JSON.stringify(msg);

Expand Down Expand Up @@ -155,21 +189,26 @@ export default function () {
}

export function handleSummary(data) {
const open = data.metrics.circuit_open_responses?.values?.count || 0;
const closed = data.metrics.circuit_closed_responses?.values?.count || 0;
const total = open + closed;
const p95 = data.metrics.chat_duration?.values?.['p(95)'] || 0;
const p99 = data.metrics.chat_duration?.values?.['p(99)'] || 0;
const open = data.metrics.circuit_open_responses?.values?.count || 0;
const closed = data.metrics.circuit_closed_responses?.values?.count || 0;
const skipped = data.metrics.skipped_by_safeguard?.values?.count || 0;
const total = open + closed;
const p95 = data.metrics.chat_duration?.values?.['p(95)'] || 0;
const p99 = data.metrics.chat_duration?.values?.['p(99)'] || 0;

const openRate = total > 0 ? ((open / total) * 100).toFixed(1) : '0.0';
const globalEstimate = MAX_ITER_PER_VU * MAX_VUS_IN_SCENARIO;
const safeguardNote = skipped > 0
? `\n[안전 가드] ${skipped}건이 MAX_ITER_PER_VU(=${MAX_ITER_PER_VU}, 글로벌 추정 ${globalEstimate}건) 도달로 차단됨 (Gemini 비용 보호)`
: `\n[안전 가드] 작동 가능: VU당 최대 ${MAX_ITER_PER_VU}건, 글로벌 추정 최대 ${globalEstimate}건`;

return {
stdout: `
===== 서킷브레이커 테스트 결과 =====
총 요청수 : ${total}건
정상 응답 (CLOSED) : ${closed}건
즉시 차단 (OPEN) : ${open}건
서킷 OPEN 비율 : ${openRate}%
서킷 OPEN 비율 : ${openRate}%${safeguardNote}

p95 응답시간 : ${(p95 / 1000).toFixed(2)}초
p99 응답시간 : ${(p99 / 1000).toFixed(2)}초
Expand Down
8 changes: 4 additions & 4 deletions k6/05-store-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@ export const options = {
},
},
thresholds: {
'store_list_duration': ['p(95)<800'],
'store_popular_duration': ['p(95)<500'],
'list_spike_duration': ['p(95)<1200'],
'list_ramp_duration': ['p(95)<800'],
'store_list_duration': ['p(95)<1200'],
'store_popular_duration': ['p(95)<800'],
'list_spike_duration': ['p(95)<1500'],
'list_ramp_duration': ['p(95)<1200'],
'store_list_error_rate': ['rate<0.01'],
'http_req_failed': ['rate<0.01'],
},
Expand Down
6 changes: 3 additions & 3 deletions k6/06-store-nearby.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,11 @@ export default function () {
group(`근처 매장 조회 (nearby) - ${loc.name}`, () => {
const res = http.get(
`${BASE_URL}/api/v1/stores/nearby?latitude=${lat}&longitude=${lng}&page=0&size=10`,
{ headers: HEADERS_JSON, timeout: 15000 },
{ headers: HEADERS_JSON, timeout: '15s' },
);
nearbyDuration.add(res.timings.duration);
record(res.timings.duration);
if (res.timings.duration > 15000 || res.status === 0) timeoutCount.add(1);
if (res.timings.duration >= 15000 || res.status === 0) timeoutCount.add(1);
const ok = check(res, {
'nearby 200': (r) => r.status === 200,
'nearby 15s 이내': (r) => r.timings.duration < 15000,
Expand All @@ -124,7 +124,7 @@ export default function () {
const res = http.get(url, { headers: HEADERS_JSON, timeout: '15s' });
inBoundsDuration.add(res.timings.duration);
record(res.timings.duration);
if (res.timings.duration > 15000 || res.status === 0) timeoutCount.add(1);
if (res.timings.duration >= 15000 || res.status === 0) timeoutCount.add(1);
check(res, {
'in-bounds 200': (r) => r.status === 200,
'in-bounds 15s 이내': (r) => r.timings.duration < 15000,
Expand Down
10 changes: 6 additions & 4 deletions k6/07-remains.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const errorRate = new Rate('remains_error_rate');

const STORE_IDS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

const REMAINS_SLA_MS = Number(__ENV.REMAINS_SLA_MS || 400);

export const options = {
scenarios: {

Expand Down Expand Up @@ -70,9 +72,9 @@ export const options = {
},
},
thresholds: {
'remains_duration': ['p(95)<300', 'p(99)<500'],
'remains_spike_duration': ['p(95)<400'],
'remains_ramp_duration': ['p(95)<300'],
'remains_duration': ['p(95)<500', 'p(99)<800'],
'remains_spike_duration': ['p(95)<600'],
'remains_ramp_duration': ['p(95)<500'],
'remains_error_rate': ['rate<0.01'],
'http_req_failed': ['rate<0.01'],
},
Expand All @@ -95,7 +97,7 @@ export default function () {

const ok = check(res, {
'잔여석 200': (r) => r.status === 200,
'응답 200ms 이내': (r) => r.timings.duration < 200,
[`응답 ${REMAINS_SLA_MS}ms 이내`]: (r) => r.timings.duration < REMAINS_SLA_MS,
});
errorRate.add(!ok);
});
Expand Down
49 changes: 41 additions & 8 deletions k6/08-coupon-issue.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,58 @@
/**
* [단일 API] 쿠폰 선착순 발급 동시성 부하테스트
* 실행: .\k6\run.ps1 -Test 08 -AuthToken "eyJ..." -CouponTemplateId 1 -BaseUrl https://api.catcheat.kro.kr
*
* 실행:
* # 단일 토큰 (검증 의미 반감 — 첫 1건만 성공, 나머지는 "이미 발급" 에러)
* .\k6\run.ps1 -Test 08 -AuthToken "eyJ..." -CouponTemplateId 1
*
* # 다중 토큰 (권장 — 비관적 락 정확히 검증)
* ./k6/run.sh -t 08 -c 1 -T build/tokens-1000.csv
*
* 목적:
* - POST /coupons/{templateId}/issue 에 100명 동시 요청
* - 비관적 락(SELECT FOR UPDATE)으로 초과 발급 방지 검증
* - 재고 소진 후 올바른 에러 응답 확인
*
* 기대 결과:
* 기대 결과 (다중 토큰 기준):
* - 쿠폰 재고(remainCount) 수만큼만 성공 (201)
* - 초과 요청은 400/409 에러 (COUPON_EXHAUSTED 등)
* - 성공 수가 재고를 초과하면 비관적 락 오작동 → 버그
*
* 주의:
* - 테스트 전 쿠폰 템플릿 restock 필요 (테스트용 재고 세팅)
* - 여러 사용자 JWT 토큰이 있어야 중복 발급 방지 로직 우회 가능
* (동일 토큰으로 요청 시 "이미 발급됨" 에러가 대부분 → 의미 반감)
* 토큰 풀 발급 (다중 토큰 사용 시):
* cd backend
* ./gradlew test \
* --tests "com.catchtable.loadtest.VacancyLoadTestTokenGenerator.generateTokens" \
* -DrunLoadTokenGen=true
* # → build/load-test-tokens.csv 에 1000개 토큰 콤마구분 출력
*/
import http from 'k6/http';
import { sleep, check, group } from 'k6';
import { Counter, Rate, Trend } from 'k6/metrics';
import { BASE_URL, HEADERS_AUTH } from './config.js';
import { BASE_URL, HEADERS_JSON, HEADERS_AUTH, AUTH_TOKEN, requireAuthToken } from './config.js';

// 빈 AUTH_TOKEN + 빈 TOKENS 환경변수 → 즉시 fail-fast (사일런트 401 방지)
export function setup() {
const hasMulti = (__ENV.TOKENS || '').split(',').filter(Boolean).length > 1;
if (!AUTH_TOKEN && !hasMulti) {
requireAuthToken('08');
}
}

// 발급할 쿠폰 템플릿 ID — run.ps1 -CouponTemplateId 로 주입
const TEMPLATE_ID = __ENV.COUPON_TEMPLATE_ID || '1';

// 다중 토큰 풀 (선택) — CSV 형식, 단일 토큰만 있으면 AUTH_TOKEN 사용 (검증 의미 반감)
const TOKENS = (__ENV.TOKENS ? __ENV.TOKENS.split(',') : []).filter(Boolean);
const USE_MULTI_TOKENS = TOKENS.length > 1;
Comment on lines +33 to +46

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

setup() 함수 내부에서 __ENV.TOKENS를 파싱하여 hasMulti를 계산하는 로직이 모듈 레벨에서 정의된 USE_MULTI_TOKENS와 완전히 중복됩니다.

TOKENS와 USE_MULTI_TOKENS 선언부를 setup() 함수 위로 이동시키면, 중복 코드를 제거하고 setup() 내부에서 USE_MULTI_TOKENS를 직접 활용하여 코드를 더 깔끔하게 유지할 수 있습니다.

Suggested change
// 빈 AUTH_TOKEN + 빈 TOKENS 환경변수 → 즉시 fail-fast (사일런트 401 방지)
export function setup() {
const hasMulti = (__ENV.TOKENS || '').split(',').filter(Boolean).length > 1;
if (!AUTH_TOKEN && !hasMulti) {
requireAuthToken('08');
}
}
// 발급할 쿠폰 템플릿 ID — run.ps1 -CouponTemplateId 로 주입
const TEMPLATE_ID = __ENV.COUPON_TEMPLATE_ID || '1';
// 다중 토큰 풀 (선택) — CSV 형식, 단일 토큰만 있으면 AUTH_TOKEN 사용 (검증 의미 반감)
const TOKENS = (__ENV.TOKENS ? __ENV.TOKENS.split(',') : []).filter(Boolean);
const USE_MULTI_TOKENS = TOKENS.length > 1;
// 발급할 쿠폰 템플릿 ID — run.ps1 -CouponTemplateId 로 주입
const TEMPLATE_ID = __ENV.COUPON_TEMPLATE_ID || '1';
// 다중 토큰 풀 (선택) — CSV 형식, 단일 토큰만 있으면 AUTH_TOKEN 사용 (검증 의미 반감)
const TOKENS = (__ENV.TOKENS ? __ENV.TOKENS.split(',') : []).filter(Boolean);
const USE_MULTI_TOKENS = TOKENS.length > 1;
// 빈 AUTH_TOKEN + 빈 TOKENS 환경변수 → 즉시 fail-fast (사일런트 401 방지)
export function setup() {
if (!AUTH_TOKEN && !USE_MULTI_TOKENS) {
requireAuthToken('08');
}
}


if (!USE_MULTI_TOKENS) {
console.warn(
'[WARN] 단일 토큰 모드 — 첫 1건 외 나머지는 "이미 발급" 에러로 처리됩니다.\n' +
' 비관적 락 검증을 정확히 하려면 TOKENS 환경변수 (CSV)로 다중 토큰을 주입하세요.\n' +
' 예: TOKENS=$(cat build/load-test-tokens.csv) k6 run ...'
);
}

const issuedCount = new Counter('coupon_issued'); // 발급 성공
const exhaustedCount = new Counter('coupon_exhausted'); // 재고 소진
const duplicateCount = new Counter('coupon_duplicate'); // 중복 발급 시도
Expand Down Expand Up @@ -55,11 +83,16 @@ export const options = {
};

export default function () {
// 다중 토큰 모드면 VU별로 다른 토큰 사용 → 사용자당 1건 발급 제약을 우회해 비관적 락만 검증
const headers = USE_MULTI_TOKENS
? { ...HEADERS_JSON, Authorization: `Bearer ${TOKENS[(__VU - 1) % TOKENS.length]}` }
: HEADERS_AUTH;

group('쿠폰 발급 (선착순 동시성)', () => {
const res = http.post(
`${BASE_URL}/api/v1/coupons/${TEMPLATE_ID}/issue`,
null, // body 없음 — POST 요청만으로 발급
{ headers: HEADERS_AUTH },
{ headers },
);

issueDuration.add(res.timings.duration);
Expand Down
3 changes: 2 additions & 1 deletion k6/11-vacancy-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@
*/

import http from 'k6/http';
import redis from 'k6/experimental/redis';
// k6 v2.0+에서 'k6/experimental/redis'가 'k6/x/redis'로 이전됨 (자동 extension 빌드 — 첫 실행 시 ~15초 소요).
import redis from 'k6/x/redis';
import { check } from 'k6';
import { Counter, Trend } from 'k6/metrics';

Expand Down
23 changes: 16 additions & 7 deletions k6/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,22 @@ export const HEADERS_JSON = {
'Content-Type': 'application/json',
};

export const HEADERS_AUTH = {
'Content-Type': 'application/json',
Authorization: `Bearer ${AUTH_TOKEN}`,
};
// 빈 토큰이면 Authorization 헤더 자체를 제거 (사일런트 401 방지)
export const HEADERS_AUTH = AUTH_TOKEN
? { 'Content-Type': 'application/json', Authorization: `Bearer ${AUTH_TOKEN}` }
: { 'Content-Type': 'application/json' };

export function requireAuthToken(scenarioId) {
if (!AUTH_TOKEN) {
throw new Error(
`[FATAL] 시나리오 ${scenarioId}는 AUTH_TOKEN 필수입니다. ` +
`예: ./k6/run.sh -t ${scenarioId} -a "eyJ..." 또는 -e AUTH_TOKEN=<JWT>`
);
}
}

// 공통 성능 기준선 — 이 수치를 넘으면 테스트 실패 처리
// 공통 기준선. 시나리오별 override는 각 스크립트의 thresholds에서.
export const THRESHOLDS = {
http_req_failed: ['rate<0.05'], // 에러율 5% 미만
http_req_duration: ['p(95)<1000'], // p95 1초 미만
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<1000'],
};
Loading
Loading