diff --git a/README.md b/README.md index 98cf57e3..38244f24 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,11 @@ Springdoc OpenAPI가 활성화되어 있습니다. 로컬 실행 후 아래 경 - `/swagger-ui/index.html` +## 부하 테스트 (k6) + +- 시나리오/seed 파일: `load-test/k6` +- 순수 백엔드 동시성 검증(메일 전송 제외) 가이드는 `load-test/k6/README.md` 참고 + ## 데이터베이스 마이그레이션 Flyway를 사용하며, SQL 파일은 다음 경로에 있습니다. diff --git a/load-test/k6/README.md b/load-test/k6/README.md new file mode 100644 index 00000000..6a54da7a --- /dev/null +++ b/load-test/k6/README.md @@ -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 -p < 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 -p < load-test/k6/seed/cleanup_users.sql +``` diff --git a/load-test/k6/config.js b/load-test/k6/config.js new file mode 100644 index 00000000..20df9453 --- /dev/null +++ b/load-test/k6/config.js @@ -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}`, + }; +} diff --git a/load-test/k6/helpers/auth.js b/load-test/k6/helpers/auth.js new file mode 100644 index 00000000..c8a3494a --- /dev/null +++ b/load-test/k6/helpers/auth.js @@ -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 }); +} \ No newline at end of file diff --git a/load-test/k6/helpers/data.js b/load-test/k6/helpers/data.js new file mode 100644 index 00000000..6578e266 --- /dev/null +++ b/load-test/k6/helpers/data.js @@ -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'); +} diff --git a/load-test/k6/scenarios/01_auth.js b/load-test/k6/scenarios/01_auth.js new file mode 100644 index 00000000..ac82cddc --- /dev/null +++ b/load-test/k6/scenarios/01_auth.js @@ -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); +} diff --git a/load-test/k6/scenarios/02_event_read.js b/load-test/k6/scenarios/02_event_read.js new file mode 100644 index 00000000..e8c830db --- /dev/null +++ b/load-test/k6/scenarios/02_event_read.js @@ -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); +} diff --git a/load-test/k6/scenarios/03_registration_concurrent.js b/load-test/k6/scenarios/03_registration_concurrent.js new file mode 100644 index 00000000..1549240a --- /dev/null +++ b/load-test/k6/scenarios/03_registration_concurrent.js @@ -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}`, + ); + } +} diff --git a/load-test/k6/seed/cleanup_users.sql b/load-test/k6/seed/cleanup_users.sql new file mode 100644 index 00000000..adbd0b5c --- /dev/null +++ b/load-test/k6/seed/cleanup_users.sql @@ -0,0 +1,2 @@ +-- 부하 테스트 완료 후 seed 유저 정리 +DELETE FROM users WHERE email LIKE 'loadtest-%@example.com'; \ No newline at end of file diff --git a/load-test/k6/seed/seed_users.sql b/load-test/k6/seed/seed_users.sql new file mode 100644 index 00000000..5ec1d833 --- /dev/null +++ b/load-test/k6/seed/seed_users.sql @@ -0,0 +1,117 @@ +-- ============================================================ +-- 부하 테스트용 유저 seed +-- 실행 전: BCRYPT_HASH 자리에 실제 해시값을 넣을 것 +-- +-- BCrypt 해시 생성 방법 (비밀번호: password123!, rounds=12): +-- $ htpasswd -bnBC 12 "" password123! | tr -d ':\n' +-- ============================================================ + +SET @password_hash = '$2a$12$NZxQIubFeVpXYzCiruohjuzFZjA.UyGVeLtndK.KGpvDO.g6A3BcS'; +SET @now = NOW(6); + +-- 호스트 계정 (이벤트 생성용) +INSERT IGNORE INTO users (email, name, password_hash, created_at, updated_at) +VALUES ('loadtest-host@example.com', 'LoadTest Host', @password_hash, @now, @now); + +-- 참여자 계정 100명 (loadtest-user-0 ~ loadtest-user-99) +INSERT IGNORE INTO users (email, name, password_hash, created_at, updated_at) VALUES +('loadtest-user-0@example.com', 'LoadTest User 0', @password_hash, @now, @now), +('loadtest-user-1@example.com', 'LoadTest User 1', @password_hash, @now, @now), +('loadtest-user-2@example.com', 'LoadTest User 2', @password_hash, @now, @now), +('loadtest-user-3@example.com', 'LoadTest User 3', @password_hash, @now, @now), +('loadtest-user-4@example.com', 'LoadTest User 4', @password_hash, @now, @now), +('loadtest-user-5@example.com', 'LoadTest User 5', @password_hash, @now, @now), +('loadtest-user-6@example.com', 'LoadTest User 6', @password_hash, @now, @now), +('loadtest-user-7@example.com', 'LoadTest User 7', @password_hash, @now, @now), +('loadtest-user-8@example.com', 'LoadTest User 8', @password_hash, @now, @now), +('loadtest-user-9@example.com', 'LoadTest User 9', @password_hash, @now, @now), +('loadtest-user-10@example.com', 'LoadTest User 10', @password_hash, @now, @now), +('loadtest-user-11@example.com', 'LoadTest User 11', @password_hash, @now, @now), +('loadtest-user-12@example.com', 'LoadTest User 12', @password_hash, @now, @now), +('loadtest-user-13@example.com', 'LoadTest User 13', @password_hash, @now, @now), +('loadtest-user-14@example.com', 'LoadTest User 14', @password_hash, @now, @now), +('loadtest-user-15@example.com', 'LoadTest User 15', @password_hash, @now, @now), +('loadtest-user-16@example.com', 'LoadTest User 16', @password_hash, @now, @now), +('loadtest-user-17@example.com', 'LoadTest User 17', @password_hash, @now, @now), +('loadtest-user-18@example.com', 'LoadTest User 18', @password_hash, @now, @now), +('loadtest-user-19@example.com', 'LoadTest User 19', @password_hash, @now, @now), +('loadtest-user-20@example.com', 'LoadTest User 20', @password_hash, @now, @now), +('loadtest-user-21@example.com', 'LoadTest User 21', @password_hash, @now, @now), +('loadtest-user-22@example.com', 'LoadTest User 22', @password_hash, @now, @now), +('loadtest-user-23@example.com', 'LoadTest User 23', @password_hash, @now, @now), +('loadtest-user-24@example.com', 'LoadTest User 24', @password_hash, @now, @now), +('loadtest-user-25@example.com', 'LoadTest User 25', @password_hash, @now, @now), +('loadtest-user-26@example.com', 'LoadTest User 26', @password_hash, @now, @now), +('loadtest-user-27@example.com', 'LoadTest User 27', @password_hash, @now, @now), +('loadtest-user-28@example.com', 'LoadTest User 28', @password_hash, @now, @now), +('loadtest-user-29@example.com', 'LoadTest User 29', @password_hash, @now, @now), +('loadtest-user-30@example.com', 'LoadTest User 30', @password_hash, @now, @now), +('loadtest-user-31@example.com', 'LoadTest User 31', @password_hash, @now, @now), +('loadtest-user-32@example.com', 'LoadTest User 32', @password_hash, @now, @now), +('loadtest-user-33@example.com', 'LoadTest User 33', @password_hash, @now, @now), +('loadtest-user-34@example.com', 'LoadTest User 34', @password_hash, @now, @now), +('loadtest-user-35@example.com', 'LoadTest User 35', @password_hash, @now, @now), +('loadtest-user-36@example.com', 'LoadTest User 36', @password_hash, @now, @now), +('loadtest-user-37@example.com', 'LoadTest User 37', @password_hash, @now, @now), +('loadtest-user-38@example.com', 'LoadTest User 38', @password_hash, @now, @now), +('loadtest-user-39@example.com', 'LoadTest User 39', @password_hash, @now, @now), +('loadtest-user-40@example.com', 'LoadTest User 40', @password_hash, @now, @now), +('loadtest-user-41@example.com', 'LoadTest User 41', @password_hash, @now, @now), +('loadtest-user-42@example.com', 'LoadTest User 42', @password_hash, @now, @now), +('loadtest-user-43@example.com', 'LoadTest User 43', @password_hash, @now, @now), +('loadtest-user-44@example.com', 'LoadTest User 44', @password_hash, @now, @now), +('loadtest-user-45@example.com', 'LoadTest User 45', @password_hash, @now, @now), +('loadtest-user-46@example.com', 'LoadTest User 46', @password_hash, @now, @now), +('loadtest-user-47@example.com', 'LoadTest User 47', @password_hash, @now, @now), +('loadtest-user-48@example.com', 'LoadTest User 48', @password_hash, @now, @now), +('loadtest-user-49@example.com', 'LoadTest User 49', @password_hash, @now, @now), +('loadtest-user-50@example.com', 'LoadTest User 50', @password_hash, @now, @now), +('loadtest-user-51@example.com', 'LoadTest User 51', @password_hash, @now, @now), +('loadtest-user-52@example.com', 'LoadTest User 52', @password_hash, @now, @now), +('loadtest-user-53@example.com', 'LoadTest User 53', @password_hash, @now, @now), +('loadtest-user-54@example.com', 'LoadTest User 54', @password_hash, @now, @now), +('loadtest-user-55@example.com', 'LoadTest User 55', @password_hash, @now, @now), +('loadtest-user-56@example.com', 'LoadTest User 56', @password_hash, @now, @now), +('loadtest-user-57@example.com', 'LoadTest User 57', @password_hash, @now, @now), +('loadtest-user-58@example.com', 'LoadTest User 58', @password_hash, @now, @now), +('loadtest-user-59@example.com', 'LoadTest User 59', @password_hash, @now, @now), +('loadtest-user-60@example.com', 'LoadTest User 60', @password_hash, @now, @now), +('loadtest-user-61@example.com', 'LoadTest User 61', @password_hash, @now, @now), +('loadtest-user-62@example.com', 'LoadTest User 62', @password_hash, @now, @now), +('loadtest-user-63@example.com', 'LoadTest User 63', @password_hash, @now, @now), +('loadtest-user-64@example.com', 'LoadTest User 64', @password_hash, @now, @now), +('loadtest-user-65@example.com', 'LoadTest User 65', @password_hash, @now, @now), +('loadtest-user-66@example.com', 'LoadTest User 66', @password_hash, @now, @now), +('loadtest-user-67@example.com', 'LoadTest User 67', @password_hash, @now, @now), +('loadtest-user-68@example.com', 'LoadTest User 68', @password_hash, @now, @now), +('loadtest-user-69@example.com', 'LoadTest User 69', @password_hash, @now, @now), +('loadtest-user-70@example.com', 'LoadTest User 70', @password_hash, @now, @now), +('loadtest-user-71@example.com', 'LoadTest User 71', @password_hash, @now, @now), +('loadtest-user-72@example.com', 'LoadTest User 72', @password_hash, @now, @now), +('loadtest-user-73@example.com', 'LoadTest User 73', @password_hash, @now, @now), +('loadtest-user-74@example.com', 'LoadTest User 74', @password_hash, @now, @now), +('loadtest-user-75@example.com', 'LoadTest User 75', @password_hash, @now, @now), +('loadtest-user-76@example.com', 'LoadTest User 76', @password_hash, @now, @now), +('loadtest-user-77@example.com', 'LoadTest User 77', @password_hash, @now, @now), +('loadtest-user-78@example.com', 'LoadTest User 78', @password_hash, @now, @now), +('loadtest-user-79@example.com', 'LoadTest User 79', @password_hash, @now, @now), +('loadtest-user-80@example.com', 'LoadTest User 80', @password_hash, @now, @now), +('loadtest-user-81@example.com', 'LoadTest User 81', @password_hash, @now, @now), +('loadtest-user-82@example.com', 'LoadTest User 82', @password_hash, @now, @now), +('loadtest-user-83@example.com', 'LoadTest User 83', @password_hash, @now, @now), +('loadtest-user-84@example.com', 'LoadTest User 84', @password_hash, @now, @now), +('loadtest-user-85@example.com', 'LoadTest User 85', @password_hash, @now, @now), +('loadtest-user-86@example.com', 'LoadTest User 86', @password_hash, @now, @now), +('loadtest-user-87@example.com', 'LoadTest User 87', @password_hash, @now, @now), +('loadtest-user-88@example.com', 'LoadTest User 88', @password_hash, @now, @now), +('loadtest-user-89@example.com', 'LoadTest User 89', @password_hash, @now, @now), +('loadtest-user-90@example.com', 'LoadTest User 90', @password_hash, @now, @now), +('loadtest-user-91@example.com', 'LoadTest User 91', @password_hash, @now, @now), +('loadtest-user-92@example.com', 'LoadTest User 92', @password_hash, @now, @now), +('loadtest-user-93@example.com', 'LoadTest User 93', @password_hash, @now, @now), +('loadtest-user-94@example.com', 'LoadTest User 94', @password_hash, @now, @now), +('loadtest-user-95@example.com', 'LoadTest User 95', @password_hash, @now, @now), +('loadtest-user-96@example.com', 'LoadTest User 96', @password_hash, @now, @now), +('loadtest-user-97@example.com', 'LoadTest User 97', @password_hash, @now, @now), +('loadtest-user-98@example.com', 'LoadTest User 98', @password_hash, @now, @now), +('loadtest-user-99@example.com', 'LoadTest User 99', @password_hash, @now, @now);