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
24 changes: 24 additions & 0 deletions loadtest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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@<EC2-IP> \
"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://<IP> loadtest/throughput-baseline.js
# (동시에 다른 터미널) 커넥션풀 pending 실시간
while true; do clear; date +%T; curl -s http://<IP>/actuator/prometheus | grep -E "^hikaricp_connections_(active|pending|max)"; sleep 1; done
```
43 changes: 32 additions & 11 deletions loadtest/capacity-contention.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -29,6 +36,7 @@ export const options = {
maxDuration: '2m',
},
},
summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'],
};

export default function () {
Expand All @@ -37,23 +45,30 @@ 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);
}
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);
}

// 검증 포인트(실행 후 확인):
Expand All @@ -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 저장(비교용)
Expand All @@ -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');
Expand Down
52 changes: 36 additions & 16 deletions loadtest/throughput-baseline.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//
// 전제: 앱이 loadtest 프로파일로 떠 있어야 함 (userId 헤더 인증)
// 실행: k6 run -e BASE_URL=http://<EC2-IP> loadtest/throughput-baseline.js
// (시계열까지 남기려면) k6 run --out csv=loadtest/results/ts.csv -e BASE_URL=... ...

import http from 'k6/http';
import { sleep } from 'k6';
Expand All @@ -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: {
Expand All @@ -32,48 +40,58 @@ 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;
}

// 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);
}

// 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; } }

Expand All @@ -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');
}
Loading