diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderService.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderService.kt index 1662d322..76a9dcdc 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderService.kt @@ -36,14 +36,18 @@ class OrderService( ) { fun confirm(reservationId: Long, paymentConfirmRequest: PaymentConfirmRequest) { - val trial = paymentAttemptRepository.countByReservationId(reservationId) + var trial: Long = 0 val paymentKey = paymentConfirmRequest.paymentKey log.info { "[confirm] 결제 및 예약 확정 시작: reservationId=${reservationId}, paymentKey=${paymentKey}" } - try { - transactionExecutionUtil.withNewTransaction(isReadOnly = false) { - validateAndMarkInProgress(reservationId) + trial = transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + getTrialAfterValidateCanConfirm(reservationId).also { + reservationService.markInProgress(reservationId) + } + } ?: run { + log.warn { "[confirm] 모든 paymentAttempts 조회 과정에서의 예상치 못한 null 응답: reservationId=${reservationId}" } + throw OrderException(OrderErrorCode.BOOKING_UNEXPECTED_ERROR) } val paymentClientResponse: PaymentGatewayResponse = @@ -61,20 +65,32 @@ class OrderService( } } - private fun validateAndMarkInProgress(reservationId: Long) { + private fun getTrialAfterValidateCanConfirm(reservationId: Long): Long { log.info { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 시작: reservationId=${reservationId}" } val reservation: ReservationStateResponse = reservationService.findStatusWithLock(reservationId) val schedule: ScheduleStateResponse = scheduleService.findStateWithLock(reservation.scheduleId) try { orderValidator.validateCanConfirm(reservation, schedule) - log.info { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 완료: reservationId=${reservationId}" } + + return getTrialIfSuccessAttemptNotExists(reservationId).also { + log.info { "[validateAndMarkInProgress] 예약 확정 가능 여부 검증 완료: reservationId=${reservationId}" } + } } catch (e: OrderException) { val errorCode = OrderErrorCode.NOT_CONFIRMABLE throw OrderException(errorCode, e.message) } + } - reservationService.markInProgress(reservationId) + private fun getTrialIfSuccessAttemptNotExists(reservationId: Long): Long { + val paymentAttempts: List = paymentAttemptRepository.findAllByReservationId(reservationId) + + if (paymentAttempts.any { it.result == AttemptResult.SUCCESS }) { + log.info { "[validateCanConfirm] 이미 결제 완료된 예약: id=${reservationId}" } + throw OrderException(OrderErrorCode.BOOKING_ALREADY_COMPLETED) + } + + return paymentAttempts.size.toLong() } private fun requestConfirmPayment( diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderValidator.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderValidator.kt index 8ee18671..7be57c88 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderValidator.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/business/OrderValidator.kt @@ -16,18 +16,11 @@ import java.time.LocalDateTime private val log: KLogger = KotlinLogging.logger {} @Component -class OrderValidator( - private val paymentAttemptRepository: PaymentAttemptRepository -) { +class OrderValidator { fun validateCanConfirm( reservation: ReservationStateResponse, schedule: ScheduleStateResponse ) { - if (paymentAttemptRepository.isSuccessAttemptExists(reservation.id)) { - log.info { "[validateCanConfirm] 이미 결제 완료된 예약: id=${reservation.id}" } - throw OrderException(OrderErrorCode.BOOKING_ALREADY_COMPLETED) - } - validateReservationStatus(reservation) validateScheduleStatus(schedule) } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PaymentAttemptRepository.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PaymentAttemptRepository.kt index 3c5e8666..4f2ff58f 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PaymentAttemptRepository.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/infrastructure/persistence/PaymentAttemptRepository.kt @@ -23,4 +23,6 @@ interface PaymentAttemptRepository: JpaRepository { """ ) fun isSuccessAttemptExists(reservationId: Long): Boolean + + fun findAllByReservationId(reservationId: Long): List } diff --git a/service/src/test/kotlin/com/sangdol/roomescape/order/OrderConcurrencyTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/order/OrderConcurrencyTest.kt index 54519080..768323d7 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/order/OrderConcurrencyTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/order/OrderConcurrencyTest.kt @@ -108,6 +108,8 @@ class OrderConcurrencyTest( } } + delay(10) + async { assertThrows { orderService.confirm(reservation.id, paymentConfirmRequest) diff --git a/test-scripts/common.js b/test-scripts/common.js index 0816cb4a..3f3e6098 100644 --- a/test-scripts/common.js +++ b/test-scripts/common.js @@ -32,7 +32,7 @@ export function maxIterations() { export function fetchUsers() { const userCount = Math.round(maxIterations() * 0.5) - const userAccountRes = http.get(`${BASE_URL}/tests/users?count=${userCount}`) + const userAccountRes = http.get(`http://localhost:8080/tests/users?count=${userCount}`) if (userAccountRes.status !== 200) { throw new Error('users 조회 실패') @@ -42,7 +42,7 @@ export function fetchUsers() { } export function fetchStores() { - const storeIdListRes = http.get(`${BASE_URL}/tests/stores`) + const storeIdListRes = http.get(`http://localhost:8080/tests/stores`) if (storeIdListRes.status !== 200) { throw new Error('stores 조회 실패') diff --git a/test-scripts/create-reservation-scripts.js b/test-scripts/create-reservation-scripts.js index 4f33b6c1..d5c26aca 100644 --- a/test-scripts/create-reservation-scripts.js +++ b/test-scripts/create-reservation-scripts.js @@ -15,19 +15,10 @@ export const options = { scenarios: { user_reservation: { executor: 'ramping-vus', - startVUs: 0, + startVUs: 1500, stages: [ - { duration: '1m', target: 100 }, - { duration: '1m', target: 200 }, - { duration: '1m', target: 300 }, - { duration: '1m', target: 300 }, - { duration: '1m', target: 400 }, - { duration: '1m', target: 500 }, - { duration: '2m', target: 500 }, - { duration: '1m', target: 600 }, - { duration: '1m', target: 800 }, - { duration: '1m', target: 1000 }, - { duration: '3m', target: 0 }, + { duration: '10m', target: 1500 }, + { duration: '1m', target: 0 } ] } }, @@ -84,13 +75,13 @@ export default function (data) { const user = randomItem(users) const accessToken = login(user.account, user.password, 'USER').accessToken - const storeId = randomItem(stores).storeId + let storeId = randomItem(stores).storeId if (!accessToken) { console.log(`로그인 실패: token=${accessToken}`) return } - const targetDate = randomDayBetween(1, 4) + let targetDate let availableScheduleId, selectedThemeId, selectedThemeInfo, reservationId, totalAmount @@ -99,6 +90,8 @@ export default function (data) { let schedules while (searchTrial < 5) { + storeId = randomItem(stores).storeId + targetDate = randomDayBetween(1, 7) const res = http.get(`${BASE_URL}/stores/${storeId}/schedules?date=${targetDate}`) const result = check(res, {'일정 조회 성공': (r) => r.status === 200}) if (result !== true) { @@ -145,7 +138,7 @@ export default function (data) { let isScheduleHeld = false group(`일정 Holding 및 테마 정보 조회 -> 예약 과정중 첫 페이지의 작업 완료`, function () { const holdRes = http.post(`${BASE_URL}/schedules/${availableScheduleId}/hold`, null, getHeaders(accessToken)) - const body = JSON.parse(holdRes) + const body = JSON.parse(holdRes.body) if (check(holdRes, {'일정 점유 성공': (r) => r.status === 200})) { const themeInfoRes = http.get(`${BASE_URL}/themes/${selectedThemeId}`) diff --git a/test-scripts/create-schedule-scripts.js b/test-scripts/create-schedule-scripts.js index 9d533cf3..35eb6a39 100644 --- a/test-scripts/create-schedule-scripts.js +++ b/test-scripts/create-schedule-scripts.js @@ -13,7 +13,7 @@ export const options = { executor: 'shared-iterations', vus: 263, iterations: TOTAL_ITERATIONS, - maxDuration: '10m', + maxDuration: '30m', }, }, thresholds: { @@ -113,7 +113,7 @@ function createSchedule(storeId, accessToken, schedule) { function generateDates(days) { const dates = []; const today = new Date(); - for (let i = 1; i < days; i++) { + for (let i = 1; i <= days; i++) { const date = new Date(today); date.setDate(today.getDate() + i); dates.push(date.toISOString().split('T')[0]);