From 7167e6b3de54ec070315c784c9557d9e3ed57a8a Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 15 Jul 2026 18:56:49 +0900 Subject: [PATCH] =?UTF-8?q?fix(live):=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=EB=A5=BC=20=EB=B3=B4=EA=B0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 31 ++++- .../reservation/LiveReservationService.kt | 1 + .../LiveReservationServiceIntegrationTest.kt | 121 ++++++++++++++++++ 3 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationServiceIntegrationTest.kt diff --git a/docs/20260715_라이브_예약_LazyInitializationException_수정/plan-task.md b/docs/20260715_라이브_예약_LazyInitializationException_수정/plan-task.md index f60234d0..12ff8f2f 100644 --- a/docs/20260715_라이브_예약_LazyInitializationException_수정/plan-task.md +++ b/docs/20260715_라이브_예약_LazyInitializationException_수정/plan-task.md @@ -31,7 +31,7 @@ ### Phase 1: LazyInitializationException 재현 -- [ ] **Task 1.1: 라이브 예약 서비스 통합 실패 테스트 작성** +- [x] **Task 1.1: 라이브 예약 서비스 통합 실패 테스트 작성** - Create: `src/test/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationServiceIntegrationTest.kt` - RED: `@SpringBootTest`와 `@Transactional(propagation = Propagation.NOT_SUPPORTED)`를 사용해 테스트 자체 트랜잭션이 서비스 경계를 가리지 않게 한다. - RED: `EmbeddedRedisInitializer`를 명시적으로 적용하고 클래스 종료 후 Context를 정리한다. @@ -61,13 +61,17 @@ assertEquals(fixture.memberId, reservation.member!!.id) - 기대 결과: production code 수정 전 `reservation.room = room`에서 `LiveRoom.reservations`를 초기화하려다 `LazyInitializationException`으로 실패한다. - GREEN: 이 task에서는 production code를 변경하지 않는다. - REFACTOR: fixture와 결과 검증용 타입은 테스트 파일 내부 private data class로 제한하고, request context는 `@AfterEach`에서 해제한다. - - 검증 기록: 구현 중 누적한다. + - 검증 기록: + - 무엇: 트랜잭션 없는 테스트 메서드에서 detached `LiveRoom`을 다시 조회하는 실제 Spring `LiveReservationService` 빈을 호출했다. + - 왜: 테스트 트랜잭션이나 OSIV가 결함을 가리지 않은 상태에서 운영 오류와 같은 lazy 컬렉션 접근을 재현하기 위해서다. + - 어떻게: production code 수정 전 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`를 실행했다. + - 결과: `LiveReservationServiceIntegrationTest.kt:55`에서 `failed to lazily initialize a collection of role: kr.co.vividnext.sodalive.live.room.LiveRoom.reservations, could not initialize proxy - no Session`으로 실패해 RED를 확인했다. Stack trace는 `PersistentBag.add`를 가리켰다. --- ### Phase 2: 서비스 쓰기 트랜잭션 적용 -- [ ] **Task 2.1: `makeReservation()` 트랜잭션 경계 추가** +- [x] **Task 2.1: `makeReservation()` 트랜잭션 경계 추가** - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt` - Consumes: `LiveReservationService.makeReservation(request: MakeLiveReservationRequest, memberId: Long): MakeLiveReservationResponse` - Produces: 같은 메서드 시그니처와 응답을 유지하는 transactional 예약 생성 흐름 @@ -81,13 +85,17 @@ fun makeReservation(request: MakeLiveReservationRequest, memberId: Long): MakeLi - 통과 확인: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest` - 기대 결과: `BUILD SUCCESSFUL`이며 저장된 예약의 방 ID와 회원 ID가 fixture와 일치한다. - REFACTOR: 불필요한 fetch 전략, setter, 응답 로직 변경이 없는지 `git diff`로 확인한다. - - 검증 기록: 구현 중 누적한다. + - 검증 기록: + - 무엇: `LiveReservationService.makeReservation()`에 쓰기 `@Transactional`을 추가했다. + - 왜: 라이브방 조회부터 lazy 컬렉션 접근, 결제, 예약 저장까지 같은 영속성 컨텍스트와 트랜잭션에서 처리하기 위해서다. + - 어떻게: RED와 같은 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`를 재실행했다. + - 결과: `BUILD SUCCESSFUL in 51s`로 통과했고 저장된 예약의 방 ID와 회원 ID가 fixture와 일치했다. --- ### Phase 3: 회귀 및 문서 검증 -- [ ] **Task 3.1: 관련 테스트와 저장소 규칙 검증** +- [x] **Task 3.1: 관련 테스트와 저장소 규칙 검증** - Verify: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest` - Verify: `./gradlew --no-daemon ktlintCheck` - Verify: `./gradlew --no-daemon tasks --all` @@ -95,7 +103,13 @@ fun makeReservation(request: MakeLiveReservationRequest, memberId: Long): MakeLi - 기대 결과: 모든 Gradle 명령은 `BUILD SUCCESSFUL`, `git diff --check`는 출력 없이 exit code 0이다. - RED/GREEN: Phase 1과 Phase 2의 실패 및 통과 결과를 다시 확인한다. - REFACTOR: 이번 요청과 무관한 코드 및 문서 변경이 없는지 확인한다. - - 검증 기록: 구현 중 누적한다. + - 검증 기록: + - `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`: GREEN 확인 실행은 `BUILD SUCCESSFUL in 51s`, 최종 재실행은 `BUILD SUCCESSFUL in 11s`로 통과했다. + - `./gradlew --no-daemon ktlintCheck`: `BUILD SUCCESSFUL in 23s`로 통과했다. + - `./gradlew --no-daemon tasks --all`: `BUILD SUCCESSFUL in 6s`로 통과했다. + - `./gradlew --no-daemon test`: 전체 테스트 스위트가 `BUILD SUCCESSFUL in 5m 48s`로 통과했다. + - `git diff --check`: 출력 없이 통과했다. + - `git diff`: production code 변경이 `makeReservation()`의 `@Transactional` 한 줄뿐이며 fetch 전략, setter, API 응답 로직은 변경하지 않았음을 확인했다. --- @@ -106,3 +120,8 @@ fun makeReservation(request: MakeLiveReservationRequest, memberId: Long): MakeLi - sandbox 실행은 Gradle wrapper lock 파일 접근 제한으로 실패했다. - 승인 실행은 `BUILD SUCCESSFUL in 11s`로 통과했다. - 2026-07-15: `git diff --check`가 출력 없이 통과해 문서 공백 오류가 없음을 확인했다. +- 2026-07-15: production code 수정 전 단일 통합 테스트가 예상한 `LiveRoom.reservations`의 `LazyInitializationException`으로 실패해 RED를 확인했다. +- 2026-07-15: `makeReservation()`에 `@Transactional`을 추가한 뒤 같은 통합 테스트가 통과해 GREEN을 확인했다. +- 2026-07-15: 관련 단일 테스트, `ktlintCheck`, `tasks --all`, `git diff --check`가 최종 통과했다. +- 2026-07-15: 완료 선언 전 `./gradlew --no-daemon test --rerun-tasks --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`를 실행해 캐시 없이 `BUILD SUCCESSFUL in 3m 39s`를 확인했다. 출력된 deprecation/unchecked cast 경고는 기존 파일에서 발생했으며 이번 변경 파일과 무관하다. +- 2026-07-15: 브랜치 완료 전 전체 회귀 검증으로 `./gradlew --no-daemon test`를 실행해 `BUILD SUCCESSFUL in 5m 48s`를 확인했다. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt index ccce6c23..de3abde4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt @@ -29,6 +29,7 @@ class LiveReservationService( @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) { + @Transactional fun makeReservation(request: MakeLiveReservationRequest, memberId: Long): MakeLiveReservationResponse { val room = liveRoomRepository.findByIdOrNull(id = request.roomId) ?: throw SodaException(messageKey = "live.reservation.invalid_request_retry") diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationServiceIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationServiceIntegrationTest.kt new file mode 100644 index 00000000..888f22fe --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationServiceIntegrationTest.kt @@ -0,0 +1,121 @@ +package kr.co.vividnext.sodalive.live.reservation + +import kr.co.vividnext.sodalive.live.room.LiveRoom +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionTemplate +import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.context.request.ServletRequestAttributes +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:live-reservation-service-integration;" + + "MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class LiveReservationServiceIntegrationTest @Autowired constructor( + private val service: LiveReservationService, + private val repository: LiveReservationRepository, + private val transactionTemplate: TransactionTemplate, + private val entityManager: EntityManager +) { + @BeforeEach + fun setUpRequestContext() { + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(MockHttpServletRequest())) + } + + @AfterEach + fun resetRequestContext() { + RequestContextHolder.resetRequestAttributes() + } + + @Test + @DisplayName("라이브 예약은 OSIV off 환경에서도 lazy reservations 접근까지 완료된다") + fun shouldMakeReservationThroughTransactionalServiceProxyWhenOpenInViewIsDisabled() { + val fixture = createFixture() + + val response = service.makeReservation( + request = MakeLiveReservationRequest( + roomId = fixture.roomId, + container = "web", + timezone = "Asia/Seoul" + ), + memberId = fixture.memberId + ) + + val savedReservation = transactionTemplate.execute { + val reservation = repository.findById(response.reservationId).orElseThrow() + SavedReservation( + roomId = reservation.room!!.id!!, + memberId = reservation.member!!.id!! + ) + }!! + + assertEquals(fixture.roomId, savedReservation.roomId) + assertEquals(fixture.memberId, savedReservation.memberId) + } + + private fun createFixture(): Fixture { + return transactionTemplate.execute { + val creator = Member( + email = "live-reservation-creator@test.com", + password = "password", + nickname = "live-reservation-creator" + ) + entityManager.persist(creator) + + val member = Member( + email = "live-reservation-member@test.com", + password = "password", + nickname = "live-reservation-member" + ) + entityManager.persist(member) + + val room = LiveRoom( + title = "예약 라이브", + notice = "예약 라이브 안내", + beginDateTime = LocalDateTime.now().plusDays(1), + numberOfPeople = 10, + isAdult = false + ) + room.member = creator + entityManager.persist(room) + + entityManager.flush() + val fixture = Fixture( + roomId = room.id!!, + memberId = member.id!! + ) + entityManager.clear() + fixture + }!! + } + + private data class Fixture( + val roomId: Long, + val memberId: Long + ) + + private data class SavedReservation( + val roomId: Long, + val memberId: Long + ) +}