fix(content): 크리에이터 관리자 시리즈 상세 조회 예외를 수정한다

This commit is contained in:
2026-08-05 19:50:15 +09:00
parent 579a48c2f1
commit 8538880d81
4 changed files with 419 additions and 0 deletions

View File

@@ -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개가 유지됨을 재확인했다.

View File

@@ -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` |

View File

@@ -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,

View File

@@ -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
)
}