Skip to content
Open
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ Springdoc OpenAPI가 활성화되어 있습니다. 로컬 실행 후 아래 경

- `/swagger-ui/index.html`

## 부하 테스트 (k6)

- 시나리오/seed 파일: `load-test/k6`
- 순수 백엔드 동시성 검증(메일 전송 제외) 가이드는 `load-test/k6/README.md` 참고

## 데이터베이스 마이그레이션

Flyway를 사용하며, SQL 파일은 다음 경로에 있습니다.
Expand Down
29 changes: 29 additions & 0 deletions load-test/k6/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# k6 Load Test (Pure Backend Concurrency)

## 1) Server start option

To exclude SMTP/SES latency from registration concurrency tests, start the server with:

```bash
REGISTRATION_EMAIL_NOTIFICATIONS_ENABLED=false ./gradlew bootRun
```

This keeps registration transaction logic active while disabling post-commit email sends.

## 2) Seed test users

```bash
mysql -u <user> -p <db> < load-test/k6/seed/seed_users.sql
```

## 3) Run scenario

```bash
k6 run -e BASE_URL=http://localhost:8080 load-test/k6/scenarios/03_registration_concurrent.js
```

## 4) Cleanup

```bash
mysql -u <user> -p <db> < load-test/k6/seed/cleanup_users.sql
```
23 changes: 23 additions & 0 deletions load-test/k6/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// 실행 시 -e BASE_URL=https://moiming-dev-api.wafflestudio.com 으로 오버라이드 가능
// 예) k6 run -e BASE_URL=https://moiming-dev-api.wafflestudio.com scenarios/01_auth.js
export const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';

export const THRESHOLDS = {
http_req_failed: [{ threshold: 'rate<0.01', abortOnFail: false }],
http_req_duration: [
{ threshold: 'p(95)<500', abortOnFail: false },
{ threshold: 'p(99)<1000', abortOnFail: false },
],
checks: [{ threshold: 'rate>0.99', abortOnFail: false }],
};

export const DEFAULT_HEADERS = {
'Content-Type': 'application/json',
};

export function authHeaders(token) {
return {
...DEFAULT_HEADERS,
Authorization: `Bearer ${token}`,
};
}
35 changes: 35 additions & 0 deletions load-test/k6/helpers/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import http from 'k6/http';
import { check } from 'k6';
import { BASE_URL, DEFAULT_HEADERS } from '../config.js';

/**
* 로그인 후 JWT 토큰을 반환한다.
* @param {string} email
* @param {string} password
* @returns {string} JWT token
*/
export function login(email, password) {
const res = http.post(
`${BASE_URL}/api/auth/login`,
JSON.stringify({ email, password }),
{ headers: DEFAULT_HEADERS },
);

check(res, { 'login 200': (r) => r.status === 200 });

return res.json('token');
}

/**
* 로그아웃 (토큰 블랙리스트 처리)
* @param {string} token
*/
export function logout(token) {
const res = http.post(
`${BASE_URL}/api/auth/logout`,
null,
{ headers: { Authorization: `Bearer ${token}` } },
);

check(res, { 'logout 204': (r) => r.status === 204 });
}
44 changes: 44 additions & 0 deletions load-test/k6/helpers/data.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import http from 'k6/http';
import { check } from 'k6';
import { BASE_URL, authHeaders } from '../config.js';

/**
* 테스트용 유저 자격증명을 생성한다 (실제 가입은 별도 seed 필요).
* @param {number} index - VU 번호 등 고유 식별자
* @returns {{ email: string, password: string }}
*/
export function makeUserCredentials(index) {
return {
email: `loadtest-user-${index}@example.com`,
password: 'password123!',
};
}

/**
* 이벤트를 생성하고 publicId를 반환한다.
* @param {string} token - 호스트 JWT 토큰
* @param {object} overrides - CreateEventRequest 필드 오버라이드
* @returns {string} publicId
*/
export function createEvent(token, overrides = {}) {
const now = new Date();
const body = {
title: 'Load Test Event',
capacity: 10,
waitlistEnabled: true,
// null = 제한 없음 (EventService: 과거 시각 불가 / RegistrationService: null이면 시작 제한 없음)
registrationStartsAt: null,
registrationEndsAt: new Date(now.getTime() + 3_600_000).toISOString(), // 1시간 후
...overrides,
};

const res = http.post(
`${BASE_URL}/api/events`,
JSON.stringify(body),
{ headers: authHeaders(token) },
);

check(res, { 'createEvent 200': (r) => r.status === 200 });

return res.json('publicId');
}
26 changes: 26 additions & 0 deletions load-test/k6/scenarios/01_auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { sleep } from 'k6';
import { THRESHOLDS } from '../config.js';
import { login, logout } from '../helpers/auth.js';

export const options = {
thresholds: THRESHOLDS,
stages: [
{ duration: '15s', target: 25 }, // 램프업
{ duration: '1m', target: 25 }, // 유지
{ duration: '15s', target: 0 }, // 램프다운
],
};

// 테스트 전 DB에 미리 생성해둔 계정 사용
// seed: loadtest-user-0@example.com ~ loadtest-user-49@example.com / password123!
export default function () {
const index = __VU % 50;
const email = `loadtest-user-${index}@example.com`;
const password = 'password123!';

const token = login(email, password);
sleep(1);

logout(token);
sleep(1);
}
41 changes: 41 additions & 0 deletions load-test/k6/scenarios/02_event_read.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { BASE_URL, THRESHOLDS, authHeaders } from '../config.js';
import { login } from '../helpers/auth.js';
import { createEvent } from '../helpers/data.js';

export const options = {
thresholds: THRESHOLDS,
stages: [
{ duration: '30s', target: 30 }, // 램프업
{ duration: '2m', target: 30 }, // 유지
{ duration: '15s', target: 0 }, // 램프다운
],
};

export function setup() {
const token = login('loadtest-host@example.com', 'password123!');
const publicId = createEvent(token);
return { token, publicId };
}

export default function ({ token, publicId }) {

// 이벤트 상세 조회
const detailRes = http.get(
`${BASE_URL}/api/events/${publicId}`,
{ headers: authHeaders(token) },
);
check(detailRes, { 'event detail 200': (r) => r.status === 200 });

sleep(0.5);

// 신청 목록 조회
const regRes = http.get(
`${BASE_URL}/api/events/${publicId}/registrations`,
{ headers: authHeaders(token) },
);
check(regRes, { 'registrations 200': (r) => r.status === 200 });

sleep(1);
}
111 changes: 111 additions & 0 deletions load-test/k6/scenarios/03_registration_concurrent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import http from 'k6/http';
import { check, fail } from 'k6';
import { Rate } from 'k6/metrics';
import { BASE_URL, THRESHOLDS, authHeaders } from '../config.js';
import { login } from '../helpers/auth.js';
import { createEvent, makeUserCredentials } from '../helpers/data.js';

const VU_COUNT = 15;
const CAPACITY = 4;
const registrationConsistency = new Rate('registration_consistency');

export const options = {
thresholds: {
...THRESHOLDS,
// 신청 자체는 정원 초과여도 200이므로 실패율 기준 유지
http_req_failed: [{ threshold: 'rate<0.01' }],
// 핵심 경로(등록 API)만 따로 latency threshold를 건다.
'http_req_duration{endpoint:registration_create}': [
{ threshold: 'p(95)<500', abortOnFail: false },
{ threshold: 'p(99)<1000', abortOnFail: false },
],
// teardown에서 기록하는 정합성 지표
registration_consistency: [{ threshold: 'rate==1' }],
},
// 각 VU가 정확히 1회씩 실행되도록 고정한다.
scenarios: {
registration_concurrent: {
executor: 'per-vu-iterations',
vus: VU_COUNT,
iterations: 1,
maxDuration: '1m',
},
},
};

export function setup() {
// 호스트 계정으로 이벤트 생성
const hostToken = login('loadtest-host@example.com', 'password123!');
const publicId = createEvent(hostToken, { capacity: CAPACITY, waitlistEnabled: true });
console.log(`이벤트 생성 완료: ${BASE_URL}/api/events/${publicId}`);

// 참여자 토큰 미리 발급 (setup에서 직렬 처리)
const tokens = [];
for (let i = 0; i < VU_COUNT; i++) {
const { email, password } = makeUserCredentials(i);
tokens.push(login(email, password));
}

return { publicId, tokens };
}

export default function ({ publicId, tokens }) {
const token = tokens[__VU - 1];

const res = http.post(
`${BASE_URL}/api/events/${publicId}/registrations`,
JSON.stringify({}),
{
headers: authHeaders(token),
tags: { endpoint: 'registration_create' },
},
);

check(res, {
'registration 200': (r) => r.status === 200,
'registrationPublicId exists': (r) => !!r.json('registrationPublicId'),
});
}

export function teardown({ publicId }) {
// 결과 요약: CONFIRMED / WAITLISTED 수량 출력 (페이지네이션 처리)
const hostToken = login('loadtest-host@example.com', 'password123!');

let allRegistrations = [];
let cursor = null;

do {
const url = cursor
? `${BASE_URL}/api/events/${publicId}/registrations?cursor=${cursor}`
: `${BASE_URL}/api/events/${publicId}/registrations`;

const res = http.get(url, { headers: authHeaders(hostToken) });
const body = res.json();

allRegistrations = allRegistrations.concat(body.participants || []);
cursor = body.hasNext ? body.nextCursor : null;
} while (cursor !== null);

const confirmed = allRegistrations.filter((r) => r.status === 'CONFIRMED').length;
const waitlisted = allRegistrations.filter((r) => r.status === 'WAITLISTED').length;
const expectedWaitlisted = VU_COUNT - CAPACITY;
const isConsistent =
confirmed === CAPACITY &&
waitlisted === expectedWaitlisted &&
allRegistrations.length === VU_COUNT;

console.log(`CONFIRMED: ${confirmed} (expected: ${CAPACITY})`);
console.log(`WAITLISTED: ${waitlisted} (expected: ${expectedWaitlisted})`);
console.log(`TOTAL: ${allRegistrations.length} (expected: ${VU_COUNT})`);
console.log(`동시성 이상 없음: ${isConsistent ? 'PASS' : 'FAIL'}`);

registrationConsistency.add(isConsistent ? 1 : 0);
check(null, {
'concurrency consistency': () => isConsistent,
});
if (!isConsistent) {
fail(
`concurrency mismatch: confirmed=${confirmed}, waitlisted=${waitlisted}, total=${allRegistrations.length}`,
);
}
}
2 changes: 2 additions & 0 deletions load-test/k6/seed/cleanup_users.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- 부하 테스트 완료 후 seed 유저 정리
DELETE FROM users WHERE email LIKE 'loadtest-%@example.com';
Loading
Loading