docs(series-banner): lazy 수정 계획을 추가한다
This commit is contained in:
@@ -0,0 +1,208 @@
|
|||||||
|
# 관리자 시리즈 배너 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.kt`
|
||||||
|
- `getActiveBanners(...)`가 `imageHost`를 받아 `SeriesBannerListPageResponse`를 반환하도록 변경한다.
|
||||||
|
- 클래스 레벨에 `@Transactional(readOnly = true)`를 추가한다.
|
||||||
|
- 기존 쓰기 메서드의 메서드 레벨 `@Transactional`은 유지한다.
|
||||||
|
- `SeriesBannerResponse.from(..., appendLanguageToSeriesTitle = true)`로 기존 목록 응답 표시를 유지한다.
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt`
|
||||||
|
- `getBannerList(...)`에서 컨트롤러 내 DTO 변환을 제거하고 서비스 응답을 `ApiResponse.ok(...)`로 반환한다.
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerControllerTest.kt`
|
||||||
|
- mock 기반 목록 테스트 `shouldAppendBannerLanguageToSeriesTitleInBannerList`를 서비스 반환 타입 변경에 맞게 제거하거나 갱신한다.
|
||||||
|
- 배너 등록/언어 역직렬화 단위 테스트는 유지한다.
|
||||||
|
- 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/list` API 응답과 보안 권한을 검증한다.
|
||||||
|
- Verify: `src/test/resources/application.yml`
|
||||||
|
- `spring.jpa.open-in-view: false` 테스트 설정을 그대로 사용한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 1: LazyInitializationException 재현 테스트
|
||||||
|
|
||||||
|
- [x] **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`, `SeriesBanner` fixture를 저장한다. `Series.member`와 `Series.genre`는 DB nullable 제약을 만족하도록 설정한다.
|
||||||
|
- RED: `EntityManager.clear()`로 영속성 컨텍스트를 비운 뒤 `service.getActiveBanners(PageRequest.of(0, 20), "https://cdn.test")`를 호출한다.
|
||||||
|
- RED: `totalCount`, `seriesId`, `seriesTitle`, `imagePath`를 검증한다.
|
||||||
|
- 실패 확인: `./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`, `seriesTitle` unresolved reference로 기존 서비스 계약 불일치를 확인했다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: 서비스 응답 생성과 트랜잭션 보강
|
||||||
|
|
||||||
|
- [x] **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)`를 사용한다.
|
||||||
|
- 통과 확인: `./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.series` lazy 접근이 완료되는지 확인했다.
|
||||||
|
|
||||||
|
- [x] **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`, `SeriesBannerResponse` import를 제거한다.
|
||||||
|
- 통과 확인: `./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(...)`로 감싸는지 확인했다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: 실제 Spring Context 기반 관리자 목록 API 검증
|
||||||
|
|
||||||
|
- [x] **Task 3.1: mock 기반 목록 테스트 정리**
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerControllerTest.kt`
|
||||||
|
- GREEN: `shouldAppendBannerLanguageToSeriesTitleInBannerList` 테스트를 제거하거나, 서비스가 `SeriesBannerListPageResponse`를 반환하는 단위 테스트로 갱신한다.
|
||||||
|
- GREEN: 목록 테스트 제거 또는 갱신 후 사용하지 않게 된 `PageImpl`, `PageRequest` import를 제거한다.
|
||||||
|
- 통과 확인: `./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` 역직렬화 테스트가 유지되는지 확인했다.
|
||||||
|
|
||||||
|
- [x] **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"`를 검증한다.
|
||||||
|
- 실패 확인: `./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 이미지 경로를 반환하는지 확인했다.
|
||||||
|
|
||||||
|
- [x] **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`
|
||||||
|
- 문서 기록: 각 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`
|
||||||
|
|
||||||
|
### Phase 4: 리뷰 후속 상세 조회 LazyInitializationException 리스크 반영
|
||||||
|
|
||||||
|
- [x] **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` 컴파일 실패를 확인했다.
|
||||||
|
|
||||||
|
- [x] **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 초기화 예외 없이 생성되는지 확인했다.
|
||||||
|
|
||||||
|
- [x] **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 초기화 예외 없이 응답하는지 확인했다.
|
||||||
|
|
||||||
|
- [x] **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 조립 제거와 서비스 위임을 단위 수준에서 확인했다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
|
||||||
|
- 구현 전 문서 작성 단계에서는 아직 테스트를 실행하지 않았다.
|
||||||
|
- 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`을 확인했다.
|
||||||
|
- 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`로 종료됐다.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# PRD: 관리자 시리즈 배너 LazyInitializationException 수정
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
`spring.jpa.open-in-view=false` 환경에서 관리자 콘텐츠 시리즈 배너 목록 조회 시 `SeriesBanner.series` lazy proxy 접근으로 발생하는 `LazyInitializationException`을 방지한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problem
|
||||||
|
- `AdminContentSeriesBannerController.getBannerList(...)`는 `bannerService.getActiveBanners(...)`로 `Page<SeriesBanner>`를 받은 뒤 컨트롤러에서 `SeriesBannerResponse.from(...)`으로 응답을 만든다.
|
||||||
|
- `SeriesBanner.series`는 `@ManyToOne(fetch = FetchType.LAZY)`이다.
|
||||||
|
- `SeriesBannerResponse.from(...)`은 `banner.series.id`, `banner.series.title`을 읽는다.
|
||||||
|
- `spring.jpa.open-in-view=false` 환경에서는 서비스/리포지토리 조회 후 영속성 컨텍스트가 닫힌 상태에서 컨트롤러가 lazy proxy를 초기화하려 하므로 `org.hibernate.LazyInitializationException: could not initialize proxy [kr.co.vividnext.sodalive.creator.admin.content.series.Series#124] - no Session`이 발생할 수 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
- OSIV off 환경에서도 관리자 콘텐츠 시리즈 배너 목록 조회가 예외 없이 응답된다.
|
||||||
|
- 사용자가 요청한 방향대로 `ContentSeriesBannerService.getActiveBanners(...)` 안에서 관리자 목록 response를 생성한다.
|
||||||
|
- 기존 관리자 콘텐츠 시리즈 배너 목록 API의 응답 스키마를 변경하지 않는다.
|
||||||
|
- 배너 언어 라벨을 시리즈 제목 뒤에 붙이는 기존 동작을 유지한다.
|
||||||
|
- 실패 재현 테스트를 먼저 작성하고, 최소 수정으로 통과시킨다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
- OSIV 설정을 다시 켜지 않는다.
|
||||||
|
- `SeriesBanner.series` fetch 전략을 전역 eager로 바꾸지 않는다.
|
||||||
|
- 관리자 콘텐츠 시리즈 배너 목록 API의 URL, 요청 파라미터, 응답 필드를 변경하지 않는다.
|
||||||
|
- 배너 등록/수정/삭제/정렬 API 동작을 변경하지 않는다.
|
||||||
|
- 공개 사용자용 시리즈 배너 조회(`getDisplayBanners`) 응답 구조를 변경하지 않는다.
|
||||||
|
- QueryDSL/projection 기반으로 배너 조회 전체를 재설계하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Target Users
|
||||||
|
- 관리자: 관리자 화면에서 콘텐츠 시리즈 배너 목록을 조회하고 정렬/수정 대상을 확인하는 사용자
|
||||||
|
- 운영자: OSIV off 운영 환경에서도 관리자 시리즈 배너 목록이 안정적으로 열리기를 기대하는 사용자
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. User Stories
|
||||||
|
- 관리자는 시리즈가 연결된 활성 배너 목록을 조회할 때 서버 오류를 만나지 않아야 한다.
|
||||||
|
- 관리자는 한국어/영어/일본어 배너가 섞여 있어도 시리즈 제목 뒤에 언어 라벨이 붙은 목록을 확인할 수 있어야 한다.
|
||||||
|
- 운영자는 OSIV off 설정을 유지하면서 lazy 초기화 예외를 회피할 수 있어야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Core Features
|
||||||
|
|
||||||
|
### Feature A. 관리자 시리즈 배너 목록 응답 생성 위치 이동
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- `ContentSeriesBannerService.getActiveBanners(...)`는 관리자 목록 응답인 `SeriesBannerListPageResponse`를 생성해 반환한다.
|
||||||
|
- `ContentSeriesBannerService`는 클래스 레벨 `@Transactional(readOnly = true)`로 조회 기본 트랜잭션을 제공하고, `getActiveBanners(...)`는 그 경계 안에서 배너 조회와 `SeriesBannerResponse.from(...)` 변환을 완료한다.
|
||||||
|
- `SeriesBannerResponse.from(...)` 호출 시 `appendLanguageToSeriesTitle = true`를 유지한다.
|
||||||
|
- `AdminContentSeriesBannerController.getBannerList(...)`는 pageable 생성 후 서비스가 만든 response를 그대로 `ApiResponse.ok(...)`로 감싼다.
|
||||||
|
- 기존 `SeriesBannerListPageResponse.totalCount`, `content[].id`, `content[].imagePath`, `content[].seriesId`, `content[].seriesTitle` 필드는 유지한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 활성 배너가 없으면 `totalCount = 0`, `content = []`를 반환한다.
|
||||||
|
- 배너 언어가 `KO`, `EN`, `JA`인 경우 기존처럼 각각 `한국어`, `영어`, `일본어` 라벨을 시리즈 제목 뒤에 붙인다.
|
||||||
|
- 이미지 경로 조합은 기존처럼 `"$imageHost/${banner.imagePath}"` 형식을 유지한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Technical Constraints
|
||||||
|
- Kotlin + Spring Boot 2.7.14 + Java 17 + Spring Data JPA 기준으로 구현한다.
|
||||||
|
- 테스트 환경의 `spring.jpa.open-in-view=false` 설정을 유지한다.
|
||||||
|
- `ContentSeriesBannerService` 클래스 레벨에 `@Transactional(readOnly = true)`를 사용하고, 기존 쓰기 메서드의 메서드 레벨 `@Transactional`은 유지한다.
|
||||||
|
- 변경 범위는 관리자 콘텐츠 시리즈 배너 목록 조회 흐름과 해당 테스트로 제한한다.
|
||||||
|
- lazy proxy 재현은 실제 JPA 환경에서 확인할 수 있도록 서비스 통합 테스트로 검증한다.
|
||||||
|
- 관리자 목록 API 응답은 mock 기반 컨트롤러 테스트가 아니라 실제 Spring Context, `MockMvc`, JPA fixture를 연결한 통합 테스트로 검증한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Metrics
|
||||||
|
- `ContentSeriesBannerServiceIntegrationTest`에서 OSIV off 조건의 관리자 시리즈 배너 목록 응답 생성 테스트가 통과한다.
|
||||||
|
- `AdminContentSeriesBannerControllerIntegrationTest`의 관리자 시리즈 배너 목록 API 테스트가 통과한다.
|
||||||
|
- 관련 단일 테스트와 `ktlintCheck`가 통과한다.
|
||||||
Reference in New Issue
Block a user