From 135b13a9bfce56d520df619a33dfff731c5f8c0c Mon Sep 17 00:00:00 2001 From: pricelees Date: Sat, 11 Oct 2025 07:38:26 +0000 Subject: [PATCH] =?UTF-8?q?[#58]=20K6=20=EC=84=B1=EB=8A=A5=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=8F=84=EC=9E=85=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 관련 이슈 및 PR **PR과 관련된 이슈 번호** - #58 ## ✨ 작업 내용 - K6 성능 테스트 스크립트 추가 및 배포 환경에서의 정상 동작 확인 - 정상 동작 과정 확인 중 발견된 slow-query 개선 => 커버링 인덱스를 생각했으나, 실제로 사용하지 않고 테이블 풀스캔을 하던 문제 ## 🧪 테스트 - 스크립트는 크게 사용자가 예약할 수 있는 일정을 만드는 작업과 사용자가 예약하는 작업 두 가지로 구분 - 후자의 테스트는 40VU까지는 여유있게 처리 확인 => 다음 과정부터는 부하를 더 높여 진행할 예정 ## 📚 참고 자료 및 기타 Reviewed-on: https://gitea.pricelees.me/pricelees/roomescape-refactored/pulls/59 Co-authored-by: pricelees Co-committed-by: pricelees --- frontend/src/api/order/orderAPI.ts | 12 + frontend/src/api/order/orderTypes.ts | 5 + frontend/src/pages/ReservationStep2Page.tsx | 4 +- .../support/resolver/UserContextResolver.kt | 5 +- .../common/config/LocalDatabaseCleaner.kt | 2 +- .../persistence/ScheduleRepository.kt | 25 +- .../roomescape/test/TestSetupController.kt | 45 ++++ .../sangdol/roomescape/test/TestSetupDTO.kt | 47 ++++ .../roomescape/test/TestSetupService.kt | 88 ++++++ .../persistence/UserRepositories.kt | 9 + .../src/main/resources/schema/schema-h2.sql | 236 ----------------- test-scripts/common.js | 91 +++++++ test-scripts/create-reservation-scripts.js | 250 ++++++++++++++++++ test-scripts/create-schedule-scripts.js | 135 ++++++++++ .../src/main/resources/application.yaml | 2 +- 15 files changed, 710 insertions(+), 246 deletions(-) create mode 100644 frontend/src/api/order/orderAPI.ts create mode 100644 frontend/src/api/order/orderTypes.ts create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupController.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupDTO.kt create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupService.kt delete mode 100644 service/src/main/resources/schema/schema-h2.sql create mode 100644 test-scripts/common.js create mode 100644 test-scripts/create-reservation-scripts.js create mode 100644 test-scripts/create-schedule-scripts.js diff --git a/frontend/src/api/order/orderAPI.ts b/frontend/src/api/order/orderAPI.ts new file mode 100644 index 00000000..ddde21ef --- /dev/null +++ b/frontend/src/api/order/orderAPI.ts @@ -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 => { + return await apiClient.post( + `/orders/${reservationId}/confirm`, + data + ); +}; diff --git a/frontend/src/api/order/orderTypes.ts b/frontend/src/api/order/orderTypes.ts new file mode 100644 index 00000000..0fa7822e --- /dev/null +++ b/frontend/src/api/order/orderTypes.ts @@ -0,0 +1,5 @@ +export interface OrderErrorResponse { + code: string; + message: string; + trial: number; +} diff --git a/frontend/src/pages/ReservationStep2Page.tsx b/frontend/src/pages/ReservationStep2Page.tsx index 38bf1e49..127e5107 100644 --- a/frontend/src/pages/ReservationStep2Page.tsx +++ b/frontend/src/pages/ReservationStep2Page.tsx @@ -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; + const error = err as AxiosError; const errorCode = error.response?.data?.code; const errorMessage = error.response?.data?.message; diff --git a/service/src/main/kotlin/com/sangdol/roomescape/auth/web/support/resolver/UserContextResolver.kt b/service/src/main/kotlin/com/sangdol/roomescape/auth/web/support/resolver/UserContextResolver.kt index e61270be..bfb7ac61 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/auth/web/support/resolver/UserContextResolver.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/auth/web/support/resolver/UserContextResolver.kt @@ -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) { diff --git a/service/src/main/kotlin/com/sangdol/roomescape/common/config/LocalDatabaseCleaner.kt b/service/src/main/kotlin/com/sangdol/roomescape/common/config/LocalDatabaseCleaner.kt index b3e30f88..43fc5ab3 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/common/config/LocalDatabaseCleaner.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/common/config/LocalDatabaseCleaner.kt @@ -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 ) { diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt index 62d6be00..15850467 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt @@ -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 { 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 { """ ) fun releaseHeldSchedules(@Param("scheduleIds") scheduleIds: List): 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 } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupController.kt b/service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupController.kt new file mode 100644 index 00000000..a514258c --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupController.kt @@ -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() + } +} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupDTO.kt b/service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupDTO.kt new file mode 100644 index 00000000..9baab4a5 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupDTO.kt @@ -0,0 +1,47 @@ +package com.sangdol.roomescape.test + +import java.time.LocalTime + +data class ThemeWithTimesList( + val results: List +) + +data class ThemeWithTimes( + val id: Long, + val times: List +) + +data class ScheduleTime( + val startFrom: LocalTime, + val endAt: LocalTime +) + +data class AccountList( + val results: List +) + +data class Account( + val account: String, + val password: String +) + +data class ScheduleWithThemeIdList( + val results: List +) + +data class ScheduleWithThemeId( + val scheduleId: Long, + val themeId: Long +) + +data class MaxIterations( + val count: Long +) + +data class StoreIdList( + val results: List +) + +data class StoreId( + val storeId: Long +) \ No newline at end of file diff --git a/service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupService.kt b/service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupService.kt new file mode 100644 index 00000000..7063385b --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/test/TestSetupService.kt @@ -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 = 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) + }) + } +} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/user/infrastructure/persistence/UserRepositories.kt b/service/src/main/kotlin/com/sangdol/roomescape/user/infrastructure/persistence/UserRepositories.kt index f10272a9..8a334358 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/user/infrastructure/persistence/UserRepositories.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/user/infrastructure/persistence/UserRepositories.kt @@ -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 { 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 } interface UserStatusHistoryRepository : JpaRepository diff --git a/service/src/main/resources/schema/schema-h2.sql b/service/src/main/resources/schema/schema-h2.sql deleted file mode 100644 index ee7218fb..00000000 --- a/service/src/main/resources/schema/schema-h2.sql +++ /dev/null @@ -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) -); diff --git a/test-scripts/common.js b/test-scripts/common.js new file mode 100644 index 00000000..d841534c --- /dev/null +++ b/test-scripts/common.js @@ -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 }; +} diff --git a/test-scripts/create-reservation-scripts.js b/test-scripts/create-reservation-scripts.js new file mode 100644 index 00000000..cd6a7e7f --- /dev/null +++ b/test-scripts/create-reservation-scripts.js @@ -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('임시 예약 확정 실패') + }) +} diff --git a/test-scripts/create-schedule-scripts.js b/test-scripts/create-schedule-scripts.js new file mode 100644 index 00000000..9d533cf3 --- /dev/null +++ b/test-scripts/create-schedule-scripts.js @@ -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 단계에서 오류가 발생하여 작업을 실행하지 못했습니다. ==='); + } +} diff --git a/tosspay-mock/src/main/resources/application.yaml b/tosspay-mock/src/main/resources/application.yaml index 32a4a577..e0a6467a 100644 --- a/tosspay-mock/src/main/resources/application.yaml +++ b/tosspay-mock/src/main/resources/application.yaml @@ -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: