refactor: 일정 재활성화 과정에서의 DeadLock 해결을 위한 SKIP LOCKED 과정 추가 및 일정 만료 검증 조건 제거

This commit is contained in:
이상진 2025-10-09 13:54:46 +09:00
parent 17fb44573d
commit d894750279
5 changed files with 96 additions and 89 deletions

View File

@ -33,13 +33,6 @@ class ReservationValidator {
throw ReservationException(ReservationErrorCode.EXPIRED_HELD_SCHEDULE) 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 scheduleDateTime = LocalDateTime.of(schedule.date, schedule.startFrom)
val nowDateTime = KoreaDateTime.now() val nowDateTime = KoreaDateTime.now()
if (scheduleDateTime.isBefore(nowDateTime)) { if (scheduleDateTime.isBefore(nowDateTime)) {

View File

@ -24,10 +24,14 @@ class IncompletedReservationScheduler(
@Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES) @Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES)
@Transactional @Transactional
fun processExpiredHoldSchedule() { fun processExpiredHoldSchedule() {
log.info { "[IncompletedReservationScheduler] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" } log.info { "[processExpiredHoldSchedule] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" }
scheduleRepository.releaseExpiredHolds(Instant.now()).also { val targets: List<Long> = scheduleRepository.findAllExpiredHeldSchedules(Instant.now()).also {
log.info { "[IncompletedReservationScheduler] ${it}개의 일정 재활성화 완료" } log.info { "[processExpiredHoldSchedule] ${it.size} 개의 일정 조회 완료" }
}
scheduleRepository.releaseHeldSchedules(targets).also {
log.info { "[processExpiredHoldSchedule] ${it}개의 일정 재활성화 완료" }
} }
} }

View File

@ -126,6 +126,26 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
expiredAt: Instant = Instant.now().plusSeconds(5 * 60) expiredAt: Instant = Instant.now().plusSeconds(5 * 60)
): Int ): 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<Long>
@Modifying @Modifying
@Query( @Query(
""" """
@ -135,14 +155,8 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
s.status = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.AVAILABLE, s.status = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.AVAILABLE,
s.holdExpiredAt = NULL s.holdExpiredAt = NULL
WHERE WHERE
s.status = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.HOLD s._id IN :scheduleIds
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
)
""" """
) )
fun releaseExpiredHolds(@Param("now") now: Instant): Int fun releaseHeldSchedules(@Param("scheduleIds") scheduleIds: List<Long>): Int
} }

View File

@ -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("현재 시간이 일정의 시작 시간 이후이면 실패한다.") { test("현재 시간이 일정의 시작 시간 이후이면 실패한다.") {
val schedule: ScheduleEntity = dummyInitializer.createSchedule( val schedule: ScheduleEntity = dummyInitializer.createSchedule(
status = ScheduleStatus.HOLD, status = ScheduleStatus.HOLD,

View File

@ -3,12 +3,11 @@ package com.sangdol.roomescape.reservation
import com.sangdol.roomescape.common.types.CurrentUserContext import com.sangdol.roomescape.common.types.CurrentUserContext
import com.sangdol.roomescape.reservation.business.ReservationService import com.sangdol.roomescape.reservation.business.ReservationService
import com.sangdol.roomescape.reservation.business.scheduler.IncompletedReservationScheduler 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.ReservationErrorCode
import com.sangdol.roomescape.reservation.exception.ReservationException import com.sangdol.roomescape.reservation.exception.ReservationException
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository
import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus 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.ScheduleEntity
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository
import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus
@ -19,7 +18,9 @@ import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.junit.jupiter.api.assertThrows
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.support.TransactionTemplate import org.springframework.transaction.support.TransactionTemplate
@ -33,23 +34,68 @@ class ReservationConcurrencyTest(
) : FunSpecSpringbootTest() { ) : FunSpecSpringbootTest() {
init { init {
context("Pending 예약 생성 도중 시작되는 schedule 재활성화 배치는 해당 로직의 완료 이후에 처리된다.") {
lateinit var user: UserEntity lateinit var user: UserEntity
lateinit var schedule: ScheduleEntity
beforeTest { beforeTest {
user = testAuthUtil.defaultUserLogin().first user = testAuthUtil.defaultUserLogin().first
} schedule = dummyInitializer.createSchedule(
test("holdExpiredAt이 지난 일정인 경우, Pending 예약 생성 중 예외가 발생하고 이후 배치가 재활성화 처리한다.") {
val schedule = dummyInitializer.createSchedule(
status = ScheduleStatus.HOLD, status = ScheduleStatus.HOLD,
isHoldExpired = true isHoldExpired = true
) )
}
try {
runConcurrency(user, schedule) test("Pending 예약 생성 도중 시작되는 schedule 재활성화 배치는 해당 일정이 만료되었더라도 처리하지 않는다.") {
} catch (e: ReservationException) { val createdReservationId = withContext(Dispatchers.IO) {
e.errorCode shouldBe ReservationErrorCode.EXPIRED_HELD_SCHEDULE val createPendingReservationJob = async {
TransactionTemplate(transactionManager).execute {
createPendingReservation(user, schedule)
}!!
}
val batchJob = async {
TransactionTemplate(transactionManager).execute {
incompletedReservationScheduler.processExpiredHoldSchedule()
}
}
createPendingReservationJob.await().also { batchJob.await() }
}
assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) {
this.status shouldBe ScheduleStatus.HOLD
this.holdExpiredAt.shouldNotBeNull()
}
assertSoftly(reservationRepository.findByIdOrNull(createdReservationId)) {
this.shouldNotBeNull()
this.status shouldBe ReservationStatus.PENDING
}
}
test("Pending 예약 생성 직전에 배치가 시작되면 예약 생성에 실패한다.") {
withContext(Dispatchers.IO) {
val batchJob = async {
TransactionTemplate(transactionManager).execute {
incompletedReservationScheduler.processExpiredHoldSchedule()
}
}
delay(5)
val createPendingReservationJob = async {
assertThrows<ReservationException> {
TransactionTemplate(transactionManager).execute {
createPendingReservation(user, schedule)
}!!
}.also {
it.errorCode shouldBe ReservationErrorCode.EXPIRED_HELD_SCHEDULE
}
}
createPendingReservationJob.await().also { batchJob.await() }
}
assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) { assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) {
this.status shouldBe ScheduleStatus.AVAILABLE this.status shouldBe ScheduleStatus.AVAILABLE
@ -58,32 +104,8 @@ class ReservationConcurrencyTest(
} }
} }
test("holdExpiredAt이 지나지 않은 일정인 경우, Pending 예약 생성이 정상적으로 종료되며 배치 작업이 적용되지 않는다.") { private fun createPendingReservation(user: UserEntity, schedule: ScheduleEntity): Long {
val schedule = dummyInitializer.createSchedule( return reservationService.createPendingReservation(
status = ScheduleStatus.HOLD,
isHoldExpired = false
)
val response = runConcurrency(user, schedule)
assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) {
this.status shouldBe ScheduleStatus.HOLD
this.holdExpiredAt.shouldNotBeNull()
}
assertSoftly(reservationRepository.findByIdOrNull(response.id)) {
this.shouldNotBeNull()
this.status shouldBe ReservationStatus.PENDING
}
}
}
}
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), user = CurrentUserContext(id = user.id, name = user.name),
request = PendingReservationCreateRequest( request = PendingReservationCreateRequest(
scheduleId = schedule.id, scheduleId = schedule.id,
@ -92,17 +114,6 @@ class ReservationConcurrencyTest(
participantCount = 3, participantCount = 3,
requirement = "없어요!" requirement = "없어요!"
) )
) ).id
}!!
}
val updateScheduleJob = async {
TransactionTemplate(transactionManager).execute {
incompletedReservationScheduler.processExpiredHoldSchedule()
}
}
createPendingReservationJob.await().also { updateScheduleJob.await() }
}
} }
} }