From d8947502795850d35d340f4480f8c2a38ff08f13 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 9 Oct 2025 13:54:46 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EC=9D=BC=EC=A0=95=20=EC=9E=AC?= =?UTF-8?q?=ED=99=9C=EC=84=B1=ED=99=94=20=EA=B3=BC=EC=A0=95=EC=97=90?= =?UTF-8?q?=EC=84=9C=EC=9D=98=20DeadLock=20=ED=95=B4=EA=B2=B0=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20SKIP=20LOCKED=20=EA=B3=BC=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=9D=BC=EC=A0=95=20=EB=A7=8C?= =?UTF-8?q?=EB=A3=8C=20=EA=B2=80=EC=A6=9D=20=EC=A1=B0=EA=B1=B4=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business/ReservationValidator.kt | 7 - .../IncompletedReservationScheduler.kt | 10 +- .../persistence/ScheduleRepository.kt | 30 +++-- .../reservation/ReservationApiTest.kt | 15 --- .../reservation/ReservationConcurrencyTest.kt | 123 ++++++++++-------- 5 files changed, 96 insertions(+), 89 deletions(-) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt index 8eb6671b..f0993c21 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationValidator.kt @@ -33,13 +33,6 @@ class ReservationValidator { throw ReservationException(ReservationErrorCode.EXPIRED_HELD_SCHEDULE) } - val scheduleHoldExpiredAt = schedule.holdExpiredAt - val nowInstant = Instant.now() - if (scheduleHoldExpiredAt != null && scheduleHoldExpiredAt.isBefore(nowInstant)) { - log.info { "[validateCanCreate] 해당 일정의 HOLD 만료 시간 초과로 인한 실패: expiredAt=${scheduleHoldExpiredAt.toKoreaDateTime()}(KST), now=${nowInstant.toKoreaDateTime()}(KST)" } - throw ReservationException(ReservationErrorCode.EXPIRED_HELD_SCHEDULE) - } - val scheduleDateTime = LocalDateTime.of(schedule.date, schedule.startFrom) val nowDateTime = KoreaDateTime.now() if (scheduleDateTime.isBefore(nowDateTime)) { 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 01edf4ad..68bb5393 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 @@ -24,10 +24,14 @@ class IncompletedReservationScheduler( @Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES) @Transactional fun processExpiredHoldSchedule() { - log.info { "[IncompletedReservationScheduler] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" } + log.info { "[processExpiredHoldSchedule] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" } - scheduleRepository.releaseExpiredHolds(Instant.now()).also { - log.info { "[IncompletedReservationScheduler] ${it}개의 일정 재활성화 완료" } + val targets: List = scheduleRepository.findAllExpiredHeldSchedules(Instant.now()).also { + log.info { "[processExpiredHoldSchedule] ${it.size} 개의 일정 조회 완료" } + } + + scheduleRepository.releaseHeldSchedules(targets).also { + log.info { "[processExpiredHoldSchedule] ${it}개의 일정 재활성화 완료" } } } 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 08f9ffb3..a04f52cf 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 @@ -126,6 +126,26 @@ interface ScheduleRepository : JpaRepository { expiredAt: Instant = Instant.now().plusSeconds(5 * 60) ): Int + @Modifying + @Query( + """ + SELECT + s.id + FROM + schedule s + 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' + ) + FOR UPDATE SKIP LOCKED + """, nativeQuery = true + ) + fun findAllExpiredHeldSchedules(@Param("now") now: Instant): List + @Modifying @Query( """ @@ -135,14 +155,8 @@ interface ScheduleRepository : JpaRepository { s.status = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.AVAILABLE, s.holdExpiredAt = NULL WHERE - s.status = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.HOLD - AND s.holdExpiredAt <= :now - AND NOT EXISTS ( - SELECT 1 - FROM ReservationEntity r - WHERE r.scheduleId = s._id AND r.status = com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus.PENDING - ) + s._id IN :scheduleIds """ ) - fun releaseExpiredHolds(@Param("now") now: Instant): Int + fun releaseHeldSchedules(@Param("scheduleIds") scheduleIds: List): Int } diff --git a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt index d29cc9d4..8436fce6 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt @@ -100,21 +100,6 @@ class ReservationApiTest( ) } - test("해당 일정의 hold_expired_at 시간이 지났다면 실패한다.") { - val schedule: ScheduleEntity = dummyInitializer.createSchedule( - status = ScheduleStatus.HOLD, - isHoldExpired = true - ) - - runExceptionTest( - token = testAuthUtil.defaultUserLogin().second, - method = HttpMethod.POST, - endpoint = endpoint, - requestBody = commonRequest.copy(scheduleId = schedule.id), - expectedErrorCode = ReservationErrorCode.EXPIRED_HELD_SCHEDULE - ) - } - test("현재 시간이 일정의 시작 시간 이후이면 실패한다.") { val schedule: ScheduleEntity = dummyInitializer.createSchedule( status = ScheduleStatus.HOLD, diff --git a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt index 1940f3f4..8195a58f 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt @@ -3,12 +3,11 @@ package com.sangdol.roomescape.reservation import com.sangdol.roomescape.common.types.CurrentUserContext import com.sangdol.roomescape.reservation.business.ReservationService import com.sangdol.roomescape.reservation.business.scheduler.IncompletedReservationScheduler +import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest import com.sangdol.roomescape.reservation.exception.ReservationErrorCode import com.sangdol.roomescape.reservation.exception.ReservationException import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus -import com.sangdol.roomescape.reservation.dto.PendingReservationCreateRequest -import com.sangdol.roomescape.reservation.dto.PendingReservationCreateResponse import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus @@ -19,7 +18,9 @@ import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe 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.transaction.PlatformTransactionManager import org.springframework.transaction.support.TransactionTemplate @@ -33,76 +34,86 @@ class ReservationConcurrencyTest( ) : FunSpecSpringbootTest() { init { - context("Pending 예약 생성 도중 시작되는 schedule 재활성화 배치는 해당 로직의 완료 이후에 처리된다.") { - lateinit var user: UserEntity + lateinit var user: UserEntity + lateinit var schedule: ScheduleEntity - beforeTest { - user = testAuthUtil.defaultUserLogin().first - } + beforeTest { + user = testAuthUtil.defaultUserLogin().first + schedule = dummyInitializer.createSchedule( + status = ScheduleStatus.HOLD, + isHoldExpired = true + ) + } - test("holdExpiredAt이 지난 일정인 경우, Pending 예약 생성 중 예외가 발생하고 이후 배치가 재활성화 처리한다.") { - val schedule = dummyInitializer.createSchedule( - status = ScheduleStatus.HOLD, - isHoldExpired = true - ) - try { - runConcurrency(user, schedule) - } catch (e: ReservationException) { - e.errorCode shouldBe ReservationErrorCode.EXPIRED_HELD_SCHEDULE + test("Pending 예약 생성 도중 시작되는 schedule 재활성화 배치는 해당 일정이 만료되었더라도 처리하지 않는다.") { + val createdReservationId = withContext(Dispatchers.IO) { + val createPendingReservationJob = async { + TransactionTemplate(transactionManager).execute { + createPendingReservation(user, schedule) + }!! + } - assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) { - this.status shouldBe ScheduleStatus.AVAILABLE - this.holdExpiredAt shouldBe null + val batchJob = async { + TransactionTemplate(transactionManager).execute { + incompletedReservationScheduler.processExpiredHoldSchedule() } } + + createPendingReservationJob.await().also { batchJob.await() } } - test("holdExpiredAt이 지나지 않은 일정인 경우, Pending 예약 생성이 정상적으로 종료되며 배치 작업이 적용되지 않는다.") { - val schedule = dummyInitializer.createSchedule( - status = ScheduleStatus.HOLD, - isHoldExpired = false - ) + assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) { + this.status shouldBe ScheduleStatus.HOLD + this.holdExpiredAt.shouldNotBeNull() + } - val response = runConcurrency(user, schedule) + assertSoftly(reservationRepository.findByIdOrNull(createdReservationId)) { + this.shouldNotBeNull() + this.status shouldBe ReservationStatus.PENDING + } + } - assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) { - this.status shouldBe ScheduleStatus.HOLD - this.holdExpiredAt.shouldNotBeNull() + test("Pending 예약 생성 직전에 배치가 시작되면 예약 생성에 실패한다.") { + withContext(Dispatchers.IO) { + val batchJob = async { + TransactionTemplate(transactionManager).execute { + incompletedReservationScheduler.processExpiredHoldSchedule() + } } - assertSoftly(reservationRepository.findByIdOrNull(response.id)) { - this.shouldNotBeNull() - this.status shouldBe ReservationStatus.PENDING + delay(5) + + val createPendingReservationJob = async { + assertThrows { + TransactionTemplate(transactionManager).execute { + createPendingReservation(user, schedule) + }!! + }.also { + it.errorCode shouldBe ReservationErrorCode.EXPIRED_HELD_SCHEDULE + } } + + createPendingReservationJob.await().also { batchJob.await() } + } + + assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) { + this.status shouldBe ScheduleStatus.AVAILABLE + this.holdExpiredAt shouldBe null } } } - private suspend fun runConcurrency(user: UserEntity, schedule: ScheduleEntity): PendingReservationCreateResponse { - return withContext(Dispatchers.IO) { - val createPendingReservationJob = async { - TransactionTemplate(transactionManager).execute { - reservationService.createPendingReservation( - user = CurrentUserContext(id = user.id, name = user.name), - request = PendingReservationCreateRequest( - scheduleId = schedule.id, - reserverName = user.name, - reserverContact = user.phone, - participantCount = 3, - requirement = "없어요!" - ) - ) - }!! - } - - val updateScheduleJob = async { - TransactionTemplate(transactionManager).execute { - incompletedReservationScheduler.processExpiredHoldSchedule() - } - } - - createPendingReservationJob.await().also { updateScheduleJob.await() } - } + private fun createPendingReservation(user: UserEntity, schedule: ScheduleEntity): Long { + return reservationService.createPendingReservation( + user = CurrentUserContext(id = user.id, name = user.name), + request = PendingReservationCreateRequest( + scheduleId = schedule.id, + reserverName = user.name, + reserverContact = user.phone, + participantCount = 3, + requirement = "없어요!" + ) + ).id } }