[#58] K6 성능 테스트 도입 #59

Merged
pricelees merged 8 commits from feat/#58 into main 2025-10-11 07:38:27 +00:00
3 changed files with 459 additions and 0 deletions
Showing only changes of commit 2613b9f895 - Show all commits

91
test-scripts/common.js Normal file
View File

@ -0,0 +1,91 @@
import http from 'k6/http';
export const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
export function generateRandomBase64String(length) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
export function parseIdToString(response) {
try {
const safeJsonString = response.body.replace(/"(\w*Id|id)"\s*:\s*(\d{16,})/g, '"$1":"$2"');
return JSON.parse(safeJsonString);
} catch (e) {
console.error(`JSON parsing failed for VU ${__VU}: ${e}`);
return null;
}
}
export function maxIterations() {
const maxIterationsRes = http.get(`${BASE_URL}/tests/max-iterations`)
if (maxIterationsRes.status !== 200) {
throw new Error('max-iterations 조회 실패')
}
return maxIterationsRes.json('count')
}
export function fetchUsers() {
const userCount = Math.round(maxIterations() * 1.2)
const userAccountRes = http.get(`${BASE_URL}/tests/users?count=${userCount}`)
if (userAccountRes.status !== 200) {
throw new Error('users 조회 실패')
}
return userAccountRes.json('results')
}
export function fetchStores() {
const storeIdListRes = http.get(`${BASE_URL}/tests/stores`)
if (storeIdListRes.status !== 200) {
throw new Error('stores 조회 실패')
}
return parseIdToString(storeIdListRes).results
}
export function login(account, password, principalType) {
const loginPayload = JSON.stringify({
account: account,
password: password,
principalType: principalType
})
const params = { headers: { 'Content-Type': 'application/json' } }
const loginRes = http.post(`${BASE_URL}/auth/login`, loginPayload, params)
if (loginRes.status !== 200) {
throw new Error(`로그인 실패: ${__VU}`)
}
const body = parseIdToString(loginRes).data
if (principalType === 'ADMIN') {
return {
storeId: body.storeId,
accessToken: body.accessToken
}
} else {
return {
accessToken: body.accessToken
}
}
}
export function getHeaders(token) {
const headers = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return { headers: headers };
}

View File

@ -0,0 +1,231 @@
import {
BASE_URL,
fetchStores,
fetchUsers,
generateRandomBase64String,
getHeaders,
login,
parseIdToString
} from "./common.js";
import {check, group, sleep} from 'k6';
import {randomIntBetween, randomItem} from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
import http from 'k6/http';
export const options = {
scenarios: {
user_reservation: {
executor: 'constant-vus',
vus: 15,
duration: '15m'
}
},
thresholds: {
http_req_duration: ['p(95)<3000'],
http_req_failed: ['rate<0.15'],
},
};
function randomDayBetween(startFrom, endAt) {
const random = randomIntBetween(startFrom, endAt)
const targetDate = new Date()
targetDate.setDate(targetDate.getDate() + random)
return targetDate.toISOString().split('T')[0];
}
function countForFetchDetails(themeCount) {
const random = Math.random()
if (random < 0.5) {
return Math.min(0, themeCount)
}
if (random < 0.75) {
return Math.min(1, themeCount)
}
if (random < 0.9) {
return Math.min(2, themeCount)
}
return Math.min(randomIntBetween(3, themeCount), themeCount)
}
function extractRandomThemeForFetchDetail(themes) {
const count = countForFetchDetails(themes.length)
const shuffled = [...themes].sort(() => Math.random() - 0.5)
return shuffled.slice(0, count).map(t => t.id)
}
export function setup() {
const users = fetchUsers()
const stores = fetchStores()
console.log(`회원 수 = ${users.length} 조회 완료`)
console.log(`매장 수 = ${stores.length} 조회 완료`)
return { users, stores }
}
export default function (data) {
const { users, stores } = data;
const user = randomItem(users)
const accessToken = login(user.account, user.password, 'USER').accessToken
const storeId = randomItem(stores).storeId
if (!accessToken) {
console.log(`로그인 실패: token=${accessToken}`)
return
}
const targetDate = randomDayBetween(1, 6)
let availableScheduleId, selectedThemeId, selectedThemeInfo, reservationId, totalAmount
group(`매장=${storeId}, 날짜=${targetDate}의 일정 조회`, function () {
const res = http.get(`${BASE_URL}/stores/${storeId}/schedules?date=${targetDate}`)
if (check(res, { '일정 조회 성공': (r) => r.status === 200 })) {
sleep(20)
const schedules = parseIdToString(res).data.schedules
if (schedules) {
group(`일부 테마는 상세 조회`, function () {
const themesByStoreAndDate = schedules.map(s => s.theme)
if (!themesByStoreAndDate && themesByStoreAndDate.length <= 0) {
return
}
const randomThemesForFetchDetail = extractRandomThemeForFetchDetail(themesByStoreAndDate)
randomThemesForFetchDetail.forEach(id => {
http.get(`${BASE_URL}/themes/${id}`)
sleep(10)
})
})
const availableSchedules = schedules.filter((s) => s.schedule.status === 'AVAILABLE')
if (availableSchedules.length > 0) {
const availableSchedule = randomItem(availableSchedules)
availableScheduleId = availableSchedule.schedule.id
selectedThemeId = availableSchedule.theme.id
}
}
}
})
if (!availableScheduleId) {
console.log("이용 가능한 일정 없음.")
return;
}
group(`일정 Holding 및 테마 정보 조회 -> 예약 과정중 첫 페이지의 작업 완료`, function () {
const holdRes = http.post(`${BASE_URL}/schedules/${availableScheduleId}/hold`, null, getHeaders(accessToken))
if (check(holdRes, { '일정 점유 성공': (r) => r.status === 200 })) {
const themeInfoRes = http.get(`${BASE_URL}/themes/${selectedThemeId}`)
if (themeInfoRes.status !== 200) {
throw new Error("테마 상세 조회 실패")
}
selectedThemeInfo = parseIdToString(themeInfoRes).data
}
})
let isPendingReservationCreated = false
group(`예약 정보 입력 페이지`, function () {
let userName, userContact
group(`회원 연락처 조회`, function () {
const userContactRes = http.get(`${BASE_URL}/users/contact`, getHeaders(accessToken))
if (!check(userContactRes, { '회원 연락처 조회 성공': (r) => r.status === 200 })) {
throw new Error("회원 연락처 조회 과정에서 예외 발생")
}
const responseBody = JSON.parse(userContactRes.body).data
userName = responseBody.name
userContact = responseBody.phone
})
// 20%의 유저는 예약 정보 입력창에서 나감 => 배치의 자동 활성화 테스트
if (Math.random() <= 0.2) {
return
}
sleep(20)
group(`예약 정보 입력 및 Pending 예약 생성`, function () {
const requirement = `${selectedThemeInfo.name}을 잘부탁드려요!`
const participants = randomIntBetween(selectedThemeInfo.minParticipants, selectedThemeInfo.maxParticipants)
totalAmount = Math.round(participants * selectedThemeInfo.price)
const payload = JSON.stringify({
scheduleId: availableScheduleId,
reserverName: userName,
reserverContact: userContact,
participantCount: participants,
requirement: requirement
})
const pendingReservationCreateRes = http.post(`${BASE_URL}/reservations/pending`, payload, getHeaders(accessToken))
const responseBody = parseIdToString(pendingReservationCreateRes)
if (pendingReservationCreateRes.status !== 200) {
const errorCode = responseBody.code
const errorMessage = responseBody.message
throw new Error(`Pending 예약 중 실패: code=${errorCode}, message=${errorMessage}`)
}
reservationId = responseBody.data.id
isPendingReservationCreated = true
})
})
if (!isPendingReservationCreated) {
console.log("회원의 예약 정보 입력 중 페이지 이탈")
return;
}
group(`결제 및 예약 확정`, function () {
// 20%의 유저는 결제 화면에서 나감 => 배치의 자동 만료 처리 테스트
if (Math.random() <= 0.2) {
console.log("결제 페이지에서의 이탈")
return
}
const paymentKey = generateRandomBase64String(64)
const orderId = generateRandomBase64String(25)
const payload = JSON.stringify({
paymentKey: paymentKey,
orderId: orderId,
amount: totalAmount,
})
let trial = 0
let isConfirmed = false
while (trial < 2) {
sleep(30)
const confirmOrderRes = http.post(`${BASE_URL}/orders/${reservationId}/confirm`, payload, getHeaders(accessToken))
if (check(confirmOrderRes, { '예약 확정 성공': (r) => r.status === 200 })) {
isConfirmed = true
break
}
const errorResponse = JSON.parse(confirmOrderRes.body)
console.log(`예약 확정 실패: message=${errorResponse.message}`)
trial = errorResponse.trial + 1
}
// 예약이 확정되었으면 종료, 아니면 임시 확정
if (isConfirmed) {
return
}
sleep(10)
const temporalConfirmRes = http.post(`${BASE_URL}/reservations/${reservationId}/confirm`)
if (check(temporalConfirmRes, { '임시 예약 확정 성공': (r) => r.status === 200})) {
return
}
throw new Error('임시 예약 확정 실패')
})
}

View File

@ -0,0 +1,137 @@
import http from 'k6/http';
import {check, sleep} from 'k6';
import exec from 'k6/execution';
import {getHeaders, login, parseIdToString} from "./common.js";
import {randomIntBetween} from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
const TOTAL_ITERATIONS = 85212;
export const options = {
scenarios: {
schedule_creation: {
executor: 'shared-iterations',
vus: 263,
iterations: TOTAL_ITERATIONS,
maxDuration: '10m',
},
},
thresholds: {
'http_req_duration': ['p(95)<3000'],
'http_req_failed': ['rate<0.1'],
},
};
export function setup() {
console.log('=== Setup: 테스트 데이터 및 작업 목록 준비 중 ===');
const themesRes = http.get(`${BASE_URL}/tests/themes`);
const themes = parseIdToString(themesRes).results
const accountsRes = http.get(`${BASE_URL}/tests/admin/accounts`);
const accounts = JSON.parse(accountsRes.body).results;
const dates = generateDates(7);
console.log(`총 매장 수: ${accounts.length}`);
console.log(`총 테마 수: ${themes.length}`);
console.log(`생성 기간: ${dates.length}`);
const tasks = [];
for (const account of accounts) {
const loginResult = login(account.account, account.password, 'ADMIN');
if (loginResult === null) {
console.error(`[Setup] 로그인 실패: ${account.account}`);
continue;
}
const { storeId, accessToken } = loginResult;
// 5 ~ ${themes.size} 인 random 숫자 생성
const selectedThemes = selectRandomThemes(themes, randomIntBetween(5, themes.length));
for (const theme of selectedThemes) {
for (const date of dates) {
for (const time of theme.times) {
tasks.push({
storeId,
accessToken,
date,
time: time.startFrom,
themeId: theme.id,
});
}
}
}
}
console.log(`총 생성할 스케줄 수(iterations): ${tasks.length}`);
return { tasks };
}
export default function(data) {
// 👈 3. 현재 반복 횟수가 준비된 작업 수를 초과하는지 확인
const taskIndex = exec.scenario.iterationInTest;
if (taskIndex >= data.tasks.length) {
// 첫 번째로 이 조건에 도달한 VU가 테스트를 종료
if (taskIndex === data.tasks.length) {
console.log('모든 스케쥴 생성 완료. 테스트 종료');
exec.test.abort();
}
return;
}
const task = data.tasks[taskIndex];
if (!task) {
console.log(`[VU ${__VU}] 알 수 없는 오류: task가 없습니다. (index: ${taskIndex})`);
return;
}
createSchedule(task.storeId, task.accessToken, task);
}
function createSchedule(storeId, accessToken, schedule) {
const payload = JSON.stringify({
date: schedule.date,
time: schedule.time,
themeId: schedule.themeId,
});
const params = getHeaders(accessToken)
const res = http.post(`${BASE_URL}/admin/stores/${storeId}/schedules`, payload, params);
const success = check(res, {'일정 생성 성공': (r) => r.status === 200 || r.status === 201});
if (!success) {
console.error(`일정 생성 실패 [${res.status}]: 매장=${storeId}, ${schedule.date} ${schedule.time} (테마: ${schedule.themeId}) | 응답: ${res.body}`);
}
sleep(5)
return success;
}
function generateDates(days) {
const dates = [];
const today = new Date();
for (let i = 1; i < days; i++) {
const date = new Date(today);
date.setDate(today.getDate() + i);
dates.push(date.toISOString().split('T')[0]);
}
return dates;
}
function selectRandomThemes(themes, count) {
const shuffled = [...themes].sort(() => 0.5 - Math.random());
return shuffled.slice(0, Math.min(count, themes.length));
}
export function teardown(data) {
if (data.tasks) {
console.log(`\n=== 테스트 완료: 총 ${data.tasks.length}개의 스케줄 생성을 시도했습니다. ===`);
} else {
console.log('\n=== 테스트 완료: setup 단계에서 오류가 발생하여 작업을 실행하지 못했습니다. ===');
}
}