20 KiB
관리자 시리즈 배너 LazyInitializationException 수정 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use
superpowers:executing-plans또는 동등한 TDD 절차로 task 단위 구현을 진행한다. 각 단계는 체크박스(- [ ])로 진행 상태를 갱신한다.
Goal: spring.jpa.open-in-view=false 환경에서 관리자 콘텐츠 시리즈 배너 목록 조회가 SeriesBanner.series lazy proxy 접근 때문에 실패하지 않게 한다.
Architecture: 기존 관리자 API 응답 DTO와 URL은 유지한다. ContentSeriesBannerService 클래스 레벨에 read-only 트랜잭션을 적용하고, getActiveBanners(...)가 그 경계 안에서 배너 조회와 SeriesBannerListPageResponse 생성을 완료하게 하여 lazy proxy 접근을 서비스 트랜잭션 경계 내부로 이동한다. 컨트롤러는 pageable 생성과 ApiResponse.ok(...) 래핑만 담당한다.
Tech Stack: Kotlin, Spring Boot 2.7.14, Java 17, Spring Data JPA, JUnit 5, Gradle Wrapper
0. 구현 전 확정 사항
- API 응답 스키마는 변경하지 않는다.
- OSIV 설정을 켜지 않는다.
SeriesBanner.series를 eager로 바꾸지 않는다.getDisplayBanners(...)등 공개 사용자용 시리즈 배너 조회 흐름은 변경하지 않는다.- 리포지토리 fetch join/projection 전면 개편은 이번 범위에서 제외한다.
- 기존 채팅 배너 수정 문서(
docs/20260629_관리자_채팅배너_LazyInitializationException_수정/prd.md)는 참고만 하며, 이번 대상은 관리자 콘텐츠 시리즈 배너이다. - 원인 확인:
AdminContentSeriesBannerController.getBannerList(...)가 컨트롤러에서 DTO 변환을 수행한다.SeriesBanner.series는 lazy association이다.SeriesBannerResponse.from(...)이banner.series.id/title을 읽는다.- OSIV off 환경에서는 컨트롤러 변환 시점에 영속성 컨텍스트가 없어 lazy proxy 초기화가 실패할 수 있다.
1. 파일 구조 계획
- Modify:
src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerService.ktgetActiveBanners(...)가imageHost를 받아SeriesBannerListPageResponse를 반환하도록 변경한다.- 클래스 레벨에
@Transactional(readOnly = true)를 추가한다. - 기존 쓰기 메서드의 메서드 레벨
@Transactional은 유지한다. SeriesBannerResponse.from(..., appendLanguageToSeriesTitle = true)로 기존 목록 응답 표시를 유지한다.
- Modify:
src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.ktgetBannerList(...)에서 컨트롤러 내 DTO 변환을 제거하고 서비스 응답을ApiResponse.ok(...)로 반환한다.
- Modify:
src/test/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerControllerTest.kt- mock 기반 목록 테스트
shouldAppendBannerLanguageToSeriesTitleInBannerList를 서비스 반환 타입 변경에 맞게 제거하거나 갱신한다. - 배너 등록/언어 역직렬화 단위 테스트는 유지한다.
- mock 기반 목록 테스트
- Create:
src/test/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerServiceIntegrationTest.kt- OSIV off JPA 환경에서 서비스가 관리자 시리즈 배너 목록 response를 생성할 때 lazy 초기화 예외가 발생하지 않는지 검증한다.
- Create:
src/test/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerControllerIntegrationTest.kt- 실제 Spring Context,
MockMvc, JPA fixture로/admin/audio-content/series/banner/listAPI 응답과 보안 권한을 검증한다.
- 실제 Spring Context,
- Verify:
src/test/resources/application.ymlspring.jpa.open-in-view: false테스트 설정을 그대로 사용한다.
Phase 1: LazyInitializationException 재현 테스트
- Task 1.1: 서비스 통합 실패 테스트 작성
- Test:
src/test/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerServiceIntegrationTest.kt - RED:
@SpringBootTest통합 테스트를 추가하고, 기존 통합 테스트처럼 테스트 전용 H2 datasource URL을 지정한다. - RED:
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])를 추가해 기존 Spring Boot 통합 테스트 Redis 패턴을 따른다. - RED: 테스트 클래스에는
@Transactional을 붙이지 않아 테스트 트랜잭션이 lazy 문제를 가리지 않게 한다. - RED:
Member,SeriesGenre,Series,SeriesBannerfixture를 저장한다.Series.member와Series.genre는 DB nullable 제약을 만족하도록 설정한다. - RED:
EntityManager.clear()로 영속성 컨텍스트를 비운 뒤service.getActiveBanners(PageRequest.of(0, 20), "https://cdn.test")를 호출한다. - RED:
totalCount,seriesId,seriesTitle,imagePath를 검증한다.
- Test:
- 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceIntegrationTest - 기대 결과: production code 수정 전에는 기존
getActiveBanners(pageable)시그니처와 반환 타입 불일치로 테스트가 실패한다. 메서드 시그니처를 먼저 테스트 기대 형태로 작성한 경우에는LazyInitializationException이 RED 기준이다.- 2026-06-29 실행:
./gradlew test --tests kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceIntegrationTest- 결과:
BUILD FAILED - RED 근거:
Too many arguments for public open fun getActiveBanners(pageable: Pageable): Page<SeriesBanner>및totalCount,seriesId,seriesTitleunresolved reference로 기존 서비스 계약 불일치를 확인했다.
- 결과:
- 2026-06-29 실행:
Phase 2: 서비스 응답 생성과 트랜잭션 보강
-
Task 2.1: 서비스 클래스 레벨 read-only 트랜잭션과 관리자 목록 response 반환 적용
- Modify:
src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerService.kt - GREEN:
ContentSeriesBannerService클래스 레벨에@Transactional(readOnly = true)를 추가한다. - GREEN:
getActiveBanners(pageable: Pageable, imageHost: String): SeriesBannerListPageResponse형태로 변경한다. - GREEN:
registerBanner,updateBanner,deleteBanner,updateBannerOrders의 기존 메서드 레벨@Transactional은 유지한다. - GREEN:
bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable)결과를 같은 메서드 안에서SeriesBannerListPageResponse로 변환한다. - GREEN:
SeriesBannerResponse.from(banner = it, imageHost = imageHost, appendLanguageToSeriesTitle = true)를 사용한다.
- Modify:
-
통과 확인:
./gradlew test --tests kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceIntegrationTest -
기대 결과:
BUILD SUCCESSFUL -
REFACTOR: 불필요한 import/format 변경이 생기지 않았는지 확인한다.
- 2026-06-29 실행:
./gradlew test --tests kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceIntegrationTest- 결과:
BUILD SUCCESSFUL - 검증 이유: OSIV off 및
EntityManager.clear()이후에도 서비스 트랜잭션 안에서SeriesBannerListPageResponse생성과SeriesBanner.serieslazy 접근이 완료되는지 확인했다.
- 결과:
- 2026-06-29 실행:
-
Task 2.2: 컨트롤러 목록 응답 조립 제거
- Modify:
src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt - GREEN:
getBannerList(...)에서val response = bannerService.getActiveBanners(pageable, imageHost)만 호출한다. - GREEN: 컨트롤러의
banners.content.map { ... }변환 코드를 제거한다. - GREEN: 사용하지 않게 된
SeriesBannerListPageResponse,SeriesBannerResponseimport를 제거한다.
- Modify:
-
통과 확인:
./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerTest -
기대 결과:
BUILD SUCCESSFUL -
REFACTOR: 컨트롤러의 다른 register/update/detail 응답 생성 흐름은 변경하지 않는다.
- 2026-06-29 실행:
./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerTest- 결과:
BUILD SUCCESSFUL - 검증 이유: 컨트롤러가 목록 DTO 조립을 하지 않고
bannerService.getActiveBanners(pageable, imageHost)결과를ApiResponse.ok(...)로 감싸는지 확인했다.
- 결과:
- 2026-06-29 실행:
Phase 3: 실제 Spring Context 기반 관리자 목록 API 검증
-
Task 3.1: mock 기반 목록 테스트 정리
- Modify:
src/test/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerControllerTest.kt - GREEN:
shouldAppendBannerLanguageToSeriesTitleInBannerList테스트를 제거하거나, 서비스가SeriesBannerListPageResponse를 반환하는 단위 테스트로 갱신한다. - GREEN: 목록 테스트 제거 또는 갱신 후 사용하지 않게 된
PageImpl,PageRequestimport를 제거한다.
- Modify:
-
통과 확인:
./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerTest -
기대 결과:
BUILD SUCCESSFUL -
REFACTOR: 기존 배너 등록 언어 테스트와
Lang역직렬화 테스트는 변경하지 않는다.- 2026-06-29 실행:
./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerTest- 결과:
BUILD SUCCESSFUL - 검증 이유: 목록 테스트를 서비스 반환 타입 기준으로 갱신했고, 기존 배너 등록 언어 테스트와
Lang역직렬화 테스트가 유지되는지 확인했다.
- 결과:
- 2026-06-29 실행:
-
Task 3.2: 관리자 목록 API 통합 실패 테스트 작성
- Create:
src/test/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerControllerIntegrationTest.kt - RED:
@SpringBootTest를 사용하고cloud.aws.cloud-front.host=https://cdn.test와 테스트 전용 H2 datasource URL을 지정한다. - RED:
@AutoConfigureMockMvc를 사용해 실제 controller/service/repository bean을 연결한다. - RED:
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])를 추가해 기존 Spring Boot 통합 테스트 Redis 패턴을 따른다. - RED: 테스트 클래스에는
@Transactional을 붙이지 않는다. 테스트 데이터 생성은TransactionTemplate안에서 수행하고EntityManager.flush(),EntityManager.clear()로 영속성 컨텍스트를 비운다. - RED:
Member,SeriesGenre,Series,SeriesBanner(lang = Lang.JA, imagePath = "banner/jp.png")를 저장한다. - RED:
MockMvc로GET /admin/audio-content/series/banner/list?page=0&size=20을 호출하고with(user("admin").roles("ADMIN"))로 관리자 권한을 부여한다. - RED:
$.success = true,$.data.totalCount = 1,$.data.content[0].seriesTitle = "series-admin-banner (일본어)",$.data.content[0].imagePath = "https://cdn.test/banner/jp.png"를 검증한다.
- Create:
-
실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerIntegrationTest -
기대 결과: production code 수정 전에는
LazyInitializationException또는 기존 서비스 시그니처/응답 생성 위치 불일치로 테스트가 실패한다.- 2026-06-29 production code 수정 전 실행:
./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerIntegrationTest- 결과:
BUILD FAILED - RED 근거: 서비스 통합 테스트와 동일하게 새
getActiveBanners(pageable, imageHost)계약 부재로 컴파일 실패했다.
- 결과:
- 2026-06-29 production code 수정 후 실행:
./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerIntegrationTest- 결과:
BUILD SUCCESSFUL - 검증 이유: 실제 Spring Context, MockMvc, JPA fixture, 관리자 권한으로
/admin/audio-content/series/banner/list가 OSIV off 환경에서success=true,totalCount=1, 언어 suffix, CDN 이미지 경로를 반환하는지 확인했다.
- 결과:
- 2026-06-29 production code 수정 전 실행:
-
Task 3.3: 관련 검증 실행 및 문서 기록
- Verify:
./gradlew test --tests kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceIntegrationTest - Verify:
./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerTest - Verify:
./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerIntegrationTest - Verify:
./gradlew --no-daemon ktlintCheck - Verify:
./gradlew --no-daemon tasks --all
- Verify:
-
문서 기록: 각 task 아래에 실행 명령, 결과, 검증 이유를 한국어로 누적한다.
-
기대 결과: 모든 명령이
BUILD SUCCESSFUL로 종료된다.- 2026-06-29 실행:
./gradlew test --tests kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceIntegrationTest- 결과:
BUILD SUCCESSFUL
- 결과:
- 2026-06-29 실행:
./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerTest- 결과:
BUILD SUCCESSFUL
- 결과:
- 2026-06-29 실행:
./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerIntegrationTest- 결과:
BUILD SUCCESSFUL
- 결과:
- 2026-06-29 추가 회귀 확인:
./gradlew test --tests kr.co.vividnext.sodalive.content.series.main.SeriesMainControllerTest- 결과:
BUILD SUCCESSFUL - 검증 이유: 공개 사용자용 시리즈 메인 흐름이 여전히
getDisplayBanners(...)를 사용하고 관리자용getActiveBanners(...)를 호출하지 않는지 확인했다.
- 결과:
- 2026-06-29 실행:
./gradlew --no-daemon ktlintCheck- 결과:
BUILD SUCCESSFUL
- 결과:
- 2026-06-29 실행:
./gradlew --no-daemon tasks --all- 결과:
BUILD SUCCESSFUL
- 결과:
- 2026-06-29 실행:
Phase 4: 리뷰 후속 상세 조회 LazyInitializationException 리스크 반영
-
Task 4.1: 서비스 상세 응답 생성 테스트 작성
- Test:
src/test/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerServiceIntegrationTest.kt - RED: OSIV off 환경에서
EntityManager.clear()이후service.getBannerDetailResponse(bannerId, "https://cdn.test")를 호출한다. - RED:
id,seriesId,seriesTitle,imagePath를 검증한다. - 2026-06-29 production code 수정 전 실행:
./gradlew test --tests kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceIntegrationTest- 결과:
BUILD FAILED - RED 근거:
getBannerDetailResponse가 아직 없어Unresolved reference: getBannerDetailResponse컴파일 실패를 확인했다.
- 결과:
- Test:
-
Task 4.2: 서비스 트랜잭션 내부 상세 DTO 변환 적용
- Modify:
src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerService.kt - GREEN:
getBannerDetailResponse(bannerId: Long, imageHost: String): SeriesBannerResponse를 추가한다. - GREEN: class-level
@Transactional(readOnly = true)경계 안에서getBannerById(...)조회와SeriesBannerResponse.from(...)변환을 완료한다. - 2026-06-29 실행:
./gradlew test --tests kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceIntegrationTest- 결과:
BUILD SUCCESSFUL - 검증 이유: 목록/상세 응답 모두 OSIV off 및 영속성 컨텍스트 clear 이후 lazy 초기화 예외 없이 생성되는지 확인했다.
- 결과:
- Modify:
-
Task 4.3: 컨트롤러 상세 응답 조립 제거 및 API 통합 검증
- Modify:
src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerControllerIntegrationTest.kt - GREEN:
getBannerDetail(...)에서val response = bannerService.getBannerDetailResponse(bannerId, imageHost)만 호출한다. - GREEN: MockMvc로
GET /admin/audio-content/series/banner/{bannerId}를 관리자 권한으로 호출하고success,id,seriesTitle,imagePath를 검증한다. - 2026-06-29 실행:
./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerIntegrationTest- 결과:
BUILD SUCCESSFUL - 검증 이유: 실제 Spring Context, MockMvc, JPA fixture로 상세 API가 OSIV off 환경에서 lazy 초기화 예외 없이 응답하는지 확인했다.
- 결과:
- Modify:
-
Task 4.4: mock 기반 상세 위임 테스트 갱신
- Modify:
src/test/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerControllerTest.kt - GREEN: 상세 조회 컨트롤러가
bannerService.getBannerDetailResponse(bannerId, imageHost)를 호출하고 결과를ApiResponse.ok(...)로 감싸는지 검증한다. - 2026-06-29 실행:
./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerTest- 결과:
BUILD SUCCESSFUL - 검증 이유: 컨트롤러 상세 조회의 DTO 조립 제거와 서비스 위임을 단위 수준에서 확인했다.
- 결과:
- Modify:
검증 기록
- 구현 전 문서 작성 단계에서는 아직 테스트를 실행하지 않았다.
- 2026-06-29: 문서 변경 후 명령 유효성 확인을 위해
./gradlew --no-daemon tasks --all을 실행했다.- 1차 실행: sandbox에서
/Users/klaus/.gradle/wrapper/dists/.../gradle-8.1.1-bin.zip.lck접근이 차단되어 실패했다. - escalated 재실행:
BUILD SUCCESSFUL을 확인했다.
- 1차 실행: sandbox에서
- 2026-06-29: 서비스 통합 테스트와 관리자 MockMvc 통합 테스트를 먼저 추가한 뒤 production code 수정 전 RED를 확인했다.
- 서비스 통합 테스트: 기존
getActiveBanners(pageable)시그니처 및Page<SeriesBanner>반환 타입과 새 기대 계약이 맞지 않아 컴파일 실패했다. - 관리자 MockMvc 통합 테스트: 동일한 서비스 계약 불일치로 컴파일 실패했다.
- 서비스 통합 테스트: 기존
- 2026-06-29:
ContentSeriesBannerService에 class-level@Transactional(readOnly = true)를 추가하고,getActiveBanners(pageable, imageHost)가SeriesBannerListPageResponse를 생성하도록 변경했다. - 2026-06-29:
AdminContentSeriesBannerController.getBannerList(...)에서 DTO 조립을 제거하고 서비스 응답을ApiResponse.ok(...)로 감싸도록 변경했다. - 2026-06-29: 다음 검증이 모두
BUILD SUCCESSFUL로 종료됐다../gradlew test --tests kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceIntegrationTest./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerTest./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerIntegrationTest./gradlew test --tests kr.co.vividnext.sodalive.content.series.main.SeriesMainControllerTest./gradlew --no-daemon ktlintCheck./gradlew --no-daemon tasks --all
- 2026-06-29: 리뷰에서 지적된 상세 조회 잔여 리스크를 후속 반영했다.
AdminContentSeriesBannerController.getBannerDetail(...)도 컨트롤러 DTO 변환을 제거하고ContentSeriesBannerService.getBannerDetailResponse(...)로 위임했다.- 서비스 통합 테스트와 MockMvc 통합 테스트에 상세 조회 OSIV off 회귀 검증을 추가했다.
./gradlew test --tests kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceIntegrationTest,./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerIntegrationTest,./gradlew test --tests kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerTest가 모두BUILD SUCCESSFUL로 종료됐다.