From 599ac071d756401d71b014867071126bbd907926 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 2 Oct 2025 11:19:18 +0900 Subject: [PATCH 01/18] =?UTF-8?q?refactor:=20=EB=A7=A4=EC=9E=A5=EC=9D=98?= =?UTF-8?q?=20=ED=8A=B9=EC=A0=95=20=EB=82=A0=EC=A7=9C=20=EC=9D=BC=EC=A0=95?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이전 날짜 선택시 실패 - 오늘 날짜인 경우 현재 시간 이후인 일정만 반환 --- .../schedule/business/ScheduleService.kt | 9 +++++ .../persistence/ScheduleRepository.kt | 10 +++--- .../roomescape/schedule/ScheduleApiTest.kt | 33 ++++++++++++++++--- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt index 3adfedcb..1bd6491c 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt @@ -18,6 +18,8 @@ import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime private val log: KLogger = KotlinLogging.logger {} @@ -43,9 +45,16 @@ class ScheduleService( @Transactional(readOnly = true) fun getStoreScheduleByDate(storeId: Long, date: LocalDate): ScheduleWithThemeListResponse { log.info { "[ScheduleService.getStoreScheduleByDate] 매장 일정 조회: storeId=${storeId}, date=$date" } + val currentDate = LocalDate.now() + + if (date.isBefore(currentDate)) { + log.warn { "[ScheduleService.getStoreScheduleByDate] 이전 날짜 선택으로 인한 실패: date=${date}" } + throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME) + } val schedules: List = scheduleRepository.findStoreSchedulesWithThemeByDate(storeId, date) + .filter { it.date.isAfter(date) || (it.date.isEqual(date) && it.time.isAfter(LocalTime.now())) } return schedules.toResponse() .also { 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 82080181..abf9484a 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 @@ -17,8 +17,8 @@ interface ScheduleRepository : JpaRepository { WHERE s.storeId = :storeId AND s.date = :date - AND s.themeId = :themeId AND s.time = :time + AND s.themeId = :themeId """ ) fun existsDuplicate(storeId: Long, date: LocalDate, themeId: Long, time: LocalTime): Boolean @@ -41,11 +41,13 @@ interface ScheduleRepository : JpaRepository { FROM ScheduleEntity s JOIN - ThemeEntity t ON t._id = s.themeId and (:themeId IS NULL OR t._id = :themeId) + ThemeEntity t ON t._id = s.themeId JOIN - StoreEntity st ON st._id = s.storeId and st._id = :storeId + StoreEntity st ON st._id = s.storeId WHERE - s.date = :date + s.storeId = :storeId + AND s.date = :date + AND (:themeId IS NULL OR t._id = :themeId) """ ) fun findStoreSchedulesWithThemeByDate( diff --git a/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleApiTest.kt index 1f8103c4..bbc55575 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleApiTest.kt @@ -20,11 +20,12 @@ class ScheduleApiTest( ) : FunSpecSpringbootTest() { init { context("특정 매장 + 날짜의 일정 및 테마 정보를 조회한다.") { - test("정상 응답") { - val size = 2 - val date = LocalDate.now().plusDays(1) + test("날짜가 오늘이면 현재 시간 이후의 정보만 조회된다.") { + val size = 3 + val date = LocalDate.now() val store = dummyInitializer.createStore() - initialize("조회를 위한 같은 날짜의 ${size}개의 일정 생성") { + + initialize("조회를 위한 오늘 날짜의 현재 시간 이후인 ${size}개의 일정, 현재 시간 이전인 1개의 일정 생성") { for (i in 1..size) { dummyInitializer.createSchedule( storeId = store.id, @@ -34,6 +35,14 @@ class ScheduleApiTest( ) ) } + + dummyInitializer.createSchedule( + storeId = store.id, + request = ScheduleFixture.createRequest.copy( + date = date, + time = LocalTime.now().minusMinutes(1) + ) + ) } runTest( @@ -50,6 +59,22 @@ class ScheduleApiTest( } ) } + + test("날짜가 오늘 이전이면 실패한다.") { + val store = initialize("매장 생성") { + dummyInitializer.createStore() + } + + runTest( + on = { + get("/stores/${store.id}/schedules?date=${LocalDate.now().minusDays(1)}") + }, + expect = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(ScheduleErrorCode.PAST_DATE_TIME.errorCode)) + } + ) + } } context("일정을 ${ScheduleStatus.HOLD} 상태로 변경한다.") { -- 2.47.2 From 86a2459d8be898dc0fd6f15ea8a59f6ffa80fa9b Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 2 Oct 2025 13:22:01 +0900 Subject: [PATCH 02/18] =?UTF-8?q?refactor:=20AdminScheduleApiTest=20?= =?UTF-8?q?=EB=82=B4=20=EC=9D=BC=EB=B6=80=20=EB=AC=B8=EB=B2=95=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/AdminScheduleApiTest.kt | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/service/src/test/kotlin/com/sangdol/roomescape/schedule/AdminScheduleApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/schedule/AdminScheduleApiTest.kt index f51663dc..1576bbca 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/schedule/AdminScheduleApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/schedule/AdminScheduleApiTest.kt @@ -95,7 +95,7 @@ class AdminScheduleApiTest( }, expect = { statusCode(HttpStatus.OK.value()) - body("data.schedules.size()", equalTo(schedules.filter { it.date == LocalDate.now() }.size)) + body("data.schedules.size()", equalTo(schedules.filter { it.date.isEqual(LocalDate.now()) }.size)) assertProperties( props = setOf("id", "themeName", "startFrom", "endAt", "status"), propsNameIfList = "schedules" @@ -114,7 +114,7 @@ class AdminScheduleApiTest( }, expect = { statusCode(HttpStatus.OK.value()) - body("data.schedules.size()", equalTo(schedules.filter { it.date == date }.size)) + body("data.schedules.size()", equalTo(schedules.filter { it.date.isEqual(date) }.size)) assertProperties( props = setOf("id", "themeName", "startFrom", "endAt", "status"), propsNameIfList = "schedules" @@ -153,7 +153,7 @@ class AdminScheduleApiTest( statusCode(HttpStatus.OK.value()) body( "data.schedules.size()", - equalTo(schedules.filter { it.date == date && it.themeId == themeId }.size) + equalTo(schedules.filter { it.date.isEqual(date) && it.themeId == themeId }.size) ) assertProperties( props = setOf("id", "themeName", "startFrom", "endAt", "status"), @@ -390,7 +390,7 @@ class AdminScheduleApiTest( val time = LocalTime.now().minusMinutes(1) val theme = dummyInitializer.createTheme() - val request = ScheduleFixture.createRequest.copy(date, time, theme.id) + val request = ScheduleFixture.createRequest.copy(date = date, time = time, themeId = theme.id) runExceptionTest( token = token, @@ -412,18 +412,18 @@ class AdminScheduleApiTest( dummyInitializer.createSchedule( storeId = admin.storeId!!, request = ScheduleFixture.createRequest.copy( - tomorrow, - LocalTime.of(14, 0), - theme.id + date = tomorrow, + time = LocalTime.of(14, 0), + themeId = theme.id ) ) } val request = initialize("내일 14:30에 시작하는 요청 객체") { ScheduleFixture.createRequest.copy( - tomorrow, - LocalTime.of(14, 30), - theme.id + date = tomorrow, + time = LocalTime.of(14, 30), + themeId = theme.id ) } @@ -636,7 +636,7 @@ class AdminScheduleApiTest( } } - (ScheduleStatus.entries - listOf(ScheduleStatus.AVAILABLE, ScheduleStatus.BLOCKED)).forEach { + (ScheduleStatus.entries - listOf(ScheduleStatus.AVAILABLE, ScheduleStatus.BLOCKED).toSet()).forEach { test("상태가 ${it}인 일정은 삭제할 수 없다.") { val (admin, token) = testAuthUtil.defaultStoreAdminLogin() val schedule = initialize("삭제를 위한 일정 생성") { -- 2.47.2 From 08b9920ee3d2a3318a2f841aacfea2296fbcbe36 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 2 Oct 2025 13:30:30 +0900 Subject: [PATCH 03/18] =?UTF-8?q?refactor:=20ScheduleEntity=EC=97=90=20\@L?= =?UTF-8?q?astModifiedBy=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EC=9D=B4=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8A=94=20hol?= =?UTF-8?q?d=20API=EB=8A=94=20Update=20=EC=BF=BC=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=94=EB=A1=9C=20=EC=93=B0=EB=8F=84=EB=A1=9D=20=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/business/ScheduleService.kt | 15 ++- .../persistence/ScheduleEntity.kt | 34 +------ .../persistence/ScheduleRepository.kt | 14 +++ .../roomescape/schedule/ScheduleApiTest.kt | 11 ++- .../schedule/ScheduleServiceTest.kt | 94 +++++++++++++++++++ 5 files changed, 128 insertions(+), 40 deletions(-) create mode 100644 service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleServiceTest.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt index 1bd6491c..0bb2d24a 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt @@ -2,15 +2,14 @@ package com.sangdol.roomescape.schedule.business import ScheduleException import com.sangdol.common.persistence.IDGenerator +import com.sangdol.roomescape.admin.business.AdminService import com.sangdol.roomescape.common.types.AuditingInfo import com.sangdol.roomescape.common.types.Auditor -import com.sangdol.roomescape.admin.business.AdminService import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntityFactory import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository -import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.schedule.web.* import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging @@ -18,7 +17,6 @@ import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDate -import java.time.LocalDateTime import java.time.LocalTime private val log: KLogger = KotlinLogging.logger {} @@ -67,14 +65,15 @@ class ScheduleService( // ======================================== @Transactional fun holdSchedule(id: Long) { - val schedule: ScheduleEntity = findOrThrow(id) + log.info { "[ScheduleService.holdSchedule] 일정 Holding 시작: id=$id" } + val result: Int = scheduleRepository.holdAvailableScheduleById(id) - if (schedule.status == ScheduleStatus.AVAILABLE) { - schedule.hold() - return + if (result == 0) { + log.info { "[ScheduleService.holdSchedule] Holding된 일정 없음: id=${id}, count = " } + throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE) } - throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE) + log.info { "[ScheduleService.holdSchedule] 일정 Holding 완료: id=$id" } } // ======================================== diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt index fde4b384..d1379c62 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt @@ -1,14 +1,9 @@ package com.sangdol.roomescape.schedule.infrastructure.persistence -import com.sangdol.common.persistence.PersistableBaseEntity -import com.sangdol.common.utils.MdcPrincipalIdUtil +import com.sangdol.common.persistence.AuditingBaseEntity import jakarta.persistence.* -import org.springframework.data.annotation.CreatedBy -import org.springframework.data.annotation.CreatedDate -import org.springframework.data.annotation.LastModifiedDate import org.springframework.data.jpa.domain.support.AuditingEntityListener import java.time.LocalDate -import java.time.LocalDateTime import java.time.LocalTime @Entity @@ -24,36 +19,13 @@ class ScheduleEntity( @Enumerated(value = EnumType.STRING) var status: ScheduleStatus, -) : PersistableBaseEntity(id) { - @Column(updatable = false) - @CreatedDate - lateinit var createdAt: LocalDateTime - - @Column(updatable = false) - @CreatedBy - var createdBy: Long = 0L - - @Column - @LastModifiedDate - lateinit var updatedAt: LocalDateTime - - var updatedBy: Long = 0L - +) : AuditingBaseEntity(id) { fun modifyIfNotNull( time: LocalTime?, status: ScheduleStatus? ) { time?.let { this.time = it } status?.let { this.status = it } - updateLastModifiedBy() - } - - fun hold() { - this.status = ScheduleStatus.HOLD - } - - fun updateLastModifiedBy() { - MdcPrincipalIdUtil.extractAsLongOrNull()?.also { this.updatedBy = it } } } @@ -66,7 +38,7 @@ object ScheduleEntityFactory { storeId = storeId, themeId = themeId, status = ScheduleStatus.AVAILABLE - ).apply { this.updateLastModifiedBy() } + ) } } 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 abf9484a..c0d92c1a 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 @@ -3,6 +3,7 @@ package com.sangdol.roomescape.schedule.infrastructure.persistence import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview +import org.springframework.data.jpa.repository.Modifying import java.time.LocalDate import java.time.LocalTime @@ -82,4 +83,17 @@ interface ScheduleRepository : JpaRepository { """ ) fun findOverviewByIdOrNull(id: Long): ScheduleOverview? + + @Modifying + @Query(""" + UPDATE + ScheduleEntity s + SET + s.status = 'HOLD' + WHERE + s._id = :id + AND + s.status = 'AVAILABLE' + """) + fun holdAvailableScheduleById(id: Long): Int } diff --git a/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleApiTest.kt index bbc55575..c5d25372 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleApiTest.kt @@ -130,7 +130,7 @@ class ScheduleApiTest( context("일정이 ${ScheduleStatus.AVAILABLE}이 아니면 실패한다.") { (ScheduleStatus.entries - ScheduleStatus.AVAILABLE).forEach { - test("${it}") { + test("$it") { val schedule = dummyInitializer.createSchedule(status = it) runExceptionTest( @@ -142,6 +142,15 @@ class ScheduleApiTest( } } } + + test("일정이 없으면 실패한다.") { + runExceptionTest( + token = testAuthUtil.defaultUserLogin().second, + method = HttpMethod.POST, + endpoint = "/schedules/${INVALID_PK}/hold", + expectedErrorCode = ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE + ) + } } } } diff --git a/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleServiceTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleServiceTest.kt new file mode 100644 index 00000000..51f47d75 --- /dev/null +++ b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleServiceTest.kt @@ -0,0 +1,94 @@ +package com.sangdol.roomescape.schedule + +import com.sangdol.common.utils.MdcPrincipalIdUtil +import com.sangdol.roomescape.schedule.business.ScheduleService +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus +import com.sangdol.roomescape.schedule.web.ScheduleCreateRequest +import com.sangdol.roomescape.schedule.web.ScheduleUpdateRequest +import com.sangdol.roomescape.supports.FunSpecSpringbootTest +import com.sangdol.roomescape.supports.IDGenerator +import com.sangdol.roomescape.supports.initialize +import io.kotest.assertions.assertSoftly +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.springframework.data.repository.findByIdOrNull +import java.time.LocalDate +import java.time.LocalTime + +class ScheduleServiceTest( + private val scheduleService: ScheduleService, + private val scheduleRepository: ScheduleRepository +) : FunSpecSpringbootTest() { + init { + afterTest { + MdcPrincipalIdUtil.clear() + } + + test("관리자가 일정을 수정하면 updatedAt, updatedBy 컬럼이 변경된다. ") { + val createdScheduleId = createSchedule() + + initialize("updatedBy 변경 확인을 위한 MDC 재설정") { + MdcPrincipalIdUtil.clear() + IDGenerator.create().also { + MdcPrincipalIdUtil.set(it.toString()) + } + } + + scheduleService.updateSchedule( + createdScheduleId, + ScheduleUpdateRequest(status = ScheduleStatus.RESERVED) + ) + + assertSoftly(scheduleRepository.findByIdOrNull(createdScheduleId)) { + this.shouldNotBeNull() + this.updatedAt shouldNotBe this.createdAt + this.updatedBy shouldNotBe this.createdBy + } + } + + test("유저가 일정을 Holding 하는 경우에는 updatedAt, updatedBy 컬럼이 변경되지 않는다.") { + val createdScheduleId: Long = createSchedule() + + initialize("updatedBy 변경 확인을 위한 MDC 재설정") { + MdcPrincipalIdUtil.clear() + IDGenerator.create().also { + MdcPrincipalIdUtil.set(it.toString()) + } + } + + scheduleService.holdSchedule(createdScheduleId) + + assertSoftly(scheduleRepository.findByIdOrNull(createdScheduleId)) { + this.shouldNotBeNull() + this.updatedAt shouldBe this.createdAt + this.updatedBy shouldBe this.createdBy + } + } + } + + fun createSchedule(): Long { + initialize("createdBy, updatedBy 설정을 위한 MDC 설정") { + IDGenerator.create().also { + MdcPrincipalIdUtil.set(it.toString()) + } + } + + val (storeId, themeId) = initialize("FK 제약조건 해소를 위한 매장, 테마 생성") { + val store = dummyInitializer.createStore() + val theme = dummyInitializer.createTheme() + + store.id to theme.id + } + + return scheduleService.createSchedule( + storeId = storeId, + request = ScheduleCreateRequest( + date = LocalDate.now().plusDays(1), + time = LocalTime.now(), + themeId = themeId + ) + ).id + } +} -- 2.47.2 From a8ed0de62591eef627452e1e84bf4256210e0894 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 2 Oct 2025 15:22:38 +0900 Subject: [PATCH 04/18] =?UTF-8?q?feat:=20Schedule=EC=9D=B4=20Hold=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EC=9D=BC=20=EB=95=8C=20=EB=A7=8C=EB=A3=8C?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=EC=97=90=20=ED=95=B4=EB=8B=B9=EB=90=98?= =?UTF-8?q?=EB=8A=94=20holdExpiredAt=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/ScheduleEntity.kt | 2 ++ .../src/main/resources/schema/schema-h2.sql | 21 ++++++++++--------- .../main/resources/schema/schema-mysql.sql | 21 ++++++++++--------- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt index d1379c62..bd6f2a6c 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt @@ -4,6 +4,7 @@ import com.sangdol.common.persistence.AuditingBaseEntity import jakarta.persistence.* import org.springframework.data.jpa.domain.support.AuditingEntityListener import java.time.LocalDate +import java.time.LocalDateTime import java.time.LocalTime @Entity @@ -19,6 +20,7 @@ class ScheduleEntity( @Enumerated(value = EnumType.STRING) var status: ScheduleStatus, + var holdExpiredAt: LocalDateTime? = null ) : AuditingBaseEntity(id) { fun modifyIfNotNull( time: LocalTime?, diff --git a/service/src/main/resources/schema/schema-h2.sql b/service/src/main/resources/schema/schema-h2.sql index 9f7b0e95..ee7218fb 100644 --- a/service/src/main/resources/schema/schema-h2.sql +++ b/service/src/main/resources/schema/schema-h2.sql @@ -114,16 +114,17 @@ create table if not exists theme ( ); create table if not exists schedule ( - id bigint primary key, - date date not null, - time time not null, - store_id bigint not null, - theme_id bigint not null, - status varchar(30) not null, - created_at timestamp not null, - created_by bigint not null, - updated_at timestamp not null, - updated_by bigint not null, + id bigint primary key, + date date not null, + time time not null, + store_id bigint not null, + theme_id bigint not null, + status varchar(30) not null, + created_at timestamp not null, + created_by bigint not null, + updated_at timestamp not null, + updated_by bigint not null, + hold_expired_at timestamp null, constraint uk_schedule__store_id_date_time_theme_id unique (store_id, date, time, theme_id), constraint fk_schedule__store_id foreign key (store_id) references store (id), diff --git a/service/src/main/resources/schema/schema-mysql.sql b/service/src/main/resources/schema/schema-mysql.sql index 56a8ee99..bc558c6d 100644 --- a/service/src/main/resources/schema/schema-mysql.sql +++ b/service/src/main/resources/schema/schema-mysql.sql @@ -114,16 +114,17 @@ create table if not exists theme ( ); create table if not exists schedule ( - id bigint primary key, - date date not null, - time time not null, - store_id bigint not null, - theme_id bigint not null, - status varchar(30) not null, - created_at datetime(6) not null, - created_by bigint not null, - updated_at datetime(6) not null, - updated_by bigint not null, + id bigint primary key, + date date not null, + time time not null, + store_id bigint not null, + theme_id bigint not null, + status varchar(30) not null, + created_at datetime(6) not null, + created_by bigint not null, + updated_at datetime(6) not null, + updated_by bigint not null, + hold_expired_at datetime(6) null, constraint uk_schedule__store_id_date_time_theme_id unique (store_id, date, time, theme_id), constraint fk_schedule__store_id foreign key (store_id) references store (id), -- 2.47.2 From 459fb331ae329cab072b9ebba3d9754dbcda9b15 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 2 Oct 2025 15:43:11 +0900 Subject: [PATCH 05/18] =?UTF-8?q?feat:=20Schedule=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=8B=9C=20holdExpiredAt=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B8=B0=EC=A1=B4?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EB=B0=98=EC=98=81=20&=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business/ReservationService.kt | 16 ++++++----- .../schedule/business/ScheduleService.kt | 16 ++++++++++- .../persistence/ScheduleRepository.kt | 21 ++++++++++----- .../schedule/ScheduleServiceTest.kt | 27 ++++++++++++++++++- 4 files changed, 64 insertions(+), 16 deletions(-) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt index f26baa16..498b6ced 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt @@ -11,7 +11,6 @@ import com.sangdol.roomescape.reservation.web.* import com.sangdol.roomescape.schedule.business.ScheduleService import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.schedule.web.ScheduleOverviewResponse -import com.sangdol.roomescape.schedule.web.ScheduleUpdateRequest import com.sangdol.roomescape.theme.business.ThemeService import com.sangdol.roomescape.user.business.UserService import com.sangdol.roomescape.user.web.UserContactResponse @@ -58,9 +57,10 @@ class ReservationService( run { reservation.confirm() - scheduleService.updateSchedule( - reservation.scheduleId, - ScheduleUpdateRequest(status = ScheduleStatus.RESERVED) + scheduleService.changeStatus( + scheduleId = reservation.scheduleId, + currentStatus = ScheduleStatus.HOLD, + changeStatus = ScheduleStatus.RESERVED ) }.also { log.info { "[ReservationService.confirmReservation] Pending 예약 확정 완료: reservationId=${id}" } @@ -74,9 +74,10 @@ class ReservationService( val reservation: ReservationEntity = findOrThrow(reservationId) run { - scheduleService.updateSchedule( - reservation.scheduleId, - ScheduleUpdateRequest(status = ScheduleStatus.AVAILABLE) + scheduleService.changeStatus( + scheduleId = reservation.scheduleId, + currentStatus = ScheduleStatus.RESERVED, + changeStatus = ScheduleStatus.AVAILABLE ) saveCanceledReservation(user, reservation, request.cancelReason) reservation.cancel() @@ -148,6 +149,7 @@ class ReservationService( status = CanceledReservationStatus.COMPLETED ).also { canceledReservationRepository.save(it) + } } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt index 0bb2d24a..02325f72 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt @@ -10,6 +10,7 @@ import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntityFactory import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.schedule.web.* import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging @@ -66,7 +67,11 @@ class ScheduleService( @Transactional fun holdSchedule(id: Long) { log.info { "[ScheduleService.holdSchedule] 일정 Holding 시작: id=$id" } - val result: Int = scheduleRepository.holdAvailableScheduleById(id) + val result: Int = scheduleRepository.changeStatus( + id = id, + currentStatus = ScheduleStatus.AVAILABLE, + changeStatus = ScheduleStatus.HOLD + ) if (result == 0) { log.info { "[ScheduleService.holdSchedule] Holding된 일정 없음: id=${id}, count = " } @@ -188,6 +193,15 @@ class ScheduleService( return overview.toOverviewResponse() } + @Transactional + fun changeStatus(scheduleId: Long, currentStatus: ScheduleStatus, changeStatus: ScheduleStatus) { + log.info { "[ScheduleService.reserveSchedule] 일정 상태 변경 시작: id=${scheduleId}, currentStatus=${currentStatus}, changeStatus=${changeStatus}" } + + scheduleRepository.changeStatus(scheduleId, currentStatus, changeStatus).also { + log.info { "[ScheduleService.reserveSchedule] 일정 상태 변경 완료: id=${scheduleId}, currentStatus=${currentStatus}, changeStatus=${changeStatus}" } + } + } + // ======================================== // Common (공통 메서드) // ======================================== 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 c0d92c1a..ceca6f16 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 @@ -1,9 +1,9 @@ package com.sangdol.roomescape.schedule.infrastructure.persistence -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Query import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview +import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query import java.time.LocalDate import java.time.LocalTime @@ -85,15 +85,22 @@ interface ScheduleRepository : JpaRepository { fun findOverviewByIdOrNull(id: Long): ScheduleOverview? @Modifying - @Query(""" + @Query( + """ UPDATE ScheduleEntity s SET - s.status = 'HOLD' + s.status = :changeStatus, + s.holdExpiredAt = CASE + WHEN :changeStatus = com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus.HOLD + THEN CURRENT_TIMESTAMP + 5 MINUTE + ELSE NULL + END WHERE s._id = :id AND - s.status = 'AVAILABLE' - """) - fun holdAvailableScheduleById(id: Long): Int + s.status = :currentStatus + """ + ) + fun changeStatus(id: Long, currentStatus: ScheduleStatus, changeStatus: ScheduleStatus): Int } diff --git a/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleServiceTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleServiceTest.kt index 51f47d75..1440e7fb 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleServiceTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleServiceTest.kt @@ -10,6 +10,7 @@ import com.sangdol.roomescape.supports.FunSpecSpringbootTest import com.sangdol.roomescape.supports.IDGenerator import com.sangdol.roomescape.supports.initialize import io.kotest.assertions.assertSoftly +import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe @@ -48,7 +49,7 @@ class ScheduleServiceTest( } } - test("유저가 일정을 Holding 하는 경우에는 updatedAt, updatedBy 컬럼이 변경되지 않는다.") { + test("유저가 일정을 Holding 상태로 변경하는 경우에는 holdExpiredAt 컬럼이 5분 뒤로 지정되며, updatedAt, updatedBy 컬럼이 변경되지 않는다.") { val createdScheduleId: Long = createSchedule() initialize("updatedBy 변경 확인을 위한 MDC 재설정") { @@ -64,6 +65,30 @@ class ScheduleServiceTest( this.shouldNotBeNull() this.updatedAt shouldBe this.createdAt this.updatedBy shouldBe this.createdBy + this.holdExpiredAt.shouldNotBeNull() + } + } + + test("유저가 일정을 Holding이 아닌 다른 상태로 변경 경우에는 holdExpiredAt 컬럼이 null로 지정되며, updatedAt, updatedBy 컬럼이 변경되지 않는다.") { + val createdScheduleId: Long = createSchedule() + + initialize("updatedBy 변경 확인을 위한 MDC 재설정") { + MdcPrincipalIdUtil.clear() + IDGenerator.create().also { + MdcPrincipalIdUtil.set(it.toString()) + } + } + + scheduleService.holdSchedule(createdScheduleId).also { + scheduleRepository.findByIdOrNull(createdScheduleId)!!.holdExpiredAt.shouldNotBeNull() + } + + scheduleService.changeStatus(createdScheduleId, ScheduleStatus.HOLD, ScheduleStatus.RESERVED).also { + assertSoftly(scheduleRepository.findByIdOrNull(createdScheduleId)!!) { + this.updatedAt shouldBe this.createdAt + this.updatedBy shouldBe this.createdBy + this.holdExpiredAt.shouldBeNull() + } } } } -- 2.47.2 From a1621e2f65047c1a2afbbaa63ce1eb121fbbc2eb Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 2 Oct 2025 20:59:36 +0900 Subject: [PATCH 06/18] =?UTF-8?q?feat:=20=EC=99=84=EB=A3=8C=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EC=9D=80=20=EC=98=88=EC=95=BD=20=EB=B0=8F?= =?UTF-8?q?=20=EC=9D=BC=EC=A0=95=20=EC=B2=98=EB=A6=AC=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A5=B4=EB=9F=AC=20=EB=8F=84=EC=9E=85=20=EB=B0=8F=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../IncompletedReservationScheduler.kt | 43 +++++++++++ .../persistence/ReservationRepository.kt | 20 +++++ .../persistence/ScheduleRepository.kt | 22 ++++++ .../IncompletedReservationSchedulerTest.kt | 73 +++++++++++++++++++ 4 files changed, 158 insertions(+) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/reservation/business/scheduler/IncompletedReservationScheduler.kt create mode 100644 service/src/test/kotlin/com/sangdol/roomescape/reservation/business/IncompletedReservationSchedulerTest.kt 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 new file mode 100644 index 00000000..dbfcc641 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/scheduler/IncompletedReservationScheduler.kt @@ -0,0 +1,43 @@ +package com.sangdol.roomescape.reservation.business.scheduler + +import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationRepository +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.scheduling.annotation.EnableScheduling +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.util.concurrent.TimeUnit + +private val log: KLogger = KotlinLogging.logger {} + +@Component +@EnableScheduling +class IncompletedReservationScheduler( + private val scheduleRepository: ScheduleRepository, + private val reservationRepository: ReservationRepository +) { + + @Scheduled(fixedRate = 1, timeUnit = TimeUnit.MINUTES) + @Transactional + fun processExpiredHoldSchedule() { + log.info { "[IncompletedReservationScheduler] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" } + + scheduleRepository.releaseExpiredHolds(LocalDateTime.now()).also { + log.info { "[IncompletedReservationScheduler] ${it}개의 일정 재활성화 완료" } + } + } + + @Scheduled(fixedRate = 1, timeUnit = TimeUnit.MINUTES) + @Transactional + fun processExpiredReservation() { + log.info { "[IncompletedReservationScheduler] 결제되지 않은 예약 만료 처리 시작 " } + + reservationRepository.expirePendingReservations(LocalDateTime.now()).also { + log.info { "[IncompletedReservationScheduler] ${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 e9dbdcde..9ef52754 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 @@ -1,8 +1,28 @@ package com.sangdol.roomescape.reservation.infrastructure.persistence import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.time.LocalDateTime interface ReservationRepository : JpaRepository { fun findAllByUserIdAndStatusIsIn(userId: Long, statuses: List): List + + @Modifying + @Query(""" + UPDATE + reservation r + JOIN + schedule s ON r.schedule_id = s.id AND s.status = 'HOLD' + SET + r.status = 'EXPIRED', + r.updated_at = :now, + s.status = 'AVAILABLE', + s.hold_expired_at = NULL + WHERE + r.status = 'PENDING' AND r.created_at <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE) + """, nativeQuery = true) + fun expirePendingReservations(@Param("now") now: LocalDateTime): Int } 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 ceca6f16..48e75cd3 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 @@ -4,7 +4,9 @@ import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param import java.time.LocalDate +import java.time.LocalDateTime import java.time.LocalTime interface ScheduleRepository : JpaRepository { @@ -103,4 +105,24 @@ interface ScheduleRepository : JpaRepository { """ ) fun changeStatus(id: Long, currentStatus: ScheduleStatus, changeStatus: ScheduleStatus): Int + + @Modifying + @Query( + """ + UPDATE + ScheduleEntity s + SET + 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 + ) + """ + ) + fun releaseExpiredHolds(@Param("now") now: LocalDateTime): Int } diff --git a/service/src/test/kotlin/com/sangdol/roomescape/reservation/business/IncompletedReservationSchedulerTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/reservation/business/IncompletedReservationSchedulerTest.kt new file mode 100644 index 00000000..b3eaabed --- /dev/null +++ b/service/src/test/kotlin/com/sangdol/roomescape/reservation/business/IncompletedReservationSchedulerTest.kt @@ -0,0 +1,73 @@ +package com.sangdol.roomescape.reservation.business + +import com.sangdol.common.persistence.TransactionExecutionUtil +import com.sangdol.roomescape.reservation.business.scheduler.IncompletedReservationScheduler +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.user.infrastructure.persistence.UserEntity +import io.kotest.assertions.assertSoftly +import io.kotest.matchers.shouldBe +import org.springframework.data.repository.findByIdOrNull +import org.springframework.jdbc.core.JdbcTemplate +import java.time.LocalDateTime + +/** + * @see com.sangdol.roomescape.reservation.business.scheduler.IncompletedReservationScheduler + * Scheduling 시간은 테스트 하지 않고, 내부 로직의 정상 동작만 확인 + */ +class IncompletedReservationSchedulerTest( + private val incompletedReservationScheduler: IncompletedReservationScheduler, + private val transactionExecutionUtil: TransactionExecutionUtil, + private val jdbcTemplate: JdbcTemplate, + private val reservationRepository: ReservationRepository, + private val scheduleRepository: ScheduleRepository, +) : FunSpecSpringbootTest() { + + init { + test("예약이 없고, hold_expired_at 시간이 지난 ${ScheduleStatus.HOLD} 일정을 ${ScheduleStatus.AVAILABLE} 상태로 바꾼다.") { + val schedule: ScheduleEntity = dummyInitializer.createSchedule().apply { + this.status = ScheduleStatus.HOLD + this.holdExpiredAt = LocalDateTime.now().minusSeconds(1) + }.also { + scheduleRepository.saveAndFlush(it) + } + + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + incompletedReservationScheduler.processExpiredHoldSchedule() + } + + assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) { + this.status shouldBe ScheduleStatus.AVAILABLE + this.holdExpiredAt shouldBe null + } + } + + test("${ReservationStatus.PENDING} 상태로 일정 시간 완료되지 않은 예약을 ${ReservationStatus.EXPIRED} 상태로 바꾼다.") { + val user: UserEntity = testAuthUtil.defaultUserLogin().first + val reservation = dummyInitializer.createPendingReservation(user = user).also { + jdbcTemplate.execute("UPDATE reservation SET created_at = DATE_SUB(NOW(), INTERVAL 5 MINUTE) WHERE id = ${it.id}") + } + + val now = LocalDateTime.now() + + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + incompletedReservationScheduler.processExpiredReservation() + } + + assertSoftly(reservationRepository.findByIdOrNull(reservation.id)!!) { + this.status shouldBe ReservationStatus.EXPIRED + this.updatedAt.hour shouldBe now.hour + this.updatedAt.minute shouldBe now.minute + } + + assertSoftly(scheduleRepository.findByIdOrNull(reservation.scheduleId)!!) { + this.status shouldBe ScheduleStatus.AVAILABLE + this.holdExpiredAt shouldBe null + } + } + } +} -- 2.47.2 From 173467821b53ef7cc36c1505ff17ed4f6631286e Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 2 Oct 2025 21:00:09 +0900 Subject: [PATCH 07/18] =?UTF-8?q?feat:=20=EB=A1=9C=EC=BB=AC=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=EB=B2=A0=EC=9D=B4=EC=8A=A4=20mysql=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=BB=A8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=84=88=20compose=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/docker-compose.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 docker/docker-compose.yaml diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml new file mode 100644 index 00000000..83ce6703 --- /dev/null +++ b/docker/docker-compose.yaml @@ -0,0 +1,14 @@ +services: + mysql-local: + image: mysql:8.4 + container_name: mysql-local + restart: always + ports: + - "23306:3306" + environment: + MYSQL_ROOT_PASSWORD: init + MYSQL_DATABASE: roomescape_local + TZ: Asia/Seoul + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci -- 2.47.2 From 99917df6003230c58033dcf184059c3f0455cf0a Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 2 Oct 2025 21:02:20 +0900 Subject: [PATCH 08/18] =?UTF-8?q?feat:=20=EB=A1=9C=EC=BB=AC=20=EB=B0=8F=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EB=B2=A0=EC=9D=B4=EC=8A=A4=20mysql=20=EC=9D=B4=EC=A0=84=20?= =?UTF-8?q?=EB=B0=8F=20=EC=95=A0=ED=94=8C=EB=A6=AC=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=A2=85=EB=A3=8C=EC=8B=9C=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20truncate=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/config/LocalDatabaseCleaner.kt | 33 +++++++++++++++++++ .../src/main/resources/application-local.yaml | 14 +++----- .../sangdol/data/DefaultDataInitializer.kt | 4 +-- .../roomescape/supports/TestDatabaseUtil.kt | 14 +++++--- ...-test-mysql.yaml => application-data.yaml} | 0 .../src/test/resources/application-test.yaml | 10 +++--- 6 files changed, 54 insertions(+), 21 deletions(-) create mode 100644 service/src/main/kotlin/com/sangdol/roomescape/common/config/LocalDatabaseCleaner.kt rename service/src/test/resources/{application-test-mysql.yaml => application-data.yaml} (100%) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/common/config/LocalDatabaseCleaner.kt b/service/src/main/kotlin/com/sangdol/roomescape/common/config/LocalDatabaseCleaner.kt new file mode 100644 index 00000000..b3e30f88 --- /dev/null +++ b/service/src/main/kotlin/com/sangdol/roomescape/common/config/LocalDatabaseCleaner.kt @@ -0,0 +1,33 @@ +package com.sangdol.roomescape.common.config + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import jakarta.annotation.PreDestroy +import jakarta.transaction.Transactional +import org.springframework.context.annotation.Profile +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Component + +private val log: KLogger = KotlinLogging.logger {} + +@Component +@Profile("local") +class LocalDatabaseCleaner( + private val jdbcTemplate: JdbcTemplate +) { + @PreDestroy + @Transactional + fun clearAll() { + log.info { "[LocalDatabaseCleaner] 데이터베이스 초기화 시작" } + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0") + + jdbcTemplate.query("SHOW TABLES") { rs, _ -> + rs.getString(1).lowercase() + }.forEach { + jdbcTemplate.execute("TRUNCATE TABLE $it") + } + + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1") + log.info { "[LocalDatabaseCleaner] 데이터베이스 초기화 완료" } + } +} diff --git a/service/src/main/resources/application-local.yaml b/service/src/main/resources/application-local.yaml index 880a61c1..095736d2 100644 --- a/service/src/main/resources/application-local.yaml +++ b/service/src/main/resources/application-local.yaml @@ -5,20 +5,16 @@ spring: format_sql: true hibernate: ddl-auto: validate - h2: - console: - enabled: true - path: /h2-console datasource: hikari: - jdbc-url: jdbc:h2:mem:database - driver-class-name: org.h2.Driver - username: sa - password: + jdbc-url: jdbc:mysql://localhost:23306/roomescape_local + driver-class-name: com.mysql.cj.jdbc.Driver + username: root + password: init sql: init: mode: always - schema-locations: classpath:schema/schema-h2.sql + schema-locations: classpath:schema/schema-mysql.sql data-locations: classpath:schema/region-data.sql security: diff --git a/service/src/test/kotlin/com/sangdol/data/DefaultDataInitializer.kt b/service/src/test/kotlin/com/sangdol/data/DefaultDataInitializer.kt index 04f7ab1c..aa1ac10e 100644 --- a/service/src/test/kotlin/com/sangdol/data/DefaultDataInitializer.kt +++ b/service/src/test/kotlin/com/sangdol/data/DefaultDataInitializer.kt @@ -1,10 +1,10 @@ package com.sangdol.data import com.sangdol.common.persistence.IDGenerator +import com.sangdol.common.persistence.TransactionExecutionUtil import com.sangdol.roomescape.admin.infrastructure.persistence.AdminEntity import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType -import com.sangdol.common.persistence.TransactionExecutionUtil import com.sangdol.roomescape.payment.infrastructure.common.* import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus @@ -32,7 +32,7 @@ import java.time.LocalDateTime import java.time.LocalTime import java.time.OffsetDateTime -@ActiveProfiles("test", "test-mysql") +@ActiveProfiles("test", "data") abstract class AbstractDataInitializer( val semaphore: Semaphore = Semaphore(permits = 10), ) : FunSpecSpringbootTest( diff --git a/service/src/test/kotlin/com/sangdol/roomescape/supports/TestDatabaseUtil.kt b/service/src/test/kotlin/com/sangdol/roomescape/supports/TestDatabaseUtil.kt index 60e4d851..74f9285e 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/supports/TestDatabaseUtil.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/supports/TestDatabaseUtil.kt @@ -23,22 +23,26 @@ class TestDatabaseUtil( } fun initializeRegion() { - this::class.java.getResource("/schema/region-data.sql")?.readText()?.let { - jdbcTemplate.execute(it) + jdbcTemplate.queryForObject("SELECT EXISTS (SELECT 1 FROM region LIMIT 1)", Boolean::class.java)!!.also { isRegionTableEmpty -> + if (!isRegionTableEmpty) { + this::class.java.getResource("/schema/region-data.sql")?.readText()?.let { regionInsertSql -> + jdbcTemplate.execute(regionInsertSql) + } + } } } fun clear(mode: CleanerMode) { entityManager.clear() - jdbcTemplate.execute("SET REFERENTIAL_INTEGRITY FALSE") + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0") tables.forEach { if (mode == CleanerMode.EXCEPT_REGION && it == "region") { return@forEach } - jdbcTemplate.execute("TRUNCATE TABLE $it RESTART IDENTITY") + jdbcTemplate.execute("TRUNCATE TABLE $it") } - jdbcTemplate.execute("SET REFERENTIAL_INTEGRITY TRUE") + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1") } } diff --git a/service/src/test/resources/application-test-mysql.yaml b/service/src/test/resources/application-data.yaml similarity index 100% rename from service/src/test/resources/application-test-mysql.yaml rename to service/src/test/resources/application-data.yaml diff --git a/service/src/test/resources/application-test.yaml b/service/src/test/resources/application-test.yaml index 914bb277..ae8ee583 100644 --- a/service/src/test/resources/application-test.yaml +++ b/service/src/test/resources/application-test.yaml @@ -10,14 +10,14 @@ spring: ddl-auto: validate datasource: hikari: - jdbc-url: jdbc:h2:mem:test - driver-class-name: org.h2.Driver - username: sa - password: + jdbc-url: jdbc:mysql://localhost:23306/roomescape_local + driver-class-name: com.mysql.cj.jdbc.Driver + username: root + password: init sql: init: mode: always - schema-locations: classpath:schema/schema-h2.sql + schema-locations: classpath:schema/schema-mysql.sql security: jwt: -- 2.47.2 From e9c8e612fa86d196283b0364306d46a74b609e65 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 2 Oct 2025 22:15:17 +0900 Subject: [PATCH 09/18] =?UTF-8?q?fix:=20=EC=8B=9C=EA=B0=84=EC=9D=B4=2000?= =?UTF-8?q?=EC=8B=9C=EB=A5=BC=20=EB=84=98=EC=96=B4=EA=B0=80=20=ED=8A=B9?= =?UTF-8?q?=EC=A0=95=20=EC=8B=9C=EA=B0=84=20=EC=9D=B4=ED=9B=84=EC=97=90?= =?UTF-8?q?=EB=8A=94=20=EC=8B=A4=ED=8C=A8=ED=95=98=EB=8A=94=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/schedule/ScheduleApiTest.kt | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleApiTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleApiTest.kt index c5d25372..96453cff 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleApiTest.kt @@ -1,9 +1,5 @@ package com.sangdol.roomescape.schedule -import io.kotest.matchers.shouldBe -import org.hamcrest.CoreMatchers.equalTo -import org.springframework.data.repository.findByIdOrNull -import org.springframework.http.HttpMethod import com.sangdol.common.types.web.HttpStatus import com.sangdol.roomescape.admin.infrastructure.persistence.AdminPermissionLevel import com.sangdol.roomescape.admin.infrastructure.persistence.AdminType @@ -12,6 +8,10 @@ import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus import com.sangdol.roomescape.supports.* +import io.kotest.matchers.shouldBe +import org.hamcrest.CoreMatchers.equalTo +import org.springframework.data.repository.findByIdOrNull +import org.springframework.http.HttpMethod import java.time.LocalDate import java.time.LocalTime @@ -20,7 +20,7 @@ class ScheduleApiTest( ) : FunSpecSpringbootTest() { init { context("특정 매장 + 날짜의 일정 및 테마 정보를 조회한다.") { - test("날짜가 오늘이면 현재 시간 이후의 정보만 조회된다.") { + test("날짜가 당일이면 현재 시간 이후의 정보만 조회된다.") { val size = 3 val date = LocalDate.now() val store = dummyInitializer.createStore() @@ -31,7 +31,7 @@ class ScheduleApiTest( storeId = store.id, request = ScheduleFixture.createRequest.copy( date = date, - time = LocalTime.now().plusHours(i.toLong()) + time = LocalTime.now().plusMinutes(i.toLong()) ) ) } @@ -45,13 +45,17 @@ class ScheduleApiTest( ) } + val expectedSize = scheduleRepository.findAll().takeIf { it.isNotEmpty() } + ?.let { it.count { schedule -> schedule.date.isEqual(date) && schedule.time.isAfter(LocalTime.now()) } } + ?: throw AssertionError("initialize 작업에서 레코드가 저장되지 않음.") + runTest( on = { get("/stores/${store.id}/schedules?date=${date}") }, expect = { statusCode(HttpStatus.OK.value()) - body("data.schedules.size()", equalTo(size)) + body("data.schedules.size()", equalTo(expectedSize)) assertProperties( props = setOf("id", "startFrom", "endAt", "themeId", "themeName", "themeDifficulty", "status"), propsNameIfList = "schedules" -- 2.47.2 From 267b93bdca874221c3fa926943640eeecff7154b Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 2 Oct 2025 22:16:05 +0900 Subject: [PATCH 10/18] =?UTF-8?q?fix:=20=EC=9D=BC=EC=A0=95=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=BF=BC=EB=A6=AC=EC=97=90=EC=84=9C=EC=9D=98=20?= =?UTF-8?q?=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95(=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EB=8A=94=20=EC=A0=95=EC=83=81=EC=9D=B4=EA=B3=A0=20=EB=8F=99?= =?UTF-8?q?=EC=9D=BC=ED=95=98=EB=82=98=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20?= =?UTF-8?q?=ED=99=9C=EC=9A=A9=20=EB=B6=88=EA=B0=80=EB=8A=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/infrastructure/persistence/ScheduleRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 48e75cd3..ab70893e 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 @@ -50,7 +50,7 @@ interface ScheduleRepository : JpaRepository { WHERE s.storeId = :storeId AND s.date = :date - AND (:themeId IS NULL OR t._id = :themeId) + AND (:themeId IS NULL OR s.themeId = :themeId) """ ) fun findStoreSchedulesWithThemeByDate( -- 2.47.2 From 49fa800ee60820ece40e497908def41297e93211 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 2 Oct 2025 22:16:30 +0900 Subject: [PATCH 11/18] =?UTF-8?q?refactor:=20=EB=B0=B0=EC=B9=98=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=B4=20Pen?= =?UTF-8?q?ding=20=EC=98=88=EC=95=BD=20=EC=83=9D=EC=84=B1=EC=8B=9C=20?= =?UTF-8?q?=EB=B0=9C=EC=83=9D=ED=95=98=EB=8A=94=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B0=8F=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/reservation/business/ReservationValidator.kt | 4 ++-- .../roomescape/reservation/exception/ReservationErrorCode.kt | 2 +- .../com/sangdol/roomescape/reservation/ReservationApiTest.kt | 4 ++-- 3 files changed, 5 insertions(+), 5 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 28fdc5d6..d8211272 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 @@ -21,8 +21,8 @@ class ReservationValidator { request: PendingReservationCreateRequest ) { if (schedule.status != ScheduleStatus.HOLD) { - log.warn { "[ReservationValidator.validateCanCreate] ${schedule.status}인 일정으로 인한 예약 실패" } - throw ReservationException(ReservationErrorCode.SCHEDULE_NOT_HOLD) + log.warn { "[ReservationValidator.validateCanCreate] ${schedule.status}로의 일정 상태 변경에 따른 실패" } + throw ReservationException(ReservationErrorCode.EXPIRED_HELD_SCHEDULE) } if (theme.minParticipants > request.participantCount) { diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/exception/ReservationErrorCode.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/exception/ReservationErrorCode.kt index a91594e7..1e0b3829 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/exception/ReservationErrorCode.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/exception/ReservationErrorCode.kt @@ -11,7 +11,7 @@ enum class ReservationErrorCode( RESERVATION_NOT_FOUND(HttpStatus.NOT_FOUND, "R001", "예약을 찾을 수 없어요."), NO_PERMISSION_TO_CANCEL_RESERVATION(HttpStatus.FORBIDDEN, "R002", "예약을 취소할 수 있는 권한이 없어요."), INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R003", "종료 날짜는 시작 날짜 이후여야 해요."), - SCHEDULE_NOT_HOLD(HttpStatus.BAD_REQUEST, "R004", "이미 예약되었거나 예약이 불가능한 일정이에요."), + EXPIRED_HELD_SCHEDULE(HttpStatus.CONFLICT, "R004", "예약 정보 입력 시간을 초과했어요. 일정 선택 후 다시 시도해주세요."), INVALID_PARTICIPANT_COUNT(HttpStatus.BAD_REQUEST, "R005", "참여 가능 인원 수를 확인해주세요.") ; } 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 491365ae..49b97aee 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationApiTest.kt @@ -100,8 +100,8 @@ class ReservationApiTest( post(endpoint) }, expect = { - statusCode(HttpStatus.BAD_REQUEST.value()) - body("code", equalTo(ReservationErrorCode.SCHEDULE_NOT_HOLD.errorCode)) + statusCode(HttpStatus.CONFLICT.value()) + body("code", equalTo(ReservationErrorCode.EXPIRED_HELD_SCHEDULE.errorCode)) } ) } -- 2.47.2 From 05994096121c722a6581d822a0cb711357782083 Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 3 Oct 2025 15:23:36 +0900 Subject: [PATCH 12/18] =?UTF-8?q?refactor:=20=EC=8A=A4=EC=BC=80=EC=A5=B4?= =?UTF-8?q?=EB=A7=81=20=EC=9E=91=EC=97=85=EC=9D=80=20=EC=95=A0=ED=94=8C?= =?UTF-8?q?=EB=A6=AC=EC=BC=80=EC=9D=B4=EC=85=98=20=EC=8B=9C=EC=9E=91=20?= =?UTF-8?q?=ED=9B=84=201=EB=B6=84=20=EB=92=A4=20=EB=B6=80=ED=84=B0=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business/scheduler/IncompletedReservationScheduler.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 dbfcc641..b15876b1 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 @@ -21,7 +21,7 @@ class IncompletedReservationScheduler( private val reservationRepository: ReservationRepository ) { - @Scheduled(fixedRate = 1, timeUnit = TimeUnit.MINUTES) + @Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES) @Transactional fun processExpiredHoldSchedule() { log.info { "[IncompletedReservationScheduler] 만료 시간이 지난 ${ScheduleStatus.HOLD} 상태의 일정 재활성화 시작" } @@ -31,7 +31,7 @@ class IncompletedReservationScheduler( } } - @Scheduled(fixedRate = 1, timeUnit = TimeUnit.MINUTES) + @Scheduled(initialDelay = 1, fixedRate = 1, timeUnit = TimeUnit.MINUTES) @Transactional fun processExpiredReservation() { log.info { "[IncompletedReservationScheduler] 결제되지 않은 예약 만료 처리 시작 " } -- 2.47.2 From dbc6847877ca6661faecee8d11c8120c35d70fc0 Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 3 Oct 2025 15:24:26 +0900 Subject: [PATCH 13/18] =?UTF-8?q?refactor:=20Pending=20=EC=98=88=EC=95=BD?= =?UTF-8?q?=20=EB=AF=B8=EC=83=9D=EC=84=B1=20=EC=9D=BC=EC=A0=95=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=8A=A4=EC=BC=80=EC=A5=B4=EB=A7=81=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=EC=97=90=EC=84=9C=20=EB=B0=9C=EC=83=9D=ED=95=A0=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=8B=9C=20Pessimistic=20Lock=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/business/ReservationService.kt | 2 +- .../roomescape/schedule/business/ScheduleService.kt | 10 ++++++++-- .../persistence/ScheduleRepository.kt | 13 +++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt index 498b6ced..61587cde 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/reservation/business/ReservationService.kt @@ -154,7 +154,7 @@ class ReservationService( } private fun validateCanCreate(request: PendingReservationCreateRequest) { - val schedule = scheduleService.findSummaryById(request.scheduleId) + val schedule = scheduleService.findSummaryWithLock(request.scheduleId) val theme = themeService.findInfoById(schedule.themeId) reservationValidator.validateCanCreate(schedule, theme, request) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt index 02325f72..91b4e2dc 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt @@ -174,10 +174,16 @@ class ScheduleService( // Other-Service (API 없이 다른 서비스에서 호출) // ======================================== @Transactional(readOnly = true) - fun findSummaryById(id: Long): ScheduleSummaryResponse { + fun findSummaryWithLock(id: Long): ScheduleSummaryResponse { log.info { "[ScheduleService.findDateTimeById] 일정 개요 조회 시작 : id=$id" } - return findOrThrow(id).toSummaryResponse() + val schedule: ScheduleEntity = scheduleRepository.findByIdForUpdate(id) + ?: run { + log.warn { "[ScheduleService.updateSchedule] 일정 조회 실패. id=$id" } + throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND) + } + + return schedule.toSummaryResponse() .also { log.info { "[ScheduleService.findDateTimeById] 일정 개요 조회 완료: id=$id" } } 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 ab70893e..a30fbbed 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 @@ -1,7 +1,9 @@ package com.sangdol.roomescape.schedule.infrastructure.persistence import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview +import jakarta.persistence.LockModeType import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param @@ -11,6 +13,17 @@ import java.time.LocalTime interface ScheduleRepository : JpaRepository { + @Lock(value = LockModeType.PESSIMISTIC_WRITE) + @Query(""" + SELECT + s + FROM + ScheduleEntity s + WHERE + s._id = :id + """) + fun findByIdForUpdate(id: Long): ScheduleEntity? + @Query( """ SELECT -- 2.47.2 From 11000f3f3d9ba126a308f390836b725f65c6e049 Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 3 Oct 2025 15:25:35 +0900 Subject: [PATCH 14/18] =?UTF-8?q?test:=20=EC=8A=A4=EC=BC=80=EC=A5=B4?= =?UTF-8?q?=EB=A7=81=20=EC=9E=91=EC=97=85=EC=9C=BC=EB=A1=9C=20=EC=9D=B8?= =?UTF-8?q?=ED=95=B4=20=EB=B0=9C=EC=83=9D=20=EA=B0=80=EB=8A=A5=ED=95=9C=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/ReservationConcurrencyTest.kt | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt diff --git a/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt new file mode 100644 index 00000000..111c14e1 --- /dev/null +++ b/service/src/test/kotlin/com/sangdol/roomescape/reservation/ReservationConcurrencyTest.kt @@ -0,0 +1,83 @@ +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.infrastructure.persistence.ReservationRepository +import com.sangdol.roomescape.reservation.infrastructure.persistence.ReservationStatus +import com.sangdol.roomescape.reservation.web.PendingReservationCreateRequest +import com.sangdol.roomescape.reservation.web.PendingReservationCreateResponse +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository +import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleStatus +import com.sangdol.roomescape.supports.FunSpecSpringbootTest +import io.kotest.assertions.assertSoftly +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext +import org.springframework.data.repository.findByIdOrNull +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate +import java.time.LocalDateTime + +class ReservationConcurrencyTest( + private val transactionManager: PlatformTransactionManager, + private val incompletedReservationScheduler: IncompletedReservationScheduler, + private val reservationService: ReservationService, + private val reservationRepository: ReservationRepository, + private val scheduleRepository: ScheduleRepository +) : FunSpecSpringbootTest() { + + init { + test("Pending 예약 생성시, Schedule 상태 검증 이후부터 커밋 이전 사이에 시작된 schedule 처리 배치 작업은 반영되지 않는다.") { + val user = testAuthUtil.defaultUserLogin().first + val schedule = dummyInitializer.createSchedule().also { + it.status = ScheduleStatus.HOLD + it.holdExpiredAt = LocalDateTime.now().minusMinutes(1) + scheduleRepository.save(it) + } + lateinit var response: PendingReservationCreateResponse + + withContext(Dispatchers.IO) { + val createPendingReservationJob = async { + response = TransactionTemplate(transactionManager).execute { + val response = reservationService.createPendingReservation( + user = CurrentUserContext(id = user.id, name = user.name), + request = PendingReservationCreateRequest( + scheduleId = schedule.id, + reserverName = user.name, + reserverContact = user.phone, + participantCount = 3, + requirement = "없어요!" + ) + ) + + Thread.sleep(200) + response + }!! + } + + val updateScheduleJob = async { + TransactionTemplate(transactionManager).execute { + incompletedReservationScheduler.processExpiredHoldSchedule() + } + } + + listOf(createPendingReservationJob, updateScheduleJob).awaitAll() + } + + + 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 + } + } + } +} -- 2.47.2 From 10318cab331e5f2a4b8cff32f9cca8a5aa84e00a Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 3 Oct 2025 15:26:00 +0900 Subject: [PATCH 15/18] =?UTF-8?q?chore:=20=EC=9D=BC=EB=B6=80=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{business => }/IncompletedReservationSchedulerTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename service/src/test/kotlin/com/sangdol/roomescape/reservation/{business => }/IncompletedReservationSchedulerTest.kt (96%) diff --git a/service/src/test/kotlin/com/sangdol/roomescape/reservation/business/IncompletedReservationSchedulerTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/reservation/IncompletedReservationSchedulerTest.kt similarity index 96% rename from service/src/test/kotlin/com/sangdol/roomescape/reservation/business/IncompletedReservationSchedulerTest.kt rename to service/src/test/kotlin/com/sangdol/roomescape/reservation/IncompletedReservationSchedulerTest.kt index b3eaabed..f71a240c 100644 --- a/service/src/test/kotlin/com/sangdol/roomescape/reservation/business/IncompletedReservationSchedulerTest.kt +++ b/service/src/test/kotlin/com/sangdol/roomescape/reservation/IncompletedReservationSchedulerTest.kt @@ -1,4 +1,4 @@ -package com.sangdol.roomescape.reservation.business +package com.sangdol.roomescape.reservation import com.sangdol.common.persistence.TransactionExecutionUtil import com.sangdol.roomescape.reservation.business.scheduler.IncompletedReservationScheduler @@ -41,7 +41,7 @@ class IncompletedReservationSchedulerTest( } assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) { - this.status shouldBe ScheduleStatus.AVAILABLE + this.status shouldBe ScheduleStatus.AVAILABLE this.holdExpiredAt shouldBe null } } @@ -70,4 +70,4 @@ class IncompletedReservationSchedulerTest( } } } -} +} \ No newline at end of file -- 2.47.2 From a50cbbe43ef852563cb9c89dc6e44147a37d0b29 Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 3 Oct 2025 16:00:37 +0900 Subject: [PATCH 16/18] =?UTF-8?q?refactor:=20schedule/hold=20API=EC=97=90?= =?UTF-8?q?=EC=84=9C=EC=9D=98=20=EB=A1=9C=EA=B9=85=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/business/ScheduleService.kt | 5 +- .../schedule/ScheduleConcurrencyTest.kt | 59 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleConcurrencyTest.kt diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt index 91b4e2dc..73549c1b 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt @@ -71,10 +71,11 @@ class ScheduleService( id = id, currentStatus = ScheduleStatus.AVAILABLE, changeStatus = ScheduleStatus.HOLD - ) + ).also { + log.info { "[ScheduleService.holdSchedule] $it 개의 row 변경 완료" } + } if (result == 0) { - log.info { "[ScheduleService.holdSchedule] Holding된 일정 없음: id=${id}, count = " } throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE) } diff --git a/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleConcurrencyTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleConcurrencyTest.kt new file mode 100644 index 00000000..c237d11e --- /dev/null +++ b/service/src/test/kotlin/com/sangdol/roomescape/schedule/ScheduleConcurrencyTest.kt @@ -0,0 +1,59 @@ +package com.sangdol.roomescape.schedule + +import com.sangdol.common.types.web.HttpStatus +import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode +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.runTest +import io.kotest.assertions.assertSoftly +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext +import org.springframework.data.repository.findByIdOrNull +import java.util.concurrent.CountDownLatch + +class ScheduleConcurrencyTest( + private val scheduleRepository: ScheduleRepository +) : FunSpecSpringbootTest() { + init { + test("하나의 ${ScheduleStatus.AVAILABLE}인 일정에 대한 동시 HOLD 변경 요청이 들어오면, 가장 먼저 들어온 요청만 성공한다.") { + val user = testAuthUtil.defaultUserLogin() + val schedule = dummyInitializer.createSchedule() + + withContext(Dispatchers.IO) { + val jobSize = 64 + val latch = CountDownLatch(jobSize) + + (1..jobSize).map { + async { + latch.countDown() + latch.await() + + runTest( + token = user.second, + on = { + post("/schedules/${schedule.id}/hold") + }, + expect = { + } + ).extract().statusCode() + } + }.awaitAll().also { + it.count { statusCode -> statusCode == HttpStatus.OK.value() } shouldBe 1 + it.count { statusCode -> statusCode == ScheduleErrorCode.SCHEDULE_NOT_AVAILABLE.httpStatus.value() } shouldBe (jobSize - 1) + } + } + + assertSoftly(scheduleRepository.findByIdOrNull(schedule.id)!!) { + this.status shouldBe ScheduleStatus.HOLD + this.holdExpiredAt.shouldNotBeNull() + } + } + } +} -- 2.47.2 From 3748df9351fee1c668e8160bedbcc995d531ced3 Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 3 Oct 2025 16:22:12 +0900 Subject: [PATCH 17/18] =?UTF-8?q?refactor:=20mysql=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=ED=8C=8C=EC=9D=BC=EC=97=90=20=EC=9D=B8=EB=8D=B1?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/src/main/resources/schema/schema-mysql.sql | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/service/src/main/resources/schema/schema-mysql.sql b/service/src/main/resources/schema/schema-mysql.sql index bc558c6d..c3a4ab47 100644 --- a/service/src/main/resources/schema/schema-mysql.sql +++ b/service/src/main/resources/schema/schema-mysql.sql @@ -128,7 +128,9 @@ create table if not exists schedule ( constraint uk_schedule__store_id_date_time_theme_id unique (store_id, date, time, theme_id), constraint fk_schedule__store_id foreign key (store_id) references store (id), - constraint fk_schedule__theme_id foreign key (theme_id) references theme (id) + constraint fk_schedule__theme_id foreign key (theme_id) references theme (id), + index idx_schedule__status_hold_expired_at (status, hold_expired_at, id), + index idx_schedule__status_date_theme_id_id (status, date, theme_id, id) ); create table if not exists reservation ( @@ -146,7 +148,9 @@ create table if not exists reservation ( updated_by bigint not null, constraint fk_reservation__user_id foreign key (user_id) references users (id), - constraint fk_reservation__schedule_id foreign key (schedule_id) references schedule (id) + constraint fk_reservation__schedule_id foreign key (schedule_id) references schedule (id), + index idx_reservation__schedule_id_status (schedule_id, status), + index idx_reservation__status_schedule_id_created_at (status, schedule_id, created_at) ); create table if not exists canceled_reservation ( -- 2.47.2 From 7af71863410b64196fe0f966026630511a8b766a Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 3 Oct 2025 16:22:32 +0900 Subject: [PATCH 18/18] =?UTF-8?q?chore:=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?=EC=84=A0=EC=96=B8=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/schedule/business/ScheduleService.kt | 2 +- .../roomescape/schedule/business/ScheduleValidator.kt | 8 ++++---- .../roomescape/schedule/exception/ScheduleException.kt | 2 ++ 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt index 73549c1b..d36ca16d 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleService.kt @@ -1,12 +1,12 @@ package com.sangdol.roomescape.schedule.business -import ScheduleException import com.sangdol.common.persistence.IDGenerator import com.sangdol.roomescape.admin.business.AdminService import com.sangdol.roomescape.common.types.AuditingInfo import com.sangdol.roomescape.common.types.Auditor import com.sangdol.roomescape.schedule.business.domain.ScheduleOverview import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode +import com.sangdol.roomescape.schedule.exception.ScheduleException import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntity import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleEntityFactory import com.sangdol.roomescape.schedule.infrastructure.persistence.ScheduleRepository diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleValidator.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleValidator.kt index daab707d..4b79cd1b 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleValidator.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/business/ScheduleValidator.kt @@ -1,15 +1,15 @@ package com.sangdol.roomescape.schedule.business -import ScheduleException -import io.github.oshai.kotlinlogging.KLogger -import io.github.oshai.kotlinlogging.KotlinLogging -import org.springframework.stereotype.Component import com.sangdol.roomescape.schedule.exception.ScheduleErrorCode +import com.sangdol.roomescape.schedule.exception.ScheduleException 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.schedule.web.ScheduleCreateRequest import com.sangdol.roomescape.schedule.web.ScheduleUpdateRequest +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Component import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime diff --git a/service/src/main/kotlin/com/sangdol/roomescape/schedule/exception/ScheduleException.kt b/service/src/main/kotlin/com/sangdol/roomescape/schedule/exception/ScheduleException.kt index 6133383a..3f935e94 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/schedule/exception/ScheduleException.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/schedule/exception/ScheduleException.kt @@ -1,3 +1,5 @@ +package com.sangdol.roomescape.schedule.exception + import com.sangdol.common.types.exception.ErrorCode import com.sangdol.common.types.exception.RoomescapeException -- 2.47.2