diff --git a/src/main/kotlin/com/tripsync/application/schedule/ScheduleGenerationPersistenceService.kt b/src/main/kotlin/com/tripsync/application/schedule/ScheduleGenerationPersistenceService.kt index 3c8d2a1..2e34183 100644 --- a/src/main/kotlin/com/tripsync/application/schedule/ScheduleGenerationPersistenceService.kt +++ b/src/main/kotlin/com/tripsync/application/schedule/ScheduleGenerationPersistenceService.kt @@ -19,6 +19,7 @@ import com.tripsync.domain.repository.RoomMemberProfileRepository import com.tripsync.domain.repository.SatisfactionScoreRepository import com.tripsync.domain.repository.ScheduleRepository import com.tripsync.domain.repository.ScheduleSlotRepository +import com.tripsync.domain.repository.TripRoomRepository import com.tripsync.domain.repository.UserRepository import com.tripsync.web.dto.GenerateScheduleDto import org.springframework.http.HttpStatus @@ -35,6 +36,7 @@ class ScheduleGenerationPersistenceService( private val placeQueryRepository: PlaceQueryRepository, private val externalPopularityMetricRepository: ExternalPopularityMetricRepository, private val userRepository: UserRepository, + private val tripRoomRepository: TripRoomRepository, private val accessPolicy: ScheduleAccessPolicy, ) { @Transactional(readOnly = true) @@ -100,7 +102,8 @@ class ScheduleGenerationPersistenceService( options: List, personaValidationByType: Map>, ): SavedScheduleGeneration { - val room = accessPolicy.getActiveRoom(roomId) + val room = tripRoomRepository.findActiveByIdForUpdate(roomId, YnFlag.N) + ?: throw DomainException(HttpStatus.NOT_FOUND, "ROOM_NOT_FOUND", "존재하지 않는 방입니다.") val version = (scheduleRepository.findTopByRoomIdAndDelYnOrderByVersionDesc(room.id, YnFlag.N)?.version ?: 0) + 1 val saved = options.map { option -> val personaValidation = personaValidationByType[option.optionType] diff --git a/src/main/kotlin/com/tripsync/domain/repository/TripRoomRepository.kt b/src/main/kotlin/com/tripsync/domain/repository/TripRoomRepository.kt index bb3a0e6..c338e23 100644 --- a/src/main/kotlin/com/tripsync/domain/repository/TripRoomRepository.kt +++ b/src/main/kotlin/com/tripsync/domain/repository/TripRoomRepository.kt @@ -2,11 +2,29 @@ package com.tripsync.domain.repository import com.tripsync.domain.entity.TripRoom import com.tripsync.domain.enums.YnFlag +import jakarta.persistence.LockModeType import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository @Repository interface TripRoomRepository : JpaRepository { fun findByShareCode(shareCode: String): TripRoom? fun findByShareCodeAndDelYn(shareCode: String, delYn: YnFlag): TripRoom? + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query( + """ + select room + from TripRoom room + where room.id = :roomId + and room.delYn = :delYn + """ + ) + fun findActiveByIdForUpdate( + @Param("roomId") roomId: Long, + @Param("delYn") delYn: YnFlag, + ): TripRoom? } diff --git a/src/test/kotlin/com/tripsync/application/schedule/ScheduleServiceTest.kt b/src/test/kotlin/com/tripsync/application/schedule/ScheduleServiceTest.kt index a6ac5f5..b247aa1 100644 --- a/src/test/kotlin/com/tripsync/application/schedule/ScheduleServiceTest.kt +++ b/src/test/kotlin/com/tripsync/application/schedule/ScheduleServiceTest.kt @@ -1,5 +1,8 @@ package com.tripsync.application.schedule +import com.tripsync.application.consensus.ScheduleOptionDraft +import com.tripsync.application.consensus.ScheduleSlotDraft +import com.tripsync.application.consensus.SatisfactionDraft import com.tripsync.common.exception.DomainException import com.tripsync.domain.entity.Place import com.tripsync.domain.entity.RoomMember @@ -11,6 +14,7 @@ import com.tripsync.domain.enums.AuthProvider import com.tripsync.domain.enums.ReasonAxis import com.tripsync.domain.enums.RoomMemberRole import com.tripsync.domain.enums.ScheduleOptionType +import com.tripsync.domain.enums.ScoreAxis import com.tripsync.domain.enums.SlotType import com.tripsync.domain.enums.TripRoomStatus import com.tripsync.domain.repository.PlaceRepository @@ -19,6 +23,7 @@ import com.tripsync.domain.repository.ScheduleRepository import com.tripsync.domain.repository.ScheduleSlotRepository import com.tripsync.domain.repository.TripRoomRepository import com.tripsync.domain.repository.UserRepository +import com.tripsync.web.dto.GenerateScheduleDto import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertTrue @@ -31,11 +36,15 @@ import java.math.BigDecimal import java.time.Instant import java.time.LocalDate import java.time.ZoneId +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit @SpringBootTest @ActiveProfiles("test") class ScheduleServiceTest( @Autowired private val scheduleService: ScheduleService, + @Autowired private val generationPersistenceService: ScheduleGenerationPersistenceService, @Autowired private val userRepository: UserRepository, @Autowired private val tripRoomRepository: TripRoomRepository, @Autowired private val roomMemberRepository: RoomMemberRepository, @@ -125,6 +134,51 @@ class ScheduleServiceTest( assertEquals("INVALID_REQUEST", error.code) } + @Test + fun `concurrent generated schedule saves allocate different versions`() { + val fixture = createFixture(isConfirmed = false) + val workers = 4 + val dto = GenerateScheduleDto( + destination = "충남", + tripDate = "2026-06-01", + startTime = "09:00", + endTime = "21:00", + ) + val options = listOf(generatedOption(fixture.host.id, fixture.newPlace.id)) + val ready = CountDownLatch(workers) + val start = CountDownLatch(1) + val pool = Executors.newFixedThreadPool(workers) + + try { + val futures = (1..workers).map { + pool.submit { + ready.countDown() + start.await() + generationPersistenceService.saveGeneratedOptions( + roomId = fixture.schedule.room.id, + dto = dto, + options = options, + personaValidationByType = emptyMap(), + ) + } + } + + assertTrue(ready.await(5, TimeUnit.SECONDS)) + start.countDown() + + val savedVersions = futures.map { it.get(20, TimeUnit.SECONDS).version }.sorted() + + assertEquals(listOf(2, 3, 4, 5), savedVersions) + val persistedVersions = scheduleRepository.findByRoomId(fixture.schedule.room.id) + .filter { it.id != fixture.schedule.id } + .map { it.version } + .sorted() + assertEquals(listOf(2, 3, 4, 5), persistedVersions) + } finally { + pool.shutdownNow() + } + } + private fun createFixture(isConfirmed: Boolean = true): Fixture { val suffix = System.nanoTime() val host = userRepository.save( @@ -186,6 +240,46 @@ class ScheduleServiceTest( return Fixture(host, schedule, newPlace, windowStart, windowEnd) } + private fun generatedOption(userId: Long, placeId: Long): ScheduleOptionDraft { + val start = Instant.parse("2026-06-01T00:00:00Z") + return ScheduleOptionDraft( + optionType = ScheduleOptionType.BALANCED, + label = "균형형", + summary = "동시 저장 테스트", + groupSatisfaction = 90, + slots = listOf( + ScheduleSlotDraft( + orderIndex = 1, + slotType = SlotType.COMMON, + targetUserId = null, + reasonAxis = ReasonAxis.COMMON, + reasonText = "공통 선호 장소", + startTime = start, + endTime = start.plusSeconds(3600), + placeId = placeId, + placeName = "태안 안면도 꽃지해수욕장", + placeAddress = "충청남도 태안군 안면읍 승언리", + isHiddenGem = false, + ) + ), + satisfactionByUser = listOf( + SatisfactionDraft( + userId = userId, + score = 90, + breakdown = SatisfactionDraft.Breakdown( + overall = 90, + byAxis = mapOf(ScoreAxis.MOBILITY to 90.0), + ), + ) + ), + llmProvider = "fallback", + llmAttemptedProvider = "fallback", + llmLatencyMs = null, + fallbackUsed = true, + llmFallbackReason = "test", + ) + } + private fun place(tourApiId: String, name: String, address: String): Place { return Place( tourApiId = tourApiId,