From 25bdca46e649b6cd8ed24d74a985f84bdf0f2589 Mon Sep 17 00:00:00 2001 From: dongan Date: Fri, 29 May 2026 15:56:35 +0900 Subject: [PATCH 1/2] =?UTF-8?q?chore:=20k6=20=EB=B6=80=ED=95=98=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20?= =?UTF-8?q?=EA=B3=A0=EB=8F=84=ED=99=94=20=EB=B0=8F=20=EB=AA=A8=EB=8B=88?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 01·03·05·06·07번: event_spike(워밍업 후 스파이크) + ramp_up(점진 증가) 두 시나리오 적용 - 04번: ramp_up 단독으로 서킷브레이커 OPEN 시점 탐색 시나리오로 개선 - docker-compose.prod.yml: Prometheus remote write 수신 활성화 및 포트 외부 노출 - run.ps1: 기본값 운영 서버 주소로 변경 - k6-dashboard.json: event_spike 시나리오명 동기화, 심화 지표 패널 4개 추가 Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.prod.yml | 5 +- k6/01-store-browse.js | 161 ++++- k6/03-full-flow.js | 189 +++++- k6/04-circuit-breaker.js | 114 +++- k6/05-store-list.js | 141 +++-- k6/06-store-nearby.js | 186 +++--- k6/07-remains.js | 125 ++-- k6/grafana/dashboards/k6-dashboard.json | 786 ++++++++++++++++++++++++ k6/run.ps1 | 30 +- 9 files changed, 1445 insertions(+), 292 deletions(-) create mode 100644 k6/grafana/dashboards/k6-dashboard.json diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 18b08ce..6bdb32e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -79,8 +79,9 @@ services: - '--storage.tsdb.retention.time=7d' - '--storage.tsdb.retention.size=1GB' - '--web.enable-lifecycle' - expose: - - "9090" + - '--web.enable-remote-write-receiver' + ports: + - "9090:9090" deploy: resources: limits: diff --git a/k6/01-store-browse.js b/k6/01-store-browse.js index 60eb48f..5afea1d 100644 --- a/k6/01-store-browse.js +++ b/k6/01-store-browse.js @@ -1,6 +1,6 @@ /** - * 매장 조회 부하테스트 - * 실행: k6 run k6/01-store-browse.js + * 매장 조회 부하테스트 — 이벤트 스파이크 vs 점진적 증가 + * 실행: .\k6\run.ps1 -Test 01 * * 테스트 항목: * - GET /stores (목록 + 필터) @@ -9,81 +9,153 @@ * - GET /stores/in-bounds (지도 영역 조회) * - GET /stores/{id} (상세) * - GET /remains (시간대별 좌석) + * + * 전체 소요 시간: 약 9분 + * [0s ~ ~2m50s] event_spike — 워밍업 후 50명 즉시 점프 + * [3m20s ~ ~9m] ramp_up — 0→50명 단계적 증가 + * + * [두 시나리오 비교 포인트] + * - nearby / in-bounds: PostGIS 쿼리는 GIST 인덱스가 없으면 spike 구간에서 타임아웃 + * - 인기 매장 조회: Redis 캐시 히트율이 높으면 두 시나리오 모두 빠름 (차이 없음) + * - event_spike 점프 직후(30~35s) p95가 급등하면 → 서버가 스파이크를 못 버팀 */ import http from 'k6/http'; import { sleep, check, group } from 'k6'; -import { Trend } from 'k6/metrics'; +import exec from 'k6/execution'; +import { Trend, Rate } from 'k6/metrics'; import { BASE_URL, HEADERS_JSON, THRESHOLDS } from './config.js'; -const nearbyDuration = new Trend('nearby_duration', true); -const inBoundsDuration = new Trend('in_bounds_duration', true); +const nearbyDuration = new Trend('browse_nearby_duration', true); +const inBoundsDuration = new Trend('browse_in_bounds_duration', true); + +// 시나리오별 메트릭 — event_spike vs ramp_up 응답시간 분포 비교 +const spikeDuration = new Trend('browse_spike_duration', true); +const rampDuration = new Trend('browse_ramp_duration', true); + +const errorRate = new Rate('browse_error_rate'); export const options = { scenarios: { - // 단계적으로 부하 증가 → 유지 → 감소 + + // ── SCENARIO A: 이벤트 스파이크 ──────────────────────────────────────── + // 실제 상황: 오전 10시 이벤트 오픈, 좌석 공개처럼 특정 시각에 트래픽이 몰리는 상황 + // + // [워밍업을 추가한 이유] + // 실제 서비스에서 이벤트가 오픈되는 시점의 서버는 이미 운영 중인 상태. + // JVM JIT 컴파일과 DB 커넥션풀이 확보된 상태에서 갑자기 50명이 몰리는 것이 현실적. + // 워밍업 없이 바로 50명을 붙이면 JVM 콜드 스타트 비용이 포함되어 + // 실제 이벤트 상황의 성능과 다른 결과가 나옴. + // + // [ramping-vus를 쓴 이유] + // warmup(10명) + constant(50명)을 별도 시나리오로 돌리면 동시 실행되어 + // 실제로는 10 + 50 = 60명이 되는 문제가 생김. + // ramping-vus 하나로 합쳐야 정확히 50명으로 제어 가능. + // + // [단계별 의미] + // 30s: 10명 유지 → 서버가 이미 운영 중인 상태 재현 (JIT 워밍업, 커넥션풀 확보) + // 5s: 10→50명 점프 → 이벤트 오픈 순간 재현 + // 2m: 50명 유지 → 피크 타임 지속 상황에서의 안정성 확인 + // + // [체크포인트] + // 점프 직후(30~35s) 구간 p95가 급등하는지 Grafana에서 확인 + // → 급등하면 서버가 순간 스파이크를 못 버팀 (커넥션풀 또는 스레드풀 부족) + event_spike: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 10 }, // 워밍업: 평상시 트래픽 재현 + { duration: '5s', target: 50 }, // 이벤트 오픈: 5초 안에 50명으로 점프 + { duration: '2m', target: 50 }, // 피크 유지 + { duration: '15s', target: 0 }, // 종료 + ], + tags: { scenario: 'event_spike' }, + }, + + // ── SCENARIO B: 점진적 증가 ──────────────────────────────────────────── + // 목적: 몇 명부터 응답시간이 꺾이는지 임계점 탐색 + // event_spike와 달리 서버가 단계적으로 적응하면서 50명에 도달 + // 체크: 10→20→35→50 각 단계에서 nearby p95 변화량 확인 ramp_up: { executor: 'ramping-vus', startVUs: 0, + startTime: '3m20s', stages: [ - { duration: '30s', target: 10 }, // 워밍업 - { duration: '30s', target: 30 }, // 1단계 - { duration: '1m', target: 50 }, // 2단계 - { duration: '1m', target: 50 }, // 50명 유지 - { duration: '30s', target: 0 }, // 종료 + { duration: '30s', target: 10 }, + { duration: '30s', target: 20 }, + { duration: '30s', target: 35 }, + { duration: '1m', target: 50 }, + { duration: '30s', target: 50 }, + { duration: '30s', target: 0 }, ], + tags: { scenario: 'ramp_up' }, }, }, thresholds: { ...THRESHOLDS, - nearby_duration: ['p(95)<2000'], // PostGIS 쿼리는 2초 기준 - in_bounds_duration: ['p(95)<2000'], + 'browse_nearby_duration': ['p(95)<2000'], + 'browse_in_bounds_duration': ['p(95)<2000'], + 'browse_spike_duration': ['p(95)<1500'], + 'browse_ramp_duration': ['p(95)<1000'], + 'browse_error_rate': ['rate<0.01'], }, }; -// 서울 중심 좌표 -const SEOUL = { lat: 37.5665, lng: 126.9780 }; - -// 테스트할 매장 ID 목록 (실제 DB에 있는 ID로 교체) +const SEOUL = { lat: 37.5665, lng: 126.9780 }; const STORE_IDS = [1, 2, 3, 4, 5]; export default function () { + const isSpike = exec.scenario.name === 'event_spike'; const storeId = STORE_IDS[Math.floor(Math.random() * STORE_IDS.length)]; + // +9h: 서버가 UTC로 실행될 경우 자정~오전9시 사이에 날짜가 하루 틀어지는 것 방지 const today = new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().split('T')[0]; + function record(duration) { + isSpike ? spikeDuration.add(duration) : rampDuration.add(duration); + } + group('매장 목록 조회', () => { const res = http.get(`${BASE_URL}/api/v1/stores?page=0&size=10`, { headers: HEADERS_JSON }); - check(res, { '목록 200': (r) => r.status === 200 }); + const ok = check(res, { '목록 200': (r) => r.status === 200 }); + errorRate.add(!ok); + record(res.timings.duration); }); group('인기 매장 조회', () => { const res = http.get(`${BASE_URL}/api/v1/stores/popular?limit=10`, { headers: HEADERS_JSON }); - check(res, { '인기 200': (r) => r.status === 200 }); + const ok = check(res, { '인기 200': (r) => r.status === 200 }); + errorRate.add(!ok); + record(res.timings.duration); }); - group('근처 매장 조회 (PostGIS)', () => { + group('근처 매장 조회 (PostGIS nearby)', () => { const res = http.get( `${BASE_URL}/api/v1/stores/nearby?latitude=${SEOUL.lat}&longitude=${SEOUL.lng}&page=0&size=10`, { headers: HEADERS_JSON }, ); - check(res, { '근처 200': (r) => r.status === 200 }); + const ok = check(res, { '근처 200': (r) => r.status === 200 }); + errorRate.add(!ok); nearbyDuration.add(res.timings.duration); + record(res.timings.duration); }); group('지도 영역 매장 조회 (in-bounds)', () => { - // 서울 강남 일대 bounding box const res = http.get( `${BASE_URL}/api/v1/stores/in-bounds` + `?minLat=37.49&maxLat=37.54&minLng=127.01&maxLng=127.07` + `¢erLat=${SEOUL.lat}¢erLng=${SEOUL.lng}&limit=100`, { headers: HEADERS_JSON }, ); - check(res, { 'in-bounds 200': (r) => r.status === 200 }); + const ok = check(res, { 'in-bounds 200': (r) => r.status === 200 }); + errorRate.add(!ok); inBoundsDuration.add(res.timings.duration); + record(res.timings.duration); }); group('매장 상세 조회', () => { const res = http.get(`${BASE_URL}/api/v1/stores/${storeId}`, { headers: HEADERS_JSON }); - check(res, { '상세 200': (r) => r.status === 200 }); + const ok = check(res, { '상세 200': (r) => r.status === 200 }); + errorRate.add(!ok); + record(res.timings.duration); }); group('시간대별 좌석 조회', () => { @@ -91,8 +163,47 @@ export default function () { `${BASE_URL}/api/v1/remains?storeId=${storeId}&date=${today}`, { headers: HEADERS_JSON }, ); - check(res, { '좌석 200': (r) => r.status === 200 }); + const ok = check(res, { '좌석 200': (r) => r.status === 200 }); + errorRate.add(!ok); + record(res.timings.duration); }); sleep(1); } + +export function handleSummary(data) { + const s_p95 = data.metrics.browse_spike_duration?.values?.['p(95)'] || 0; + const s_p99 = data.metrics.browse_spike_duration?.values?.['p(99)'] || 0; + const r_p95 = data.metrics.browse_ramp_duration?.values?.['p(95)'] || 0; + const r_p99 = data.metrics.browse_ramp_duration?.values?.['p(99)'] || 0; + const n_p95 = data.metrics.browse_nearby_duration?.values?.['p(95)'] || 0; + const b_p95 = data.metrics.browse_in_bounds_duration?.values?.['p(95)'] || 0; + const totalReqs = data.metrics.http_reqs?.values?.count || 0; + const errorR = data.metrics.browse_error_rate?.values?.rate || 0; + + const diff = s_p95 - r_p95; + const spikeNote = diff > 200 + ? `⚠ 스파이크 구간이 ${diff.toFixed(0)}ms 더 느림 → 커넥션풀/스레드풀 점검 필요` + : `✓ 스파이크 안정적 처리. 차이 ${diff.toFixed(0)}ms`; + + return { + stdout: ` +===== 매장 조회 부하테스트 결과 — 시나리오 비교 ===== +총 요청수 : ${totalReqs.toLocaleString()}건 +에러율 : ${(errorR * 100).toFixed(3)}% + +[SCENARIO A] 이벤트 스파이크 (워밍업 10명 → 50명 점프) + p95 : ${s_p95.toFixed(0)}ms / p99 : ${s_p99.toFixed(0)}ms + +[SCENARIO B] 점진적 증가 (0→50명) + p95 : ${r_p95.toFixed(0)}ms / p99 : ${r_p99.toFixed(0)}ms + +[스파이크 분석] ${spikeNote} + +[API별 p95] + nearby (PostGIS) : ${n_p95.toFixed(0)}ms (기준: 2000ms) + in-bounds (PostGIS) : ${b_p95.toFixed(0)}ms (기준: 2000ms) +==================================================== +`, + }; +} diff --git a/k6/03-full-flow.js b/k6/03-full-flow.js index 176a9ce..2f29cfc 100644 --- a/k6/03-full-flow.js +++ b/k6/03-full-flow.js @@ -1,61 +1,132 @@ /** - * 실제 사용자 플로우 부하테스트 - * 실행: k6 run -e AUTH_TOKEN= k6/03-full-flow.js + * 실제 사용자 플로우 부하테스트 — 이벤트 스파이크 vs 점진적 증가 + * 실행: .\k6\run.ps1 -Test 03 -AuthToken "eyJ..." * - * 시나리오: 홈 진입 → 매장 검색 → 상세 조회 → 좌석 확인 - * (결제가 필요한 예약 확정은 외부 API 의존이라 포함하지 않음) + * 시나리오: 홈 진입 → 매장 검색 → 상세 조회 → 메뉴/리뷰 확인 → 좌석 확인 + * + * 전체 소요 시간: 약 9분 + * [0s ~ ~2m50s] event_spike — 워밍업 후 50명 즉시 점프 + * [3m20s ~ ~9m] ramp_up — 0→50명 단계적 증가 */ import http from 'k6/http'; import { sleep, check, group } from 'k6'; -import { Trend } from 'k6/metrics'; +import exec from 'k6/execution'; +import { Trend, Rate } from 'k6/metrics'; import { BASE_URL, HEADERS_JSON, HEADERS_AUTH, THRESHOLDS } from './config.js'; const flowDuration = new Trend('full_flow_duration', true); +// 시나리오별 메트릭 +const spikeDuration = new Trend('flow_spike_duration', true); +const rampDuration = new Trend('flow_ramp_duration', true); + +// Step별 Trend — 어느 단계에서 병목이 발생하는지 파악 +const stepDurations = { + popular: new Trend('flow_step_popular', true), + search: new Trend('flow_step_search', true), + mapView: new Trend('flow_step_map', true), + detail: new Trend('flow_step_detail', true), + batch: new Trend('flow_step_batch', true), + remains: new Trend('flow_step_remains', true), +}; + +const errorRate = new Rate('flow_error_rate'); + export const options = { scenarios: { - // 일반 트래픽 — 점진적 증가 - normal_traffic: { + + // ── SCENARIO A: 이벤트 스파이크 ──────────────────────────────────────── + // 실제 상황: 오전 10시 이벤트 오픈, 좌석 공개처럼 특정 시각에 트래픽이 몰리는 상황 + // + // [워밍업을 추가한 이유] + // 실제 서비스에서 이벤트가 오픈되는 시점의 서버는 이미 운영 중인 상태. + // JVM JIT 컴파일과 DB 커넥션풀이 확보된 상태에서 갑자기 50명이 몰리는 것이 현실적. + // 워밍업 없이 바로 50명을 붙이면 JVM 콜드 스타트 비용이 포함되어 + // 실제 이벤트 상황의 성능과 다른 결과가 나옴. + // + // [ramping-vus를 쓴 이유] + // warmup(10명) + constant(50명)을 별도 시나리오로 돌리면 동시 실행되어 + // 실제로는 10 + 50 = 60명이 되는 문제가 생김. + // ramping-vus 하나로 합쳐야 정확히 50명으로 제어 가능. + // + // [체크포인트] + // 점프 직후(30~35s) Step별 p95 중 어느 step이 먼저 터지는지 확인 + // → 가장 먼저 급등하는 step = 이벤트 상황에서의 첫 번째 병목 + event_spike: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 10 }, // 워밍업: 평상시 트래픽 재현 + { duration: '5s', target: 50 }, // 이벤트 오픈: 5초 안에 50명으로 점프 + { duration: '2m', target: 50 }, // 피크 유지 + { duration: '15s', target: 0 }, + ], + tags: { scenario: 'event_spike' }, + }, + + // ── SCENARIO B: 점진적 증가 ──────────────────────────────────────────── + // 목적: 몇 명부터 어느 step이 먼저 나빠지는지 임계점 탐색 + ramp_up: { executor: 'ramping-vus', startVUs: 0, + startTime: '3m20s', stages: [ { duration: '30s', target: 10 }, - { duration: '1m', target: 30 }, + { duration: '30s', target: 20 }, + { duration: '30s', target: 35 }, { duration: '1m', target: 50 }, - { duration: '30s', target: 0 }, + { duration: '30s', target: 50 }, + { duration: '30s', target: 0 }, ], + tags: { scenario: 'ramp_up' }, }, }, thresholds: { ...THRESHOLDS, - full_flow_duration: ['p(95)<5000'], // 전체 플로우 5초 이내 + 'full_flow_duration': ['p(95)<5000'], + 'flow_spike_duration': ['p(95)<6000'], + 'flow_ramp_duration': ['p(95)<5000'], + 'flow_step_detail': ['p(95)<1000'], + 'flow_step_batch': ['p(95)<1500'], + 'flow_step_remains': ['p(95)<600'], + 'flow_error_rate': ['rate<0.01'], }, }; -const SEOUL = { lat: 37.5665, lng: 126.9780 }; -const STORE_IDS = [1, 2, 3, 4, 5]; +const SEOUL = { lat: 37.5665, lng: 126.9780 }; +const STORE_IDS = [1, 2, 3, 4, 5]; +const CATEGORIES = ['KOREAN', 'JAPANESE', 'CHINESE', 'WESTERN', 'MEAT', 'DESSERT']; export default function () { - const start = Date.now(); + const isSpike = exec.scenario.name === 'event_spike'; + const start = Date.now(); const storeId = STORE_IDS[Math.floor(Math.random() * STORE_IDS.length)]; + // +9h: 서버가 UTC로 실행될 경우 자정~오전9시 사이에 날짜가 하루 틀어지는 것 방지 const today = new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().split('T')[0]; + function record(duration) { + isSpike ? spikeDuration.add(duration) : rampDuration.add(duration); + } + group('1. 홈 - 인기 매장 조회', () => { - const res = http.get(`${BASE_URL}/api/v1/stores/popular?limit=10`, { - headers: HEADERS_JSON, - }); - check(res, { '인기 매장 200': (r) => r.status === 200 }); + const res = http.get(`${BASE_URL}/api/v1/stores/popular?limit=10`, { headers: HEADERS_JSON }); + const ok = check(res, { '인기 매장 200': (r) => r.status === 200 }); + errorRate.add(!ok); + stepDurations.popular.add(res.timings.duration); + record(res.timings.duration); sleep(0.5); }); group('2. 카테고리/지역 필터 검색', () => { - const categories = ['KOREAN', 'JAPANESE', 'CHINESE', 'WESTERN']; - const category = categories[Math.floor(Math.random() * categories.length)]; + const category = CATEGORIES[Math.floor(Math.random() * CATEGORIES.length)]; const res = http.get( - `${BASE_URL}/api/v1/stores?category=${encodeURIComponent(category)}&page=0&size=10`, + `${BASE_URL}/api/v1/stores?category=${category}&page=0&size=10`, { headers: HEADERS_JSON }, ); - check(res, { '필터 검색 200': (r) => r.status === 200 }); + const ok = check(res, { '필터 검색 200': (r) => r.status === 200 }); + errorRate.add(!ok); + stepDurations.search.add(res.timings.duration); + record(res.timings.duration); sleep(0.5); }); @@ -66,15 +137,19 @@ export default function () { `¢erLat=${SEOUL.lat}¢erLng=${SEOUL.lng}&limit=100`, { headers: HEADERS_JSON }, ); - check(res, { '지도 200': (r) => r.status === 200 }); + const ok = check(res, { '지도 200': (r) => r.status === 200 }); + errorRate.add(!ok); + stepDurations.mapView.add(res.timings.duration); + record(res.timings.duration); sleep(0.3); }); group('4. 매장 상세 조회', () => { - const res = http.get(`${BASE_URL}/api/v1/stores/${storeId}`, { - headers: HEADERS_JSON, - }); - check(res, { '상세 200': (r) => r.status === 200 }); + const res = http.get(`${BASE_URL}/api/v1/stores/${storeId}`, { headers: HEADERS_JSON }); + const ok = check(res, { '상세 200': (r) => r.status === 200 }); + errorRate.add(!ok); + stepDurations.detail.add(res.timings.duration); + record(res.timings.duration); sleep(1); }); @@ -83,8 +158,11 @@ export default function () { ['GET', `${BASE_URL}/api/v1/stores/${storeId}/menu`, null, { headers: HEADERS_JSON }], ['GET', `${BASE_URL}/api/v1/reviews/store/${storeId}`, null, { headers: HEADERS_JSON }], ]); - check(responses[0], { '메뉴 200': (r) => r.status === 200 }); - check(responses[1], { '리뷰 200': (r) => r.status === 200 }); + const ok = check(responses[0], { '메뉴 200': (r) => r.status === 200 }) && + check(responses[1], { '리뷰 200': (r) => r.status === 200 }); + errorRate.add(!ok); + stepDurations.batch.add(Math.max(responses[0].timings.duration, responses[1].timings.duration)); + record(Math.max(responses[0].timings.duration, responses[1].timings.duration)); sleep(0.5); }); @@ -93,20 +171,63 @@ export default function () { `${BASE_URL}/api/v1/remains?storeId=${storeId}&date=${today}`, { headers: HEADERS_JSON }, ); - check(res, { '좌석 200': (r) => r.status === 200 }); + const ok = check(res, { '좌석 200': (r) => r.status === 200 }); + errorRate.add(!ok); + stepDurations.remains.add(res.timings.duration); + record(res.timings.duration); sleep(0.5); }); - // 로그인 유저만: 북마크 폴더 조회 if (HEADERS_AUTH.Authorization !== 'Bearer ') { group('7. 북마크 폴더 조회 (로그인)', () => { - const res = http.get(`${BASE_URL}/api/v1/bookmark-folders`, { - headers: HEADERS_AUTH, - }); - check(res, { '북마크 200': (r) => r.status === 200 }); + const res = http.get(`${BASE_URL}/api/v1/bookmark-folders`, { headers: HEADERS_AUTH }); + const ok = check(res, { '북마크 200': (r) => r.status === 200 }); + errorRate.add(!ok); + record(res.timings.duration); }); } flowDuration.add(Date.now() - start); sleep(1); } + +export function handleSummary(data) { + const s_p95 = data.metrics.flow_spike_duration?.values?.['p(95)'] || 0; + const s_p99 = data.metrics.flow_spike_duration?.values?.['p(99)'] || 0; + const r_p95 = data.metrics.flow_ramp_duration?.values?.['p(95)'] || 0; + const r_p99 = data.metrics.flow_ramp_duration?.values?.['p(99)'] || 0; + const flow_p95 = data.metrics.full_flow_duration?.values?.['p(95)'] || 0; + + const steps = [ + { name: '1. 인기 매장', p95: data.metrics.flow_step_popular?.values?.['p(95)'] || 0 }, + { name: '2. 필터 검색', p95: data.metrics.flow_step_search?.values?.['p(95)'] || 0 }, + { name: '3. 지도 조회', p95: data.metrics.flow_step_map?.values?.['p(95)'] || 0 }, + { name: '4. 매장 상세', p95: data.metrics.flow_step_detail?.values?.['p(95)'] || 0 }, + { name: '5. 메뉴+리뷰', p95: data.metrics.flow_step_batch?.values?.['p(95)'] || 0 }, + { name: '6. 잔여석', p95: data.metrics.flow_step_remains?.values?.['p(95)'] || 0 }, + ]; + const bottleneck = steps.reduce((a, b) => a.p95 > b.p95 ? a : b); + const totalReqs = data.metrics.http_reqs?.values?.count || 0; + const errorR = data.metrics.flow_error_rate?.values?.rate || 0; + + return { + stdout: ` +===== 전체 사용자 플로우 부하테스트 결과 ===== +총 요청수 : ${totalReqs.toLocaleString()}건 +에러율 : ${(errorR * 100).toFixed(3)}% +전체 플로우 p95 : ${flow_p95.toFixed(0)}ms + +[SCENARIO A] 이벤트 스파이크 (워밍업 10명 → 50명 점프) + p95 : ${s_p95.toFixed(0)}ms / p99 : ${s_p99.toFixed(0)}ms + +[SCENARIO B] 점진적 증가 (0→50명) + p95 : ${r_p95.toFixed(0)}ms / p99 : ${r_p99.toFixed(0)}ms + +[Step별 p95] +${steps.map(s => ` ${s.name.padEnd(12)}: ${s.p95.toFixed(0)}ms`).join('\n')} + +[병목 1순위] → ${bottleneck.name} (p95: ${bottleneck.p95.toFixed(0)}ms) +============================================= +`, + }; +} diff --git a/k6/04-circuit-breaker.js b/k6/04-circuit-breaker.js index b9fdef2..3ff7d95 100644 --- a/k6/04-circuit-breaker.js +++ b/k6/04-circuit-breaker.js @@ -1,15 +1,40 @@ /** - * 서킷브레이커 부하테스트 - * 실행: k6 run --out experimental-prometheus-rw -e AUTH_TOKEN= -e BASE_URL=https://api.catcheat.kro.kr k6/04-circuit-breaker.js + * 서킷브레이커 부하테스트 — 점진적 증가로 OPEN 전환 시점 탐색 + * 실행: .\k6\run.ps1 -Test 04 -AuthToken "eyJ..." -BaseUrl https://api.catcheat.kro.kr * * 목적: - * - LLM API(Spring AI)에 동시 요청을 쏴서 서킷브레이커가 열리는 시점 확인 - * - 서킷 CLOSED → OPEN → HALF-OPEN 상태 전환 관찰 + * - LLM API(Spring AI)에 점진적으로 부하를 올려 서킷브레이커가 열리는 VU 수 확인 + * - 서킷 CLOSED → OPEN → HALF-OPEN → CLOSED 전체 상태 전환 사이클 관찰 * - * 서킷브레이커 상태 판별: - * - 응답 느림 + 성공/실패 → CLOSED (정상 흐름, LLM 호출 중) - * - 응답 빠름 + 에러 → OPEN (서킷 차단, 즉시 실패 반환) - * - 응답 일부 성공 → HALF-OPEN (복구 테스트 중) + * [서킷브레이커에 ramp_up을 쓰는 이유] + * - LLM API는 응답시간이 수초~수십초로 가변적 + * - 50명 동시 접속(constant_50)은 서버가 감당하기 전에 타임아웃이 폭발해서 + * 서킷이 열리는 시점 자체를 관찰할 수 없음 + * - 점진적으로 올려야 "2명일 때는 괜찮다가 10명에서 서킷이 열린다"는 정보를 얻을 수 있음 + * + * 전체 소요 시간: 약 5분 30초 + * [0s ~ 30s] warmup — 2VU, 서킷 CLOSED 기준선 측정 + * [30s ~ 3m] ramp_up — 2→20VU 점진 증가, 서킷 OPEN 유발 + * [3m ~ 4m] sustained — 20VU 유지, OPEN 상태 지속 관찰 + * [4m ~ 4m30s] recovery — 2VU 복귀, HALF-OPEN → CLOSED 복구 확인 + * + * [서킷 상태 판별 방법] + * CLOSED (정상): 응답이 느리지만 200 반환. LLM이 실제 처리 중. + * OPEN (차단): 응답이 매우 빠르면서(< 500ms) 에러. 서킷이 즉시 차단. + * HALF-OPEN(복구): 부하 제거 후 일부 요청만 성공. 서킷이 복구 테스트 중. + * + * [체크포인트] + * 1. circuit_open_responses 카운터가 올라가기 시작하는 VU 수 + * → 그 수가 현재 서킷브레이커 설정의 failureRateThreshold 도달 지점 + * 2. ramp_up → sustained 전환 후에도 circuit_open_responses가 계속 올라가는지 + * → 계속 올라가면 서킷이 계속 OPEN 상태 (복구 안 됨) + * 3. recovery 구간에서 circuit_closed_responses가 다시 올라가는지 + * → 올라가면 HALF-OPEN → CLOSED 복구 성공 + * + * [Grafana 모니터링] + * - circuit_open_responses vs circuit_closed_responses 동시에 보기 + * - vus 그래프와 함께 보면 몇 명에서 서킷이 열리는지 정확히 파악 + * - chat_duration: OPEN 상태면 < 500ms, CLOSED 상태면 수초 */ import http from 'k6/http'; import { sleep, check, group } from 'k6'; @@ -21,31 +46,41 @@ const circuitClosedCount = new Counter('circuit_closed_responses'); // 정상 const llmErrorRate = new Rate('llm_error_rate'); const chatDuration = new Trend('chat_duration', true); -// 서킷 OPEN 판별 기준: 500ms 미만이면서 에러 → 즉시 차단된 것 +// 서킷 OPEN 판별 기준: 500ms 미만이면서 에러 → 서킷이 즉시 차단한 것 +// LLM 정상 처리는 최소 수백ms~수초이므로 500ms 이하 빠른 실패 = OPEN 확실 const CIRCUIT_OPEN_THRESHOLD_MS = 500; export const options = { scenarios: { - // 1단계: 워밍업 (서킷 CLOSED 기준선 확인) + // ── 워밍업: 서킷 CLOSED 기준선 측정 ───────────────────────────────────── + // 2VU로 정상 상태에서의 응답시간 확인. 이 구간 chat_duration이 기준선. + // 여기서도 에러가 나면 → LLM API 자체 문제 (부하와 무관) warmup: { executor: 'constant-vus', vus: 2, duration: '30s', tags: { phase: 'warmup' }, }, - // 2단계: 부하 증가 → 서킷 OPEN 유도 + + // ── ramp_up: 서킷 OPEN 유발 구간 ───────────────────────────────────────── + // VU를 점진적으로 올리면서 circuit_open_responses 카운터 증가 시점을 포착 + // 2→5→10→20명 단계에서 Grafana 확인: + // - 5명까지 정상 → 10명에서 circuit_open 증가 시작 → 10명이 임계점 ramp_up: { executor: 'ramping-vus', startVUs: 2, + startTime: '30s', stages: [ - { duration: '30s', target: 5 }, - { duration: '1m', target: 10 }, - { duration: '1m', target: 20 }, + { duration: '30s', target: 5 }, // LLM은 소규모에서도 느릴 수 있음 + { duration: '1m', target: 10 }, // 대부분 서킷 OPEN 시작 구간 + { duration: '1m', target: 20 }, // 확실한 OPEN 상태 유도 ], - startTime: '30s', tags: { phase: 'ramp_up' }, }, - // 3단계: 고부하 유지 → 서킷 OPEN 상태 관찰 + + // ── sustained: OPEN 상태 지속 관찰 ────────────────────────────────────── + // 서킷이 열린 상태를 1분간 유지. circuit_open_responses 비율 측정. + // 대부분의 요청이 즉시 차단(< 500ms)되어야 정상적인 OPEN 상태 sustained: { executor: 'constant-vus', vus: 20, @@ -53,7 +88,11 @@ export const options = { startTime: '3m', tags: { phase: 'sustained' }, }, - // 4단계: 부하 제거 → HALF-OPEN 전환 관찰 + + // ── recovery: HALF-OPEN → CLOSED 복구 확인 ────────────────────────────── + // 부하를 낮추면 서킷브레이커가 HALF-OPEN 상태로 전환되어 일부 요청만 통과시킴. + // circuit_closed_responses가 다시 올라오면 복구 성공. + // 복구가 안 되면 → 서킷브레이커 waitDurationInOpenState 설정 확인 recovery: { executor: 'constant-vus', vus: 2, @@ -63,12 +102,13 @@ export const options = { }, }, thresholds: { - llm_error_rate: ['rate<0.8'], // 에러율 80% 미만 (서킷 완전 차단 시 100%) - chat_duration: ['p(95)<60000'], // LLM 특성상 타임아웃 기준 60초 + // LLM 특성상 응답시간 기준을 60초로 매우 여유 있게 설정 + // 실제 서킷이 제대로 작동하면 OPEN 상태에서 대부분 < 500ms로 즉시 차단됨 + llm_error_rate: ['rate<0.8'], // 에러율 80% 미만 (서킷 OPEN 시 거의 100%) + chat_duration: ['p(95)<60000'], // LLM 타임아웃 기준 }, }; -// 테스트용 메시지 목록 (다양한 복잡도) const TEST_MESSAGES = [ { message: '안녕하세요', latitude: 37.5665, longitude: 126.9780 }, { message: '강남 근처 한식 맛집 추천해줘', latitude: 37.4979, longitude: 127.0276 }, @@ -77,22 +117,22 @@ const TEST_MESSAGES = [ ]; export default function () { - const msg = TEST_MESSAGES[Math.floor(Math.random() * TEST_MESSAGES.length)]; + const msg = TEST_MESSAGES[Math.floor(Math.random() * TEST_MESSAGES.length)]; const payload = JSON.stringify(msg); group('챗봇 메시지 전송', () => { const res = http.post(`${BASE_URL}/api/v1/chat/messages`, payload, { headers: HEADERS_AUTH, - timeout: 65000, // LLM 응답 최대 대기 (65초) + timeout: '65s', }); chatDuration.add(res.timings.duration); - const isSuccess = res.status === 200 || res.status === 201; + const isSuccess = res.status === 200 || res.status === 201; + // 빠른 실패 판별: 500ms 미만 + 에러 → 서킷 OPEN 상태 const isFastFail = res.timings.duration < CIRCUIT_OPEN_THRESHOLD_MS && !isSuccess; if (isFastFail) { - // 빠른 실패 = 서킷 OPEN 상태 circuitOpenCount.add(1); llmErrorRate.add(true); console.log(`[서킷 OPEN] ${res.timings.duration.toFixed(0)}ms - status: ${res.status}`); @@ -100,7 +140,7 @@ export default function () { circuitClosedCount.add(1); llmErrorRate.add(false); } else { - // 느린 실패 (LLM 타임아웃 등) + // 느린 실패 — LLM 타임아웃 또는 서킷 CLOSED 상태에서의 LLM 오류 llmErrorRate.add(true); console.log(`[LLM 실패] ${res.timings.duration.toFixed(0)}ms - status: ${res.status} - ${res.body?.substring(0, 100)}`); } @@ -121,21 +161,31 @@ export function handleSummary(data) { 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'; + return { stdout: ` ===== 서킷브레이커 테스트 결과 ===== -총 요청수 : ${total} -정상 응답 (CLOSED) : ${closed} -즉시 차단 (OPEN) : ${open} -서킷 OPEN 비율 : ${total > 0 ? ((open / total) * 100).toFixed(1) : 0}% +총 요청수 : ${total}건 +정상 응답 (CLOSED) : ${closed}건 +즉시 차단 (OPEN) : ${open}건 +서킷 OPEN 비율 : ${openRate}% p95 응답시간 : ${(p95 / 1000).toFixed(2)}초 p99 응답시간 : ${(p99 / 1000).toFixed(2)}초 [분석] -- "즉시 차단" 수가 늘어나는 시점 = 서킷 OPEN 전환 시점 -- 부하 제거 후 정상 응답 돌아오면 HALF-OPEN → CLOSED 복구 확인 -===================================== + circuit_open_responses 카운터가 증가하기 시작한 시점 (Grafana 확인): + → warmup(2VU): 0건이어야 정상 + → ramp_up 중 5VU / 10VU / 20VU 구간 중 어느 시점에 open 증가? + 그 VU 수가 현재 서킷브레이커 설정의 실질적 임계 부하 + + recovery 구간(4m~4m30s)에서 closed_responses가 다시 올라오면 → 복구 성공 + 올라오지 않으면 → waitDurationInOpenState 설정 확인: + @CircuitBreaker(name=..., waitDurationInOpenState=30s) + + OPEN 비율 > 80% → LLM API 응답이 매우 불안정. 폴백 로직 강화 필요. +=================================== `, }; } diff --git a/k6/05-store-list.js b/k6/05-store-list.js index 57cee1f..23f416a 100644 --- a/k6/05-store-list.js +++ b/k6/05-store-list.js @@ -1,80 +1,105 @@ /** - * [단일 API] 매장 목록 조회 부하테스트 - * 실행: .\k6\run.ps1 -Test 05 -BaseUrl https://api.catcheat.kro.kr + * [단일 API] 매장 목록 조회 부하테스트 — 이벤트 스파이크 vs 점진적 증가 + * 실행: .\k6\run.ps1 -Test 05 * - * 목적: - * - GET /stores (필터 조합) 단일 API에 집중 부하 - * - Specification 기반 동적 필터링 + 페이지네이션 성능 확인 - * - GET /stores/popular (인기 매장 Top10) 함께 측정 - * - * 기대 결과: - * - p95 < 500ms (인덱스 정상 활용 시) - * - p95 > 1s 이면 popularity 정렬 컬럼 인덱스 검토 필요 + * 전체 소요 시간: 약 9분 + * [0s ~ ~2m50s] event_spike — 워밍업 후 50명 즉시 점프 + * [3m20s ~ ~9m] ramp_up — 0→50명 단계적 증가 */ 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 } from './config.js'; const listDuration = new Trend('store_list_duration', true); const popularDuration = new Trend('store_popular_duration', true); +const spikeDuration = new Trend('list_spike_duration', true); +const rampDuration = new Trend('list_ramp_duration', true); const errorRate = new Rate('store_list_error_rate'); -// 테스트할 카테고리 필터 목록 — 실제 DB에 있는 값으로 교체 -const CATEGORIES = ['KOREAN', 'JAPANESE', 'CHINESE', 'WESTERN', 'CAFE']; -// 테스트할 지역(district) 필터 목록 -const DISTRICTS = ['GANGNAM', 'MAPO', 'JONGNO', 'YONGSAN', 'SEONGDONG']; - export const options = { scenarios: { - // 1단계: 워밍업 (JVM JIT, DB 커넥션풀 안정화) - warmup: { - executor: 'constant-vus', - vus: 5, - duration: '30s', - tags: { phase: 'warmup' }, + + // ── SCENARIO A: 이벤트 스파이크 ──────────────────────────────────────── + // 실제 상황: 오전 10시 이벤트 오픈, 좌석 공개처럼 특정 시각에 트래픽이 몰리는 상황 + // + // [워밍업을 추가한 이유] + // 실제 서비스에서 이벤트가 오픈되는 시점의 서버는 이미 운영 중인 상태. + // JVM JIT 컴파일과 DB 커넥션풀이 확보된 상태에서 갑자기 50명이 몰리는 것이 현실적. + // 워밍업 없이 바로 50명을 붙이면 JVM 콜드 스타트 비용이 포함되어 + // 실제 이벤트 상황의 성능과 다른 결과가 나옴. + // + // [ramping-vus를 쓴 이유] + // warmup(10명) + constant(50명)을 별도 시나리오로 돌리면 동시 실행되어 + // 실제로는 10 + 50 = 60명이 되는 문제가 생김. + // ramping-vus 하나로 합쳐야 정확히 50명으로 제어 가능. + // + // [체크포인트] + // 인덱스가 제대로 걸려 있으면 점프 직후에도 p95가 크게 안 올라야 함 + // 급등하면 → DB 커넥션풀 부족 또는 Specification 쿼리 Full Scan + event_spike: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 10 }, // 워밍업: 평상시 트래픽 재현 + { duration: '5s', target: 50 }, // 이벤트 오픈: 5초 안에 50명으로 점프 + { duration: '2m', target: 50 }, // 피크 유지 + { duration: '15s', target: 0 }, + ], + tags: { scenario: 'event_spike' }, }, - // 2단계: 단계적 부하 증가 → 50VU까지 + + // ── SCENARIO B: 점진적 증가 ──────────────────────────────────────────── + // 목적: 몇 명부터 store_list_duration p95가 800ms를 넘는지 임계점 탐색 ramp_up: { executor: 'ramping-vus', - startVUs: 5, + startVUs: 0, + startTime: '3m20s', stages: [ + { duration: '30s', target: 10 }, { duration: '30s', target: 20 }, { duration: '30s', target: 35 }, { duration: '1m', target: 50 }, - { duration: '1m', target: 50 }, // 50VU 유지 + { duration: '30s', target: 50 }, { duration: '30s', target: 0 }, ], - startTime: '30s', - tags: { phase: 'ramp_up' }, + tags: { scenario: 'ramp_up' }, }, }, thresholds: { - store_list_duration: ['p(95)<800'], // 목록 조회 p95 800ms 기준 - store_popular_duration: ['p(95)<500'], // 인기 매장은 단순 정렬이라 500ms 기준 - store_list_error_rate: ['rate<0.01'], // 에러율 1% 미만 - http_req_failed: ['rate<0.01'], + '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_error_rate': ['rate<0.01'], + 'http_req_failed': ['rate<0.01'], }, }; +const CATEGORIES = ['KOREAN', 'JAPANESE', 'CHINESE', 'WESTERN', 'CAFE']; +const DISTRICTS = ['강남구', '마포구', '종로구', '용산구', '성동구']; + export default function () { - // 랜덤 필터 조합 — 실제 사용자가 다양한 필터로 조회하는 상황 재현 + const isSpike = exec.scenario.name === 'event_spike'; const randomCategory = CATEGORIES[Math.floor(Math.random() * CATEGORIES.length)]; const randomDistrict = DISTRICTS[Math.floor(Math.random() * DISTRICTS.length)]; - const randomPage = Math.floor(Math.random() * 5); // 0~4 페이지 - const useFilter = Math.random() > 0.5; // 50% 확률로 필터 적용 + const randomPage = Math.floor(Math.random() * 5); + const useFilter = Math.random() > 0.5; + + function record(duration) { + isSpike ? spikeDuration.add(duration) : rampDuration.add(duration); + } group('매장 목록 조회 (필터 조합)', () => { let url = `${BASE_URL}/api/v1/stores?page=${randomPage}&size=10`; if (useFilter) { - // 필터 조합을 랜덤하게 — 카테고리만, 지역만, 둘 다 섞어서 테스트 if (Math.random() > 0.5) url += `&category=${randomCategory}`; if (Math.random() > 0.5) url += `&district=${randomDistrict}`; } - const res = http.get(url, { headers: HEADERS_JSON }); listDuration.add(res.timings.duration); - + record(res.timings.duration); const ok = check(res, { '목록 조회 200': (r) => r.status === 200, '응답 body 존재': (r) => r.body && r.body.length > 0, @@ -85,39 +110,45 @@ export default function () { group('인기 매장 조회', () => { const res = http.get(`${BASE_URL}/api/v1/stores/popular?limit=10`, { headers: HEADERS_JSON }); popularDuration.add(res.timings.duration); - - check(res, { - '인기 매장 200': (r) => r.status === 200, - }); + record(res.timings.duration); + check(res, { '인기 매장 200': (r) => r.status === 200 }); }); sleep(1); } export function handleSummary(data) { - const p95List = data.metrics.store_list_duration?.values?.['p(95)'] || 0; - const p99List = data.metrics.store_list_duration?.values?.['p(99)'] || 0; - const p95Popular = data.metrics.store_popular_duration?.values?.['p(95)'] || 0; - const totalReqs = data.metrics.http_reqs?.values?.count || 0; - const errorR = data.metrics.store_list_error_rate?.values?.rate || 0; + const s_p95 = data.metrics.list_spike_duration?.values?.['p(95)'] || 0; + const s_p99 = data.metrics.list_spike_duration?.values?.['p(99)'] || 0; + const r_p95 = data.metrics.list_ramp_duration?.values?.['p(95)'] || 0; + const r_p99 = data.metrics.list_ramp_duration?.values?.['p(99)'] || 0; + const p95List = data.metrics.store_list_duration?.values?.['p(95)'] || 0; + const p99List = data.metrics.store_list_duration?.values?.['p(99)'] || 0; + const p95Popular = data.metrics.store_popular_duration?.values?.['p(95)'] || 0; + const totalReqs = data.metrics.http_reqs?.values?.count || 0; + const errorR = data.metrics.store_list_error_rate?.values?.rate || 0; return { stdout: ` -===== 매장 목록 조회 단일 API 부하테스트 결과 ===== -총 요청수 : ${totalReqs} +===== 매장 목록 단일 API 부하테스트 결과 ===== +총 요청수 : ${totalReqs.toLocaleString()}건 에러율 : ${(errorR * 100).toFixed(2)}% -[GET /stores] -p95 응답시간 : ${p95List.toFixed(0)}ms -p99 응답시간 : ${p99List.toFixed(0)}ms +[SCENARIO A] 이벤트 스파이크 (워밍업 10명 → 50명 점프) + p95 : ${s_p95.toFixed(0)}ms / p99 : ${s_p99.toFixed(0)}ms + +[SCENARIO B] 점진적 증가 (0→50명) + p95 : ${r_p95.toFixed(0)}ms / p99 : ${r_p99.toFixed(0)}ms -[GET /stores/popular] -p95 응답시간 : ${p95Popular.toFixed(0)}ms +[API별 p95] + GET /stores (필터 조합) : ${p95List.toFixed(0)}ms (기준: 800ms) + GET /stores (p99) : ${p99List.toFixed(0)}ms + GET /stores/popular : ${p95Popular.toFixed(0)}ms (기준: 500ms) [분석] -- p95 > 800ms → popularity 정렬 컬럼(star, reviewCount, bookmarkCount)에 복합 인덱스 검토 -- p95 > 1s → Specification 동적 쿼리 실행계획 확인 (EXPLAIN ANALYZE) -================================================= + p95 > 800ms → popularity 정렬 컬럼 복합 인덱스 검토 + p95 > 1s → Specification 동적 쿼리 실행계획 확인: EXPLAIN ANALYZE +============================================== `, }; } diff --git a/k6/06-store-nearby.js b/k6/06-store-nearby.js index 493f12a..6bace33 100644 --- a/k6/06-store-nearby.js +++ b/k6/06-store-nearby.js @@ -1,30 +1,27 @@ /** - * [단일 API] PostGIS 지리 쿼리 부하테스트 - * 실행: .\k6\run.ps1 -Test 06 -BaseUrl https://api.catcheat.kro.kr - * - * 목적: - * - GET /stores/nearby (ST_DistanceSphere 거리 계산 + 정렬) - * - GET /stores/in-bounds (지도 bounding box 조회) - * 두 API가 PostGIS 지리 쿼리를 사용하는 병목 지점 + * [단일 API] PostGIS 지리 쿼리 부하테스트 — 이벤트 스파이크 vs 점진적 증가 + * 실행: .\k6\run.ps1 -Test 06 * * 이전 테스트 결과: 50VU에서 nearby p95=10.33s, in-bounds p95=8.36s * → GIST 공간 인덱스 미적용 의심 → 이 테스트로 수치 재확인 * - * 기대 결과: - * - GIST 인덱스 적용 후: p95 < 1s - * - 미적용 시: 50VU 이상에서 타임아웃 발생 확인 + * 전체 소요 시간: 약 9분 + * [0s ~ ~2m50s] event_spike — 워밍업 후 50명 즉시 점프 + * [3m20s ~ ~9m] ramp_up — 0→50명 단계적 증가 */ import http from 'k6/http'; import { sleep, check, group } from 'k6'; +import exec from 'k6/execution'; import { Trend, Rate, Counter } from 'k6/metrics'; import { BASE_URL, HEADERS_JSON } from './config.js'; const nearbyDuration = new Trend('nearby_duration', true); const inBoundsDuration = new Trend('in_bounds_duration', true); +const spikeDuration = new Trend('geo_spike_duration', true); +const rampDuration = new Trend('geo_ramp_duration', true); const timeoutCount = new Counter('geo_timeout_count'); const errorRate = new Rate('geo_error_rate'); -// 서울 주요 지역 좌표 — 다양한 지점에서 조회하여 캐시 히트 방지 const LOCATIONS = [ { name: '강남', lat: 37.4979, lng: 127.0276, minLat: 37.49, maxLat: 37.52, minLng: 127.01, maxLng: 127.05 }, { name: '홍대', lat: 37.5563, lng: 126.9236, minLat: 37.54, maxLat: 37.57, minLng: 126.91, maxLng: 126.94 }, @@ -36,60 +33,81 @@ const LOCATIONS = [ export const options = { scenarios: { - // 1단계: 소부하 — 기준선 측정 (인덱스 있어도 없어도 여기선 빠름) - baseline: { - executor: 'constant-vus', - vus: 10, - duration: '30s', - tags: { phase: 'baseline' }, - }, - // 2단계: 중부하 — 인덱스 없으면 이 구간부터 급격히 느려짐 - medium_load: { - executor: 'constant-vus', - vus: 30, - duration: '1m', - startTime: '35s', - tags: { phase: 'medium_load' }, + + // ── SCENARIO A: 이벤트 스파이크 ──────────────────────────────────────── + // 실제 상황: 오전 10시 이벤트 오픈, 좌석 공개처럼 특정 시각에 트래픽이 몰리는 상황 + // + // [워밍업을 추가한 이유] + // 실제 서비스에서 이벤트가 오픈되는 시점의 서버는 이미 운영 중인 상태. + // JVM JIT 컴파일과 DB 커넥션풀이 확보된 상태에서 갑자기 50명이 몰리는 것이 현실적. + // 워밍업 없이 바로 50명을 붙이면 JVM 콜드 스타트 비용이 포함되어 + // 실제 이벤트 상황의 성능과 다른 결과가 나옴. + // + // [ramping-vus를 쓴 이유] + // warmup(10명) + constant(50명)을 별도 시나리오로 돌리면 동시 실행되어 + // 실제로는 10 + 50 = 60명이 되는 문제가 생김. + // ramping-vus 하나로 합쳐야 정확히 50명으로 제어 가능. + // + // [체크포인트] + // GIST 인덱스 없으면 점프 직후 타임아웃 폭발 → timeoutCount 급증 + // GIST 인덱스 있으면 50명에서도 p95 < 2s 유지 + event_spike: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 10 }, // 워밍업: 평상시 트래픽 재현 + { duration: '5s', target: 50 }, // 이벤트 오픈: 5초 안에 50명으로 점프 + { duration: '2m', target: 50 }, // 피크 유지 + { duration: '15s', target: 0 }, + ], + tags: { scenario: 'event_spike' }, }, - // 3단계: 고부하 — 이전 테스트에서 타임아웃 발생한 구간 (50VU) - high_load: { - executor: 'constant-vus', - vus: 50, - duration: '1m', - startTime: '2m', - tags: { phase: 'high_load' }, + + // ── SCENARIO B: 점진적 증가 ──────────────────────────────────────────── + // 목적: 몇 명부터 타임아웃이 발생하는지 → 인덱스 없는 서버의 한계 탐색 + ramp_up: { + executor: 'ramping-vus', + startVUs: 0, + startTime: '3m20s', + stages: [ + { duration: '30s', target: 10 }, + { duration: '30s', target: 20 }, + { duration: '30s', target: 35 }, + { duration: '1m', target: 50 }, + { duration: '30s', target: 50 }, + { duration: '30s', target: 0 }, + ], + tags: { scenario: 'ramp_up' }, }, }, thresholds: { - nearby_duration: ['p(95)<2000'], // 2초 기준 (PostGIS 특성 고려) - in_bounds_duration: ['p(95)<2000'], - geo_error_rate: ['rate<0.05'], - http_req_failed: ['rate<0.05'], + 'nearby_duration': ['p(95)<2000'], + 'in_bounds_duration': ['p(95)<2000'], + 'geo_spike_duration': ['p(95)<3000'], + 'geo_ramp_duration': ['p(95)<2000'], + 'geo_error_rate': ['rate<0.05'], + 'http_req_failed': ['rate<0.05'], }, }; export default function () { - // 랜덤 위치 선택 — 매번 다른 좌표로 DB 풀스캔 유도 - const loc = LOCATIONS[Math.floor(Math.random() * LOCATIONS.length)]; - // 좌표에 미세한 오프셋 추가 → 캐시 효과 없애고 실제 쿼리 유도 - const latOffset = (Math.random() - 0.5) * 0.005; - const lngOffset = (Math.random() - 0.5) * 0.005; - const lat = (loc.lat + latOffset).toFixed(6); - const lng = (loc.lng + lngOffset).toFixed(6); - - group(`근처 매장 조회 (PostGIS nearby) - ${loc.name}`, () => { + const isSpike = exec.scenario.name === 'event_spike'; + const loc = LOCATIONS[Math.floor(Math.random() * LOCATIONS.length)]; + const lat = (loc.lat + (Math.random() - 0.5) * 0.005).toFixed(6); + const lng = (loc.lng + (Math.random() - 0.5) * 0.005).toFixed(6); + + function record(duration) { + isSpike ? spikeDuration.add(duration) : rampDuration.add(duration); + } + + 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 }, ); - nearbyDuration.add(res.timings.duration); - - // 15초 이상 걸리면 타임아웃으로 카운트 - if (res.timings.duration > 15000 || res.status === 0) { - timeoutCount.add(1); - } - + record(res.timings.duration); + 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, @@ -103,14 +121,10 @@ export default function () { `?minLat=${loc.minLat}&maxLat=${loc.maxLat}` + `&minLng=${loc.minLng}&maxLng=${loc.maxLng}` + `¢erLat=${lat}¢erLng=${lng}&limit=50`; - - const res = http.get(url, { headers: HEADERS_JSON, timeout: 15000 }); + const res = http.get(url, { headers: HEADERS_JSON, timeout: '15s' }); inBoundsDuration.add(res.timings.duration); - - if (res.timings.duration > 15000 || res.status === 0) { - timeoutCount.add(1); - } - + record(res.timings.duration); + 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, @@ -121,35 +135,37 @@ export default function () { } export function handleSummary(data) { - const p50Nearby = data.metrics.nearby_duration?.values?.['p(50)'] || 0; - const p95Nearby = data.metrics.nearby_duration?.values?.['p(95)'] || 0; - const p99Nearby = data.metrics.nearby_duration?.values?.['p(99)'] || 0; - const p95Bounds = data.metrics.in_bounds_duration?.values?.['p(95)'] || 0; - const p99Bounds = data.metrics.in_bounds_duration?.values?.['p(99)'] || 0; - const timeouts = data.metrics.geo_timeout_count?.values?.count || 0; - const totalReqs = data.metrics.http_reqs?.values?.count || 0; + const s_p95 = data.metrics.geo_spike_duration?.values?.['p(95)'] || 0; + const s_p99 = data.metrics.geo_spike_duration?.values?.['p(99)'] || 0; + const r_p95 = data.metrics.geo_ramp_duration?.values?.['p(95)'] || 0; + const r_p99 = data.metrics.geo_ramp_duration?.values?.['p(99)'] || 0; + const p95Nearby = data.metrics.nearby_duration?.values?.['p(95)'] || 0; + const p95Bounds = data.metrics.in_bounds_duration?.values?.['p(95)'] || 0; + const timeouts = data.metrics.geo_timeout_count?.values?.count || 0; + const totalReqs = data.metrics.http_reqs?.values?.count || 0; + + const indexStatus = timeouts > 0 + ? `⚠ 타임아웃 ${timeouts}건 → GIST 인덱스 미적용 가능성` + : `✓ 타임아웃 0건 → GIST 인덱스 정상`; return { stdout: ` -===== PostGIS 지리 쿼리 단일 API 부하테스트 결과 ===== -총 요청수 : ${totalReqs} -타임아웃 횟수 : ${timeouts} - -[GET /stores/nearby] -p50 응답시간 : ${p50Nearby.toFixed(0)}ms -p95 응답시간 : ${p95Nearby.toFixed(0)}ms -p99 응답시간 : ${p99Nearby.toFixed(0)}ms - -[GET /stores/in-bounds] -p95 응답시간 : ${p95Bounds.toFixed(0)}ms -p99 응답시간 : ${p99Bounds.toFixed(0)}ms - -[분석] -- p95 > 2s 또는 타임아웃 존재 → GIST 공간 인덱스 미적용 가능성 높음 - 확인 쿼리: SELECT indexname FROM pg_indexes WHERE tablename = 'stores'; - 적용 예시: CREATE INDEX idx_stores_location ON stores USING GIST(location); -- p95 < 500ms → 인덱스 정상 적용 상태 -====================================================== +===== PostGIS 지리 쿼리 부하테스트 결과 ===== +총 요청수 : ${totalReqs.toLocaleString()}건 +타임아웃 횟수 : ${timeouts}건 + +[SCENARIO A] 이벤트 스파이크 (워밍업 10명 → 50명 점프) + p95 : ${s_p95.toFixed(0)}ms / p99 : ${s_p99.toFixed(0)}ms + +[SCENARIO B] 점진적 증가 (0→50명) + p95 : ${r_p95.toFixed(0)}ms / p99 : ${r_p99.toFixed(0)}ms + +[API별 p95] + GET /stores/nearby : ${p95Nearby.toFixed(0)}ms (기준: 2000ms) + GET /stores/in-bounds : ${p95Bounds.toFixed(0)}ms (기준: 2000ms) + +[GIST 인덱스 진단] ${indexStatus} +============================================= `, }; } diff --git a/k6/07-remains.js b/k6/07-remains.js index e58929f..285a588 100644 --- a/k6/07-remains.js +++ b/k6/07-remains.js @@ -1,55 +1,89 @@ /** - * [단일 API] 잔여석 조회 부하테스트 - * 실행: .\k6\run.ps1 -Test 07 -BaseUrl https://api.catcheat.kro.kr + * [단일 API] 잔여석 조회 부하테스트 — 이벤트 스파이크 vs 점진적 증가 + * 실행: .\k6\run.ps1 -Test 07 * - * 목적: - * - GET /remains?storeId={id}&date={date} 단일 API 집중 부하 - * - 예약 전 반드시 호출되는 API → 실제 트래픽에서 가장 빈번하게 호출됨 - * - (store_id, remain_date) 복합 인덱스 효과 검증 - * - * 기대 결과: - * - p95 < 200ms (인덱스 정상 활용 시, 단순 조회 쿼리) - * - 300VU에서도 안정적으로 처리 가능해야 함 + * 전체 소요 시간: 약 9분 + * [0s ~ ~2m50s] event_spike — 워밍업 후 50명 즉시 점프 + * [3m20s ~ ~9m] ramp_up — 0→50명 단계적 증가 */ 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 } from './config.js'; const remainsDuration = new Trend('remains_duration', true); +const spikeDuration = new Trend('remains_spike_duration', true); +const rampDuration = new Trend('remains_ramp_duration', true); const errorRate = new Rate('remains_error_rate'); -// 테스트할 매장 ID 목록 — 실제 DB에 존재하는 ID로 교체 const STORE_IDS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; export const options = { scenarios: { - // 최대 50VU + + // ── SCENARIO A: 이벤트 스파이크 ──────────────────────────────────────── + // 실제 상황: 오전 10시 이벤트 오픈, 좌석 공개처럼 특정 시각에 트래픽이 몰리는 상황 + // + // [워밍업을 추가한 이유] + // 실제 서비스에서 이벤트가 오픈되는 시점의 서버는 이미 운영 중인 상태. + // JVM JIT 컴파일과 DB 커넥션풀이 확보된 상태에서 갑자기 50명이 몰리는 것이 현실적. + // 워밍업 없이 바로 50명을 붙이면 JVM 콜드 스타트 비용이 포함되어 + // 실제 이벤트 상황의 성능과 다른 결과가 나옴. + // + // [ramping-vus를 쓴 이유] + // warmup(10명) + constant(50명)을 별도 시나리오로 돌리면 동시 실행되어 + // 실제로는 10 + 50 = 60명이 되는 문제가 생김. + // ramping-vus 하나로 합쳐야 정확히 50명으로 제어 가능. + // + // [체크포인트] + // 잔여석은 단순 인덱스 조회라 점프 후에도 p95 300ms 이내가 정상 + // 넘으면 → (store_id, remain_date) 복합 인덱스 미적용 또는 Row Lock 경합 + event_spike: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 10 }, // 워밍업: 평상시 트래픽 재현 + { duration: '5s', target: 50 }, // 이벤트 오픈: 5초 안에 50명으로 점프 + { duration: '2m', target: 50 }, // 피크 유지 + { duration: '15s', target: 0 }, + ], + tags: { scenario: 'event_spike' }, + }, + + // ── SCENARIO B: 점진적 증가 ──────────────────────────────────────────── + // 목적: p95가 VU 수와 비례해서 증가하는지 확인 + // 인덱스가 제대로 걸리면 VU 수와 무관하게 p95가 일정해야 함 ramp_up: { executor: 'ramping-vus', startVUs: 0, + startTime: '3m20s', stages: [ - { duration: '30s', target: 10 }, // 워밍업 - { duration: '30s', target: 30 }, - { duration: '1m', target: 50 }, // 최대 50VU - { duration: '1m', target: 50 }, // 유지 - { duration: '30s', target: 0 }, // 종료 + { duration: '30s', target: 10 }, + { duration: '30s', target: 20 }, + { duration: '30s', target: 35 }, + { duration: '1m', target: 50 }, + { duration: '30s', target: 50 }, + { duration: '30s', target: 0 }, ], + tags: { scenario: 'ramp_up' }, }, }, thresholds: { - remains_duration: ['p(95)<300', 'p(99)<500'], // 단순 인덱스 조회라 300ms 기준 - remains_error_rate: ['rate<0.01'], - http_req_failed: ['rate<0.01'], + 'remains_duration': ['p(95)<300', 'p(99)<500'], + 'remains_spike_duration': ['p(95)<400'], + 'remains_ramp_duration': ['p(95)<300'], + 'remains_error_rate': ['rate<0.01'], + 'http_req_failed': ['rate<0.01'], }, }; export default function () { - const storeId = STORE_IDS[Math.floor(Math.random() * STORE_IDS.length)]; - - // 오늘 포함 향후 7일 중 랜덤 날짜 — 캐시 히트 방지 + 실제 사용 패턴 재현 + const isSpike = exec.scenario.name === 'event_spike'; + const storeId = STORE_IDS[Math.floor(Math.random() * STORE_IDS.length)]; + // +9h: 서버가 UTC로 실행될 경우 자정~오전9시 사이에 날짜가 하루 틀어지는 것 방지 const today = new Date(Date.now() + 9 * 60 * 60 * 1000); - const dayOffset = Math.floor(Math.random() * 7); // 0~6일 후 + const dayOffset = Math.floor(Math.random() * 7); today.setDate(today.getDate() + dayOffset); const date = today.toISOString().split('T')[0]; @@ -58,8 +92,8 @@ export default function () { `${BASE_URL}/api/v1/remains?storeId=${storeId}&date=${date}`, { headers: HEADERS_JSON }, ); - remainsDuration.add(res.timings.duration); + isSpike ? spikeDuration.add(res.timings.duration) : rampDuration.add(res.timings.duration); const ok = check(res, { '잔여석 200': (r) => r.status === 200, @@ -68,33 +102,40 @@ export default function () { errorRate.add(!ok); }); - sleep(0.5); // 잔여석은 짧은 sleep — 실제 클릭 패턴 반영 + sleep(0.5); } export function handleSummary(data) { - const p50 = data.metrics.remains_duration?.values?.['p(50)'] || 0; - const p95 = data.metrics.remains_duration?.values?.['p(95)'] || 0; - const p99 = data.metrics.remains_duration?.values?.['p(99)'] || 0; - const max = data.metrics.remains_duration?.values?.max || 0; - const reqs = data.metrics.http_reqs?.values?.count || 0; - const rps = data.metrics.http_reqs?.values?.rate || 0; + const s_p95 = data.metrics.remains_spike_duration?.values?.['p(95)'] || 0; + const s_p99 = data.metrics.remains_spike_duration?.values?.['p(99)'] || 0; + const r_p95 = data.metrics.remains_ramp_duration?.values?.['p(95)'] || 0; + const r_p99 = data.metrics.remains_ramp_duration?.values?.['p(99)'] || 0; + const p95 = data.metrics.remains_duration?.values?.['p(95)'] || 0; + const p99 = data.metrics.remains_duration?.values?.['p(99)'] || 0; + const reqs = data.metrics.http_reqs?.values?.count || 0; + const rps = data.metrics.http_reqs?.values?.rate || 0; + const errR = data.metrics.remains_error_rate?.values?.rate || 0; + + const indexNote = p95 < 300 + ? `✓ p95 ${p95.toFixed(0)}ms → 복합 인덱스 정상` + : `⚠ p95 ${p95.toFixed(0)}ms → 인덱스 미적용 또는 Row Lock 경합 의심`; return { stdout: ` ===== 잔여석 조회 단일 API 부하테스트 결과 ===== -총 요청수 : ${reqs} +총 요청수 : ${reqs.toLocaleString()}건 최대 RPS : ${rps.toFixed(1)} req/s +에러율 : ${(errR * 100).toFixed(3)}% + +[SCENARIO A] 이벤트 스파이크 (워밍업 10명 → 50명 점프) + p95 : ${s_p95.toFixed(0)}ms / p99 : ${s_p99.toFixed(0)}ms + +[SCENARIO B] 점진적 증가 (0→50명) + p95 : ${r_p95.toFixed(0)}ms / p99 : ${r_p99.toFixed(0)}ms -p50 응답시간 : ${p50.toFixed(0)}ms -p95 응답시간 : ${p95.toFixed(0)}ms -p99 응답시간 : ${p99.toFixed(0)}ms -최대 응답시간 : ${max.toFixed(0)}ms +[전체 p95 : ${p95.toFixed(0)}ms / p99 : ${p99.toFixed(0)}ms] -[분석] -- p95 < 200ms → 정상 (인덱스 활용 중) -- p95 200~500ms → 쿼리 실행계획 확인 권장 - EXPLAIN ANALYZE SELECT * FROM store_remains WHERE store_id=? AND remain_date=?; -- p95 > 500ms → (store_id, remain_date) 복합 인덱스 누락 가능성 +[인덱스 진단] ${indexNote} ================================================ `, }; diff --git a/k6/grafana/dashboards/k6-dashboard.json b/k6/grafana/dashboards/k6-dashboard.json new file mode 100644 index 0000000..ec6ccfc --- /dev/null +++ b/k6/grafana/dashboards/k6-dashboard.json @@ -0,0 +1,786 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__elements": {}, + "__requires": [ + { "type": "grafana", "id": "grafana", "name": "Grafana", "version": "10.0.0" }, + { "type": "datasource", "id": "prometheus", "name": "Prometheus", "version": "1.0.0" }, + { "type": "panel", "id": "stat", "name": "Stat", "version": "" }, + { "type": "panel", "id": "timeseries", "name": "Time series", "version": "" }, + { "type": "panel", "id": "gauge", "name": "Gauge", "version": "" } + ], + "annotations": { "list": [] }, + "description": "k6 부하테스트 실시간 모니터링", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "id": 1, + "title": "요약", + "type": "row" + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, + "id": 2, + "options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] } }, + "title": "가상 유저 (VUs)", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "k6_vus", + "instant": true, + "refId": "A" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, + "id": 3, + "options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] } }, + "title": "초당 요청수 (RPS)", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "sum(rate(k6_http_reqs_total[1m]))", + "instant": true, + "refId": "A" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 5 } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, + "id": 4, + "options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] } }, + "title": "에러율", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "sum(rate(k6_http_reqs_total{expected_response=\"false\"}[1m])) / sum(rate(k6_http_reqs_total[1m])) * 100", + "instant": true, + "refId": "A" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 500 }, + { "color": "red", "value": 1000 } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, + "id": 5, + "options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] } }, + "title": "p95 응답시간", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "histogram_quantile(0.95, sum(rate(k6_http_req_duration_seconds_bucket[1m])) by (le)) * 1000", + "instant": true, + "refId": "A" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1000 }, + { "color": "red", "value": 3000 } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, + "id": 6, + "options": { "colorMode": "background", "graphMode": "area", "justifyMode": "center", "reduceOptions": { "calcs": ["lastNotNull"] } }, + "title": "p99 응답시간", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "histogram_quantile(0.99, sum(rate(k6_http_req_duration_seconds_bucket[1m])) by (le)) * 1000", + "instant": true, + "refId": "A" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 }, + "id": 7, + "options": { "colorMode": "background", "graphMode": "none", "justifyMode": "center", "reduceOptions": { "calcs": ["sum"] } }, + "title": "총 요청수", + "type": "stat", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "sum(k6_http_reqs_total)", + "instant": true, + "refId": "A" + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, + "id": 8, + "title": "트래픽", + "type": "row" + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "showPoints": "never", "spanNulls": false }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 }, + "id": 9, + "options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi" } }, + "title": "가상 유저 수 (VUs)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "k6_vus", + "legendFormat": "활성 VUs", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "k6_vus_max", + "legendFormat": "최대 VUs", + "refId": "B" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "showPoints": "never", "spanNulls": false }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 }, + "id": 10, + "options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi" } }, + "title": "초당 요청수 (RPS)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "sum(rate(k6_http_reqs_total[1m]))", + "legendFormat": "전체 RPS", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "sum(rate(k6_http_reqs_total{expected_response=\"false\"}[1m]))", + "legendFormat": "에러 RPS", + "refId": "B" + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 }, + "id": 11, + "title": "응답시간", + "type": "row" + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2, "showPoints": "never", "spanNulls": false }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 }, + "id": 12, + "options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi" } }, + "title": "응답시간 백분위 (p50 / p95 / p99)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "histogram_quantile(0.50, sum(rate(k6_http_req_duration_seconds_bucket[1m])) by (le)) * 1000", + "legendFormat": "p50", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "histogram_quantile(0.95, sum(rate(k6_http_req_duration_seconds_bucket[1m])) by (le)) * 1000", + "legendFormat": "p95", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "histogram_quantile(0.99, sum(rate(k6_http_req_duration_seconds_bucket[1m])) by (le)) * 1000", + "legendFormat": "p99", + "refId": "C" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "showPoints": "never", "spanNulls": false }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 }, + "id": 13, + "options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi" } }, + "title": "구간별 응답시간 (대기 / 연결 / 수신)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "rate(k6_http_req_waiting_seconds_sum[1m]) / rate(k6_http_req_waiting_seconds_count[1m]) * 1000", + "legendFormat": "서버 대기 (TTFB)", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "rate(k6_http_req_connecting_seconds_sum[1m]) / rate(k6_http_req_connecting_seconds_count[1m]) * 1000", + "legendFormat": "TCP 연결", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "rate(k6_http_req_receiving_seconds_sum[1m]) / rate(k6_http_req_receiving_seconds_count[1m]) * 1000", + "legendFormat": "응답 수신", + "refId": "C" + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 }, + "id": 14, + "title": "예약 동시성 테스트 (02번 전용)", + "type": "row" + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "showPoints": "never", "spanNulls": false }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 24 }, + "id": 15, + "options": { "legend": { "calcs": ["sum"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi" } }, + "title": "예약 결과 (성공 / 좌석소진 / 락충돌)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "rate(k6_reservation_success_total[1m])", + "legendFormat": "예약 성공", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "rate(k6_reservation_exhausted_total[1m])", + "legendFormat": "좌석 소진", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "rate(k6_reservation_conflict_total[1m])", + "legendFormat": "락 충돌", + "refId": "C" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2, "showPoints": "never", "spanNulls": false }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 24 }, + "id": 16, + "options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi" } }, + "title": "예약 API 응답시간 (p95 / p99)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "histogram_quantile(0.95, sum(rate(k6_reservation_duration_seconds_bucket[1m])) by (le)) * 1000", + "legendFormat": "p95", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "histogram_quantile(0.99, sum(rate(k6_reservation_duration_seconds_bucket[1m])) by (le)) * 1000", + + "legendFormat": "p99", + "refId": "B" + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 32 }, + "id": 20, + "title": "시나리오 비교 — event_spike (워밍업 후 스파이크) vs ramp_up (점진 증가)", + "type": "row" + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "description": "같은 테스트를 두 시나리오로 실행했을 때 p95 응답시간 차이.\n두 선의 간격이 크면 → 서버가 워밍업(JIT, DB 커넥션풀)에 민감한 것.\n간격이 작으면 → 잘 설계된 서버.", + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2, "showPoints": "never", "spanNulls": false }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 33 }, + "id": 21, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi" } + }, + "title": "시나리오별 p95 / p99 응답시간 비교", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "histogram_quantile(0.95, sum(rate(k6_http_req_duration_seconds_bucket{scenario=\"event_spike\"}[1m])) by (le)) * 1000", + "legendFormat": "event_spike p95 (워밍업 후 스파이크)", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "histogram_quantile(0.95, sum(rate(k6_http_req_duration_seconds_bucket{scenario=\"ramp_up\"}[1m])) by (le)) * 1000", + "legendFormat": "ramp_up p95 (점진 증가)", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "histogram_quantile(0.99, sum(rate(k6_http_req_duration_seconds_bucket{scenario=\"event_spike\"}[1m])) by (le)) * 1000", + "legendFormat": "event_spike p99", + "refId": "C" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "histogram_quantile(0.99, sum(rate(k6_http_req_duration_seconds_bucket{scenario=\"ramp_up\"}[1m])) by (le)) * 1000", + "legendFormat": "ramp_up p99", + "refId": "D" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "description": "VU 수(막대)와 ramp_up 시나리오 p95(선)를 겹쳐서 보면\n몇 명에서 응답시간이 꺾이는지(임계점) 한눈에 파악 가능.\n꺾임 지점 = 현재 서버 설정(커넥션풀, 스레드풀)의 실질적 한계.", + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 2, "showPoints": "never", "spanNulls": false }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "ramp_up p95 응답시간 (ms)" }, + "properties": [ + { "id": "custom.axisPlacement", "value": "right" }, + { "id": "unit", "value": "ms" }, + { "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } } + ] + } + ] + }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 41 }, + "id": 22, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi" } + }, + "title": "VU 수 vs ramp_up p95 — 임계점(Inflection Point) 탐색", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "k6_vus", + "legendFormat": "활성 VU 수", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "histogram_quantile(0.95, sum(rate(k6_http_req_duration_seconds_bucket{scenario=\"ramp_up\"}[1m])) by (le)) * 1000", + "legendFormat": "ramp_up p95 응답시간 (ms)", + "refId": "B" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "description": "두 시나리오의 초당 요청 수(RPS) 비교.\nVU 수가 같은 시점(ramp_up 50VU 도달 후 vs event_spike)에서 RPS가 비슷하면 → 처리량 동일.\nevent_spike 초반 RPS가 낮으면 → 워밍업 구간 이후 스파이크 처리 안정화 확인.", + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "showPoints": "never", "spanNulls": false }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 49 }, + "id": 23, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi" } + }, + "title": "시나리오별 RPS (처리량 비교)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "sum(rate(k6_http_reqs_total{scenario=\"event_spike\"}[1m]))", + "legendFormat": "event_spike RPS", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "sum(rate(k6_http_reqs_total{scenario=\"ramp_up\"}[1m]))", + "legendFormat": "ramp_up RPS", + "refId": "B" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "description": "두 시나리오의 에러율 비교.\nevent_spike 초반에만 에러가 나면 → DB 커넥션풀 확보 전 타임아웃.\n에러가 특정 시점부터 계속 올라가면 → 서버 포화 상태.", + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "showPoints": "never", "spanNulls": false }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 49 }, + "id": 24, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi" } + }, + "title": "시나리오별 에러율", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "sum(rate(k6_http_reqs_total{scenario=\"event_spike\",expected_response=\"false\"}[1m])) / sum(rate(k6_http_reqs_total{scenario=\"event_spike\"}[1m]))", + "legendFormat": "event_spike 에러율", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "sum(rate(k6_http_reqs_total{scenario=\"ramp_up\",expected_response=\"false\"}[1m])) / sum(rate(k6_http_reqs_total{scenario=\"ramp_up\"}[1m]))", + "legendFormat": "ramp_up 에러율", + "refId": "B" + } + ] + }, + + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 57 }, + "id": 25, + "title": "심화 지표", + "type": "row" + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "description": "4xx와 5xx를 분리해서 보는 이유:\n4xx = 비즈니스 로직 문제 (토큰 만료, 좌석 소진 등) → 코드 수정 필요\n5xx = 서버 오류 (DB 장애, OOM, 타임아웃 등) → 즉시 원인 분석 필요\n에러율만 보면 원인을 알 수 없어서 이 패널이 반드시 필요함.", + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "showPoints": "never", "spanNulls": false }, + "unit": "reqps" + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "5xx (서버 오류)" }, + "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byName", "options": "4xx (비즈니스 오류)" }, + "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] + } + ] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 58 }, + "id": 26, + "options": { + "legend": { "calcs": ["max", "sum"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi" } + }, + "title": "HTTP 상태 코드별 에러 분리 (4xx vs 5xx)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "sum(rate(k6_http_reqs_total{status=~\"5..\"}[1m]))", + "legendFormat": "5xx (서버 오류)", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "sum(rate(k6_http_reqs_total{status=~\"4..\"}[1m]))", + "legendFormat": "4xx (비즈니스 오류)", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "sum(rate(k6_http_reqs_total{status=~\"2..\"}[1m]))", + "legendFormat": "2xx (성공)", + "refId": "C" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "description": "Dropped Iterations: k6가 이전 이터레이션을 미처 끝내지 못해 요청 자체를 버린 횟수.\n이 수치가 올라가면 서버가 너무 느려서 k6가 계획한 대로 요청조차 못 보내는 상황.\n→ 테스트 결과 자체가 의미 없어지는 신호. 반드시 0이어야 함.", + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "bars", "fillOpacity": 80, "lineWidth": 1, "showPoints": "never", "spanNulls": false }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "Dropped Iterations" }, + "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] + } + ] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 58 }, + "id": 27, + "options": { + "legend": { "calcs": ["sum"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi" } + }, + "title": "Dropped Iterations — 테스트 무결성 확인", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "rate(k6_dropped_iterations_total[1m])", + "legendFormat": "Dropped Iterations", + "refId": "A" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "description": "응답이 느릴 때 원인을 구분하는 방법:\n- TTFB(서버 대기)는 길고 수신량이 작으면 → 서버 처리 병목 (쿼리 최적화 필요)\n- 수신량이 크고 수신 시간이 길면 → 응답 크기 문제 (페이지네이션·캐시 검토)\n- 전송량이 크면 → 요청 body가 불필요하게 큰 것", + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 10, "lineWidth": 1, "showPoints": "never", "spanNulls": false }, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 66 }, + "id": 28, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi" } + }, + "title": "데이터 송수신량 (네트워크 vs 서버 병목 구분)", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "rate(k6_data_received_bytes_total[1m])", + "legendFormat": "수신 (서버 → 클라이언트)", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "rate(k6_data_sent_bytes_total[1m])", + "legendFormat": "송신 (클라이언트 → 서버)", + "refId": "B" + } + ] + }, + + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "description": "두 시나리오의 VU 수를 분리해서 보여줌.\nevent_spike: 0~3m20s 구간에서 실행 (워밍업 10명 → 50명 점프)\nramp_up: 3m20s 이후 구간에서 실행 (0→50명 단계 증가)\n두 시나리오가 겹치지 않으므로 타임라인에서 구간별로 확인.", + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "drawStyle": "line", "fillOpacity": 20, "lineWidth": 2, "showPoints": "never", "spanNulls": false }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 66 }, + "id": 29, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi" } + }, + "title": "시나리오별 VU 수 분리", + "type": "timeseries", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "k6_vus{scenario=\"event_spike\"}", + "legendFormat": "event_spike VUs", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "k6_vus{scenario=\"ramp_up\"}", + "legendFormat": "ramp_up VUs", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" }, + "expr": "k6_vus", + "legendFormat": "전체 VUs", + "refId": "C" + } + ] + } + + ], + "refresh": "5s", + "schemaVersion": 38, + "tags": ["k6", "load-test"], + "templating": { "list": [] }, + "time": { "from": "now-15m", "to": "now" }, + "timepicker": {}, + "timezone": "Asia/Seoul", + "title": "k6 부하테스트 모니터링", + "uid": "catcheat-k6-v1", + "version": 1, + "weekStart": "" +} diff --git a/k6/run.ps1 b/k6/run.ps1 index aaa4b69..513191c 100644 --- a/k6/run.ps1 +++ b/k6/run.ps1 @@ -1,23 +1,18 @@ # k6 부하테스트 실행 스크립트 # # 사용법: -# .\k6\run.ps1 -Test 01 # 인증 불필요 -# .\k6\run.ps1 -Test 02 -AuthToken "eyJ..." -RemainId 5 # 예약 동시성 -# .\k6\run.ps1 -Test 04 -AuthToken "eyJ..." # 서킷브레이커 -# .\k6\run.ps1 -Test 05 -BaseUrl https://api.catcheat.kro.kr # 운영서버 목록 부하 -# .\k6\run.ps1 -Test 06 -BaseUrl https://api.catcheat.kro.kr # PostGIS 지리 쿼리 -# .\k6\run.ps1 -Test 07 -BaseUrl https://api.catcheat.kro.kr # 잔여석 조회 -# .\k6\run.ps1 -Test 08 -AuthToken "eyJ..." -CouponTemplateId 1 # 쿠폰 선착순 발급 -# .\k6\run.ps1 -Test 10 -AuthToken "eyJ..." -BaseUrl https://api.catcheat.kro.kr # Soak +# .\k6\run.ps1 -Test 01 # 기본 (운영서버 + 서버 Grafana) +# .\k6\run.ps1 -Test 02 -AuthToken "eyJ..." -RemainId 5 +# .\k6\run.ps1 -Test 01 -BaseUrl http://localhost:8080 # 로컬 서버 테스트 # # 테스트 번호 목록: -# 01 - 매장 전체 탐색 플로우 (6개 API 조합) +# 01 - 매장 전체 탐색 플로우 (constant_50 + ramp_up) # 02 - 예약 동시성 (분산락 + 낙관적락 검증) -# 03 - 전체 사용자 플로우 (home → 예약) -# 04 - AI 챗봇 서킷브레이커 -# 05 - [단일] 매장 목록 조회 (필터 조합, 최대 200VU) -# 06 - [단일] PostGIS 지리 쿼리 (nearby + in-bounds, 최대 150VU) -# 07 - [단일] 잔여석 조회 (최대 300VU) +# 03 - 전체 사용자 플로우 (constant_50 + ramp_up) +# 04 - AI 챗봇 서킷브레이커 (ramp_up) +# 05 - [단일] 매장 목록 조회 (constant_50 + ramp_up) +# 06 - [단일] PostGIS 지리 쿼리 (constant_50 + ramp_up) +# 07 - [단일] 잔여석 조회 (constant_50 + ramp_up) # 08 - [단일] 쿠폰 선착순 발급 동시성 # 10 - [내구성] Soak 테스트 (10VU × 30분) @@ -29,8 +24,9 @@ param( [string]$AuthToken = "", [string]$RemainId = "1", [string]$CouponTemplateId = "1", - [string]$BaseUrl = "http://localhost:8080", - [string]$PrometheusUrl = "http://localhost:9091/api/v1/write" + [string]$BaseUrl = "https://api.catcheat.kro.kr", + [string]$PrometheusUrl = "http://54.116.98.27:9090/api/v1/write", + [string]$GrafanaUrl = "http://54.116.98.27:3001" ) $scriptMap = @{ @@ -52,7 +48,7 @@ Write-Host "===== k6 부하테스트 시작 =====" -ForegroundColor Cyan Write-Host "테스트 : $Test" Write-Host "스크립트 : $scriptName" Write-Host "대상 서버 : $BaseUrl" -Write-Host "Grafana : http://localhost:3002 (실시간 모니터링)" +Write-Host "Grafana : $GrafanaUrl (실시간 모니터링)" Write-Host "" $env:K6_PROMETHEUS_RW_SERVER_URL = $PrometheusUrl From 7a9b728839dd5f15dfe80c54130dafb019889f03 Mon Sep 17 00:00:00 2001 From: dongan Date: Fri, 29 May 2026 16:06:19 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20k6=20URL=20=EC=9D=B8=EC=BD=94?= =?UTF-8?q?=EB=94=A9=20=EB=B0=8F=20Date=20=ED=83=80=EC=9E=84=EC=A1=B4=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 05: district 파라미터 한글 encodeURIComponent 적용 - 07: setDate/getDate 타임존 혼용 → ms 연산으로 통일 Co-Authored-By: Claude Sonnet 4.6 --- k6/05-store-list.js | 2 +- k6/07-remains.js | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/k6/05-store-list.js b/k6/05-store-list.js index 23f416a..4322798 100644 --- a/k6/05-store-list.js +++ b/k6/05-store-list.js @@ -95,7 +95,7 @@ export default function () { let url = `${BASE_URL}/api/v1/stores?page=${randomPage}&size=10`; if (useFilter) { if (Math.random() > 0.5) url += `&category=${randomCategory}`; - if (Math.random() > 0.5) url += `&district=${randomDistrict}`; + if (Math.random() > 0.5) url += `&district=${encodeURIComponent(randomDistrict)}`; } const res = http.get(url, { headers: HEADERS_JSON }); listDuration.add(res.timings.duration); diff --git a/k6/07-remains.js b/k6/07-remains.js index 285a588..5ca2e58 100644 --- a/k6/07-remains.js +++ b/k6/07-remains.js @@ -82,10 +82,8 @@ export default function () { const isSpike = exec.scenario.name === 'event_spike'; const storeId = STORE_IDS[Math.floor(Math.random() * STORE_IDS.length)]; // +9h: 서버가 UTC로 실행될 경우 자정~오전9시 사이에 날짜가 하루 틀어지는 것 방지 - const today = new Date(Date.now() + 9 * 60 * 60 * 1000); - const dayOffset = Math.floor(Math.random() * 7); - today.setDate(today.getDate() + dayOffset); - const date = today.toISOString().split('T')[0]; + const dayOffset = Math.floor(Math.random() * 7) * 24 * 60 * 60 * 1000; + const date = new Date(Date.now() + 9 * 60 * 60 * 1000 + dayOffset).toISOString().split('T')[0]; group('잔여석 조회', () => { const res = http.get(