diff --git a/loadtest/README.md b/loadtest/README.md index cf984de..5fcea3f 100644 --- a/loadtest/README.md +++ b/loadtest/README.md @@ -44,3 +44,27 @@ done - `enroll_success` == `30 * HOT_SUBJECTS` (정원만큼만 성공) - 각 과목 `registeredNum <= limitedNum(30)` → **정원 초과 0** - 같은 유저 중복 성공 0 + +## 실패 원인 추적 (중요) +`http_req_failed`는 5xx/네트워크만 집계(비즈니스 400 제외)하고, +요약에 **5xx가 어느 단계에서 났는지**(apply/try/confirm)가 표시된다: +``` +실제 실패율(5xx) : 11.12 % +└ 5xx 발생 단계 : apply=0 / try=0 / confirm=25156 +``` +→ "어디서" 터졌는지 확인한 뒤, **서버 로그로 "왜"를 확정**한다 (ground truth): +```bash +ssh -i ~/.ssh/id_ed25519 ubuntu@ \ + "sudo docker logs springboot-app 2>&1 | grep -iE 'exception|timeout|HikariPool|Connection is not available' | tail -40" +``` +예: `HikariPool-1 - Connection is not available, request timed out` → +"confirm 단계 5xx = 커넥션풀 고갈로 인한 타임아웃(500)" 으로 원인 확정. + +## 시계열(무릎 지점) 분석 +램프 중 어느 VU에서 깨지는지 보려면 시계열 출력 + 라이브 모니터링: +```bash +# k6 시계열 저장 +k6 run --out csv=loadtest/results/ts.csv -e BASE_URL=http:// loadtest/throughput-baseline.js +# (동시에 다른 터미널) 커넥션풀 pending 실시간 +while true; do clear; date +%T; curl -s http:///actuator/prometheus | grep -E "^hikaricp_connections_(active|pending|max)"; sleep 1; done +``` diff --git a/loadtest/capacity-contention.js b/loadtest/capacity-contention.js index c457fca..0d5e1ff 100644 --- a/loadtest/capacity-contention.js +++ b/loadtest/capacity-contention.js @@ -19,6 +19,13 @@ const VUS = parseInt(__ENV.VUS || '300'); // 동시 신청 인 const enrollSuccess = new Counter('enroll_success'); // 정원 안에 들어 성공 const capacityFull = new Counter('capacity_full'); // 정원 초과로 거절 const otherReject = new Counter('other_reject'); // 그 외 거절 +// 서버 에러(5xx/네트워크) 단계별 집계 → 실패 "원인/위치" 추적 +const errApply = new Counter('err_apply_5xx'); +const errTry = new Counter('err_try_5xx'); +const errConfirm = new Counter('err_confirm_5xx'); + +// 비즈니스 거절(400)은 정상 응답으로 취급 → http_req_failed는 실제 실패(5xx/네트워크)만 집계 +http.setResponseCallback(http.expectedStatuses(200, 400)); export const options = { scenarios: { @@ -29,6 +36,7 @@ export const options = { maxDuration: '2m', }, }, + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'], }; export default function () { @@ -37,13 +45,17 @@ export default function () { const code = 'SUBJ' + ('00' + hot).slice(-3); // 1) 대기열 진입 - const apply = safeJson(http.post(`${BASE_URL}/courses/apply`, `code=${code}`, formHeaders(userId))); + const aRes = http.post(`${BASE_URL}/courses/apply`, `code=${code}`, hdr(userId, 'apply')); + count5xx(aRes, errApply); + const apply = safeJson(aRes); if (!apply || apply.status !== 'WAITING') { classify(apply); return; } // 2) 내 차례 폴링 let allowed = false; for (let i = 0; i < 15; i++) { - const t = safeJson(http.get(`${BASE_URL}/courses/apply/try?code=${code}`, formHeaders(userId))); + const tRes = http.get(`${BASE_URL}/courses/apply/try?code=${code}`, hdr(userId, 'try')); + count5xx(tRes, errTry); + const t = safeJson(tRes); if (t && t.status === 'ALLOWED') { allowed = true; break; } if (t && t.status === 'FAIL') { classify(t); return; } sleep(0.3); @@ -51,9 +63,12 @@ export default function () { if (!allowed) { otherReject.add(1); return; } // 3) 최종 확정 - const c = safeJson(http.post(`${BASE_URL}/courses/apply/confirm?code=${code}`, null, formHeaders(userId))); + const cRes = http.post(`${BASE_URL}/courses/apply/confirm?code=${code}`, null, hdr(userId, 'confirm')); + count5xx(cRes, errConfirm); + const c = safeJson(cRes); if (c && c.status === 'SUCCESS') enrollSuccess.add(1); - else classify(c); + else if (cRes.status === 400) capacityFull.add(1); // 정원 참(confirm 400) → 정원 초과 거절 + else otherReject.add(1); } // 검증 포인트(실행 후 확인): @@ -63,12 +78,16 @@ export default function () { function classify(body) { const msg = (body && body.message) ? String(body.message) : ''; - if (msg.indexOf('정원') >= 0 || msg.toUpperCase().indexOf('CAPACITY') >= 0) capacityFull.add(1); + if (msg.indexOf('인원') >= 0 || msg.indexOf('정원') >= 0) capacityFull.add(1); else otherReject.add(1); } -function formHeaders(userId) { - return { headers: { userId: String(userId), 'Content-Type': 'application/x-www-form-urlencoded' } }; +function hdr(userId, step) { + return { + headers: { userId: String(userId), 'Content-Type': 'application/x-www-form-urlencoded' }, + tags: { step: step }, + }; } +function count5xx(res, counter) { if (res.status >= 500 || res.status === 0) counter.add(1); } function safeJson(res) { try { return res.json(); } catch (e) { return null; } } // 터미널엔 전체 요약 + 정합성 지표 강조, 파일엔 JSON 저장(비교용) @@ -81,17 +100,19 @@ export function handleSummary(data) { } function highlight(data) { const m = data.metrics; - const cnt = (k) => (m[k] ? m[k].values.count : 0); + const cnt = (k) => (m[k] && m[k].values.count != null ? m[k].values.count : 0); + const trend = (k, s) => (m[k] && m[k].values[s] != null ? m[k].values[s] : 0); const hot = parseInt(__ENV.HOT_SUBJECTS || '1'); - const p95 = m.http_req_duration ? m.http_req_duration.values['p(95)'] : 0; - const failed = m.http_req_failed ? (m.http_req_failed.values.rate * 100).toFixed(2) : '0.00'; + const p95 = trend('http_req_duration', 'p(95)'); + const failed = (trend('http_req_failed', 'rate') * 100).toFixed(2); return [ '\n========== 정합성 검증 (경합) ==========', `신청 성공(정원 내) : ${cnt('enroll_success')} (기대: 30 x ${hot} = ${30 * hot})`, `정원 초과 거절 : ${cnt('capacity_full')}`, `기타 거절 : ${cnt('other_reject')}`, `응답시간 p95 : ${p95.toFixed(0)} ms`, - `HTTP 실패율 : ${failed} %`, + `실제 실패율(5xx) : ${failed} %`, + `└ 5xx 발생 단계 : apply=${cnt('err_apply_5xx')} / try=${cnt('err_try_5xx')} / confirm=${cnt('err_confirm_5xx')}`, '>>> 각 과목 registeredNum <= 30 인지 서버에서 추가 확인', '=======================================\n', ].join('\n'); diff --git a/loadtest/throughput-baseline.js b/loadtest/throughput-baseline.js index 2fb367c..ecb55d5 100644 --- a/loadtest/throughput-baseline.js +++ b/loadtest/throughput-baseline.js @@ -4,6 +4,7 @@ // // 전제: 앱이 loadtest 프로파일로 떠 있어야 함 (userId 헤더 인증) // 실행: k6 run -e BASE_URL=http:// loadtest/throughput-baseline.js +// (시계열까지 남기려면) k6 run --out csv=loadtest/results/ts.csv -e BASE_URL=... ... import http from 'k6/http'; import { sleep } from 'k6'; @@ -15,6 +16,13 @@ const SUBJECT_COUNT = parseInt(__ENV.SUBJECT_COUNT || '30'); // 시드된 과목 const enrollSuccess = new Counter('enroll_success'); // 최종 신청 성공 const enrollRejected = new Counter('enroll_rejected'); // 비즈니스 거절(정원/중복/학점초과 등) +// 서버 에러(5xx/네트워크) 단계별 집계 → 실패 "원인/위치" 추적 +const errApply = new Counter('err_apply_5xx'); +const errTry = new Counter('err_try_5xx'); +const errConfirm = new Counter('err_confirm_5xx'); + +// 비즈니스 거절(400)은 정상 응답으로 취급 → http_req_failed는 실제 실패(5xx/네트워크)만 집계 +http.setResponseCallback(http.expectedStatuses(200, 400)); export const options = { scenarios: { @@ -32,20 +40,22 @@ export const options = { }, }, thresholds: { - http_req_failed: ['rate<0.05'], // HTTP 레벨 실패(5xx/네트워크) 5% 미만 + http_req_failed: ['rate<0.05'], // 실제 실패(5xx/네트워크) 5% 미만 http_req_duration: ['p(95)<2000'], // p95 2초 미만 (관찰 기준선) }, + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'], }; export default function () { - const userId = __VU; // VU별 고정 userId (시드 user1~user2500) + const userId = __VU; // VU별 고정 userId (시드 user1~) const code = 'SUBJ' + ('00' + randomInt(1, SUBJECT_COUNT)).slice(-3); // 1) 대기열 진입 - const applyRes = http.post(`${BASE_URL}/courses/apply`, `code=${code}`, formHeaders(userId)); - const apply = safeJson(applyRes); + const aRes = http.post(`${BASE_URL}/courses/apply`, `code=${code}`, hdr(userId, 'apply')); + count5xx(aRes, errApply); + const apply = safeJson(aRes); if (!apply || apply.status !== 'WAITING') { - enrollRejected.add(1); // rate limit / 중복 / 검증 실패 등 + enrollRejected.add(1); sleep(randomInt(1, 3)); return; } @@ -53,7 +63,9 @@ export default function () { // 2) 내 차례 폴링 (최대 10회) let allowed = false; for (let i = 0; i < 10; i++) { - const t = safeJson(http.get(`${BASE_URL}/courses/apply/try?code=${code}`, formHeaders(userId))); + const tRes = http.get(`${BASE_URL}/courses/apply/try?code=${code}`, hdr(userId, 'try')); + count5xx(tRes, errTry); + const t = safeJson(tRes); if (t && t.status === 'ALLOWED') { allowed = true; break; } if (t && t.status === 'FAIL') break; sleep(0.5); @@ -61,19 +73,25 @@ export default function () { // 3) 최종 확정 if (allowed) { - const c = safeJson(http.post(`${BASE_URL}/courses/apply/confirm?code=${code}`, null, formHeaders(userId))); + const cRes = http.post(`${BASE_URL}/courses/apply/confirm?code=${code}`, null, hdr(userId, 'confirm')); + count5xx(cRes, errConfirm); + const c = safeJson(cRes); if (c && c.status === 'SUCCESS') enrollSuccess.add(1); else enrollRejected.add(1); } else { enrollRejected.add(1); } - sleep(randomInt(1, 3)); // think time (rate limit 회피 + 현실성) + sleep(randomInt(1, 3)); // think time } -function formHeaders(userId) { - return { headers: { userId: String(userId), 'Content-Type': 'application/x-www-form-urlencoded' } }; +function hdr(userId, step) { + return { + headers: { userId: String(userId), 'Content-Type': 'application/x-www-form-urlencoded' }, + tags: { step: step }, // 단계별 지연/실패 분석용 + }; } +function count5xx(res, counter) { if (res.status >= 500 || res.status === 0) counter.add(1); } function randomInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } function safeJson(res) { try { return res.json(); } catch (e) { return null; } } @@ -87,20 +105,22 @@ export function handleSummary(data) { } function highlight(data) { const m = data.metrics; - const cnt = (k) => (m[k] ? m[k].values.count : 0); + const cnt = (k) => (m[k] && m[k].values.count != null ? m[k].values.count : 0); + const trend = (k, s) => (m[k] && m[k].values[s] != null ? m[k].values[s] : 0); const succ = cnt('enroll_success'); const total = succ + cnt('enroll_rejected'); const rate = total > 0 ? ((succ / total) * 100).toFixed(1) : '0.0'; - const p95 = m.http_req_duration ? m.http_req_duration.values['p(95)'] : 0; - const p99 = m.http_req_duration ? m.http_req_duration.values['p(99)'] : 0; - const rps = m.http_reqs ? m.http_reqs.values.rate : 0; - const failed = m.http_req_failed ? (m.http_req_failed.values.rate * 100).toFixed(2) : '0.00'; + const p95 = trend('http_req_duration', 'p(95)'); + const p99 = trend('http_req_duration', 'p(99)'); + const rps = trend('http_reqs', 'rate'); + const failed = (trend('http_req_failed', 'rate') * 100).toFixed(2); return [ '\n========== 핵심 지표 (처리량 baseline) ==========', `신청 성공 / 시도 : ${succ} / ${total} (성공률 ${rate}%)`, `처리량(http_reqs) : ${rps.toFixed(1)} req/s`, `응답시간 p95 / p99 : ${p95.toFixed(0)} / ${p99.toFixed(0)} ms`, - `HTTP 실패율 : ${failed} %`, + `실제 실패율(5xx) : ${failed} %`, + `└ 5xx 발생 단계 : apply=${cnt('err_apply_5xx')} / try=${cnt('err_try_5xx')} / confirm=${cnt('err_confirm_5xx')}`, '================================================\n', ].join('\n'); }