From 7fe33d24d292d57542f163b81bb3dd70afcb8947 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 9 Oct 2025 13:55:37 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EC=98=88=EC=95=BD=20=EB=A7=8C?= =?UTF-8?q?=EB=A3=8C=20=EA=B3=BC=EC=A0=95=EC=97=90=EC=84=9C=EC=9D=98=20Dea?= =?UTF-8?q?dLock=20=ED=95=B4=EA=B2=B0=EC=9D=84=20=EC=9C=84=ED=95=9C=20SKIP?= =?UTF-8?q?=20LOCKED=20=EA=B3=BC=EC=A0=95=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EC=98=88=EC=95=BD=20=ED=99=95=EC=A0=95=20=EA=B3=BC=EC=A0=95?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=A7=8C=EB=A3=8C=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../order/business/OrderValidator.kt | 14 +- .../order/exception/OrderErrorCode.kt | 9 +- .../IncompletedReservationScheduler.kt | 10 +- .../persistence/ReservationRepository.kt | 18 ++- .../roomescape/order/OrderConcurrencyTest.kt | 130 ++++++++++++++++++ .../sangdol/roomescape/supports/Fixtures.kt | 6 +- 6 files changed, 162 insertions(+), 25 deletions(-) create mode 100644 service/src/test/kotlin/com/sangdol/roomescape/order/OrderConcurrencyTest.kt 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 e475f260..8ee18671 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 @@ -11,7 +11,6 @@ import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.stereotype.Component -import java.time.Instant import java.time.LocalDateTime private val log: KLogger = KotlinLogging.logger {} @@ -40,22 +39,13 @@ class OrderValidator( throw OrderException(OrderErrorCode.BOOKING_ALREADY_COMPLETED) } ReservationStatus.EXPIRED -> { - log.info { "[validateCanConfirm] 만료된 예약 예약: id=${reservation.id}" } + log.info { "[validateCanConfirm] 만료된 예약: id=${reservation.id}" } throw OrderException(OrderErrorCode.EXPIRED_RESERVATION) } ReservationStatus.CANCELED -> { - log.info { "[validateCanConfirm] 취소된 예약 예약: id=${reservation.id}" } + log.info { "[validateCanConfirm] 취소된 예약: id=${reservation.id}" } throw OrderException(OrderErrorCode.CANCELED_RESERVATION) } - ReservationStatus.PENDING -> { - val pendingExpiredAt = reservation.createdAt.plusSeconds(5 * 60) - val now = Instant.now() - - if (now.isAfter(pendingExpiredAt)) { - log.info { "[validateCanConfirm] Pending 예약 시간 내 미결제로 인한 실패: id=${reservation.id}, expiredAt=${pendingExpiredAt}, now=${now}" } - throw OrderException(OrderErrorCode.BOOKING_PAYMENT_TIMEOUT) - } - } else -> {} } } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderErrorCode.kt b/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderErrorCode.kt index 7bebf555..4ed09f47 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderErrorCode.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/order/exception/OrderErrorCode.kt @@ -9,11 +9,10 @@ enum class OrderErrorCode( override val message: String ) : ErrorCode { NOT_CONFIRMABLE(HttpStatus.CONFLICT, "B000", "예약을 확정할 수 없어요."), - BOOKING_PAYMENT_TIMEOUT(HttpStatus.CONFLICT, "B001", "결제 가능 시간을 초과했어요. 처음부터 다시 시도해주세요."), - BOOKING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "B002", "이미 완료된 예약이에요."), - EXPIRED_RESERVATION(HttpStatus.CONFLICT, "B003", "결제 가능 시간이 지나 만료된 예약이에요. 처음부터 다시 시도해주세요."), - CANCELED_RESERVATION(HttpStatus.CONFLICT, "B004", "이미 취소된 예약이에요. 본인이 취소하지 않았다면 매장에 문의해주세요."), - PAST_SCHEDULE(HttpStatus.CONFLICT, "B005", "지난 일정은 예약할 수 없어요."), + BOOKING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "B001", "이미 완료된 예약이에요."), + EXPIRED_RESERVATION(HttpStatus.CONFLICT, "B002", "결제 가능 시간이 지나 만료된 예약이에요. 처음부터 다시 시도해주세요."), + CANCELED_RESERVATION(HttpStatus.CONFLICT, "B003", "이미 취소된 예약이에요. 본인이 취소하지 않았다면 매장에 문의해주세요."), + PAST_SCHEDULE(HttpStatus.CONFLICT, "B004", "지난 일정은 예약할 수 없어요."), BOOKING_UNEXPECTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "B999", "예상치 못한 예외가 발생했어요. 잠시 후 다시 시도해주세요.") ; diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/scheduler/IncompletedReservationScheduler.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/scheduler/IncompletedReservationScheduler.kt index 68bb5393..6b596e41 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/scheduler/IncompletedReservationScheduler.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/scheduler/IncompletedReservationScheduler.kt @@ -38,10 +38,14 @@ class IncompletedReservationScheduler( @Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES) @Transactional fun processExpiredReservation() { - log.info { "[IncompletedReservationScheduler] 결제되지 않은 예약 만료 처리 시작 " } + log.info { "[processExpiredReservation] 결제되지 않은 예약 만료 처리 시작" } - reservationRepository.expirePendingReservations(Instant.now()).also { - log.info { "[IncompletedReservationScheduler] ${it}개의 예약 및 일정 처리 완료" } + val targets: List = reservationRepository.findAllExpiredReservation().also { + log.info { "[processExpiredReservation] ${it.size} 개의 예약 조회 완료" } + } + + reservationRepository.expirePendingReservations(Instant.now(), targets).also { + log.info { "[processExpiredReservation] ${it}개의 예약 및 일정 처리 완료" } } } } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt index 29a3500a..fc56aa64 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt @@ -16,6 +16,20 @@ interface ReservationRepository : JpaRepository { @Query("SELECT r FROM ReservationEntity r WHERE r._id = :id") fun findByIdForUpdate(@Param("id") id: Long): ReservationEntity? + + @Query(""" + SELECT + r.id + FROM + reservation r + JOIN + schedule s ON r.schedule_id = s.id AND s.status = 'HOLD' + WHERE + r.status = 'PENDING' AND r.created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE) + FOR UPDATE SKIP LOCKED + """, nativeQuery = true) + fun findAllExpiredReservation(): List + @Modifying @Query( """ @@ -29,8 +43,8 @@ interface ReservationRepository : JpaRepository { s.status = 'AVAILABLE', s.hold_expired_at = NULL WHERE - r.status = 'PENDING' AND r.created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE) + r.id IN :reservationIds """, nativeQuery = true ) - fun expirePendingReservations(@Param("now") now: Instant): Int + fun expirePendingReservations(@Param("now") now: Instant, @Param("reservationIds") reservationIds: List): Int } diff --git a/service/src/test/kotlin/com/sangdol/roomescape/order/OrderConcurrencyTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/order/OrderConcurrencyTest.kt new file mode 100644 index 00000000..54519080 --- /dev/null +++ b/service/src/test/kotlin/com/sangdol/roomescape/order/OrderConcurrencyTest.kt @@ -0,0 +1,130 @@ +package com.sangdol.roomescape.order + +import com.ninjasquad.springmockk.MockkBean +import com.sangdol.roomescape.order.business.OrderService +import com.sangdol.roomescape.order.exception.OrderException +import com.sangdol.roomescape.payment.business.PaymentService +import com.sangdol.roomescape.payment.business.domain.PaymentMethod +import com.sangdol.roomescape.reservation.business.scheduler.IncompletedReservationScheduler +import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationEntity +import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository +import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus +import com.sangdol.roomescape.supports.FunSpecSpringbootTest +import com.sangdol.roomescape.supports.PaymentFixture +import com.sangdol.roomescape.supports.ReservationFixture +import com.sangdol.roomescape.user.infrastructure.persistence.UserEntity +import io.kotest.assertions.assertSoftly +import io.kotest.matchers.shouldBe +import io.mockk.every +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import org.junit.jupiter.api.assertThrows +import org.springframework.data.repository.findByIdOrNull +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate + +class OrderConcurrencyTest( + @MockkBean(relaxed = true) private val paymentService: PaymentService, + private val orderService: OrderService, + private val transactionManager: PlatformTransactionManager, + private val incompletedReservationScheduler: IncompletedReservationScheduler, + private val jdbcTemplate: JdbcTemplate, + private val scheduleRepository: ScheduleRepository, + private val reservationRepository: ReservationRepository +) : FunSpecSpringbootTest() { + + init { + val paymentConfirmRequest = PaymentFixture.confirmRequest + val paymentGatewayResponse = PaymentFixture.confirmResponse( + paymentConfirmRequest.paymentKey, + paymentConfirmRequest.amount, + PaymentMethod.CARD + ) + + lateinit var user: UserEntity + lateinit var schedule: ScheduleEntity + lateinit var reservation: ReservationEntity + + beforeTest { + user = testAuthUtil.defaultUserLogin().first + schedule = dummyInitializer.createSchedule(status = ScheduleStatus.HOLD, isHoldExpired = true) + reservation = dummyInitializer.createPendingReservation( + user = user, + reservationRequest = ReservationFixture.pendingCreateRequest.copy(scheduleId = schedule.id) + ).also { + val reservationId = it.id + TransactionTemplate(transactionManager).execute { + val sql = + "UPDATE reservation SET created_at = DATE_SUB(NOW(), INTERVAL 6 MINUTE) WHERE id = $reservationId" + jdbcTemplate.execute(sql) + } + } + } + + test("결제 요청 직후 시작되는 예약 만료 배치는, 해당 예약이 만료되었더라도 적용되지 않는다.") { + every { + paymentService.requestConfirm(paymentConfirmRequest) + } returns paymentGatewayResponse + + withContext(Dispatchers.IO) { + async { + orderService.confirm(reservation.id, paymentConfirmRequest) + } + + delay(10) + + async { + TransactionTemplate(transactionManager).execute { + incompletedReservationScheduler.processExpiredReservation() + } + } + } + + assertSoftly(reservationRepository.findByIdOrNull(reservation.id)!!) { + this.status shouldBe ReservationStatus.CONFIRMED + } + + assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) { + this.status shouldBe ScheduleStatus.RESERVED + this.holdExpiredAt shouldBe null + } + } + + test("결제 요청 직전에 예약 만료 배치가 시작하면, 결제 요청은 실패한다.") { + every { + paymentService.requestConfirm(paymentConfirmRequest) + } returns paymentGatewayResponse + + withContext(Dispatchers.IO) { + async { + TransactionTemplate(transactionManager).execute { + incompletedReservationScheduler.processExpiredReservation() + } + } + + async { + assertThrows { + orderService.confirm(reservation.id, paymentConfirmRequest) + }.also { + it.trial shouldBe 0 + } + } + } + + assertSoftly(reservationRepository.findByIdOrNull(reservation.id)!!) { + this.status shouldBe ReservationStatus.EXPIRED + } + + assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) { + this.status shouldBe ScheduleStatus.AVAILABLE + this.holdExpiredAt shouldBe null + } + } + } +} diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt index 821517f8..d90148f6 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/Fixtures.kt @@ -295,9 +295,9 @@ object PaymentFixture { paymentKey: String, amount: Int, method: PaymentMethod, - cardDetail: CardDetailResponse?, - easyPayDetail: EasyPayDetailResponse?, - transferDetail: TransferDetailResponse?, + cardDetail: CardDetailResponse? = null, + easyPayDetail: EasyPayDetailResponse? = null, + transferDetail: TransferDetailResponse? = null, orderId: String = randomString(25), ) = PaymentGatewayResponse( paymentKey = paymentKey,