From 9aeb5cfc698f82d934b30a765e522e069baf3688 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Sat, 21 Mar 2026 18:28:13 +0900 Subject: [PATCH 1/4] =?UTF-8?q?:white=5Fcheck=5Fmark:=20=EB=B6=80=ED=95=98?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- load-test/k6/config.js | 23 ++++ load-test/k6/helpers/auth.js | 35 ++++++ load-test/k6/helpers/data.js | 44 +++++++ load-test/k6/scenarios/01_auth.js | 26 ++++ load-test/k6/scenarios/02_event_read.js | 41 ++++++ .../scenarios/03_registration_concurrent.js | 77 ++++++++++++ load-test/k6/seed/cleanup_users.sql | 2 + load-test/k6/seed/seed_users.sql | 117 ++++++++++++++++++ 8 files changed, 365 insertions(+) create mode 100644 load-test/k6/config.js create mode 100644 load-test/k6/helpers/auth.js create mode 100644 load-test/k6/helpers/data.js create mode 100644 load-test/k6/scenarios/01_auth.js create mode 100644 load-test/k6/scenarios/02_event_read.js create mode 100644 load-test/k6/scenarios/03_registration_concurrent.js create mode 100644 load-test/k6/seed/cleanup_users.sql create mode 100644 load-test/k6/seed/seed_users.sql diff --git a/load-test/k6/config.js b/load-test/k6/config.js new file mode 100644 index 00000000..8f97b57d --- /dev/null +++ b/load-test/k6/config.js @@ -0,0 +1,23 @@ +// 실행 시 -e BASE_URL=https://api.moiming.app 로 오버라이드 가능 +// 예) k6 run -e BASE_URL=https://api.moiming.app 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}`, + }; +} \ No newline at end of file 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..82bbc70e --- /dev/null +++ b/load-test/k6/scenarios/03_registration_concurrent.js @@ -0,0 +1,77 @@ +import http from 'k6/http'; +import { check } from 'k6'; +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; + +export const options = { + thresholds: { + ...THRESHOLDS, + // 신청 자체는 정원 초과여도 200이므로 실패율 기준 유지 + http_req_failed: [{ threshold: 'rate<0.01' }], + }, + // 램프업 없이 즉시 100명 동시 실행 — 동시성 압박이 목적 + vus: VU_COUNT, + iterations: VU_COUNT, +}; + +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) }, + ); + + 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; + + console.log(`CONFIRMED: ${confirmed} (expected: ${CAPACITY})`); + console.log(`WAITLISTED: ${waitlisted} (expected: ${VU_COUNT - CAPACITY})`); + console.log(`동시성 이상 없음: ${confirmed === CAPACITY ? 'PASS' : 'FAIL'}`); +} 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..b9933626 --- /dev/null +++ b/load-test/k6/seed/seed_users.sql @@ -0,0 +1,117 @@ +-- ============================================================ +-- 부하 테스트용 유저 seed +-- 실행 전: BCRYPT_HASH 자리에 실제 해시값을 넣을 것 +-- +-- BCrypt 해시 생성 방법 (비밀번호: Test1234!, rounds=10): +-- $ htpasswd -bnBC 10 "" Test1234! | 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); From 4d8ff1f2f1aa4fca58b854a4bc611240b24d23cb Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 5 May 2026 23:27:07 +0900 Subject: [PATCH 2/4] =?UTF-8?q?:memo:=20BASE=20URL=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- load-test/k6/config.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/load-test/k6/config.js b/load-test/k6/config.js index 8f97b57d..20df9453 100644 --- a/load-test/k6/config.js +++ b/load-test/k6/config.js @@ -1,5 +1,5 @@ -// 실행 시 -e BASE_URL=https://api.moiming.app 로 오버라이드 가능 -// 예) k6 run -e BASE_URL=https://api.moiming.app scenarios/01_auth.js +// 실행 시 -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 = { @@ -20,4 +20,4 @@ export function authHeaders(token) { ...DEFAULT_HEADERS, Authorization: `Bearer ${token}`, }; -} \ No newline at end of file +} From bf09ddf26c90666513a3cbc3514268e3b846d9c7 Mon Sep 17 00:00:00 2001 From: uykhc Date: Sun, 10 May 2026 13:05:42 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(load-test):=20k6=20=EB=8F=99=EC=8B=9C?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20=EA=B3=A0?= =?UTF-8?q?=EB=8F=84=ED=99=94=20=EB=B0=8F=20=EB=A9=94=EC=9D=BC=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EB=B6=84=EB=A6=AC=20=EC=8A=A4=EC=9C=84=EC=B9=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 + load-test/k6/README.md | 29 +++ .../scenarios/03_registration_concurrent.js | 48 ++++- load-test/k6/seed/seed_users.sql | 4 +- .../service/RegistrationService.kt | 178 +++++++++--------- src/main/resources/application.yaml | 3 + 6 files changed, 174 insertions(+), 93 deletions(-) create mode 100644 load-test/k6/README.md 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/scenarios/03_registration_concurrent.js b/load-test/k6/scenarios/03_registration_concurrent.js index 82bbc70e..1549240a 100644 --- a/load-test/k6/scenarios/03_registration_concurrent.js +++ b/load-test/k6/scenarios/03_registration_concurrent.js @@ -1,21 +1,36 @@ import http from 'k6/http'; -import { check } from 'k6'; +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', + }, }, - // 램프업 없이 즉시 100명 동시 실행 — 동시성 압박이 목적 - vus: VU_COUNT, - iterations: VU_COUNT, }; export function setup() { @@ -40,7 +55,10 @@ export default function ({ publicId, tokens }) { const res = http.post( `${BASE_URL}/api/events/${publicId}/registrations`, JSON.stringify({}), - { headers: authHeaders(token) }, + { + headers: authHeaders(token), + tags: { endpoint: 'registration_create' }, + }, ); check(res, { @@ -70,8 +88,24 @@ export function teardown({ publicId }) { 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: ${VU_COUNT - CAPACITY})`); - console.log(`동시성 이상 없음: ${confirmed === CAPACITY ? 'PASS' : 'FAIL'}`); + 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/seed_users.sql b/load-test/k6/seed/seed_users.sql index b9933626..5ec1d833 100644 --- a/load-test/k6/seed/seed_users.sql +++ b/load-test/k6/seed/seed_users.sql @@ -2,8 +2,8 @@ -- 부하 테스트용 유저 seed -- 실행 전: BCRYPT_HASH 자리에 실제 해시값을 넣을 것 -- --- BCrypt 해시 생성 방법 (비밀번호: Test1234!, rounds=10): --- $ htpasswd -bnBC 10 "" Test1234! | tr -d ':\n' +-- BCrypt 해시 생성 방법 (비밀번호: password123!, rounds=12): +-- $ htpasswd -bnBC 12 "" password123! | tr -d ':\n' -- ============================================================ SET @password_hash = '$2a$12$NZxQIubFeVpXYzCiruohjuzFZjA.UyGVeLtndK.KGpvDO.g6A3BcS'; diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index 0d363f47..9f818962 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -26,6 +26,7 @@ import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus import com.wafflestudio.spring2025.domain.registration.repository.RegistrationRepository import com.wafflestudio.spring2025.domain.user.model.User import com.wafflestudio.spring2025.domain.user.repository.UserRepository +import org.springframework.beans.factory.annotation.Value import org.springframework.dao.DuplicateKeyException import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service @@ -47,6 +48,8 @@ RegistrationService( private val userRepository: UserRepository, private val emailService: EmailService, private val imageService: ImageService, + @Value("\${registration.email-notifications-enabled:true}") + private val registrationEmailNotificationsEnabled: Boolean, ) : WaitlistReconciliationService { private val emailRegex = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$") private val tokenValidity = Duration.ofHours(24) @@ -157,9 +160,12 @@ RegistrationService( userRepository.findById(id).orElse(null) } - val recipientEmail = user?.email ?: guestEmail + if (registrationEmailNotificationsEnabled) { + val recipientEmail = user?.email ?: guestEmail + if (recipientEmail.isNullOrBlank()) { + throw RegistrationValidationException(RegistrationErrorCode.REGISTRATION_WRONG_EMAIL) + } - if (!recipientEmail.isNullOrBlank()) { val confirmedCount = registrationRepository .countByEventIdAndStatus(eventPk, RegistrationStatus.CONFIRMED) @@ -192,8 +198,6 @@ RegistrationService( afterCommit { emailService.sendRegistrationStatusEmail(emailData) } - } else { - throw RegistrationValidationException(RegistrationErrorCode.REGISTRATION_WRONG_EMAIL) } return CreateRegistrationResponse( @@ -259,41 +263,43 @@ RegistrationService( reconcileWaitlist(registration.eventId) } - val registrationUser = - registration.userId?.let { id -> - userRepository.findById(id).orElse(null) - } - - val recipientEmail = registrationUser?.email ?: registration.guestEmail - - if (!recipientEmail.isNullOrBlank()) { - val confirmedCount = - registrationRepository - .countByEventIdAndStatus(event.id!!, RegistrationStatus.CONFIRMED) - .toInt() - val waitlistedCount = - registrationRepository - .countByEventIdAndStatus(event.id!!, RegistrationStatus.WAITLISTED) - .toInt() - val totalCount = confirmedCount + waitlistedCount + if (registrationEmailNotificationsEnabled) { + val registrationUser = + registration.userId?.let { id -> + userRepository.findById(id).orElse(null) + } - val emailData = - EmailService.RegistrationDeleteEmailData( - toEmail = recipientEmail, - name = registration.guestName ?: registrationUser?.name ?: "참여자", - eventTitle = event.title, - startsAt = event.startsAt, - endsAt = event.endsAt, - location = event.location, - totalCount = totalCount, - capacity = event.capacity, - registrationStartsAt = event.registrationStartsAt, - registrationEndsAt = event.registrationEndsAt, - description = event.description, - ) + val recipientEmail = registrationUser?.email ?: registration.guestEmail + + if (!recipientEmail.isNullOrBlank()) { + val confirmedCount = + registrationRepository + .countByEventIdAndStatus(event.id!!, RegistrationStatus.CONFIRMED) + .toInt() + val waitlistedCount = + registrationRepository + .countByEventIdAndStatus(event.id!!, RegistrationStatus.WAITLISTED) + .toInt() + val totalCount = confirmedCount + waitlistedCount + + val emailData = + EmailService.RegistrationDeleteEmailData( + toEmail = recipientEmail, + name = registration.guestName ?: registrationUser?.name ?: "참여자", + eventTitle = event.title, + startsAt = event.startsAt, + endsAt = event.endsAt, + location = event.location, + totalCount = totalCount, + capacity = event.capacity, + registrationStartsAt = event.registrationStartsAt, + registrationEndsAt = event.registrationEndsAt, + description = event.description, + ) - afterCommit { - emailService.sendRegistrationDeleteEmail(emailData) + afterCommit { + emailService.sendRegistrationDeleteEmail(emailData) + } } } } @@ -627,6 +633,8 @@ RegistrationService( registration: Registration, event: Event, ) { + if (!registrationEmailNotificationsEnabled) return + val registrationUser = registration.userId?.let { uid -> userRepository.findById(uid).orElse(null) @@ -700,57 +708,59 @@ RegistrationService( val remainingWaitlisted = waitlistedRegs.size - promoted.size val totalCount = confirmedAfter + remainingWaitlisted - val emailDataList = - promoted.mapNotNull { registration -> - val user: User? = - registration.userId?.let { uid -> - userRepository.findById(uid).orElse(null) - } + if (registrationEmailNotificationsEnabled) { + val emailDataList = + promoted.mapNotNull { registration -> + val user: User? = + registration.userId?.let { uid -> + userRepository.findById(uid).orElse(null) + } - val recipientEmail = user?.email ?: registration.guestEmail - val recipientName = user?.name ?: registration.guestName ?: "참여자" - val waitingNum = waitlistNumbers[registration.registrationPublicId] + val recipientEmail = user?.email ?: registration.guestEmail + val recipientName = user?.name ?: registration.guestName ?: "참여자" + val waitingNum = waitlistNumbers[registration.registrationPublicId] - if (recipientEmail.isNullOrBlank()) { - null - } else { - WaitlistPromotionEmailData( - toEmail = recipientEmail, - eventTitle = event.title, - name = recipientName, - waitingNum = waitingNum, - startsAt = event.startsAt, - endsAt = event.endsAt, - location = event.location, - totalCount = totalCount, - capacity = capacity, - registrationStartsAt = event.registrationStartsAt, - registrationEndsAt = event.registrationEndsAt, - description = event.description, - eventPublicId = event.publicId, - registrationPublicId = registration.registrationPublicId, - ) + if (recipientEmail.isNullOrBlank()) { + null + } else { + WaitlistPromotionEmailData( + toEmail = recipientEmail, + eventTitle = event.title, + name = recipientName, + waitingNum = waitingNum, + startsAt = event.startsAt, + endsAt = event.endsAt, + location = event.location, + totalCount = totalCount, + capacity = capacity, + registrationStartsAt = event.registrationStartsAt, + registrationEndsAt = event.registrationEndsAt, + description = event.description, + eventPublicId = event.publicId, + registrationPublicId = registration.registrationPublicId, + ) + } } - } - afterCommit { - emailDataList.forEach { data -> - emailService.sendWaitlistPromotionEmail( - toEmail = data.toEmail, - eventTitle = data.eventTitle, - name = data.name, - waitingNum = data.waitingNum, - startsAt = data.startsAt, - endsAt = data.endsAt, - location = data.location, - totalCount = data.totalCount, - capacity = data.capacity, - registrationStartsAt = data.registrationStartsAt, - registrationEndsAt = data.registrationEndsAt, - description = data.description, - eventPublicId = data.eventPublicId, - registrationPublicId = data.registrationPublicId, - ) + afterCommit { + emailDataList.forEach { data -> + emailService.sendWaitlistPromotionEmail( + toEmail = data.toEmail, + eventTitle = data.eventTitle, + name = data.name, + waitingNum = data.waitingNum, + startsAt = data.startsAt, + endsAt = data.endsAt, + location = data.location, + totalCount = data.totalCount, + capacity = data.capacity, + registrationStartsAt = data.registrationStartsAt, + registrationEndsAt = data.registrationEndsAt, + description = data.description, + eventPublicId = data.eventPublicId, + registrationPublicId = data.registrationPublicId, + ) + } } } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 29fc2f77..63058eba 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -79,6 +79,9 @@ management: cors: allowed-origins: http://localhost:3000,http://localhost:5173 +registration: + email-notifications-enabled: ${REGISTRATION_EMAIL_NOTIFICATIONS_ENABLED:true} + aws: s3: bucket: waffle-team4-web-temp From f5b95e0a33e0129d2022dbb1feb75a35bddd439e Mon Sep 17 00:00:00 2001 From: uykhc Date: Tue, 19 May 2026 13:47:41 +0900 Subject: [PATCH 4/4] chore: align non-load-test files with dev --- .../service/RegistrationService.kt | 176 +++++++++--------- src/main/resources/application.yaml | 3 - 2 files changed, 83 insertions(+), 96 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index 7bd883e2..46ea8c70 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -26,7 +26,6 @@ import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus import com.wafflestudio.spring2025.domain.registration.repository.RegistrationRepository import com.wafflestudio.spring2025.domain.user.model.User import com.wafflestudio.spring2025.domain.user.repository.UserRepository -import org.springframework.beans.factory.annotation.Value import org.springframework.dao.DuplicateKeyException import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Pageable @@ -49,8 +48,6 @@ RegistrationService( private val userRepository: UserRepository, private val emailService: EmailService, private val imageService: ImageService, - @Value("\${registration.email-notifications-enabled:true}") - private val registrationEmailNotificationsEnabled: Boolean, ) : WaitlistReconciliationService { private val emailRegex = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$") private val tokenValidity = Duration.ofHours(24) @@ -157,12 +154,9 @@ RegistrationService( .toInt() val waitlistedNumber: Int? = if (saved.status == RegistrationStatus.WAITLISTED) waitlistedCount else null - if (registrationEmailNotificationsEnabled) { - val recipientEmail = user?.email ?: guestEmail - if (recipientEmail.isNullOrBlank()) { - throw RegistrationValidationException(RegistrationErrorCode.REGISTRATION_WRONG_EMAIL) - } + val recipientEmail = user?.email ?: guestEmail + if (!recipientEmail.isNullOrBlank()) { val confirmedCount = registrationRepository .countByEventIdAndStatus(eventPk, RegistrationStatus.CONFIRMED) @@ -191,6 +185,8 @@ RegistrationService( afterCommit { emailService.sendRegistrationStatusEmail(emailData) } + } else { + throw RegistrationValidationException(RegistrationErrorCode.REGISTRATION_WRONG_EMAIL) } return CreateRegistrationResponse( @@ -246,43 +242,41 @@ RegistrationService( reconcileWaitlist(registration.eventId) } - if (registrationEmailNotificationsEnabled) { - val registrationUser = - registration.userId?.let { id -> - userRepository.findById(id).orElse(null) - } + val registrationUser = + registration.userId?.let { id -> + userRepository.findById(id).orElse(null) + } - val recipientEmail = registrationUser?.email ?: registration.guestEmail - - if (!recipientEmail.isNullOrBlank()) { - val confirmedCount = - registrationRepository - .countByEventIdAndStatus(event.id!!, RegistrationStatus.CONFIRMED) - .toInt() - val waitlistedCount = - registrationRepository - .countByEventIdAndStatus(event.id!!, RegistrationStatus.WAITLISTED) - .toInt() - val totalCount = confirmedCount + waitlistedCount - - val emailData = - EmailService.RegistrationDeleteEmailData( - toEmail = recipientEmail, - name = registration.guestName ?: registrationUser?.name ?: "참여자", - eventTitle = event.title, - startsAt = event.startsAt, - endsAt = event.endsAt, - location = event.location, - totalCount = totalCount, - capacity = event.capacity, - registrationStartsAt = event.registrationStartsAt, - registrationEndsAt = event.registrationEndsAt, - description = event.description, - ) + val recipientEmail = registrationUser?.email ?: registration.guestEmail - afterCommit { - emailService.sendRegistrationDeleteEmail(emailData) - } + if (!recipientEmail.isNullOrBlank()) { + val confirmedCount = + registrationRepository + .countByEventIdAndStatus(event.id!!, RegistrationStatus.CONFIRMED) + .toInt() + val waitlistedCount = + registrationRepository + .countByEventIdAndStatus(event.id!!, RegistrationStatus.WAITLISTED) + .toInt() + val totalCount = confirmedCount + waitlistedCount + + val emailData = + EmailService.RegistrationDeleteEmailData( + toEmail = recipientEmail, + name = registration.guestName ?: registrationUser?.name ?: "참여자", + eventTitle = event.title, + startsAt = event.startsAt, + endsAt = event.endsAt, + location = event.location, + totalCount = totalCount, + capacity = event.capacity, + registrationStartsAt = event.registrationStartsAt, + registrationEndsAt = event.registrationEndsAt, + description = event.description, + ) + + afterCommit { + emailService.sendRegistrationDeleteEmail(emailData) } } } @@ -616,8 +610,6 @@ RegistrationService( registration: Registration, event: Event, ) { - if (!registrationEmailNotificationsEnabled) return - val registrationUser = registration.userId?.let { uid -> userRepository.findById(uid).orElse(null) @@ -755,60 +747,58 @@ RegistrationService( val remainingWaitlisted = totalWaitlisted - promoted.size val totalCount = confirmedAfter + remainingWaitlisted - if (registrationEmailNotificationsEnabled) { - val promotedUserIds = promoted.mapNotNull { it.userId }.distinct() - val promotedUsersById = userRepository.findAllById(promotedUserIds).associateBy { it.id!! } + val promotedUserIds = promoted.mapNotNull { it.userId }.distinct() + val promotedUsersById = userRepository.findAllById(promotedUserIds).associateBy { it.id!! } - val emailDataList = - promoted.mapNotNull { registration -> - val user: User? = registration.userId?.let { promotedUsersById[it] } - - val recipientEmail = user?.email ?: registration.guestEmail - val recipientName = user?.name ?: registration.guestName ?: "참여자" - val waitingNum = waitlistNumbers[registration.registrationPublicId] + val emailDataList = + promoted.mapNotNull { registration -> + val user: User? = registration.userId?.let { promotedUsersById[it] } - if (recipientEmail.isNullOrBlank()) { - null - } else { - WaitlistPromotionEmailData( - toEmail = recipientEmail, - eventTitle = event.title, - name = recipientName, - waitingNum = waitingNum, - startsAt = event.startsAt, - endsAt = event.endsAt, - location = event.location, - totalCount = totalCount, - capacity = capacity, - registrationStartsAt = event.registrationStartsAt, - registrationEndsAt = event.registrationEndsAt, - description = event.description, - eventPublicId = event.publicId, - registrationPublicId = registration.registrationPublicId, - ) - } - } + val recipientEmail = user?.email ?: registration.guestEmail + val recipientName = user?.name ?: registration.guestName ?: "참여자" + val waitingNum = waitlistNumbers[registration.registrationPublicId] - afterCommit { - emailDataList.forEach { data -> - emailService.sendWaitlistPromotionEmail( - toEmail = data.toEmail, - eventTitle = data.eventTitle, - name = data.name, - waitingNum = data.waitingNum, - startsAt = data.startsAt, - endsAt = data.endsAt, - location = data.location, - totalCount = data.totalCount, - capacity = data.capacity, - registrationStartsAt = data.registrationStartsAt, - registrationEndsAt = data.registrationEndsAt, - description = data.description, - eventPublicId = data.eventPublicId, - registrationPublicId = data.registrationPublicId, + if (recipientEmail.isNullOrBlank()) { + null + } else { + WaitlistPromotionEmailData( + toEmail = recipientEmail, + eventTitle = event.title, + name = recipientName, + waitingNum = waitingNum, + startsAt = event.startsAt, + endsAt = event.endsAt, + location = event.location, + totalCount = totalCount, + capacity = capacity, + registrationStartsAt = event.registrationStartsAt, + registrationEndsAt = event.registrationEndsAt, + description = event.description, + eventPublicId = event.publicId, + registrationPublicId = registration.registrationPublicId, ) } } + + afterCommit { + emailDataList.forEach { data -> + emailService.sendWaitlistPromotionEmail( + toEmail = data.toEmail, + eventTitle = data.eventTitle, + name = data.name, + waitingNum = data.waitingNum, + startsAt = data.startsAt, + endsAt = data.endsAt, + location = data.location, + totalCount = data.totalCount, + capacity = data.capacity, + registrationStartsAt = data.registrationStartsAt, + registrationEndsAt = data.registrationEndsAt, + description = data.description, + eventPublicId = data.eventPublicId, + registrationPublicId = data.registrationPublicId, + ) + } } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 02e689f3..ee7ef521 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -79,9 +79,6 @@ management: cors: allowed-origins: http://localhost:3000,http://localhost:5173 -registration: - email-notifications-enabled: ${REGISTRATION_EMAIL_NOTIFICATIONS_ENABLED:true} - aws: s3: bucket: waffle-team4-web-temp