diff --git a/loadtest/README.md b/loadtest/README.md new file mode 100644 index 0000000..cf984de --- /dev/null +++ b/loadtest/README.md @@ -0,0 +1,46 @@ +# 부하테스트 (k6) + +수강신청 시스템의 한계 처리량과 정원 정합성을 측정한다. + +## 사전 조건 +1. 앱이 **loadtest 프로파일**로 떠 있어야 함 (userId 헤더 인증) + - EC2 `.env`에 `SPRING_PROFILES_ACTIVE=prod,loadtest` 설정 후 재배포 +2. 시드 데이터 생성됨 (회원 user1~user2500, 과목 SUBJ001~SUBJ030) +3. k6 설치 (`k6 version`) +4. ⚠️ 정확한 측정은 **같은 리전(ap-northeast-2)의 부하생성용 EC2**에서 실행 (로컬은 대역폭 왜곡) + +## 시나리오 + +### 1. 처리량 baseline (`throughput-baseline.js`) +30과목에 분산, VU 100→500→1000→2000 램프 → 한계 처리량(무릎) 탐색 +```bash +k6 run -e BASE_URL=http:// loadtest/throughput-baseline.js +``` + +### 2. 동시성 경합 (`capacity-contention.js`) +인기과목에 집중 → 정원 초과/중복 차단 검증. 핫과목 1개/2개 각각 실행. +```bash +# 핫과목 1개 (최대 경합) +k6 run -e BASE_URL=http:// -e HOT_SUBJECTS=1 -e VUS=300 loadtest/capacity-contention.js +# 핫과목 2개 +k6 run -e BASE_URL=http:// -e HOT_SUBJECTS=2 -e VUS=300 loadtest/capacity-contention.js +``` +> 경합 테스트는 **매 실행 전 DB를 깨끗한 상태**(destroy→apply)로 두어야 정원 검증이 정확함. + +## 측정 지표 (k6 외) +부하 도는 동안 별도 터미널에서: +```bash +# CPU/메모리 (컨테이너별) +ssh -i ~/.ssh/id_ed25519 ubuntu@ "docker stats" + +# 스레드풀 / 커넥션풀 (1초마다 샘플링) +while true; do + curl -s http:///actuator/prometheus | grep -E 'hikaricp_connections_active|tomcat_threads_busy|process_cpu_usage' + sleep 1 +done +``` + +## 검증 포인트 (경합 테스트 후) +- `enroll_success` == `30 * HOT_SUBJECTS` (정원만큼만 성공) +- 각 과목 `registeredNum <= limitedNum(30)` → **정원 초과 0** +- 같은 유저 중복 성공 0 diff --git a/loadtest/capacity-contention.js b/loadtest/capacity-contention.js new file mode 100644 index 0000000..679e57e --- /dev/null +++ b/loadtest/capacity-contention.js @@ -0,0 +1,71 @@ +// 동시성 경합 · 정원 정합성 테스트 +// 실제 상황: 인기 과목(꿀강) 쟁탈전 — 정원 30짜리에 수백 명이 같은 과목·같은 순간에 광클 +// 목적: 정원 초과 등록이 안 되는가 / 중복 신청이 차단되는가 (Redis Lua + DB 조건부 UPDATE 검증) +// +// 전제: 앱이 loadtest 프로파일로 떠 있어야 함 +// 실행(핫과목 1개): k6 run -e BASE_URL=http:// -e HOT_SUBJECTS=1 -e VUS=300 loadtest/capacity-contention.js +// (핫과목 2개): k6 run -e BASE_URL=http:// -e HOT_SUBJECTS=2 -e VUS=300 loadtest/capacity-contention.js +// ⚠️ 매 실행 전 DB를 깨끗한 상태로 (destroy→apply) 두어야 정원 검증이 정확함. + +import http from 'k6/http'; +import { sleep } from 'k6'; +import { Counter } from 'k6/metrics'; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost'; +const HOT_SUBJECTS = parseInt(__ENV.HOT_SUBJECTS || '1'); // 경쟁 대상 과목 수 (1 또는 2) +const VUS = parseInt(__ENV.VUS || '300'); // 동시 신청 인원 + +const enrollSuccess = new Counter('enroll_success'); // 정원 안에 들어 성공 +const capacityFull = new Counter('capacity_full'); // 정원 초과로 거절 +const otherReject = new Counter('other_reject'); // 그 외 거절 + +export const options = { + scenarios: { + contention: { + executor: 'per-vu-iterations', + vus: VUS, + iterations: 1, // 각 유저 1회 신청 → 동시 경합 + maxDuration: '2m', + }, + }, +}; + +export default function () { + const userId = __VU; + const hot = ((__VU - 1) % HOT_SUBJECTS) + 1; // 1..HOT_SUBJECTS 로 고르게 배정 + const code = 'SUBJ' + ('00' + hot).slice(-3); + + // 1) 대기열 진입 + const apply = safeJson(http.post(`${BASE_URL}/courses/apply`, `code=${code}`, formHeaders(userId))); + 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))); + if (t && t.status === 'ALLOWED') { allowed = true; break; } + if (t && t.status === 'FAIL') { classify(t); return; } + sleep(0.3); + } + if (!allowed) { otherReject.add(1); return; } + + // 3) 최종 확정 + const c = safeJson(http.post(`${BASE_URL}/courses/apply/confirm?code=${code}`, null, formHeaders(userId))); + if (c && c.status === 'SUCCESS') enrollSuccess.add(1); + else classify(c); +} + +// 검증 포인트(실행 후 확인): +// - enroll_success == 30 * HOT_SUBJECTS (정원만큼만 성공) +// - 각 과목 registeredNum <= limitedNum(30) → 정원 초과 0 +// - 같은 유저 중복 성공 0 + +function classify(body) { + const msg = (body && body.message) ? String(body.message) : ''; + if (msg.indexOf('정원') >= 0 || msg.toUpperCase().indexOf('CAPACITY') >= 0) capacityFull.add(1); + else otherReject.add(1); +} +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; } } diff --git a/loadtest/throughput-baseline.js b/loadtest/throughput-baseline.js new file mode 100644 index 0000000..a438a44 --- /dev/null +++ b/loadtest/throughput-baseline.js @@ -0,0 +1,77 @@ +// 처리량 baseline 테스트 (분산 부하) +// 실제 상황: 수강신청 오픈 직후 전교생이 여러 과목에 동시 접속하는 정상 피크 +// 목적: 30과목에 부하를 분산시키며 VU를 올려 "시스템 한계 처리량(무릎 지점)"을 찾는다. +// +// 전제: 앱이 loadtest 프로파일로 떠 있어야 함 (userId 헤더 인증) +// 실행: k6 run -e BASE_URL=http:// loadtest/throughput-baseline.js + +import http from 'k6/http'; +import { sleep } from 'k6'; +import { Counter } from 'k6/metrics'; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost'; +const SUBJECT_COUNT = parseInt(__ENV.SUBJECT_COUNT || '30'); // 시드된 과목 수 + +const enrollSuccess = new Counter('enroll_success'); // 최종 신청 성공 +const enrollRejected = new Counter('enroll_rejected'); // 비즈니스 거절(정원/중복/학점초과 등) + +export const options = { + scenarios: { + baseline: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '1m', target: 100 }, + { duration: '2m', target: 500 }, + { duration: '2m', target: 1000 }, + { duration: '2m', target: 2000 }, // 목표 피크(4학년 코호트 가정) + { duration: '2m', target: 2000 }, // 피크 유지 + { duration: '1m', target: 0 }, + ], + }, + }, + thresholds: { + http_req_failed: ['rate<0.05'], // HTTP 레벨 실패(5xx/네트워크) 5% 미만 + http_req_duration: ['p(95)<2000'], // p95 2초 미만 (관찰 기준선) + }, +}; + +export default function () { + const userId = __VU; // VU별 고정 userId (시드 user1~user2500) + 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); + if (!apply || apply.status !== 'WAITING') { + enrollRejected.add(1); // rate limit / 중복 / 검증 실패 등 + sleep(randomInt(1, 3)); + return; + } + + // 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))); + if (t && t.status === 'ALLOWED') { allowed = true; break; } + if (t && t.status === 'FAIL') break; + sleep(0.5); + } + + // 3) 최종 확정 + if (allowed) { + const c = safeJson(http.post(`${BASE_URL}/courses/apply/confirm?code=${code}`, null, formHeaders(userId))); + if (c && c.status === 'SUCCESS') enrollSuccess.add(1); + else enrollRejected.add(1); + } else { + enrollRejected.add(1); + } + + sleep(randomInt(1, 3)); // think time (rate limit 회피 + 현실성) +} + +function formHeaders(userId) { + return { headers: { userId: String(userId), 'Content-Type': 'application/x-www-form-urlencoded' } }; +} +function randomInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } +function safeJson(res) { try { return res.json(); } catch (e) { return null; } }