Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions loadtest/README.md
Original file line number Diff line number Diff line change
@@ -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://<EC2-IP> loadtest/throughput-baseline.js
```

### 2. 동시성 경합 (`capacity-contention.js`)
인기과목에 집중 → 정원 초과/중복 차단 검증. 핫과목 1개/2개 각각 실행.
```bash
# 핫과목 1개 (최대 경합)
k6 run -e BASE_URL=http://<EC2-IP> -e HOT_SUBJECTS=1 -e VUS=300 loadtest/capacity-contention.js
# 핫과목 2개
k6 run -e BASE_URL=http://<EC2-IP> -e HOT_SUBJECTS=2 -e VUS=300 loadtest/capacity-contention.js
```
> 경합 테스트는 **매 실행 전 DB를 깨끗한 상태**(destroy→apply)로 두어야 정원 검증이 정확함.

## 측정 지표 (k6 외)
부하 도는 동안 별도 터미널에서:
```bash
# CPU/메모리 (컨테이너별)
ssh -i ~/.ssh/id_ed25519 ubuntu@<EC2-IP> "docker stats"

# 스레드풀 / 커넥션풀 (1초마다 샘플링)
while true; do
curl -s http://<EC2-IP>/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
71 changes: 71 additions & 0 deletions loadtest/capacity-contention.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// 동시성 경합 · 정원 정합성 테스트
// 실제 상황: 인기 과목(꿀강) 쟁탈전 — 정원 30짜리에 수백 명이 같은 과목·같은 순간에 광클
// 목적: 정원 초과 등록이 안 되는가 / 중복 신청이 차단되는가 (Redis Lua + DB 조건부 UPDATE 검증)
//
// 전제: 앱이 loadtest 프로파일로 떠 있어야 함
// 실행(핫과목 1개): k6 run -e BASE_URL=http://<EC2-IP> -e HOT_SUBJECTS=1 -e VUS=300 loadtest/capacity-contention.js
// (핫과목 2개): k6 run -e BASE_URL=http://<EC2-IP> -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; } }
77 changes: 77 additions & 0 deletions loadtest/throughput-baseline.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// 처리량 baseline 테스트 (분산 부하)
// 실제 상황: 수강신청 오픈 직후 전교생이 여러 과목에 동시 접속하는 정상 피크
// 목적: 30과목에 부하를 분산시키며 VU를 올려 "시스템 한계 처리량(무릎 지점)"을 찾는다.
//
// 전제: 앱이 loadtest 프로파일로 떠 있어야 함 (userId 헤더 인증)
// 실행: k6 run -e BASE_URL=http://<EC2-IP> 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; } }
Loading