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'); +} 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