Files

13 KiB

라이브 예약 LazyInitializationException 수정 Plan/TASK

For agentic workers: REQUIRED SUB-SKILL: superpowers:executing-plans를 사용해 각 task를 순서대로 실행한다. 각 단계는 체크박스 상태와 검증 기록을 즉시 갱신한다.

Goal: OSIV off 환경에서 라이브 예약 생성이 LiveRoom.reservations lazy 컬렉션 초기화 예외 없이 완료되고, 결제와 예약 저장이 하나의 트랜잭션에 참여하게 한다.

Architecture: 기존 API와 엔티티 매핑은 유지한다. LiveReservationService.makeReservation()을 서비스 계층의 쓰기 트랜잭션 경계로 만들고, 트랜잭션이 없는 통합 테스트에서 실제 Spring 프록시를 호출해 detached LiveRoom의 lazy 컬렉션 접근 오류를 재현하고 수정한다.

Tech Stack: Kotlin, Java 17, Spring Boot 2.7.14, Spring Data JPA, Hibernate, JUnit 5, H2, Gradle Wrapper

Global Constraints

  • 공개 API URL, 요청 및 응답 스키마를 변경하지 않는다.
  • spring.jpa.open-in-view=false를 유지한다.
  • LiveRoom.reservations의 fetch 전략과 LiveReservation.room setter를 변경하지 않는다.
  • 예약 중복 방지, 결제 정책, 응답 포맷을 변경하지 않는다.
  • 변경은 PRD, Plan/TASK, LiveReservationService.makeReservation(), 해당 통합 테스트로 제한한다.

파일 구조 계획

  • Create: src/test/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationServiceIntegrationTest.kt
    • 실제 Spring 서비스 프록시와 JPA 엔티티로 OSIV off 예약 생성 경로를 검증한다.
  • Modify: src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt
    • makeReservation()에 쓰기 @Transactional을 추가한다.
  • Modify: docs/20260715_라이브_예약_LazyInitializationException_수정/plan-task.md
    • RED/GREEN/회귀 검증 결과를 누적 기록한다.

Phase 1: LazyInitializationException 재현

  • 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를 정리한다.
    • RED: TransactionTemplate 안에서 예약자, 크리에이터, 가격이 0인 예약 라이브방을 저장하고 EntityManager.flush(), EntityManager.clear()를 실행한다.
    • RED: MockHttpServletRequestRequestContextHolder에 등록해 request-scoped LangContext를 사용할 수 있게 한 뒤 실제 Spring 빈 service.makeReservation(...)을 호출한다.
    • RED 코드의 핵심 검증은 다음과 같다.
val response = service.makeReservation(
    request = MakeLiveReservationRequest(
        roomId = fixture.roomId,
        container = "web",
        timezone = "Asia/Seoul"
    ),
    memberId = fixture.memberId
)

val reservation = transactionTemplate.execute {
    repository.findById(response.reservationId).orElseThrow()
}!!

assertEquals(fixture.roomId, reservation.room!!.id)
assertEquals(fixture.memberId, reservation.member!!.id)
  • 실패 확인: ./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest
  • 기대 결과: 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() 트랜잭션 경계 추가
    • Modify: src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt
    • Consumes: LiveReservationService.makeReservation(request: MakeLiveReservationRequest, memberId: Long): MakeLiveReservationResponse
    • Produces: 같은 메서드 시그니처와 응답을 유지하는 transactional 예약 생성 흐름
    • GREEN: 기존 import인 org.springframework.transaction.annotation.Transactional을 사용해 다음 한 줄만 추가한다.
@Transactional
fun makeReservation(request: MakeLiveReservationRequest, memberId: Long): MakeLiveReservationResponse {
  • 통과 확인: ./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: 관련 테스트와 저장소 규칙 검증
    • Verify: ./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest
    • Verify: ./gradlew --no-daemon ktlintCheck
    • Verify: ./gradlew --no-daemon tasks --all
    • Verify: git diff --check
    • 기대 결과: 모든 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 응답 로직은 변경하지 않았음을 확인했다.

Phase 4: 유료 예약 원자성 회귀 보강

  • Task 4.1: 예약 저장 실패 시 결제와 예약의 전체 롤백 검증
    • Modify: src/test/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationServiceIntegrationTest.kt
    • Modify: docs/20260715_라이브_예약_LazyInitializationException_수정/plan-task.md
    • RED: 실제 CanPaymentService와 JPA 저장소를 사용하는 유료 예약 fixture를 만들고, LiveReservationRepository.save() spy에서 UseCan 증가를 확인한 뒤 DataIntegrityViolationException을 던지게 한다.
    • RED: 기존 production code가 이미 @Transactional을 포함하므로, 임시로 noRollbackFor = [DataIntegrityViolationException::class] 변이를 적용했을 때 결제 잔액 검증이 실패하는지 확인한 뒤 즉시 원복한다.
    • GREEN: 원래 @Transactional에서 같은 테스트가 통과하고 회원 캔 잔액, 충전 잔액, 사용 내역 건수와 예약 존재 여부가 호출 전 상태와 같은지 검증한다.
    • REFACTOR: 새 production code나 별도 추상화를 추가하지 않고 기존 통합 테스트 fixture만 최소 확장한다.
    • Verify: ./gradlew --no-daemon test --rerun-tasks --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest
    • 기대 결과: 2개 테스트가 실행되고 모두 통과하며, 테스트 결과 XML의 failures/errors가 0이다.
    • 범위 분리: 전체 테스트/clean build의 KAPT 산출물 재현성 문제는 이번 기능 변경에 포함하지 않고 별도 빌드 작업으로 분리한다.
    • 검증 기록:
      • 무엇: 유료 예약의 저장 실패 뒤 회원 캔, 충전 잔액, UseCan 건수와 예약 존재 여부를 별도 트랜잭션에서 다시 조회했다.
      • 왜: CanPaymentService.spendCan()의 기본 REQUIRED 전파가 외부 예약 트랜잭션에 참여해 결제 변경도 함께 롤백되는지 직접 확인하기 위해서다.
      • 결제 선행 확인: 예약 저장 실패를 발생시키기 직전에 같은 트랜잭션의 UseCan 건수가 호출 전보다 1 증가했는지 확인해 결제가 저장보다 먼저 실행됐음을 고정했다.
      • RED: makeReservation()에 임시 noRollbackFor = [DataIntegrityViolationException::class] 변이를 적용하고 새 단일 테스트를 실행했다. XML은 tests=1, failures=1이며 회원 캔 검증이 expected 100, actual 0으로 실패했다.
      • GREEN: 변이를 즉시 원복하고 같은 단일 테스트를 재실행했다. BUILD SUCCESSFUL in 58s, XML은 tests=1, failures=0, errors=0이다.
      • 리뷰 보정: 예약 저장 실패 직전 UseCan 증가 assertion을 추가한 뒤 정상 경계에서 단일 테스트가 BUILD SUCCESSFUL in 39s로 통과했다. 같은 변이를 다시 적용하면 XML tests=1, failures=1과 expected 100, actual 0을 재현했다.
      • 최종 GREEN: 변이를 원복하고 ./gradlew --no-daemon test --rerun-tasks --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest를 실행했다. BUILD SUCCESSFUL in 3m 47s, XML은 tests=2, failures=0, errors=0이다.
      • production code 원복 확인: git diff -- src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt가 출력 없이 종료됐다.

검증 기록

  • 계획 작성 시점에는 production code와 테스트를 변경하지 않았다.
  • 2026-07-15: 문서 변경 후 ./gradlew --no-daemon tasks --all 명령 유효성을 확인했다.
    • sandbox 실행은 Gradle wrapper lock 파일 접근 제한으로 실패했다.
    • 승인 실행은 BUILD SUCCESSFUL in 11s로 통과했다.
  • 2026-07-15: git diff --check가 출력 없이 통과해 문서 공백 오류가 없음을 확인했다.
  • 2026-07-15: production code 수정 전 단일 통합 테스트가 예상한 LiveRoom.reservationsLazyInitializationException으로 실패해 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를 확인했다.
  • 2026-07-15: 유료 예약 원자성 회귀 테스트 보강 후 같은 통합 테스트 클래스를 --rerun-tasks로 실행해 BUILD SUCCESSFUL in 3m 47s, XML tests=2, failures=0, errors=0을 확인했다.
  • 2026-07-15: ./gradlew --no-daemon ktlintCheckBUILD SUCCESSFUL in 22s, ./gradlew --no-daemon tasks --allBUILD SUCCESSFUL in 6s로 통과했다.
  • 2026-07-15: 최초 캐시 사용 단일 테스트 명령은 :test NO-SOURCE와 빈 테스트 산출물로 종료되어 검증 증거로 인정하지 않았다. --rerun-tasks 실행에서는 실제 테스트 XML을 확인했으며, 이 산출물 재현성 현상의 원인 조사와 수정은 별도 빌드 작업으로 분리한다.