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

Merged
pricelees merged 8 commits from feat/#58 into main 2025-10-11 07:38:27 +00:00
15 changed files with 710 additions and 246 deletions

View File

@ -0,0 +1,12 @@
import apiClient from "@_api/apiClient";
import type { PaymentConfirmRequest } from "@_api/payment/PaymentTypes";
export const confirm = async (
reservationId: string,
data: PaymentConfirmRequest,
): Promise<void> => {
return await apiClient.post<void>(
`/orders/${reservationId}/confirm`,
data
);
};

View File

@ -0,0 +1,5 @@
export interface OrderErrorResponse {
code: string;
message: string;
trial: number;
}

View File

@ -1,5 +1,5 @@
import { confirm } from '@_api/order/orderAPI';
import type { BookingErrorResponse } from '@_api/order/orderTypes';
import type { OrderErrorResponse } from '@_api/order/orderTypes';
import { type PaymentConfirmRequest } from '@_api/payment/PaymentTypes';
import { confirmReservation } from '@_api/reservation/reservationAPI';
import '@_css/reservation-v2-1.css';
@ -83,7 +83,7 @@ const ReservationStep2Page: React.FC = () => {
});
})
.catch(err => {
const error = err as AxiosError<BookingErrorResponse>;
const error = err as AxiosError<OrderErrorResponse>;
const errorCode = error.response?.data?.code;
const errorMessage = error.response?.data?.message;

View File

@ -1,5 +1,6 @@
package com.sangdol.roomescape.auth.web.support.resolver
import com.sangdol.common.utils.MdcPrincipalIdUtil
import com.sangdol.roomescape.auth.exception.AuthErrorCode
import com.sangdol.roomescape.auth.exception.AuthException
import com.sangdol.roomescape.auth.infrastructure.jwt.JwtUtils
@ -38,7 +39,9 @@ class UserContextResolver(
val token: String? = request.accessToken()
try {
val id: Long = jwtUtils.extractSubject(token).toLong()
val id: Long = jwtUtils.extractSubject(token).also {
MdcPrincipalIdUtil.set(it)
}.toLong()
return userService.findContextById(id)
} catch (e: Exception) {

View File

@ -11,7 +11,7 @@ import org.springframework.stereotype.Component
private val log: KLogger = KotlinLogging.logger {}
@Component
@Profile("local")
@Profile("!deploy & local")
class LocalDatabaseCleaner(
private val jdbcTemplate: JdbcTemplate
) {

View File

@ -1,6 +1,7 @@
package com.sangdol.roomescape.schedule.infrastructure.persistence
import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview
import com.sangdol.roomescape.test.ScheduleWithThemeId
import jakarta.persistence.LockModeType
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock
@ -133,14 +134,14 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
s.id
FROM
schedule s
LEFT JOIN
reservation r
ON
r.schedule_id = s.id AND r.status IN ('PENDING', 'PAYMENT_IN_PROGRESS')
WHERE
s.status = 'HOLD'
AND s.hold_expired_at <= :now
AND NOT EXISTS (
SELECT 1
FROM reservation r
WHERE r.schedule_id = s.id AND (r.status = 'PENDING' OR r.status = 'PAYMENT_IN_PROGRESS')
)
AND r.id IS NULL
FOR UPDATE SKIP LOCKED
""", nativeQuery = true
)
@ -159,4 +160,18 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
"""
)
fun releaseHeldSchedules(@Param("scheduleIds") scheduleIds: List<Long>): Int
/**
* for test
*/
@Query("""
SELECT
s.id, s.theme_id
FROM
schedule s
WHERE
s.status = 'AVAILABLE'
AND s.date > CURRENT_DATE
""", nativeQuery = true)
fun findAllAvailableSchedules(): List<ScheduleWithThemeId>
}

View File

@ -0,0 +1,45 @@
package com.sangdol.roomescape.test
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/tests")
class TestSetupController(
private val testSetupService: TestSetupService
) {
@GetMapping("/themes")
fun findAllThemeWithTimes(): ThemeWithTimesList {
return testSetupService.findAllThemeWithTimes()
}
@GetMapping("/max-iterations")
fun maxIterations(): MaxIterations {
return testSetupService.calculateMaxIterations()
}
@GetMapping("/admin/accounts")
fun findAllStoreAdminAccounts(): AccountList {
return testSetupService.findAllStoreAdminAccounts()
}
@GetMapping("/schedules/available")
fun findAllAvailableSchedules(): ScheduleWithThemeIdList {
return testSetupService.findAllAvailableSchedule()
}
@GetMapping("/users")
fun findAllUsers(
@RequestParam("count") count: Long
): AccountList {
return testSetupService.findAllUserAccounts(count)
}
@GetMapping("/stores")
fun findAllStoreIds(): StoreIdList {
return testSetupService.findAllStores()
}
}

View File

@ -0,0 +1,47 @@
package com.sangdol.roomescape.test
import java.time.LocalTime
data class ThemeWithTimesList(
val results: List<ThemeWithTimes>
)
data class ThemeWithTimes(
val id: Long,
val times: List<ScheduleTime>
)
data class ScheduleTime(
val startFrom: LocalTime,
val endAt: LocalTime
)
data class AccountList(
val results: List<Account>
)
data class Account(
val account: String,
val password: String
)
data class ScheduleWithThemeIdList(
val results: List<ScheduleWithThemeId>
)
data class ScheduleWithThemeId(
val scheduleId: Long,
val themeId: Long
)
data class MaxIterations(
val count: Long
)
data class StoreIdList(
val results: List<StoreId>
)
data class StoreId(
val storeId: Long
)

View File

@ -0,0 +1,88 @@
package com.sangdol.roomescape.test
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminRepository
import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
import com.sangdol.roomescape.store.infrastructure.persistence.StoreRepository
import com.sangdol.roomescape.theme.infrastructure.persistence.ThemeRepository
import com.sangdol.roomescape.user.infrastructure.persistence.UserRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalTime
@Service
class TestSetupService(
private val themeRepository: ThemeRepository,
private val storeRepository: StoreRepository,
private val adminRepository: AdminRepository,
private val userRepository: UserRepository,
private val scheduleRepository: ScheduleRepository,
) {
@Transactional(readOnly = true)
fun findAllThemeWithTimes(): ThemeWithTimesList {
val storeOpenTime = LocalTime.of(10, 0)
val storeCloseTime = LocalTime.of(22, 0)
val timeGapMinutes = 10L
return ThemeWithTimesList(themeRepository.findAll()
.filter { it.isActive }
.map { theme ->
val times: MutableList<ScheduleTime> = mutableListOf()
var startTime: LocalTime = storeOpenTime
while (startTime.isBefore(storeCloseTime)) {
val themeAvailableMinute = theme.availableMinutes.toLong()
val endTime: LocalTime = startTime.plusMinutes(themeAvailableMinute)
if (endTime.isAfter(storeCloseTime)) {
break
}
times.add(ScheduleTime(startTime, endTime))
startTime = endTime.plusMinutes(timeGapMinutes)
}
ThemeWithTimes(theme.id, times)
}
)
}
@Transactional(readOnly = true)
fun calculateMaxIterations(): MaxIterations {
val max = findAllThemeWithTimes().results.sumOf { it.times.size }
val stores = storeRepository.findAll().size
val days = 6
return MaxIterations((max * stores * days).toLong())
}
@Transactional(readOnly = true)
fun findAllStoreAdminAccounts(): AccountList {
return AccountList(adminRepository.findAll()
.filter { it.permissionLevel == AdminPermissionLevel.FULL_ACCESS }
.filter { it.type == AdminType.STORE }
.map { Account(it.account, it.password) }
)
}
@Transactional(readOnly = true)
fun findAllUserAccounts(count: Long): AccountList {
return AccountList(userRepository.findUsersByCount(count)
.map { Account(it.email, it.password) }
)
}
@Transactional(readOnly = true)
fun findAllAvailableSchedule(): ScheduleWithThemeIdList {
return ScheduleWithThemeIdList(scheduleRepository.findAllAvailableSchedules())
}
@Transactional(readOnly = true)
fun findAllStores(): StoreIdList {
return StoreIdList(storeRepository.findAll().map {
StoreId(it.id)
})
}
}

View File

@ -1,12 +1,21 @@
package com.sangdol.roomescape.user.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
interface UserRepository : JpaRepository<UserEntity, Long> {
fun existsByEmail(email: String): Boolean
fun existsByPhone(phone: String): Boolean
fun findByEmail(email: String): UserEntity?
/**
* for test
*/
@Query("""
SELECT * FROM users u LIMIT :count
""", nativeQuery = true)
fun findUsersByCount(count: Long): List<UserEntity>
}
interface UserStatusHistoryRepository : JpaRepository<UserStatusHistoryEntity, Long>

View File

@ -1,236 +0,0 @@
create table if not exists region (
code varchar(10) primary key,
sido_code varchar(2) not null,
sigungu_code varchar(3) not null,
sido_name varchar(20) not null,
sigungu_name varchar(20) not null,
constraint uk_region__sido_sigungu_code unique (sido_code, sigungu_code)
);
create table if not exists store(
id bigint primary key,
name varchar(20) not null,
address varchar(100) not null,
contact varchar(50) not null,
business_reg_num varchar(12) not null,
region_code varchar(10) not null,
status varchar(20) not null,
created_at timestamp not null,
created_by bigint not null,
updated_at timestamp not null,
updated_by bigint not null,
constraint uk_store__name unique (name),
constraint uk_store__contact unique (contact),
constraint uk_store__address unique (address),
constraint uk_store__business_reg_num unique (business_reg_num),
constraint fk_store__region_code foreign key (region_code) references region (code)
);
create table if not exists users(
id bigint primary key,
name varchar(50) not null,
email varchar(255) not null,
password varchar(255) not null,
phone varchar(20) not null,
region_code varchar(10) null,
status varchar(20) not null,
created_at datetime(6) not null,
created_by bigint not null,
updated_at datetime(6) not null,
updated_by bigint not null,
constraint uk__users_email unique (email),
constraint uk__users_phone unique (phone),
constraint fk__users_region_code foreign key (region_code) references region (code)
);
create table if not exists user_status_history(
id bigint primary key,
user_id bigint not null,
status varchar(20) not null,
reason varchar(255) not null,
created_at datetime(6) not null,
created_by bigint not null,
updated_at datetime(6) not null,
updated_by bigint not null,
constraint fk__user_status_history_user_id foreign key (user_id) references users (id)
);
create table if not exists admin(
id bigint primary key,
account varchar(20) not null,
password varchar(255) not null,
name varchar(20) not null,
phone varchar(20) not null,
type varchar(20) not null,
store_id bigint,
permission_level varchar(20) not null,
created_at timestamp not null,
created_by bigint not null,
updated_at timestamp not null,
updated_by bigint not null,
constraint uk_admin__account unique (account),
constraint uk_admin__phone unique (phone),
constraint chk_admin__type check (type in ('HQ', 'STORE')),
constraint chk_admin__store_id check (
(type = 'HQ' AND store_id IS NULL) OR
(type = 'STORE' AND store_id IS NOT NULL)
),
constraint fk_admin__store_id foreign key (store_id) references store (id)
);
create table if not exists login_history(
id bigint primary key,
principal_id bigint not null,
principal_type varchar(20) not null,
success boolean not null,
ip_address varchar(45) not null,
user_agent varchar(255) not null,
created_at timestamp not null
);
create table if not exists theme (
id bigint primary key ,
name varchar(30) not null,
difficulty varchar(20) not null,
description varchar(255) not null,
thumbnail_url varchar(255) not null,
price int not null,
min_participants smallint not null,
max_participants smallint not null,
available_minutes smallint not null,
expected_minutes_from smallint not null,
expected_minutes_to smallint not null,
is_active boolean not null,
created_at timestamp not null,
created_by bigint not null,
updated_at timestamp not null,
updated_by bigint not null
);
create table if not exists schedule (
id bigint primary key,
date date not null,
time time not null,
store_id bigint not null,
theme_id bigint not null,
status varchar(30) not null,
created_at timestamp not null,
created_by bigint not null,
updated_at timestamp not null,
updated_by bigint not null,
hold_expired_at timestamp null,
constraint uk_schedule__store_id_date_time_theme_id unique (store_id, date, time, theme_id),
constraint fk_schedule__store_id foreign key (store_id) references store (id),
constraint fk_schedule__theme_id foreign key (theme_id) references theme (id)
);
create table if not exists reservation (
id bigint primary key,
user_id bigint not null,
schedule_id bigint not null,
reserver_name varchar(30) not null,
reserver_contact varchar(30) not null,
participant_count smallint not null,
requirement varchar(255) not null,
status varchar(30) not null,
created_at timestamp not null,
created_by bigint not null,
updated_at timestamp not null,
updated_by bigint not null,
constraint fk_reservation__user_id foreign key (user_id) references users (id),
constraint fk_reservation__schedule_id foreign key (schedule_id) references schedule (id)
);
create table if not exists canceled_reservation (
id bigint primary key,
reservation_id bigint not null,
canceled_by bigint not null,
cancel_reason varchar(50) not null,
canceled_at timestamp not null,
status varchar(30) not null,
constraint uk_canceled_reservations__reservation_id unique (reservation_id),
constraint fk_canceled_reservations__reservation_id foreign key (reservation_id) references reservation (id)
);
create table if not exists payment (
id bigint primary key,
reservation_id bigint not null,
type varchar(20) not null,
method varchar(30) not null,
payment_key varchar(255) not null unique,
order_id varchar(255) not null unique,
total_amount integer not null,
status varchar(20) not null,
requested_at timestamp not null,
approved_at timestamp not null,
constraint uk_payment__reservationId unique (reservation_id),
constraint fk_payment__reservationId foreign key (reservation_id) references reservation (id)
);
create table if not exists payment_detail(
id bigint primary key,
payment_id bigint not null unique,
supplied_amount integer not null,
vat integer not null,
constraint fk_payment_detail__paymentId foreign key (payment_id) references payment (id)
);
create table if not exists payment_bank_transfer_detail (
id bigint primary key,
bank_code varchar(20) not null,
settlement_status varchar(20) not null,
constraint fk_payment_bank_transfer_details__id foreign key (id) references payment_detail (id)
);
create table if not exists payment_card_detail (
id bigint primary key,
issuer_code varchar(20) not null,
card_type varchar(10) not null,
owner_type varchar(10) not null,
amount integer not null,
card_number varchar(20) not null,
approval_number varchar(8) not null, -- 실제로는 unique 이지만 테스트 결제 위젯에서는 항상 000000으로 동일한 값이 나옴.
installment_plan_months tinyint not null,
is_interest_free boolean not null,
easypay_provider_code varchar(20),
easypay_discount_amount integer,
constraint fk_payment_card_detail__id foreign key (id) references payment_detail (id)
);
create table if not exists payment_easypay_prepaid_detail(
id bigint primary key,
easypay_provider_code varchar(20) not null,
amount integer not null,
discount_amount integer not null,
constraint fk_payment_easypay_prepaid_detail__id foreign key (id) references payment_detail (id)
);
create table if not exists canceled_payment(
id bigint primary key,
payment_id bigint not null,
requested_at timestamp not null,
canceled_at timestamp not null,
canceled_by bigint not null,
cancel_reason varchar(255) not null,
cancel_amount integer not null,
card_discount_amount integer not null,
transfer_discount_amount integer not null,
easypay_discount_amount integer not null,
constraint uk_canceled_payment__paymentId unique (payment_id),
constraint fk_canceled_payment__paymentId foreign key (payment_id) references payment(id)
);

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,250 @@
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: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '1m', target: 5 },
{ duration: '1m', target: 10 },
{ duration: '1m', target: 15 },
{ duration: '1m', target: 20 },
{ duration: '1m', target: 25 },
{ duration: '1m', target: 30 },
{ duration: '1m', target: 35 },
{ duration: '1m', target: 40 },
{ duration: '1m', target: 0 },
]
}
},
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)
let schedules = parseIdToString(res).data.schedules
if (!schedules || schedules.length === 0) {
console.log("일정 없음. 1회 재시도")
const storeId = randomItem(stores).storeId
const targetDate = randomDayBetween(0, 6)
const res = http.get(`${BASE_URL}/stores/${storeId}/schedules?date=${targetDate}`)
schedules = parseIdToString(res).data.schedules
}
if (schedules && schedules.length > 0) {
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) {
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})) {
console.log("예약 확정 성공")
return
}
throw new Error('임시 예약 확정 실패')
})
}

View File

@ -0,0 +1,135 @@
import http from 'k6/http';
import {check, sleep} from 'k6';
import exec from 'k6/execution';
import {BASE_URL, getHeaders, login, parseIdToString} from "./common.js";
import {randomIntBetween} from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
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 단계에서 오류가 발생하여 작업을 실행하지 못했습니다. ===');
}
}

View File

@ -28,7 +28,7 @@ spring:
sql:
init:
mode: always
schema-locations: classpath:schema/schema-h2.sql
schema-locations: classpath:schema/schema-mysql.sql
management:
endpoints: