149 lines
9.9 KiB
Markdown
149 lines
9.9 KiB
Markdown
# PRD: 크리에이터 관리자 시리즈 상세 LazyInitializationException 수정
|
|
|
|
## 문서 정보
|
|
|
|
| 항목 | 내용 |
|
|
|---|---|
|
|
| 문서 상태 | 구현 기준 확정 |
|
|
| 작성일 | 2026-08-05 |
|
|
| 최종 수정일 | 2026-08-05 |
|
|
| 대상 기능 | 크리에이터 관리자 시리즈 상세 조회 |
|
|
| 관련 구현 계획 | `docs/20260805_크리에이터관리자_시리즈상세_LazyInitializationException_수정/plan-task.md` |
|
|
|
|
## 1. Overview
|
|
|
|
`spring.jpa.open-in-view=false` 환경에서 `GET /creator-admin/audio-content/series/{seriesId}` 호출 시
|
|
`Series.keywordList` 접근으로 발생하는 `LazyInitializationException`을 서비스 클래스의 기본 read-only 트랜잭션 경계로 방지한다.
|
|
|
|
## 2. Problem Statement
|
|
|
|
- `CreatorAdminContentSeriesController.getDetail()`은 `CreatorAdminContentSeriesService.getDetail()`에 상세 조회를 위임한다.
|
|
- `CreatorAdminContentSeriesService.getDetail()`에는 트랜잭션이 없으며,
|
|
`CreatorAdminContentSeriesRepository.findByIdAndCreatorId()`가 반환한 `Series`로 상세 DTO를 생성한다.
|
|
- 같은 서비스의 트랜잭션 없는 public 메서드는 모두 조회 기능이고, 데이터를 변경하는 public 메서드에는 이미 메서드 레벨
|
|
`@Transactional`이 적용되어 있다.
|
|
- `Series.keywordList`는 별도 fetch 설정이 없는 `@OneToMany`이므로 lazy 컬렉션이다.
|
|
- `Series.toDetailResponse()`은 `keywordList.map { it.keyword!!.tag }`를 실행한다.
|
|
- 운영·테스트 설정의 `spring.jpa.open-in-view=false` 때문에 리포지토리 호출 후 영속성 컨텍스트가 종료되고, DTO 변환 중
|
|
`org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: kr.co.vividnext.sodalive.creator.admin.content.series.Series.keywordList, could not initialize proxy - no Session`
|
|
예외가 발생한다.
|
|
- 기존 `LegacyCreatorAdminSeriesCharacterizationTest`는 클래스 레벨 `@Transactional`과 직접 생성한 서비스 객체를 사용하므로,
|
|
실제 Spring 서비스 프록시의 트랜잭션 유무에 따른 회귀를 검증하지 못한다.
|
|
|
|
문제를 해결했다는 판단은 외부 테스트 트랜잭션이 없는 OSIV off 통합 테스트에서 실제 Spring 서비스 프록시로 상세 조회 후
|
|
키워드가 포함된 기존 응답을 정상 생성하는 것으로 한다.
|
|
|
|
## 3. Goals
|
|
|
|
- OSIV off 환경에서도 소유한 시리즈 상세 조회가 `LazyInitializationException` 없이 완료된다.
|
|
- 서비스 클래스의 조회 기본값을 read-only 트랜잭션으로 두고, 시리즈 조회부터 `toDetailResponse()`의 lazy 컬렉션 접근까지
|
|
같은 영속성 컨텍스트에서 처리한다.
|
|
- 실제 Spring 서비스 프록시를 호출하는 통합 테스트로 수정 전 실패와 수정 후 성공을 검증한다.
|
|
- 기존 endpoint, 인증·소유권 검사, 성공 응답 필드와 값 형식을 유지한다.
|
|
|
|
## 4. Non-Goals
|
|
|
|
- `spring.jpa.open-in-view`를 활성화하지 않는다.
|
|
- `Series.keywordList`를 전역 eager fetch로 변경하지 않는다.
|
|
- 상세 조회 쿼리를 fetch join 또는 projection으로 재작성하지 않는다.
|
|
- `Series.toDetailResponse()` 또는 `GetCreatorAdminContentSeriesDetailResponse` 구조를 변경하지 않는다.
|
|
- 시리즈 목록·수정·콘텐츠 연결 등 다른 흐름을 함께 리팩터링하지 않는다.
|
|
|
|
## 5. Target Users and Permissions
|
|
|
|
- 대상 사용자: 본인이 소유한 시리즈 상세를 조회하는 `CREATOR` 역할의 크리에이터 관리자
|
|
- 인증·권한: 기존 `@PreAuthorize("hasRole('CREATOR')")`와 인증 회원 검사를 유지한다.
|
|
- 소유권: 기존 `findByIdAndCreatorId(id, creatorId)` 조건과 `creator.admin.series.invalid_access` 오류를 유지한다.
|
|
|
|
## 6. 기능 요구사항
|
|
|
|
| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 |
|
|
|---|---|---|---|---|
|
|
| `CASD-001` | 확정 | 서비스 클래스에 read-only 트랜잭션을 기본 적용하고 기존 쓰기 메서드의 메서드 레벨 트랜잭션을 유지한다. | 키워드가 있는 소유 시리즈 조회가 OSIV off 환경에서 예외 없이 완료되고 기존 쓰기 메서드 annotation이 보존된다. | `P1-T1` |
|
|
| `CASD-002` | 확정 | 기존 상세 조회 API 계약을 유지한다. | endpoint, 권한, 오류 key, 응답 DTO의 필드·형식이 바뀌지 않는다. | `P1-T1`, `P1-GATE` |
|
|
| `CASD-003` | 확정 | 테스트 외부 트랜잭션 없이 실제 서비스 프록시를 검증한다. | 수정 전 `Series.keywordList` 예외를 재현하고, 수정 후 같은 테스트가 통과한다. | `P1-T1` |
|
|
|
|
## 7. API 계약
|
|
|
|
| Method | Path | 변경 사항 |
|
|
|---|---|---|
|
|
| `GET` | `/creator-admin/audio-content/series/{seriesId}` | 공개 계약 변경 없음 |
|
|
|
|
- 성공 응답은 기존 `ApiResponse.ok(GetCreatorAdminContentSeriesDetailResponse)`를 유지한다.
|
|
- `seriesId`, `title`, `introduction`, `coverImageUrl`, `publishedDaysOfWeek`, `genre`, `keywords`, `isAdult`,
|
|
`state`, `writer`, `studio` 필드와 기존 문자열 변환 규칙을 유지한다.
|
|
- 인증 실패와 타 소유자·미존재 시리즈 오류 처리를 변경하지 않는다.
|
|
|
|
## 8. 해결 방안
|
|
|
|
`CreatorAdminContentSeriesService` 클래스에 `@Transactional(readOnly = true)`를 기본 적용한다.
|
|
|
|
```kotlin
|
|
@Service
|
|
@Transactional(readOnly = true)
|
|
class CreatorAdminContentSeriesService(
|
|
```
|
|
|
|
`getDetail()`의 `findByIdAndCreatorId()` 조회와 `series.toDetailResponse()` 변환이 이 경계 안에서 모두 끝나므로 `keywordList`를
|
|
정상 초기화할 수 있다. 다른 조회 메서드도 같은 기본 경계를 사용하며, 기존 쓰기 메서드의 메서드 레벨 `@Transactional`은
|
|
class-level `readOnly = true`를 쓰기 트랜잭션으로 재정의한다. 이미 서비스 파일에서 `Transactional`을 사용하고 있어 새 의존성이나
|
|
import는 필요하지 않다.
|
|
|
|
### 현재 메서드 분류
|
|
|
|
| 기본 read-only 트랜잭션을 사용하는 조회 메서드 | 메서드 레벨 쓰기 트랜잭션을 유지하는 메서드 |
|
|
|---|---|
|
|
| `getSeriesList()` | `createSeries()` |
|
|
| `getDetail()` | `modifySeries()` |
|
|
| `getSeriesContent()` | `addingContentToTheSeries()` |
|
|
| `searchContentNotInSeries()` | `removeContentInTheSeries()` |
|
|
| | `updateSeriesOrders()` |
|
|
|
|
### 제외한 대안
|
|
|
|
- OSIV 활성화: 요청 전체로 영속성 컨텍스트를 확장해 현재 저장소 정책을 되돌리므로 제외한다.
|
|
- `keywordList` eager 변경: 모든 `Series` 조회 비용에 영향을 주는 전역 변경이므로 제외한다.
|
|
- fetch join/projection 추가: 이 endpoint만의 결함을 고치는 데 리포지토리 계약과 쿼리 변경이 불필요하므로 제외한다.
|
|
- 컨트롤러 트랜잭션: 영속성 및 DTO 변환 경계는 서비스가 소유하는 기존 구조에 맞지 않으므로 제외한다.
|
|
- `getDetail()`에만 read-only 트랜잭션 적용: 현재 트랜잭션 없는 메서드가 모두 조회 기능이므로 class-level 기본값보다 반복과
|
|
누락 가능성이 크다.
|
|
|
|
## 9. 기술적 제약
|
|
|
|
- Kotlin, Java 17, Spring Boot 2.7.14, Spring Data JPA, Hibernate, JUnit 5, Gradle Wrapper를 사용한다.
|
|
- `src/main/resources/application.yml`과 `src/test/resources/application.yml`의 `spring.jpa.open-in-view=false`를 유지한다.
|
|
- 테스트는 클래스 외부 트랜잭션을 비활성화하고 fixture 생성만 `TransactionTemplate`로 분리한다.
|
|
- 실제 Spring `CreatorAdminContentSeriesService` 빈을 주입해 proxy annotation 동작을 검증한다.
|
|
- production code 변경은 `CreatorAdminContentSeriesService` class-level annotation 한 줄로 제한한다.
|
|
- `createSeries()`, `modifySeries()`, `addingContentToTheSeries()`, `removeContentInTheSeries()`, `updateSeriesOrders()`의 기존
|
|
메서드 레벨 `@Transactional`을 유지한다.
|
|
|
|
## 10. 성공 기준
|
|
|
|
- [ ] 수정 전 focused test가 `Series.keywordList`의 `LazyInitializationException`으로 실패한다.
|
|
- [ ] 서비스 클래스에 `@Transactional(readOnly = true)` 적용 후 같은 테스트가 통과한다.
|
|
- [ ] 응답의 `keywords`와 주요 기존 상세 필드 값이 fixture와 일치한다.
|
|
- [ ] 기존 소유권·상세 동작 characterization test와 `ktlintCheck`가 통과한다.
|
|
- [ ] `tasks --all`과 `git diff --check`가 통과한다.
|
|
- [ ] API 스키마, 엔티티 fetch 전략, repository query에는 변경이 없다.
|
|
- [ ] 모든 기존 쓰기 메서드의 메서드 레벨 `@Transactional`이 유지된다.
|
|
|
|
## 11. 요구사항 추적표
|
|
|
|
| 요구사항 | 계획 Phase | Goal | 자동 검증 |
|
|
|---|---:|---|---|
|
|
| `CASD-001`, `CASD-003` | 1 | `P1-T1` | `CreatorAdminContentSeriesServiceIntegrationTest` |
|
|
| `CASD-002` | 1 | `P1-T1`, `P1-GATE` | focused test, `LegacyCreatorAdminSeriesCharacterizationTest` |
|
|
|
|
## 12. Open Questions
|
|
|
|
없음. 운영 stack trace, entity mapping, 서비스 호출 경계와 OSIV 설정으로 원인과 최소 해결 범위가 확인됐다.
|
|
|
|
## 13. Decision Log
|
|
|
|
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|
|
|---|---|---|---|---|---|
|
|
| 2026-08-05 | `DEC-CASD-001` | 확정 | `CreatorAdminContentSeriesService.getDetail()`에 메서드 단위 read-only 트랜잭션을 적용한다. | DTO 변환이 이미 서비스 내부에 있어 한 줄로 lazy 접근 전체를 영속성 컨텍스트 안에 포함할 수 있다. | `CASD-001`, `CASD-002`, `P1-T1` |
|
|
| 2026-08-05 | `DEC-CASD-002` | 확정 | 실제 Spring 서비스 프록시와 외부 트랜잭션이 없는 통합 테스트로 회귀를 고정한다. | 기존 characterization test는 테스트 트랜잭션과 직접 생성한 서비스 때문에 annotation 회귀를 검증할 수 없다. | `CASD-003`, `P1-T1` |
|
|
| 2026-08-05 | `DEC-CASD-003` | 정정 | `DEC-CASD-001`의 메서드 단위 적용을 class-level `@Transactional(readOnly = true)` 적용으로 정정하고 기존 쓰기 메서드의 메서드 레벨 `@Transactional`을 유지한다. | 트랜잭션 없는 기존 public 메서드는 모두 조회 기능이며, 쓰기 메서드는 이미 메서드 레벨 annotation으로 read-only 기본값을 재정의한다. | `CASD-001`, `CASD-002`, `P1-T1` |
|