From 472f5695ebfc0a4bf2108db6709dc8f05d756011 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Sat, 3 Oct 2026 17:24:05 +0900 Subject: [PATCH 1/5] =?UTF-8?q?chore:=20Terraform=EC=97=90=20loadtest=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=8C=8C=EC=9D=BC=20=ED=86=A0=EA=B8=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 부하테스트 시 EC2를 헤더 기반 인증(loadtest 프로파일)으로 띄우기 위해 SPRING_PROFILES_ACTIVE를 변수화하여 .env로 주입. - variables.tf: spring_profiles_active (기본 prod) - main.tf: env_content에 SPRING_PROFILES_ACTIVE 추가 - tfvars.example: prod,loadtest 토글 안내 --- terraform/main.tf | 1 + terraform/terraform.tfvars.example | 3 +++ terraform/variables.tf | 6 ++++++ 3 files changed, 10 insertions(+) diff --git a/terraform/main.tf b/terraform/main.tf index 926e928..a14eb3e 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -9,6 +9,7 @@ locals { # EC2에 전달할 .env 내용 env_content = <<-EOT DOCKER_USERNAME=${var.docker_username} + SPRING_PROFILES_ACTIVE=${var.spring_profiles_active} MYSQL_ROOT_PASSWORD=${var.mysql_root_password} DB_NAME=${var.db_name} DB_USERNAME=${var.db_username} diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example index 2ae0621..7ec3104 100644 --- a/terraform/terraform.tfvars.example +++ b/terraform/terraform.tfvars.example @@ -7,5 +7,8 @@ db_username = "course" mysql_root_password = "change-me-root-password" db_password = "change-me-app-password" +# Spring 프로파일 (평소 prod / 부하테스트 시 prod,loadtest 로 변경하면 헤더 기반 userId 활성화) +# spring_profiles_active = "prod,loadtest" + # SSH 허용 IP를 직접 지정하려면 주석 해제 (비우면 apply 시 현재 공인 IP 자동 감지) # ssh_ingress_cidr = "1.2.3.4/32" diff --git a/terraform/variables.tf b/terraform/variables.tf index a6ad36a..7f1e870 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -42,6 +42,12 @@ variable "docker_username" { type = string } +variable "spring_profiles_active" { + description = "Spring 프로파일 (평소 prod / 부하테스트 시 prod,loadtest)" + type = string + default = "prod" +} + variable "db_name" { description = "MySQL 데이터베이스명" type = string From d2e2e283d2c519e3a4e8d935bdf69259aa2489f8 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Sat, 3 Oct 2026 17:24:11 +0900 Subject: [PATCH 2/5] =?UTF-8?q?chore:=20k6=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EC=9A=94=EC=95=BD(handleSummary)=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B2=B0=EA=B3=BC=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 터미널에 전체 요약(p95/처리량) + 핵심 지표(성공률/정원 정합성) 강조 출력 - 실행 결과를 loadtest/results/*.json 으로 저장 (단일 vs 다중 비교용) - results 덤프는 .gitignore 처리 (필요한 것만 수동 add) --- .gitignore | 4 +++- loadtest/capacity-contention.js | 27 +++++++++++++++++++++++++++ loadtest/results/.gitkeep | 0 loadtest/throughput-baseline.js | 29 +++++++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 loadtest/results/.gitkeep diff --git a/.gitignore b/.gitignore index abf3587..34c86d5 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,6 @@ docker-compose.yml !*.tfvars.example crash.log override.tf -override.tf.json \ No newline at end of file +override.tf.json +### k6 결과 덤프 ### +loadtest/results/*.json diff --git a/loadtest/capacity-contention.js b/loadtest/capacity-contention.js index 679e57e..c457fca 100644 --- a/loadtest/capacity-contention.js +++ b/loadtest/capacity-contention.js @@ -10,6 +10,7 @@ import http from 'k6/http'; import { sleep } from 'k6'; import { Counter } from 'k6/metrics'; +import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.4/index.js'; const BASE_URL = __ENV.BASE_URL || 'http://localhost'; const HOT_SUBJECTS = parseInt(__ENV.HOT_SUBJECTS || '1'); // 경쟁 대상 과목 수 (1 또는 2) @@ -69,3 +70,29 @@ function formHeaders(userId) { return { headers: { userId: String(userId), 'Content-Type': 'application/x-www-form-urlencoded' } }; } function safeJson(res) { try { return res.json(); } catch (e) { return null; } } + +// 터미널엔 전체 요약 + 정합성 지표 강조, 파일엔 JSON 저장(비교용) +export function handleSummary(data) { + const ts = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19); + return { + stdout: textSummary(data, { indent: ' ', enableColors: true }) + highlight(data), + [`loadtest/results/capacity-contention-${ts}.json`]: JSON.stringify(data, null, 2), + }; +} +function highlight(data) { + const m = data.metrics; + const cnt = (k) => (m[k] ? m[k].values.count : 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'; + return [ + '\n========== 정합성 검증 (경합) ==========', + `신청 성공(정원 내) : ${cnt('enroll_success')} (기대: 30 x ${hot} = ${30 * hot})`, + `정원 초과 거절 : ${cnt('capacity_full')}`, + `기타 거절 : ${cnt('other_reject')}`, + `응답시간 p95 : ${p95.toFixed(0)} ms`, + `HTTP 실패율 : ${failed} %`, + '>>> 각 과목 registeredNum <= 30 인지 서버에서 추가 확인', + '=======================================\n', + ].join('\n'); +} diff --git a/loadtest/results/.gitkeep b/loadtest/results/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/loadtest/throughput-baseline.js b/loadtest/throughput-baseline.js index a438a44..2fb367c 100644 --- a/loadtest/throughput-baseline.js +++ b/loadtest/throughput-baseline.js @@ -8,6 +8,7 @@ import http from 'k6/http'; import { sleep } from 'k6'; import { Counter } from 'k6/metrics'; +import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.4/index.js'; const BASE_URL = __ENV.BASE_URL || 'http://localhost'; const SUBJECT_COUNT = parseInt(__ENV.SUBJECT_COUNT || '30'); // 시드된 과목 수 @@ -75,3 +76,31 @@ function formHeaders(userId) { } function randomInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } function safeJson(res) { try { return res.json(); } catch (e) { return null; } } + +// 터미널엔 전체 요약 + 핵심 지표 강조, 파일엔 JSON 저장(비교용) +export function handleSummary(data) { + const ts = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19); + return { + stdout: textSummary(data, { indent: ' ', enableColors: true }) + highlight(data), + [`loadtest/results/throughput-baseline-${ts}.json`]: JSON.stringify(data, null, 2), + }; +} +function highlight(data) { + const m = data.metrics; + const cnt = (k) => (m[k] ? m[k].values.count : 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'; + return [ + '\n========== 핵심 지표 (처리량 baseline) ==========', + `신청 성공 / 시도 : ${succ} / ${total} (성공률 ${rate}%)`, + `처리량(http_reqs) : ${rps.toFixed(1)} req/s`, + `응답시간 p95 / p99 : ${p95.toFixed(0)} / ${p99.toFixed(0)} ms`, + `HTTP 실패율 : ${failed} %`, + '================================================\n', + ].join('\n'); +} From c7eb45c1cd90c0b3b464798bef536eb58b15d4de Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Sat, 3 Oct 2026 17:43:22 +0900 Subject: [PATCH 3/5] =?UTF-8?q?chore:=20k6=20=EC=A7=80=ED=91=9C=20?= =?UTF-8?q?=EC=A0=95=ED=99=95=EB=8F=84=20=EA=B0=9C=EC=84=A0=20(=EB=B9=84?= =?UTF-8?q?=EC=A6=88=EB=8B=88=EC=8A=A4=20=EA=B1=B0=EC=A0=88=20=EB=B6=84?= =?UTF-8?q?=EB=A5=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setResponseCallback(expectedStatuses(200,400)): 정원참 등 비즈니스 거절(400)을 정상으로 취급 -> http_req_failed는 실제 실패(5xx)만 집계 - 경합 스크립트: confirm 400을 capacity_full로 정확히 분류 (confirm 에러가 HTML로 내려와 body 파싱 불가 -> HTTP 상태로 판정) --- loadtest/capacity-contention.js | 9 +++++++-- loadtest/throughput-baseline.js | 3 +++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/loadtest/capacity-contention.js b/loadtest/capacity-contention.js index c457fca..9b5709d 100644 --- a/loadtest/capacity-contention.js +++ b/loadtest/capacity-contention.js @@ -20,6 +20,9 @@ const enrollSuccess = new Counter('enroll_success'); // 정원 안에 들어 성 const capacityFull = new Counter('capacity_full'); // 정원 초과로 거절 const otherReject = new Counter('other_reject'); // 그 외 거절 +// 비즈니스 거절(400)은 정상 응답으로 취급 → http_req_failed는 실제 실패(5xx/네트워크)만 집계 +http.setResponseCallback(http.expectedStatuses(200, 400)); + export const options = { scenarios: { contention: { @@ -51,9 +54,11 @@ 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 confRes = http.post(`${BASE_URL}/courses/apply/confirm?code=${code}`, null, formHeaders(userId)); + const c = safeJson(confRes); if (c && c.status === 'SUCCESS') enrollSuccess.add(1); - else classify(c); + else if (confRes.status === 400) capacityFull.add(1); // 정원 참(confirm 400) → 정원 초과 거절 + else otherReject.add(1); } // 검증 포인트(실행 후 확인): diff --git a/loadtest/throughput-baseline.js b/loadtest/throughput-baseline.js index 2fb367c..b7204cb 100644 --- a/loadtest/throughput-baseline.js +++ b/loadtest/throughput-baseline.js @@ -16,6 +16,9 @@ const SUBJECT_COUNT = parseInt(__ENV.SUBJECT_COUNT || '30'); // 시드된 과목 const enrollSuccess = new Counter('enroll_success'); // 최종 신청 성공 const enrollRejected = new Counter('enroll_rejected'); // 비즈니스 거절(정원/중복/학점초과 등) +// 비즈니스 거절(400)은 정상 응답으로 취급 → http_req_failed는 실제 실패(5xx/네트워크)만 집계 +http.setResponseCallback(http.expectedStatuses(200, 400)); + export const options = { scenarios: { baseline: { From bdd95a592593a226db333ff1a6659601785d86e9 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Sat, 3 Oct 2026 17:59:47 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20handleSummary=20p99=20=EB=AF=B8?= =?UTF-8?q?=EC=A1=B4=EC=9E=AC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20toFixed=20?= =?UTF-8?q?=ED=81=AC=EB=9E=98=EC=8B=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - summaryTrendStats에 p(99) 추가 (기본 통계엔 p99 없음) - highlight의 지표 접근을 null-safe 처리 --- loadtest/capacity-contention.js | 8 +++++--- loadtest/throughput-baseline.js | 12 +++++++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/loadtest/capacity-contention.js b/loadtest/capacity-contention.js index 9b5709d..fd3f49f 100644 --- a/loadtest/capacity-contention.js +++ b/loadtest/capacity-contention.js @@ -32,6 +32,7 @@ export const options = { maxDuration: '2m', }, }, + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'], }; export default function () { @@ -86,10 +87,11 @@ 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})`, diff --git a/loadtest/throughput-baseline.js b/loadtest/throughput-baseline.js index b7204cb..299d44f 100644 --- a/loadtest/throughput-baseline.js +++ b/loadtest/throughput-baseline.js @@ -38,6 +38,7 @@ export const options = { http_req_failed: ['rate<0.05'], // HTTP 레벨 실패(5xx/네트워크) 5% 미만 http_req_duration: ['p(95)<2000'], // p95 2초 미만 (관찰 기준선) }, + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'], }; export default function () { @@ -90,14 +91,15 @@ 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}%)`, From 4ec1ff8ab1c80f2a7cf9bd006be7aa2888198d61 Mon Sep 17 00:00:00 2001 From: kanghana1 Date: Sat, 3 Oct 2026 18:08:47 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20k6=20=EC=8B=A4=ED=8C=A8=20=EC=9B=90?= =?UTF-8?q?=EC=9D=B8=20=EC=B6=94=EC=A0=81=20=EA=B3=84=EC=B8=A1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 요청 단계별(apply/try/confirm) 태그 부여 - 5xx/네트워크 오류를 단계별 카운터로 집계 -> 실패 위치 파악 - 요약에 "5xx 발생 단계" 표시 (http_req_failed를 단계별로 분해) - README: 서버 로그 grep으로 실패 원인 확정 + 시계열/무릎 분석 가이드 --- loadtest/README.md | 24 +++++++++++++++++++++ loadtest/capacity-contention.js | 32 ++++++++++++++++++++-------- loadtest/throughput-baseline.js | 37 +++++++++++++++++++++++---------- 3 files changed, 73 insertions(+), 20 deletions(-) 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 fd3f49f..0d5e1ff 100644 --- a/loadtest/capacity-contention.js +++ b/loadtest/capacity-contention.js @@ -19,6 +19,10 @@ 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)); @@ -41,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); @@ -55,10 +63,11 @@ export default function () { if (!allowed) { otherReject.add(1); return; } // 3) 최종 확정 - const confRes = http.post(`${BASE_URL}/courses/apply/confirm?code=${code}`, null, formHeaders(userId)); - const c = safeJson(confRes); + 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 if (confRes.status === 400) capacityFull.add(1); // 정원 참(confirm 400) → 정원 초과 거절 + else if (cRes.status === 400) capacityFull.add(1); // 정원 참(confirm 400) → 정원 초과 거절 else otherReject.add(1); } @@ -69,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 저장(비교용) @@ -98,7 +111,8 @@ function highlight(data) { `정원 초과 거절 : ${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 299d44f..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,10 @@ 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)); @@ -35,21 +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; } @@ -57,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); @@ -65,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; } } @@ -105,7 +119,8 @@ function highlight(data) { `신청 성공 / 시도 : ${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'); }