refactor: 예약 만료 과정에서의 DeadLock 해결을 위한 SKIP LOCKED 과정 추가 및 예약 확정 과정에서 만료 검증 조건 제거

This commit is contained in:
이상진 2025-10-09 13:55:37 +09:00
parent d894750279
commit 7fe33d24d2
6 changed files with 162 additions and 25 deletions

View File

@ -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 -> {}
}
}

View File

@ -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", "예상치 못한 예외가 발생했어요. 잠시 후 다시 시도해주세요.")
;

View File

@ -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<Long> = reservationRepository.findAllExpiredReservation().also {
log.info { "[processExpiredReservation] ${it.size} 개의 예약 조회 완료" }
}
reservationRepository.expirePendingReservations(Instant.now(), targets).also {
log.info { "[processExpiredReservation] ${it}개의 예약 및 일정 처리 완료" }
}
}
}

View File

@ -16,6 +16,20 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
@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<Long>
@Modifying
@Query(
"""
@ -29,8 +43,8 @@ interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
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<Long>): Int
}

View File

@ -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<OrderException> {
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
}
}
}
}

View File

@ -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,