diff --git a/docs/20260805_크리에이터관리자_시리즈상세_LazyInitializationException_수정/plan-task.md b/docs/20260805_크리에이터관리자_시리즈상세_LazyInitializationException_수정/plan-task.md new file mode 100644 index 00000000..bdc4388c --- /dev/null +++ b/docs/20260805_크리에이터관리자_시리즈상세_LazyInitializationException_수정/plan-task.md @@ -0,0 +1,174 @@ +# 크리에이터 관리자 시리즈 상세 LazyInitializationException 수정 Plan/TASK + +| 문서 항목 | 내용 | +|---|---| +| 상태 | 구현 완료 | +| 작성일 | 2026-08-05 | +| 요구사항 기준 | `docs/20260805_크리에이터관리자_시리즈상세_LazyInitializationException_수정/prd.md` | +| API 기준 | 기존 `GET /creator-admin/audio-content/series/{seriesId}` 계약 유지 | +| 현재 Phase | Phase 1 완료 | +| 현재 활성 Goal | 없음, 구현 완료 | + +## 목표 + +OSIV off 환경에서 크리에이터 관리자가 본인 시리즈 상세를 조회할 때 `Series.keywordList` lazy 초기화 예외 없이 기존 응답을 받게 한다. + +## 현재 상태 + +| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 | +|---:|---|---:|---|---| +| 1 | 완료 | `1/1` | 없음 | 없음 | + +- 동시에 하나의 미완료 goal만 운용한다. +- 완료된 Task와 검증 기록은 되돌리거나 삭제하지 않는다. + +## 범위 + +### 포함 + +- `CreatorAdminContentSeriesService`의 class-level read-only 트랜잭션 경계 +- `Series.keywordList` lazy 예외를 재현하고 방지하는 서비스 통합 테스트 +- 기존 상세 응답과 소유권 동작의 영향 범위 회귀 검증 + +### 제외 + +- endpoint와 `GetCreatorAdminContentSeriesDetailResponse` 변경 +- OSIV, entity fetch 전략, repository query 변경 +- 다른 시리즈 조회·수정 흐름 리팩터링 +- 새 abstraction 또는 dependency 추가 + +## 기술적 제약 + +- Kotlin, Java 17, Spring Boot 2.7.14, Spring Data JPA, Hibernate, JUnit 5를 유지한다. +- production code는 `CreatorAdminContentSeriesService` class-level annotation 한 줄만 변경한다. +- 기존 쓰기 메서드 `createSeries()`, `modifySeries()`, `addingContentToTheSeries()`, `removeContentInTheSeries()`, + `updateSeriesOrders()`의 메서드 레벨 `@Transactional`은 유지해 class-level read-only 기본값을 재정의한다. +- 테스트는 실제 Spring 서비스 프록시를 사용하고 외부 테스트 트랜잭션으로 서비스 경계를 가리지 않는다. +- focused test부터 실행하고 직접 영향받는 characterization test까지만 회귀 범위를 확장한다. +- 전체 테스트는 class-level annotation 한 줄 변경과 targeted test로 영향 범위를 판정할 수 있으므로 기본적으로 생략한다. targeted test에서 + 범위를 설명할 수 없는 실패가 발생하거나 공통 경계 변경으로 확대될 때만 실행한다. + +## Phase 1: 상세 조회 트랜잭션 회귀 수정 + +**Phase 결과:** 크리에이터 관리자 시리즈 상세 조회가 OSIV off 환경에서 키워드를 포함한 기존 DTO를 정상 반환한다. + +**선행조건:** `CASD-001`~`CASD-003` 요구사항과 `DEC-CASD-002`, `DEC-CASD-003` 결정 확정. + +**Phase 완료 조건:** `P1-T1`과 `P1-GATE` 완료, focused·영향 범위 회귀 결과 기록. + +### 구현 항목 + +#### Task 1.1 상세 조회 lazy 예외 재현 및 최소 수정 + +**Goal 실행 `P1-T1`:** 서비스 클래스의 기본 read-only 트랜잭션 안에서 상세 조회가 키워드 lazy 컬렉션을 DTO로 변환하게 한다. + +- **시작 조건:** PRD 구현 기준 확정, production code 미수정 상태. +- **완료 증거:** RED/GREEN/REFACTOR 체크박스 완료, focused test 실제 실행 결과, class-level annotation 이외 production diff 없음, + 기존 쓰기 메서드 annotation 유지. +- **범위 밖:** OSIV·entity mapping·repository query·controller·response DTO 변경. + +**Files:** + +- Create: `src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesServiceIntegrationTest.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesService.kt` +- Modify: `docs/20260805_크리에이터관리자_시리즈상세_LazyInitializationException_수정/plan-task.md` +- Verify: `src/test/resources/application.yml` + +**Interfaces:** + +- Consumes: `CreatorAdminContentSeriesService.getDetail(id: Long, memberId: Long)`과 기존 `Series.toDetailResponse(imageHost)` +- Produces: 기존 시그니처·DTO를 유지하면서 class-level read-only 트랜잭션 안에서 완성된 + `GetCreatorAdminContentSeriesDetailResponse` + +- [x] **RED:** `@SpringBootTest`, `EmbeddedRedisInitializer`, 실제 `CreatorAdminContentSeriesService` 빈을 사용하는 통합 테스트를 작성한다. + 테스트 외부 트랜잭션은 사용하지 않고 `TransactionTemplate` 안에서 소유 회원, 장르, 시리즈, 해시태그와 `SeriesKeyword` fixture를 + 저장한 뒤 트랜잭션 밖에서 `service.getDetail()`을 호출해 `keywords`와 주요 상세 필드를 검증한다. +- [x] **RED 확인:** `./gradlew --no-daemon test --rerun-tasks --tests kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesServiceIntegrationTest` + 를 실행해 `Series.keywordList`의 `LazyInitializationException`으로 실패하는지 확인한다. 환경·fixture·컴파일 실패는 RED 증거로 + 인정하지 않고 먼저 바로잡는다. +- [x] **GREEN:** `CreatorAdminContentSeriesService` 클래스 선언 바로 위에 기존 import를 사용하는 + `@Transactional(readOnly = true)` 한 줄을 추가하고 기존 쓰기 메서드의 메서드 레벨 `@Transactional`을 유지한다. +- [x] **GREEN 확인:** RED와 같은 focused test를 다시 실행해 `BUILD SUCCESSFUL`과 fixture의 `keywords`, `seriesId`, + `publishedDaysOfWeek`, `state` 값 일치를 확인한다. +- [x] **REFACTOR:** 새 abstraction 없이 테스트 fixture의 중복만 파일 내부 private helper로 제한한다. focused test와 + `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.LegacyCreatorAdminSeriesCharacterizationTest` + 를 실행하고, production diff가 class-level annotation 한 줄이며 모든 기존 쓰기 메서드 annotation이 유지되는지 확인해 결과를 + 이 Task 아래에 기록한다. + +### 완료 조건 + +- [x] `P1-T1`의 RED/GREEN/REFACTOR와 완료 증거가 모두 충족됐다. +- [x] `CASD-001`~`CASD-003`이 자동 검증 결과로 추적된다. +- [x] API 계약, OSIV, entity mapping, repository query에 변경이 없다. + +### 검증 방법 + +#### Phase 1 Gate + +**Goal 실행 `P1-GATE`:** 상세 조회 수정의 기능·회귀·문서 범위를 최종 판정한다. + +- **시작 조건:** `P1-T1` 완료. +- **완료 증거:** 아래 명령 통과 및 실제 결과를 검증 기록에 누적. +- **범위 밖:** Gate 통과를 위한 테스트 완화와 관련 없는 코드 수정. + +```bash +./gradlew --no-daemon test --rerun-tasks --tests kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesServiceIntegrationTest +./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.LegacyCreatorAdminSeriesCharacterizationTest +./gradlew --no-daemon ktlintCheck +./gradlew --no-daemon tasks --all +git diff --check +./gradlew --no-daemon test +``` + +- [x] focused test가 실제 실행되고 failures/errors 0으로 통과한다. +- [x] 기존 시리즈 상세·소유권 characterization test가 통과한다. +- [x] `ktlintCheck`, `tasks --all`, `git diff --check`가 통과한다. +- [x] ultrawork verification 요구에 따라 전체 테스트를 실행하고 통과했다. +- [x] production code 변경이 `CreatorAdminContentSeriesService`의 class-level `@Transactional(readOnly = true)` 한 줄뿐이다. +- [x] 기존 쓰기 메서드 5개의 메서드 레벨 `@Transactional`이 유지된다. + +## 실행 순서 + +| 순서 | Goal | 완료 후 다음 Goal | +|---:|---|---| +| 1 | `P1-T1` | `P1-GATE` | +| 2 | `P1-GATE` | 구현 완료 | + +## Progress + +- 2026-08-05: 운영 stack trace와 controller → service → repository → entity DTO 변환 흐름을 확인했다. +- 2026-08-05: `Series.keywordList` lazy 접근과 트랜잭션 없는 `getDetail()`을 원인으로 확정했다. +- 2026-08-05: PRD와 Plan/TASK를 작성했으며 production code와 테스트는 아직 변경하지 않았다. +- 2026-08-05: 메서드 분류 재검토 결과 트랜잭션 없는 public 메서드 4개는 모두 조회이고, 쓰기 메서드 5개는 모두 메서드 레벨 + `@Transactional`을 보유함을 확인했다. 해결안을 `getDetail()` 메서드 단위에서 서비스 class-level read-only 기본값으로 정정했다. +- 2026-08-05: `P1-T1` RED/GREEN/REFACTOR를 완료했다. 다음 Goal은 `P1-GATE`이며 Gate 체크박스는 아직 미완료다. +- 2026-08-05: `P1-GATE`의 focused·characterization·정적 검사·Gradle task 확인·전체 테스트를 모두 통과해 Phase 1과 구현을 완료했다. + +## 검증 기록 + +- 문서 작성 시점에는 구현용 RED/GREEN 테스트를 실행하지 않았다. +- 2026-08-05: 문서 변경 후 `./gradlew --no-daemon tasks --all`로 계획에 사용한 Gradle 명령이 유효한지 확인했다. + - sandbox 실행은 `/Users/klaus/.gradle/wrapper/dists/.../gradle-8.1.1-bin.zip.lck` 접근 제한으로 실패했다. + - 승인 실행은 `BUILD SUCCESSFUL in 8s`로 통과했다. +- 2026-08-05: `git diff --check`가 출력 없이 통과했고, 구현 파일과 테스트 파일은 변경하지 않았음을 확인했다. +- 2026-08-05: class-level read-only 트랜잭션 기준으로 문서를 보완한 뒤 `./gradlew --no-daemon tasks --all`을 재실행해 + `BUILD SUCCESSFUL in 7s`를 확인했다. +- 2026-08-05: production 수정 전 focused test를 실행해 `BUILD FAILED in 6m 50s`와 + `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`을 확인했다. +- 2026-08-05: `CreatorAdminContentSeriesService`에 `@Transactional(readOnly = true)` 한 줄을 추가한 뒤 같은 focused test를 + 재실행해 `BUILD SUCCESSFUL in 4m 52s`를 확인했다. 테스트는 fixture의 `seriesId`, `title`, `introduction`, `coverImageUrl`, + `publishedDaysOfWeek`, `genre`, `keywords`, `isAdult`, `state`, `writer`, `studio` 값을 검증한다. +- 2026-08-05: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.LegacyCreatorAdminSeriesCharacterizationTest` + 를 실행해 `BUILD SUCCESSFUL in 47s`를 확인했다. +- 2026-08-05: production diff는 `CreatorAdminContentSeriesService` 클래스 선언 위의 `@Transactional(readOnly = true)` 한 줄뿐이며, + `createSeries()`, `modifySeries()`, `addingContentToTheSeries()`, `removeContentInTheSeries()`, `updateSeriesOrders()`의 기존 메서드 레벨 + `@Transactional` 5개가 유지됨을 확인했다. API 계약, OSIV, entity mapping, repository query 변경은 없다. +- 2026-08-05: `P1-GATE`에서 명령을 순차 실행해 다음 결과를 확인했다. + - `./gradlew --no-daemon test --rerun-tasks --tests kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesServiceIntegrationTest`: exit code 0, `BUILD SUCCESSFUL in 4m 44s`. + - `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.LegacyCreatorAdminSeriesCharacterizationTest`: exit code 0, `BUILD SUCCESSFUL in 41s`. + - `./gradlew --no-daemon ktlintCheck`: exit code 0, `BUILD SUCCESSFUL in 30s`. + - `./gradlew --no-daemon tasks --all`: exit code 0, `BUILD SUCCESSFUL in 7s`. + - `git diff --check`: exit code 0, 출력 없음. + - `./gradlew --no-daemon test`: exit code 0, `BUILD SUCCESSFUL in 6m 20s`. 기본 계획의 targeted 검증 범위를 넘어 전체 테스트를 실행한 이유는 ultrawork verification 요구사항 때문이다. +- 2026-08-05: Gate 검증 후 production diff가 class-level `@Transactional(readOnly = true)` 한 줄뿐이고, 새 테스트 파일은 + `CreatorAdminContentSeriesServiceIntegrationTest.kt` 하나이며, 기존 쓰기 메서드의 `@Transactional` 5개가 유지됨을 재확인했다. diff --git a/docs/20260805_크리에이터관리자_시리즈상세_LazyInitializationException_수정/prd.md b/docs/20260805_크리에이터관리자_시리즈상세_LazyInitializationException_수정/prd.md new file mode 100644 index 00000000..ee8dcf9e --- /dev/null +++ b/docs/20260805_크리에이터관리자_시리즈상세_LazyInitializationException_수정/prd.md @@ -0,0 +1,148 @@ +# 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` | diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesService.kt index 3d1c0318..5fa73f13 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesService.kt @@ -26,6 +26,7 @@ import org.springframework.transaction.annotation.Transactional import org.springframework.web.multipart.MultipartFile @Service +@Transactional(readOnly = true) class CreatorAdminContentSeriesService( private val repository: CreatorAdminContentSeriesRepository, private val genreRepository: CreatorAdminContentSeriesGenreRepository, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesServiceIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesServiceIntegrationTest.kt new file mode 100644 index 00000000..4d6ec7f1 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesServiceIntegrationTest.kt @@ -0,0 +1,96 @@ +package kr.co.vividnext.sodalive.creator.admin.content.series + +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.content.hashtag.HashTag +import kr.co.vividnext.sodalive.creator.admin.content.series.keyword.SeriesKeyword +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +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.test.context.ContextConfiguration +import org.springframework.transaction.support.TransactionTemplate +import javax.persistence.EntityManager + +@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://cdn.test"]) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class CreatorAdminContentSeriesServiceIntegrationTest @Autowired constructor( + private val service: CreatorAdminContentSeriesService, + private val entityManager: EntityManager, + private val transactionTemplate: TransactionTemplate +) { + @Test + @DisplayName("OSIV off 환경에서 크리에이터 관리자 시리즈 상세는 키워드와 기존 필드를 반환한다") + fun shouldReturnOwnedSeriesDetailWithKeywordsWhenOpenInViewIsDisabled() { + val fixtureIds = createSeriesFixture() + + val response = service.getDetail(id = fixtureIds.seriesId, memberId = fixtureIds.memberId) + + assertEquals(fixtureIds.seriesId, response.seriesId) + assertEquals("lazy series", response.title) + assertEquals("lazy series introduction", response.introduction) + assertEquals("https://cdn.test/series/lazy-series.png", response.coverImageUrl) + assertEquals("월, 수", response.publishedDaysOfWeek) + assertEquals("lazy genre", response.genre) + assertEquals("#lazy-keyword", response.keywords) + assertEquals(true, response.isAdult) + assertEquals("완결", response.state) + assertEquals("lazy writer", response.writer) + assertEquals("lazy studio", response.studio) + } + + private fun createSeriesFixture(): FixtureIds { + return transactionTemplate.execute { + val member = Member( + email = "creator-admin-series-detail@test.com", + password = "password", + nickname = "creator-admin-series-detail", + role = MemberRole.CREATOR + ) + entityManager.persist(member) + + val genre = SeriesGenre(genre = "lazy genre") + entityManager.persist(genre) + + val hashTag = HashTag("#lazy-keyword") + entityManager.persist(hashTag) + + val series = Series( + title = "lazy series", + introduction = "lazy series introduction", + state = SeriesState.COMPLETE, + writer = "lazy writer", + studio = "lazy studio", + publishedDaysOfWeek = mutableSetOf( + SeriesPublishedDaysOfWeek.MON, + SeriesPublishedDaysOfWeek.WED + ), + isAdult = true + ).apply { + this.member = member + this.genre = genre + coverImage = "series/lazy-series.png" + } + series.keywordList.add( + SeriesKeyword().apply { + this.series = series + keyword = hashTag + } + ) + entityManager.persist(series) + + entityManager.flush() + val fixtureIds = FixtureIds(memberId = member.id!!, seriesId = series.id!!) + entityManager.clear() + fixtureIds + }!! + } + + private data class FixtureIds( + val memberId: Long, + val seriesId: Long + ) +}