diff --git a/grafana/provisioning/dashboards/catcheat-monitoring-dashboard.json b/grafana/provisioning/dashboards/catcheat-monitoring-dashboard.json index 51e7884..d968a89 100644 --- a/grafana/provisioning/dashboards/catcheat-monitoring-dashboard.json +++ b/grafana/provisioning/dashboards/catcheat-monitoring-dashboard.json @@ -434,7 +434,7 @@ "title": "CB 호출 결과 (성공/실패/차단)", "description": "failed 비율 50% + 5초 지속 시 OPEN 임박. not_permitted 증가 = CB가 OPEN 상태에서 차단 중.", "type": "timeseries", - "targets": [ { "expr": "sum by (name, kind) (rate(resilience4j_circuitbreaker_calls_total[5m]))", "legendFormat": "{{name}} {{kind}}", "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } ] + "targets": [ { "expr": "sum by (name, kind) (rate(resilience4j_circuitbreaker_calls_seconds_count[5m])) or label_replace(sum by (name) (rate(resilience4j_circuitbreaker_not_permitted_calls_total[5m])), \"kind\", \"not_permitted\", \"\", \"\")", "legendFormat": "{{name}} {{kind}}", "refId": "A", "datasource": { "type": "prometheus", "uid": "DS_PROMETHEUS" } } ] }, { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 68 }, "id": 900, "title": "Row 9 — 시스템 자원", "type": "row" }, diff --git a/k6/01-store-browse.js b/k6/01-store-browse.js index 5afea1d..15cf225 100644 --- a/k6/01-store-browse.js +++ b/k6/01-store-browse.js @@ -10,9 +10,9 @@ * - GET /stores/{id} (상세) * - GET /remains (시간대별 좌석) * - * 전체 소요 시간: 약 9분 + * 전체 소요 시간: 약 5분 * [0s ~ ~2m50s] event_spike — 워밍업 후 50명 즉시 점프 - * [3m20s ~ ~9m] ramp_up — 0→50명 단계적 증가 + * [3m ~ ~5m] ramp_up — 0→50명 단축 단계 (10→30→50, 임계점 1차 탐색) * * [두 시나리오 비교 포인트] * - nearby / in-bounds: PostGIS 쿼리는 GIST 인덱스가 없으면 spike 구간에서 타임아웃 @@ -74,16 +74,14 @@ export const options = { // ── SCENARIO B: 점진적 증가 ──────────────────────────────────────────── // 목적: 몇 명부터 응답시간이 꺾이는지 임계점 탐색 // event_spike와 달리 서버가 단계적으로 적응하면서 50명에 도달 - // 체크: 10→20→35→50 각 단계에서 nearby p95 변화량 확인 + // 체크: 10→30→50 각 단계에서 nearby p95 변화량 확인 ramp_up: { executor: 'ramping-vus', startVUs: 0, - startTime: '3m20s', + startTime: '3m', stages: [ { duration: '30s', target: 10 }, - { duration: '30s', target: 20 }, - { duration: '30s', target: 35 }, - { duration: '1m', target: 50 }, + { duration: '30s', target: 30 }, { duration: '30s', target: 50 }, { duration: '30s', target: 0 }, ], diff --git a/k6/03-full-flow.js b/k6/03-full-flow.js index 72a0e75..0f19b96 100644 --- a/k6/03-full-flow.js +++ b/k6/03-full-flow.js @@ -4,9 +4,9 @@ * * 시나리오: 홈 진입 → 매장 검색 → 상세 조회 → 메뉴/리뷰 확인 → 좌석 확인 * - * 전체 소요 시간: 약 9분 + * 전체 소요 시간: 약 5분 * [0s ~ ~2m50s] event_spike — 워밍업 후 50명 즉시 점프 - * [3m20s ~ ~9m] ramp_up — 0→50명 단계적 증가 + * [3m ~ ~5m] ramp_up — 0→50명 단축 단계 (10→30→50, 임계점 1차 탐색) */ import http from 'k6/http'; import { sleep, check, group } from 'k6'; @@ -35,23 +35,8 @@ const errorRate = new Rate('flow_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명으로 제어 가능. - // - // [체크포인트] - // 점프 직후(30~35s) Step별 p95 중 어느 step이 먼저 터지는지 확인 - // → 가장 먼저 급등하는 step = 이벤트 상황에서의 첫 번째 병목 + // SCENARIO A: 이벤트 스파이크 — 50명 즉시 점프로 이벤트 오픈 순간 재현 (패턴 근거는 01-store-browse.js 헤더) + // 체크: 점프 직후 Step별 p95 — 가장 먼저 급등하는 step = 첫 번째 병목 event_spike: { executor: 'ramping-vus', startVUs: 0, @@ -64,17 +49,14 @@ export const options = { tags: { scenario: 'event_spike' }, }, - // ── SCENARIO B: 점진적 증가 ──────────────────────────────────────────── - // 목적: 몇 명부터 어느 step이 먼저 나빠지는지 임계점 탐색 + // SCENARIO B: 점진적 증가 — 임계점 1차 탐색 (몇 명부터 어느 step이 먼저 나빠지는가) ramp_up: { executor: 'ramping-vus', startVUs: 0, - startTime: '3m20s', + startTime: '3m', stages: [ { duration: '30s', target: 10 }, - { duration: '30s', target: 20 }, - { duration: '30s', target: 35 }, - { duration: '1m', target: 50 }, + { duration: '30s', target: 30 }, { duration: '30s', target: 50 }, { duration: '30s', target: 0 }, ], diff --git a/k6/04-circuit-breaker.js b/k6/04-circuit-breaker.js index d46b1b0..bd790c0 100644 --- a/k6/04-circuit-breaker.js +++ b/k6/04-circuit-breaker.js @@ -1,138 +1,113 @@ /** - * 서킷브레이커 부하테스트 — 점진적 증가로 OPEN 전환 시점 탐색 - * 실행: .\k6\run.ps1 -Test 04 -AuthToken "eyJ..." -BaseUrl https://api.catcheat.kro.kr + * 서킷브레이커 풀 사이클 시연 — 무료 quota로 CLOSED → OPEN → HALF_OPEN → CLOSED 전체 검증 + * 실행: ./k6/run.sh -t 04 -a "eyJ..." -u https://api.catcheat.kro.kr * * ⚠️ ⚠️ ⚠️ 비용 경고 ⚠️ ⚠️ ⚠️ - * 이 시나리오는 실제 Gemini API를 호출합니다. 전체 5분 30초 동안 약 2,000~5,000 요청 발생. - * 서킷이 빨리 열리지 않으면 그만큼 외부 API 비용이 발생합니다. + * 이 시나리오는 실제 Gemini API를 호출합니다. + * CB OPEN 후엔 호출이 Gemini까지 안 가므로 실제 quota 출혈은 약 ~18건 수준이지만, + * 부하 시점에 다른 사용자가 챗봇을 쓰면 같은 API key 공유로 영향받습니다. * * 안전 가드: - * 1. MAX_ITER_PER_VU 환경변수로 VU당 요청 상한 (기본 250 → 글로벌 추정 5000) + * 1. MAX_ITER_PER_VU 환경변수로 VU당 요청 상한 (기본 50) * ⚠️ 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건 후 자동 차단됨 + * 글로벌 최대 = MAX_ITER_PER_VU × 시나리오 max VU 수(1) + * 2. 일일 메시지 제한(100회/사용자) — 단일 토큰 ~33건 소모, 한도 내 * 3. mock LLM 사용 권장 — application.yml에서 spring.ai.* 를 mock provider로 변경 후 테스트 * - * 목적: - * - LLM API(Spring AI)에 점진적으로 부하를 올려 서킷브레이커가 열리는 VU 수 확인 - * - 서킷 CLOSED → OPEN → HALF-OPEN → CLOSED 전체 상태 전환 사이클 관찰 + * [원리] + * CB OPEN 후엔 호출이 Gemini까지 안 가므로 quota 출혈이 0. + * "분당 15회 키 단위 제한"만 살짝 초과시키면 ~40s 시점에 OPEN 트리거됨. + * 부하 중지 후 30s 대기로 자동 HALF_OPEN 전환, 다시 1분 더 대기해 Gemini quota + * 회복 후 3회 시도로 CLOSED 복귀 확인. * - * [서킷브레이커에 ramp_up을 쓰는 이유] - * - LLM API는 응답시간이 수초~수십초로 가변적 - * - 50명 동시 접속(constant_50)은 서버가 감당하기 전에 타임아웃이 폭발해서 - * 서킷이 열리는 시점 자체를 관찰할 수 없음 - * - 점진적으로 올려야 "2명일 때는 괜찮다가 10명에서 서킷이 열린다"는 정보를 얻을 수 있음 + * [타임라인 — 약 2분 15초] + * 0~60s flood : constant-arrival-rate 30/min — 응답 시간(LLM 평균 6s) 무관하게 정확히 분당 30회 발사. + * VU 풀(10명)에서 2초마다 한 명씩 발사. 첫 15회 통과, 다음 15회 429. + * sliding window 10건 중 5건 429 누적 시 ~40s에 OPEN. + * 60~120s 휴지 : 호출 없음. 70s쯤 자동 HALF_OPEN 전환되어 대기. + * Gemini 1분 rolling quota 회복 (마지막 호출 60s + 60s = 120s). + * 120~135s recover : 3회만 천천히 발사. quota 회복돼 모두 200 → CLOSED 복귀. * - * 전체 소요 시간: 약 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 복구 확인 + * [설계 변경 이력] + * v1: VU 1 + sleep(2s) — LLM 응답 6s에 가로막혀 실제 분당 7~9회만 발사되어 CB OPEN 미도달. + * v2: constant-arrival-rate로 변경 (현재). 발사 페이스가 LLM 응답 시간과 분리됨. * * [서킷 상태 판별 방법] * 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 상태면 수초 + * [Grafana CB 패널 (수정한 PromQL)] + * 0=closed / 1=half_open / 2=open + * 0~40s : 0 (closed) + * ~40s : 2 (open) 점프 + * ~70s : 1 (half_open) 자동 전환 + * ~120s+ : 0 (closed) 복귀 + * chat_duration: open 구간만 < 500ms (즉시 차단) */ import http from 'k6/http'; -import { sleep, check, group } from 'k6'; +import { sleep, check } from 'k6'; import { Counter, Rate, Trend } from 'k6/metrics'; 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 circuitOpenCount = new Counter('circuit_open_responses'); // CB OPEN fallback (status 200 + "점검 중" body) +const circuitClosedCount = new Counter('circuit_closed_responses'); // 정상 LLM 응답 +const bulkheadCount = new Counter('bulkhead_fallback'); // Bulkhead Full fallback +const rateLimitCount = new Counter('rate_limit_fallback'); // 429 Rate Limit fallback +const timeoutCount = new Counter('timeout_fallback'); // Timeout fallback 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; // 시나리오 옵션 변경 시 함께 갱신 +const skippedSafeguard = new Counter('skipped_by_safeguard'); + +// 백엔드 ChatbotService의 fallback 메시지 키워드 — buildFallbackMessage()와 catch 블록 기준 +// CB OPEN/Bulkhead Full은 status 200 + fallback 문자열로 응답되어 status code만으론 구분 불가 +const FALLBACK_PATTERNS = { + cbOpen: '일시적으로 점검 중', // CallNotPermittedException + bulkhead: '많은 요청이 몰려', // BulkheadFullException + rateLimit: '요청 한도에 도달', // CHAT_AI_RATE_LIMIT (429) + timeout: '응답이 지연', // CHAT_AI_TIMEOUT +}; + +// 안전 가드: VU당 최대 요청 수 — 예상 호출수(~33)보다 약간 큰 50으로 기본값 +const MAX_ITER_PER_VU = Number(__ENV.MAX_ITER_PER_VU || 50); +const MAX_VUS_IN_SCENARIO = 1; let vuIterCount = 0; export const options = { + // stdout 메트릭에 p99 노출 (기본은 avg/min/med/max/p(90)/p(95)만) + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(95)', 'p(99)'], scenarios: { - // ── 워밍업: 서킷 CLOSED 기준선 측정 ───────────────────────────────────── - // 2VU로 정상 상태에서의 응답시간 확인. 이 구간 chat_duration이 기준선. - // 여기서도 에러가 나면 → LLM API 자체 문제 (부하와 무관) - warmup: { - executor: 'constant-vus', - vus: 2, - duration: '30s', - tags: { phase: 'warmup' }, + // ── flood: 분당 30회로 Gemini 15 RPM 초과 → CB OPEN 유도 ───────────────── + // constant-arrival-rate — 응답 시간 무관하게 정확히 30회/분 강제 발사 + // VU 풀(10명)에서 2초마다 한 명씩 발사, LLM 응답 평균 6s라도 페이스 유지 + flood: { + executor: 'constant-arrival-rate', + rate: 30, + timeUnit: '1m', + duration: '60s', + preAllocatedVUs: 10, + maxVUs: 20, + exec: 'flood', + tags: { phase: 'flood' }, }, - - // ── 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 }, // LLM은 소규모에서도 느릴 수 있음 - { duration: '1m', target: 10 }, // 대부분 서킷 OPEN 시작 구간 - { duration: '1m', target: 20 }, // 확실한 OPEN 상태 유도 - ], - tags: { phase: 'ramp_up' }, - }, - - // ── sustained: OPEN 상태 지속 관찰 ────────────────────────────────────── - // 서킷이 열린 상태를 1분간 유지. circuit_open_responses 비율 측정. - // 대부분의 요청이 즉시 차단(< 500ms)되어야 정상적인 OPEN 상태 - sustained: { - executor: 'constant-vus', - vus: 20, - duration: '1m', - startTime: '3m', - tags: { phase: 'sustained' }, - }, - - // ── recovery: HALF-OPEN → CLOSED 복구 확인 ────────────────────────────── - // 부하를 낮추면 서킷브레이커가 HALF-OPEN 상태로 전환되어 일부 요청만 통과시킴. - // circuit_closed_responses가 다시 올라오면 복구 성공. - // 복구가 안 되면 → 서킷브레이커 waitDurationInOpenState 설정 확인 - recovery: { - executor: 'constant-vus', - vus: 2, - duration: '30s', - startTime: '4m', - tags: { phase: 'recovery' }, + // ── recover: 60s 휴지 후 3건만 발사 → HALF_OPEN → CLOSED 복귀 확인 ─────── + recover: { + executor: 'per-vu-iterations', + vus: 1, + iterations: 3, + maxDuration: '30s', + startTime: '120s', + exec: 'recover', + tags: { phase: 'recover' }, }, }, thresholds: { - // LLM 특성상 응답시간 기준을 60초로 매우 여유 있게 설정 - // 실제 서킷이 제대로 작동하면 OPEN 상태에서 대부분 < 500ms로 즉시 차단됨 - llm_error_rate: ['rate<0.8'], // 에러율 80% 미만 (서킷 OPEN 시 거의 100%) - chat_duration: ['p(95)<60000'], // LLM 타임아웃 기준 + llm_error_rate: ['rate<0.8'], + chat_duration: ['p(95)<60000'], }, }; @@ -143,8 +118,7 @@ const TEST_MESSAGES = [ { message: '이탈리안 레스토랑 예약하고 싶어', latitude: 37.5172, longitude: 127.0473 }, ]; -export default function () { - // 안전 가드 — 이 VU가 MAX_ITER_PER_VU 도달 시 추가 호출 중단 (Gemini 비용 차단) +function callChat(phase) { vuIterCount++; if (MAX_ITER_PER_VU > 0 && vuIterCount > MAX_ITER_PER_VU) { skippedSafeguard.add(1); @@ -154,77 +128,110 @@ export default function () { 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: '65s', - }); - - chatDuration.add(res.timings.duration); - - const isSuccess = res.status === 200 || res.status === 201; - // 빠른 실패 판별: 500ms 미만 + 에러 → 서킷 OPEN 상태 - const isFastFail = res.timings.duration < CIRCUIT_OPEN_THRESHOLD_MS && !isSuccess; - - if (isFastFail) { - circuitOpenCount.add(1); - llmErrorRate.add(true); - console.log(`[서킷 OPEN] ${res.timings.duration.toFixed(0)}ms - status: ${res.status}`); - } else if (isSuccess) { - circuitClosedCount.add(1); - llmErrorRate.add(false); - } else { - // 느린 실패 — LLM 타임아웃 또는 서킷 CLOSED 상태에서의 LLM 오류 - llmErrorRate.add(true); - console.log(`[LLM 실패] ${res.timings.duration.toFixed(0)}ms - status: ${res.status} - ${res.body?.substring(0, 100)}`); - } - - check(res, { - '서킷 OPEN (즉시 차단)': () => isFastFail, - '정상 응답 (서킷 CLOSED)': () => isSuccess, - }); + const res = http.post(`${BASE_URL}/api/v1/chat/messages`, payload, { + headers: HEADERS_AUTH, + timeout: '65s', }); - sleep(1); + chatDuration.add(res.timings.duration); + + const body = res.body || ''; + const isSuccess = res.status === 200 || res.status === 201; + // ChatbotService는 CB/Bulkhead/Rate/Timeout 모두 status 200 + fallback 문자열로 응답함. + // 따라서 status code만으론 구분 불가 → 응답 body의 fallback 키워드로 분류. + const isCbOpen = body.includes(FALLBACK_PATTERNS.cbOpen); + const isBulkhead = body.includes(FALLBACK_PATTERNS.bulkhead); + const isRateLimit = body.includes(FALLBACK_PATTERNS.rateLimit); + const isTimeout = body.includes(FALLBACK_PATTERNS.timeout); + const isFallback = isCbOpen || isBulkhead || isRateLimit || isTimeout; + + let label; + if (isCbOpen) { + circuitOpenCount.add(1); + llmErrorRate.add(true); + label = 'CB-OPEN'; + } else if (isBulkhead) { + bulkheadCount.add(1); + llmErrorRate.add(true); + label = 'BULKHEAD'; + } else if (isRateLimit) { + rateLimitCount.add(1); + llmErrorRate.add(true); + label = 'RATE-LIMIT'; + } else if (isTimeout) { + timeoutCount.add(1); + llmErrorRate.add(true); + label = 'TIMEOUT'; + } else if (isSuccess) { + circuitClosedCount.add(1); + llmErrorRate.add(false); + label = 'OK'; + } else { + llmErrorRate.add(true); + label = `FAIL-${res.status}`; + } + console.log(`[${phase} ${label}] ${res.timings.duration.toFixed(0)}ms status=${res.status}`); + + check(res, { + 'CB OPEN fallback 감지': () => isCbOpen, + '정상 LLM 응답': () => isSuccess && !isFallback, + }); +} + +export function flood() { + // arrival-rate executor가 발사 페이스를 제어하므로 sleep 불필요 + callChat('flood'); +} + +export function recover() { + callChat('recover'); + sleep(3); } 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 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 open = data.metrics.circuit_open_responses?.values?.count || 0; + const closed = data.metrics.circuit_closed_responses?.values?.count || 0; + const bulkhead = data.metrics.bulkhead_fallback?.values?.count || 0; + const rateLimit = data.metrics.rate_limit_fallback?.values?.count || 0; + const timeout = data.metrics.timeout_fallback?.values?.count || 0; + const skipped = data.metrics.skipped_by_safeguard?.values?.count || 0; + const total = open + closed + bulkhead + rateLimit + timeout; + 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[안전 가드] ${skipped}건이 MAX_ITER_PER_VU(=${MAX_ITER_PER_VU}, 글로벌 추정 ${globalEstimate}건) 도달로 차단됨` : `\n[안전 가드] 작동 가능: VU당 최대 ${MAX_ITER_PER_VU}건, 글로벌 추정 최대 ${globalEstimate}건`; + const cbVerdict = open >= 5 ? '✅ OPEN 트리거 확인 (fallback body 감지)' : '⚠️ OPEN 미관측 — Grafana CB 상태 패널과 교차 확인'; + const recoverVerdict = closed >= 17 ? '✅ CLOSED 복귀 확인' : '⚠️ recover 단계 정상응답 부족 — quota 회복 시간 또는 부하 잔류 확인'; + return { stdout: ` -===== 서킷브레이커 테스트 결과 ===== +===== 서킷브레이커 풀 사이클 시연 결과 ===== 총 요청수 : ${total}건 -정상 응답 (CLOSED) : ${closed}건 -즉시 차단 (OPEN) : ${open}건 +정상 LLM 응답 : ${closed}건 (Gemini 통과) +CB OPEN fallback : ${open}건 ← "일시적으로 점검 중" +Bulkhead Full fallback : ${bulkhead}건 ← "많은 요청이 몰려" +Rate Limit fallback : ${rateLimit}건 ← "요청 한도에 도달" +Timeout fallback : ${timeout}건 ← "응답이 지연" 서킷 OPEN 비율 : ${openRate}%${safeguardNote} p95 응답시간 : ${(p95 / 1000).toFixed(2)}초 p99 응답시간 : ${(p99 / 1000).toFixed(2)}초 -[분석] - circuit_open_responses 카운터가 증가하기 시작한 시점 (Grafana 확인): - → warmup(2VU): 0건이어야 정상 - → ramp_up 중 5VU / 10VU / 20VU 구간 중 어느 시점에 open 증가? - 그 VU 수가 현재 서킷브레이커 설정의 실질적 임계 부하 - - recovery 구간(4m~4m30s)에서 closed_responses가 다시 올라오면 → 복구 성공 - 올라오지 않으면 → waitDurationInOpenState 설정 확인: - @CircuitBreaker(name=..., waitDurationInOpenState=30s) +[판정] + ${cbVerdict} + ${recoverVerdict} - OPEN 비율 > 80% → LLM API 응답이 매우 불안정. 폴백 로직 강화 필요. -=================================== +[Grafana CB 패널 (0=closed / 1=half_open / 2=open)] + 0~40s → 0 + ~40s → 2 (OPEN 점프) + ~70s → 1 (HALF_OPEN 자동 전환) + ~120s+ → 0 (CLOSED 복귀) +============================================ `, }; } diff --git a/k6/05-store-list.js b/k6/05-store-list.js index 9720df8..30d6385 100644 --- a/k6/05-store-list.js +++ b/k6/05-store-list.js @@ -2,9 +2,9 @@ * [단일 API] 매장 목록 조회 부하테스트 — 이벤트 스파이크 vs 점진적 증가 * 실행: .\k6\run.ps1 -Test 05 * - * 전체 소요 시간: 약 9분 + * 전체 소요 시간: 약 5분 * [0s ~ ~2m50s] event_spike — 워밍업 후 50명 즉시 점프 - * [3m20s ~ ~9m] ramp_up — 0→50명 단계적 증가 + * [3m ~ ~5m] ramp_up — 0→50명 단축 단계 (10→30→50, 임계점 1차 탐색) */ import http from 'k6/http'; import { sleep, check, group } from 'k6'; @@ -21,23 +21,8 @@ const errorRate = new Rate('store_list_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명으로 제어 가능. - // - // [체크포인트] - // 인덱스가 제대로 걸려 있으면 점프 직후에도 p95가 크게 안 올라야 함 - // 급등하면 → DB 커넥션풀 부족 또는 Specification 쿼리 Full Scan + // SCENARIO A: 이벤트 스파이크 — 50명 즉시 점프로 이벤트 오픈 순간 재현 (패턴 근거는 01-store-browse.js 헤더) + // 체크: 점프 직후 p95 급등 = DB 커넥션풀 부족 또는 Specification 쿼리 Full Scan event_spike: { executor: 'ramping-vus', startVUs: 0, @@ -50,17 +35,14 @@ export const options = { tags: { scenario: 'event_spike' }, }, - // ── SCENARIO B: 점진적 증가 ──────────────────────────────────────────── - // 목적: 몇 명부터 store_list_duration p95가 800ms를 넘는지 임계점 탐색 + // SCENARIO B: 점진적 증가 — 임계점 1차 탐색 (몇 명부터 store_list_duration p95가 800ms를 넘는가) ramp_up: { executor: 'ramping-vus', startVUs: 0, - startTime: '3m20s', + startTime: '3m', stages: [ { duration: '30s', target: 10 }, - { duration: '30s', target: 20 }, - { duration: '30s', target: 35 }, - { duration: '1m', target: 50 }, + { duration: '30s', target: 30 }, { duration: '30s', target: 50 }, { duration: '30s', target: 0 }, ], diff --git a/k6/06-store-nearby.js b/k6/06-store-nearby.js index 5abb90d..0b2c8fe 100644 --- a/k6/06-store-nearby.js +++ b/k6/06-store-nearby.js @@ -5,9 +5,9 @@ * 이전 테스트 결과: 50VU에서 nearby p95=10.33s, in-bounds p95=8.36s * → GIST 공간 인덱스 미적용 의심 → 이 테스트로 수치 재확인 * - * 전체 소요 시간: 약 9분 + * 전체 소요 시간: 약 5분 * [0s ~ ~2m50s] event_spike — 워밍업 후 50명 즉시 점프 - * [3m20s ~ ~9m] ramp_up — 0→50명 단계적 증가 + * [3m ~ ~5m] ramp_up — 0→50명 단축 단계 (10→30→50, 임계점 1차 탐색) */ import http from 'k6/http'; import { sleep, check, group } from 'k6'; @@ -34,23 +34,8 @@ const LOCATIONS = [ 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명으로 제어 가능. - // - // [체크포인트] - // GIST 인덱스 없으면 점프 직후 타임아웃 폭발 → timeoutCount 급증 - // GIST 인덱스 있으면 50명에서도 p95 < 2s 유지 + // SCENARIO A: 이벤트 스파이크 — 50명 즉시 점프로 이벤트 오픈 순간 재현 (패턴 근거는 01-store-browse.js 헤더) + // 체크: GIST 인덱스 없으면 점프 직후 timeoutCount 급증, 있으면 50명에서도 p95 < 2s event_spike: { executor: 'ramping-vus', startVUs: 0, @@ -63,17 +48,14 @@ export const options = { tags: { scenario: 'event_spike' }, }, - // ── SCENARIO B: 점진적 증가 ──────────────────────────────────────────── - // 목적: 몇 명부터 타임아웃이 발생하는지 → 인덱스 없는 서버의 한계 탐색 + // SCENARIO B: 점진적 증가 — 임계점 1차 탐색 (몇 명부터 타임아웃 = GIST 없는 서버의 한계) ramp_up: { executor: 'ramping-vus', startVUs: 0, - startTime: '3m20s', + startTime: '3m', stages: [ { duration: '30s', target: 10 }, - { duration: '30s', target: 20 }, - { duration: '30s', target: 35 }, - { duration: '1m', target: 50 }, + { duration: '30s', target: 30 }, { duration: '30s', target: 50 }, { duration: '30s', target: 0 }, ], diff --git a/k6/07-remains.js b/k6/07-remains.js index ecf38f5..1b6f023 100644 --- a/k6/07-remains.js +++ b/k6/07-remains.js @@ -2,9 +2,9 @@ * [단일 API] 잔여석 조회 부하테스트 — 이벤트 스파이크 vs 점진적 증가 * 실행: .\k6\run.ps1 -Test 07 * - * 전체 소요 시간: 약 9분 + * 전체 소요 시간: 약 5분 * [0s ~ ~2m50s] event_spike — 워밍업 후 50명 즉시 점프 - * [3m20s ~ ~9m] ramp_up — 0→50명 단계적 증가 + * [3m ~ ~5m] ramp_up — 0→50명 단축 단계 (10→30→50, 임계점 1차 탐색) */ import http from 'k6/http'; import { sleep, check, group } from 'k6'; @@ -24,23 +24,8 @@ const REMAINS_SLA_MS = Number(__ENV.REMAINS_SLA_MS || 400); 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명으로 제어 가능. - // - // [체크포인트] - // 잔여석은 단순 인덱스 조회라 점프 후에도 p95 300ms 이내가 정상 - // 넘으면 → (store_id, remain_date) 복합 인덱스 미적용 또는 Row Lock 경합 + // SCENARIO A: 이벤트 스파이크 — 50명 즉시 점프로 이벤트 오픈 순간 재현 (패턴 근거는 01-store-browse.js 헤더) + // 체크: 점프 후 p95 > 300ms = (store_id, remain_date) 복합 인덱스 미적용 또는 Row Lock 경합 event_spike: { executor: 'ramping-vus', startVUs: 0, @@ -53,18 +38,14 @@ export const options = { tags: { scenario: 'event_spike' }, }, - // ── SCENARIO B: 점진적 증가 ──────────────────────────────────────────── - // 목적: p95가 VU 수와 비례해서 증가하는지 확인 - // 인덱스가 제대로 걸리면 VU 수와 무관하게 p95가 일정해야 함 + // SCENARIO B: 점진적 증가 — p95가 VU 수와 비례해 증가하는가 (인덱스 OK면 VU와 무관하게 일정해야 함) ramp_up: { executor: 'ramping-vus', startVUs: 0, - startTime: '3m20s', + startTime: '3m', stages: [ { duration: '30s', target: 10 }, - { duration: '30s', target: 20 }, - { duration: '30s', target: 35 }, - { duration: '1m', target: 50 }, + { duration: '30s', target: 30 }, { duration: '30s', target: 50 }, { duration: '30s', target: 0 }, ], @@ -72,9 +53,10 @@ export const options = { }, }, thresholds: { - 'remains_duration': ['p(95)<500', 'p(99)<800'], - 'remains_spike_duration': ['p(95)<600'], - 'remains_ramp_duration': ['p(95)<500'], + // REMAINS_SLA_MS와 정합: 기본 400ms → p95 SLA, p99는 2배 마진 + 'remains_duration': [`p(95)<${REMAINS_SLA_MS}`, `p(99)<${REMAINS_SLA_MS * 2}`], + 'remains_spike_duration': [`p(95)<${REMAINS_SLA_MS + 200}`], + 'remains_ramp_duration': [`p(95)<${REMAINS_SLA_MS}`], 'remains_error_rate': ['rate<0.01'], 'http_req_failed': ['rate<0.01'], }, diff --git a/k6/run.ps1 b/k6/run.ps1 index 3587305..484bc0f 100644 --- a/k6/run.ps1 +++ b/k6/run.ps1 @@ -1,20 +1,21 @@ # k6 부하테스트 실행 스크립트 # # 사용법: -# .\k6\run.ps1 -Test 01 # 기본 (운영서버 + 서버 Grafana) +# .\k6\run.ps1 -Test 01 # 기본 (로컬 서버) # .\k6\run.ps1 -Test 02 -AuthToken "eyJ..." -RemainId 5 # .\k6\run.ps1 -Test 01 -BaseUrl http://localhost:8080 # 로컬 서버 테스트 # # 테스트 번호 목록: -# 01 - 매장 전체 탐색 플로우 (constant_50 + ramp_up) +# 01 - 매장 전체 탐색 플로우 (event_spike + ramp_up) # 02 - 예약 동시성 (분산락 + 낙관적락 검증) -# 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) +# 03 - 전체 사용자 플로우 (event_spike + ramp_up) +# 04 - AI 챗봇 서킷브레이커 (step: flood + recover) ⚠ Gemini 비용 — MAX_ITER_PER_VU 가드 권장 +# 05 - [단일] 매장 목록 조회 (event_spike + ramp_up) +# 06 - [단일] PostGIS 지리 쿼리 (event_spike + ramp_up) +# 07 - [단일] 잔여석 조회 (event_spike + ramp_up) # 08 - [단일] 쿠폰 선착순 발급 동시성 # 10 - [내구성] Soak 테스트 (10VU × 30분) +# 11 - 빈자리 SADD/SMEMBERS — PHASE 인자 별도 (11-vacancy-test.js 헤더 참고) param( [Parameter(Mandatory=$true)] diff --git a/k6/run.sh b/k6/run.sh index 1be647b..00778c2 100755 --- a/k6/run.sh +++ b/k6/run.sh @@ -2,7 +2,7 @@ # k6 부하테스트 실행 스크립트 (bash, run.ps1과 동일 인터페이스) # # 사용법: -# ./k6/run.sh -t 01 # 기본 (운영서버 + 서버 Grafana) +# ./k6/run.sh -t 01 # 기본 (로컬 서버) # ./k6/run.sh -t 02 -a "eyJ..." -r 5 # 예약 동시성 # ./k6/run.sh -t 08 -a "eyJ..." -c 1 # 쿠폰 선착순 # ./k6/run.sh -t 01 -b http://localhost:8080 # 로컬 서버 @@ -11,7 +11,7 @@ # 01 - 매장 전체 탐색 플로우 (event_spike + ramp_up) # 02 - 예약 동시성 (분산락 + 낙관적락 검증) # 03 - 전체 사용자 플로우 (event_spike + ramp_up) -# 04 - AI 챗봇 서킷브레이커 (ramp_up) ⚠ Gemini 비용 발생 — 4-MAX_REQUESTS 가드 사용 권장 +# 04 - AI 챗봇 서킷브레이커 (step: flood + recover) ⚠ Gemini 비용 — MAX_ITER_PER_VU 가드 사용 권장 # 05 - [단일] 매장 목록 조회 # 06 - [단일] PostGIS 지리 쿼리 # 07 - [단일] 잔여석 조회