# 크리에이터 채널 시리즈 탭 API Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다. **Goal:** 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/series`로 크리에이터 채널 시리즈 탭의 전체 시리즈 개수와 정렬/페이징된 시리즈 목록을 조회할 수 있게 한다. **Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.series` 조립 계층에 둔다. 시리즈 탭 조회 service, 순수 fallback/page/rate/day-of-week 정책, tab domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.series` 하위에 두고 `v2.api.*`에 의존하지 않는다. 기존 오디오 탭의 `ContentSort`, `CreatorChannelPage`, 인증/차단/성인 콘텐츠 노출 정책 흐름을 재사용하되, 홈 API의 `CreatorChannelSeries`는 확장하지 않는다. **Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper --- ## 0. 구현 전 확정 사항 - API endpoint: `GET /api/v2/creator-channels/{creatorId}/series` - 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다. - request: - path variable: `creatorId` - query parameter: `sort`, `required = false`, 기본값/fallback `LATEST` - query parameter: `page`, `required = false`, 기본값 `0`, `page < 0`이면 `0`으로 fallback - query parameter: `size`, `required = false`, 기본값 `20`, `size < 20`이면 `20`, `size > 50`이면 `50`으로 fallback - controller는 invalid `sort` fallback을 위해 `sort: String?`으로 받고 service/facade 경계에서 `ContentSort`로 보정한다. - response: - `seriesCount`: sort-bar에 표시할 조회 가능한 전체 시리즈 개수 - `series`: 시리즈 목록 - `sort`: 실제 적용한 `ContentSort` - `page`: fallback 보정 후 실제 적용된 page index - `size`: fallback 보정 후 실제 적용된 page size - `hasNext`: 다음 page 존재 여부 - series item: - `seriesId`, `title`, `coverImageUrl`, `publishedDaysOfWeek`, `isOriginal`, `isAdult`, `isProceeding`, `contentCount` - 조회자가 해당 시리즈의 크리에이터가 아니면 `purchasedContentCount`, `paidContentCount`, `purchasedPaidContentRate`를 계산한다. - 조회자가 해당 시리즈의 크리에이터이면 `purchasedContentCount`, `paidContentCount`, `purchasedPaidContentRate`는 `null`이다. - `purchasedPaidContentRate`: `Int?`, 비크리에이터 조회 시 `paidContentCount == 0`이면 `0`, 그 외 `(purchasedContentCount * 100) / paidContentCount`로 계산하고 소수점 이하는 버린다. - 공개 콘텐츠 기준: `AudioContent.isActive == true`, `AudioContent.duration != null`, `AudioContent.releaseDate != null`, `AudioContent.releaseDate <= now`. - 공개 시리즈 기준: `Series.isActive == true`, `Series.member.id == creatorId`. - `coverImageUrl`은 `Series.coverImage`를 `String?.toCdnUrl(cloudFrontHost)`로 변환한 값이다. 커버 이미지 경로가 없거나 blank이면 `null`로 내려준다. - 성인 콘텐츠는 조회자의 성인 콘텐츠 노출 정책이 false이면 목록과 count에서 제외한다. - 시리즈명은 `LangContext.lang.code` 기준으로 `SeriesTranslation`을 우선하고, 없거나 빈 문자열이면 `Series.title` 원문으로 fallback한다. - 연재 요일: - `RANDOM`이 포함되면 다른 요일을 무시하고 랜덤 문구만 반환한다. - 랜덤 문구: `ko=랜덤`, `en=Random`, `ja=ランダム` - 7개 요일이 모두 있으면 `ko=매일`, `en=Every day`, `ja=毎日` - 그 외 `ko=매주 월, 목, 토`, `en=Every Mon, Thu, Sat`, `ja=毎週 月, 木, 土` 형식 - 정렬: - `LATEST`: 대표 `releaseDate desc`, 대표 `price desc`, `series.id desc` - `POPULAR`: 시리즈 콘텐츠의 `orders.can` 합계 desc, 대표 `releaseDate desc`, `series.id desc`; `orders.is_active = true`만 포함 - `OWNED`: 조회자가 유효하게 소장/대여 중인 시리즈 콘텐츠 개수 desc, 대표 `releaseDate desc`, `series.id desc` - `PRICE_HIGH`: 대표 `price desc`, 대표 `releaseDate desc`, `series.id desc` - `PRICE_LOW`: 대표 `price asc`, 대표 `releaseDate desc`, `series.id desc` - 대표값: - 대표 `releaseDate`: 각 시리즈의 조회 가능한 공개 콘텐츠 중 가장 최근 `releaseDate` - `price desc` 대표값: 각 시리즈의 조회 가능한 공개 콘텐츠 중 가장 높은 가격 - `price asc` 대표값: 각 시리즈의 조회 가능한 공개 콘텐츠 중 가장 낮은 가격 --- ## 1. 파일 구조 계획 ### 시리즈 탭 신규 API 조립 계층 - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesController.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacade.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/dto/CreatorChannelSeriesTabResponse.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesEndToEndTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt` ### 시리즈 탭 도메인 조회 계층 - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesTab.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicy.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/port/out/CreatorChannelSeriesQueryPort.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/CreatorChannelSeriesQueryRepository.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicyTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt` ### 기존 파일 확인/재사용 - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSort.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioQueryPolicy.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/Series.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/SeriesContent.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt` ### 문서 산출물 - Create: `docs/20260620_크리에이터_채널_시리즈_탭_API/plan-task.md` - Verify: `docs/20260620_크리에이터_채널_시리즈_탭_API/prd.md` --- ## 2. Response data class 초안 구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/dto/CreatorChannelSeriesTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다. ```kotlin package kr.co.vividnext.sodalive.v2.api.creator.channel.series.dto import com.fasterxml.jackson.annotation.JsonProperty import kr.co.vividnext.sodalive.v2.common.domain.ContentSort import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeries import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesTab data class CreatorChannelSeriesTabResponse( val seriesCount: Int, val series: List, val sort: ContentSort, val page: Int, val size: Int, @JsonProperty("hasNext") val hasNext: Boolean ) { companion object { fun from(tab: CreatorChannelSeriesTab): CreatorChannelSeriesTabResponse { return CreatorChannelSeriesTabResponse( seriesCount = tab.seriesCount, series = tab.series.map(CreatorChannelSeriesResponse::from), sort = tab.sort, page = tab.page.page, size = tab.page.size, hasNext = tab.hasNext ) } } } data class CreatorChannelSeriesResponse( val seriesId: Long, val title: String, val coverImageUrl: String?, val publishedDaysOfWeek: String, @JsonProperty("isOriginal") val isOriginal: Boolean, @JsonProperty("isAdult") val isAdult: Boolean, @JsonProperty("isProceeding") val isProceeding: Boolean, val contentCount: Int, val purchasedContentCount: Int?, val paidContentCount: Int?, val purchasedPaidContentRate: Int? ) { companion object { fun from(series: CreatorChannelSeries): CreatorChannelSeriesResponse { return CreatorChannelSeriesResponse( seriesId = series.seriesId, title = series.title, coverImageUrl = series.coverImageUrl, publishedDaysOfWeek = series.publishedDaysOfWeek, isOriginal = series.isOriginal, isAdult = series.isAdult, isProceeding = series.isProceeding, contentCount = series.contentCount, purchasedContentCount = series.purchasedContentCount, paidContentCount = series.paidContentCount, purchasedPaidContentRate = series.purchasedPaidContentRate ) } } } ``` --- ## 3. Domain / Port 초안 구현 시 아래 형태를 기준으로 추가한다. API DTO가 domain model을 참조하지만 domain/port는 API DTO를 참조하지 않는다. ```kotlin package kr.co.vividnext.sodalive.v2.creator.channel.series.domain import kr.co.vividnext.sodalive.v2.common.domain.ContentSort import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage data class CreatorChannelSeriesTab( val seriesCount: Int, val series: List, val sort: ContentSort, val page: CreatorChannelPage, val hasNext: Boolean ) data class CreatorChannelSeries( val seriesId: Long, val title: String, val coverImageUrl: String?, val publishedDaysOfWeek: String, val isOriginal: Boolean, val isAdult: Boolean, val isProceeding: Boolean, val contentCount: Int, val purchasedContentCount: Int?, val paidContentCount: Int?, val purchasedPaidContentRate: Int? ) ``` ```kotlin package kr.co.vividnext.sodalive.v2.creator.channel.series.port.out import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.v2.common.domain.ContentSort import java.time.LocalDateTime interface CreatorChannelSeriesQueryPort { fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelSeriesCreatorRecord? fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean fun countSeries(creatorId: Long, now: LocalDateTime, canViewAdultContent: Boolean): Int fun findSeries( creatorId: Long, viewerId: Long, now: LocalDateTime, canViewAdultContent: Boolean, sort: ContentSort, locale: String, offset: Long, limit: Int ): List } data class CreatorChannelSeriesCreatorRecord( val creatorId: Long, val role: MemberRole, val nickname: String ) data class CreatorChannelSeriesRecord( val seriesId: Long, val title: String, val coverImagePath: String?, val publishedDaysOfWeek: Set, val isOriginal: Boolean, val isAdult: Boolean, val state: SeriesState, val contentCount: Int, val purchasedContentCount: Int?, val paidContentCount: Int? ) ``` --- ## 4. 작업 계획 ### Phase 1: 순수 정책과 도메인 모델 추가 - [x] **Task 1.1: `CreatorChannelSeriesQueryPolicy` 테스트 작성** - Files: - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicyTest.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicy.kt` - RED: 아래 케이스를 테스트로 먼저 작성한다. - `sort == null`, `UNKNOWN`은 `ContentSort.LATEST`로 fallback한다. - `page = -1`, `size = 10`은 `page=0`, `size=20`, `fetchLimit=21`이 된다. - `page = 2`, `size = 100`은 `page=2`, `size=50`, `offset=100`, `fetchLimit=51`이 된다. - `limitItems`는 `size`만큼만 남기고 `hasNext`는 `fetched.size > size`로 계산한다. - 구매율은 `paidContentCount == 0`이면 `0`, `paid=4`, `purchased=3`이면 `75`, `paid=3`, `purchased=2`이면 `66`이다. - `publishedDaysOfWeek`는 `RANDOM` 포함 시 다른 요일을 무시하고 locale별 랜덤 문구를 반환한다. - 7개 요일은 locale별 매일 문구를 반환한다. - 일부 요일은 `SUN`부터 `SAT` 순서로 locale별 `매주/Every/毎週` 문구를 반환한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest` - GREEN: `CreatorChannelAudioQueryPolicy`와 같은 page/sort/list 정책을 구현하되 `purchaseRate`는 `Int`를 반환한다. `publishedDaysOfWeekText(days, locale)`는 `ko`, `en`, `ja` 명시 매핑으로 구현한다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest` - REFACTOR: `CreatorChannelAudioQueryPolicy`를 수정하지 않는다. 중복 제거는 이번 범위에서 하지 않는다. - 구현 기록(2026-06-20): `CreatorChannelSeriesQueryPolicyTest`를 추가해 sort/page/list/구매율/연재 요일 정책을 문서 명세대로 고정했다. - RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest` 실행 시 신규 `CreatorChannelSeriesQueryPolicy`, domain, port 타입 부재로 `compileTestKotlin` 실패를 확인했다. - GREEN: `CreatorChannelSeriesQueryPolicy`를 추가하고 동일 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다. - [x] **Task 1.2: 시리즈 탭 domain model과 port record 추가** - Files: - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesTab.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/port/out/CreatorChannelSeriesQueryPort.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicyTest.kt` - RED: Task 1.1 테스트 컴파일이 새 domain/port 타입 부재로 실패하는 상태를 확인한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest` - GREEN: 문서의 Domain / Port 초안 그대로 타입을 추가한다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest` - REFACTOR: domain/port가 `kr.co.vividnext.sodalive.v2.api.*` 패키지를 import하지 않는지 확인한다. - 구현 기록(2026-06-20): 문서의 Domain / Port 초안 기준으로 `CreatorChannelSeriesTab`, `CreatorChannelSeriesQueryPort`와 관련 record를 추가했다. - 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. - 의존성 확인: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series` 실행 결과 출력이 없어 domain/port의 API 패키지 의존이 없음을 확인했다. ### Phase 2: API 조립 계층 추가 - [x] **Task 2.1: 응답 DTO mapper 테스트와 DTO 추가** - Files: - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/dto/CreatorChannelSeriesTabResponse.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt` - RED: facade 테스트 또는 DTO mapper 테스트에서 `CreatorChannelSeriesTabResponse.from` 결과가 `seriesCount`, `series`, `sort`, `page`, `size`, `hasNext`, `coverImageUrl`, `purchasedPaidContentRate: Int?`를 그대로 매핑하는지 기대한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest` - GREEN: Response data class 초안대로 DTO와 mapper를 추가한다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest` - REFACTOR: Jackson boolean property는 `@JsonProperty("isOriginal")`, `@JsonProperty("isAdult")`, `@JsonProperty("isProceeding")`, `@JsonProperty("hasNext")`로 명시한다. - 구현 기록(2026-06-20): `CreatorChannelSeriesFacadeTest`에 DTO mapper 검증을 추가하고 `CreatorChannelSeriesTabResponse`, `CreatorChannelSeriesResponse`를 초안대로 추가했다. - RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest` 실행 시 DTO/facade/query service 타입 부재로 `compileTestKotlin` 실패를 확인했다. - GREEN: 동일 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다. - [x] **Task 2.2: Facade 추가** - Files: - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacade.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt` - RED: `CreatorChannelSeriesFacade.getSeriesTab(creatorId, viewer, sort, page, size, now)`가 query service 호출 결과를 `CreatorChannelSeriesTabResponse`로 변환하는 테스트를 작성한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest` - GREEN: `CreatorChannelAudioFacade`와 같은 형태로 read-only service를 추가한다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest` - REFACTOR: facade는 HTTP 예외 처리와 repository 세부 사항을 알지 않도록 query service에 위임한다. - 구현 기록(2026-06-20): `CreatorChannelSeriesFacade.getSeriesTab`을 추가해 query service 결과를 공개 DTO로 변환하도록 했다. - RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest` 실행 시 facade/query service 타입 부재로 `compileTestKotlin` 실패를 확인했다. - GREEN: 동일 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다. - [x] **Task 2.3: Controller 추가** - Files: - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesController.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt` - RED: MockMvc 테스트를 작성한다. - `GET /api/v2/creator-channels/{creatorId}/series?sort=POPULAR&page=1&size=20` 요청이 facade에 `sort="POPULAR"`, `page=1`, `size=20`을 전달한다. - 응답 JSON에 `seriesCount`, `series[0].seriesId`, `series[0].coverImageUrl`, `series[0].publishedDaysOfWeek`, `series[0].purchasedPaidContentRate`, `sort`, `page`, `size`, `hasNext`가 있다. - 비회원 요청은 `common.error.bad_credentials` 계열 오류를 반환한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest` - GREEN: `CreatorChannelAudioController`와 같은 `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/series")`, `requireMember` 구조로 controller를 추가한다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest` - REFACTOR: `sort`는 `String?`으로 받고 `ContentSort` enum binding 오류가 발생하지 않게 한다. - 구현 기록(2026-06-20): `CreatorChannelSeriesController`와 MockMvc 테스트를 추가해 인증 회원 요청, query parameter 전달, invalid sort 전달, 비회원 거부를 검증했다. - RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest` 실행 시 Kotlin incremental cache 손상(`Malformed input`)으로 중단되어 controller 부재 메시지까지 도달하지 못했다. - GREEN: `./gradlew clean test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. ### Phase 3: 도메인 조회 서비스 추가 - [x] **Task 3.1: QueryService 인증/차단/creator 검증 테스트 작성** - Files: - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt` - RED: 아래 서비스 테스트를 작성한다. - `findCreator`가 `null`이면 `member.validation.user_not_found` 예외를 던진다. - creator role이 `CREATOR`가 아니면 `member.validation.creator_not_found` 예외를 던진다. - 차단 관계가 있으면 기존 크리에이터 채널과 같은 blocked access 예외를 던진다. - 정상 조회 시 policy가 보정한 sort/page를 사용해 port를 호출한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest` - GREEN: `CreatorChannelAudioQueryService` 흐름을 기준으로 `ObjectProvider`, `MemberContentPreferenceService`, `SodaMessageSource`, `LangContext`, `cloud.aws.cloud-front.host`를 주입받는 service를 추가한다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest` - REFACTOR: 서비스는 repository record의 `coverImagePath`를 `String?.toCdnUrl(cloudFrontHost)`로 변환해 domain의 `coverImageUrl`에 채운다. - 구현 기록(2026-06-20): `CreatorChannelSeriesQueryServiceTest`에 creator 조회 실패, creator role 검증, 차단 예외, sort/page fallback과 port 호출 검증을 추가하고 service를 구현했다. - RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest` 실행 시 query service 타입 부재로 `compileTestKotlin` 실패를 확인했다. - GREEN: 동일 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다. - [x] **Task 3.2: QueryService 응답 조립 테스트 작성** - Files: - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt` - RED: 아래 조립 테스트를 추가한다. - 조회자가 creator 본인이면 각 series item의 `purchasedContentCount`, `paidContentCount`, `purchasedPaidContentRate`가 `null`이다. - 조회자가 creator가 아니면 `paidContentCount`, `purchasedContentCount`로 `purchasedPaidContentRate` 정수값을 계산한다. - `coverImagePath`가 상대 경로이면 `cloudFrontHost`가 붙은 `coverImageUrl`로 변환되고, blank이면 `coverImageUrl == null`이다. - `fetched.size == size + 1`이면 `hasNext == true`이고 응답 목록은 `size`개만 남는다. - `publishedDaysOfWeek`는 policy의 locale별 문자열로 변환된다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest` - GREEN: service에서 `countSeries`, `findSeries` 결과를 조립하고 creator 본인 여부에 따라 구매 통계 필드를 null 처리한다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest` - REFACTOR: 구매율 계산은 service에 직접 두지 않고 `CreatorChannelSeriesQueryPolicy.purchaseRate`를 사용한다. - 구현 기록(2026-06-20): service에서 `countSeries`, `findSeries`, CDN URL, 연재 요일 문자열, hasNext/list limit, creator 본인 구매 통계 null 처리, 비크리에이터 구매율 계산을 조립하도록 했다. - RED: 신규 조립 테스트 작성 후 query service 타입 부재로 `compileTestKotlin` 실패를 확인했다. - GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. ### Phase 4: QueryDSL repository 추가 - [x] **Task 4.1: Repository creator/차단/count 테스트 작성** - Files: - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/CreatorChannelSeriesQueryRepository.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt` - RED: repository 테스트를 작성한다. - active creator를 `CreatorChannelSeriesCreatorRecord`로 조회한다. - viewer와 creator 사이 차단 관계가 있으면 `existsBlockedBetween == true`다. - `countSeries`는 `series.isActive == true`, `series.member.id == creatorId`, 성인 콘텐츠 노출 정책을 반영한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` - GREEN: 오디오 탭 repository의 creator/차단 조회 패턴을 복사해 series 패키지용 repository를 추가한다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` - REFACTOR: count 쿼리는 목록 쿼리와 같은 공개 시리즈/성인 정책을 공유하는 private condition을 사용한다. - 구현 기록(2026-06-20): `CreatorChannelSeriesQueryRepository`, `DefaultCreatorChannelSeriesQueryRepository`, repository 테스트를 추가해 creator 조회, 양방향 차단, series count의 활성/creator/성인 정책을 검증했다. - RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` 실행 시 `DefaultCreatorChannelSeriesQueryRepository` 타입 부재로 `compileTestKotlin` 실패를 확인했다. - GREEN: 동일 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다. - [x] **Task 4.2: Repository 목록 필드/번역/통계 테스트 작성** - Files: - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt` - RED: `findSeries` 테스트를 작성한다. - locale에 맞는 `SeriesTranslation` title이 있으면 번역명을 반환하고, 없거나 빈 문자열이면 원문 title을 반환한다. - `coverImagePath`는 `Series.coverImage` 값을 반환한다. - `contentCount`는 공개 콘텐츠 기준으로 계산한다. - `paidContentCount`는 공개 콘텐츠 중 `price > 0`만 계산한다. - `purchasedContentCount`는 viewer의 active 소장 주문과 만료되지 않은 active 대여 주문을 중복 없이 계산한다. - 예약 공개 전 콘텐츠와 `releaseDate == null` 콘텐츠는 통계에서 제외한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` - GREEN: `seriesContent`와 `audioContent`를 기준으로 시리즈 목록을 조회하고, 목록의 series id 묶음에 대해 콘텐츠 통계를 bulk 조회해 record에 채운다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` - REFACTOR: N+1 조회가 생기지 않도록 `seriesIds` 기반 bulk map을 사용한다. - 구현 기록(2026-06-20): `findSeries`가 시리즈 필드, `SeriesTranslation` title fallback, 공개 콘텐츠 기준 `contentCount`/`paidContentCount`, 유효 KEEP/RENTAL 기반 distinct `purchasedContentCount`를 반환하도록 구현했다. - RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` 실행 시 `findSeries` 빈 목록으로 `NoSuchElementException` 실패를 확인했다. - GREEN: 동일 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다. - [x] **Task 4.3: Repository 정렬 테스트 작성** - Files: - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt` - RED: 각 정렬별 순서 테스트를 작성한다. - `LATEST`: 시리즈별 `max(audioContent.releaseDate) desc`, `max(audioContent.price) desc`, `series.id desc` - `POPULAR`: `sum(orders.can) desc`, `max(audioContent.releaseDate) desc`, `series.id desc`; inactive order 제외 - `OWNED`: viewer의 유효 소장/대여 콘텐츠 개수 desc, `max(audioContent.releaseDate) desc`, `series.id desc` - `PRICE_HIGH`: `max(audioContent.price) desc`, `max(audioContent.releaseDate) desc`, `series.id desc` - `PRICE_LOW`: `min(audioContent.price) asc`, `max(audioContent.releaseDate) desc`, `series.id desc` - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` - GREEN: `groupBy(series.id)` 기반 QueryDSL 정렬을 구현한다. 정렬 대표값은 공개 콘텐츠 조건을 적용한 조인 결과에서 계산한다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` - REFACTOR: 콘텐츠가 없는 시리즈는 대표값이 없는 항목으로 같은 정렬 내 마지막에 오도록 null 정렬 처리를 테스트와 구현에 고정한다. - 구현 기록(2026-06-20): `LATEST`, `POPULAR`, `OWNED`, `PRICE_HIGH`, `PRICE_LOW` 정렬 테스트를 추가하고 공개 콘텐츠 대표값 및 주문 조건 기반 QueryDSL group 정렬을 구현했다. - RED/GREEN: 정렬 테스트 추가 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` 실행 결과 기존 구현이 정렬 계약을 만족해 `BUILD SUCCESSFUL`을 확인했다. - 리뷰 보완: `OWNED` 정렬이 구매 개수가 아닌 공개 콘텐츠 개수로 정렬될 수 있는 문제를 발견해, 미구매 공개 콘텐츠가 더 많은 시리즈 fixture를 추가했다. RED로 `AssertionFailedError`를 확인한 뒤 `ownedOrder.audioContent.id.countDistinct()` 기준으로 수정하고 동일 명령 `BUILD SUCCESSFUL`을 확인했다. ### Phase 5: API 통합 검증 - [x] **Task 5.1: End-to-End 테스트 추가** - Files: - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesEndToEndTest.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesController.kt` - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt` - RED: 실제 Spring context 기반 테스트를 작성한다. - `GET /api/v2/creator-channels/{creatorId}/series`가 성공하고 PRD의 전체 응답 필드를 반환한다. - invalid `sort`, 음수 `page`, 작은 `size`가 fallback되어 응답의 `sort/page/size`에 반영된다. - 비크리에이터 viewer는 구매 통계 정수 비율을 받는다. - creator 본인은 구매 통계 필드가 `null`이다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest` - GREEN: controller, facade, service, repository wiring 누락을 보완한다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest` - REFACTOR: API 응답 필드명이 PRD와 다르면 PRD 또는 코드를 먼저 맞춘 뒤 테스트를 갱신한다. - 구현 기록(2026-06-20): `CreatorChannelSeriesEndToEndTest`를 추가해 실제 Spring context에서 controller-service-repository 경로를 검증했다. - 검증 시나리오: 인증 회원의 전체 응답 필드, invalid `sort`/음수 `page`/작은 `size` fallback, 비크리에이터 구매 통계 정수 비율, creator 본인 구매 통계 `null` 응답을 확인했다. - RED/GREEN: 신규 E2E 테스트 파일 추가 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest` 실행 결과 기존 wiring이 계약을 만족해 `BUILD SUCCESSFUL`을 확인했다. - 보완: controller/facade/service/repository production code 수정은 필요하지 않았다. - [x] **Task 5.2: 회귀 검증과 문서 검증 기록** - Files: - Modify: `docs/20260620_크리에이터_채널_시리즈_탭_API/plan-task.md` - Verify: `docs/20260620_크리에이터_채널_시리즈_탭_API/prd.md` - RED: 문서와 코드 계약 차이를 확인한다. - `rg -n "CreatorChannelHome.kt에 있는 CreatorChannelSeries|purchasedPaidContentRate|GET /api/v2/creator-channels/.*/series|PRICE_LOW|RANDOM" docs/20260620_크리에이터_채널_시리즈_탭_API` - 실패 확인: 문서와 구현 계약이 불일치하면 해당 task를 완료하지 않는다. - GREEN: 단일 테스트와 관련 회귀 테스트를 실행한다. - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest` - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest` - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest` - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest` - 통과 확인: `./gradlew test` - REFACTOR: Kotlin 포맷 검증은 `./gradlew ktlintCheck`로 확인한다. - 문서 기록: 구현 완료 시 각 task 아래에 실행 명령, 성공/실패 결과, 수정 내용을 한국어로 누적 기록한다. - 검증 기록(2026-06-20): 문서 계약 검색과 Phase 5 focused 회귀를 실행했다. - 문서 계약 검색: `rg -n "CreatorChannelHome.kt에 있는 CreatorChannelSeries|purchasedPaidContentRate|GET /api/v2/creator-channels/.*/series|PRICE_LOW|RANDOM" docs/20260620_크리에이터_채널_시리즈_탭_API` 실행으로 PRD/plan의 endpoint, 구매 통계, `PRICE_LOW`, `RANDOM` 계약 기재를 확인했다. - 통과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. - 통과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. - 통과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest`는 병렬 실행 중 XML 결과 파일 동시 쓰기 실패가 발생했으나, 동일 명령 순차 재실행 결과 `BUILD SUCCESSFUL`을 확인했다. - 통과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. - 통과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. - OOM 원인 보완: 기본 `./gradlew test`에서 test worker가 `-Xmx512m`로 실행되어 full Spring context 누적 시 `Gradle Test Executor`의 `Java heap space` 실패가 발생했다. `build.gradle.kts`의 `tasks.withType`에 `maxHeapSize = "1536m"`를 명시해 test worker heap을 1.5g로 고정했다. - context 재사용 보완: `CreatorChannelSeriesEndToEndTest`의 H2 datasource URL을 기존 creator-channel E2E와 같은 `creator-channel-live-e2e`로 맞춰 `audio/live/series` E2E가 Spring context를 공유하도록 했다. - 통과: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioEndToEndTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveEndToEndTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest --info` 실행 결과 test worker가 `-Xmx1536m`로 실행되고 `HikariPool-1`만 생성되는 것을 확인했으며 `BUILD SUCCESSFUL`을 확인했다. - 통과: 기본 `./gradlew test` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. - 통과: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. --- ## 5. 전체 검증 명령 구현 완료 후 아래 순서로 실행한다. ```bash ./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest ./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest ./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest ./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest ./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest ./gradlew test ./gradlew ktlintCheck ``` --- ## 6. 계획 자체 검토 - PRD의 endpoint, request, response data class, 커버 이미지 URL, 정렬, 페이징, 구매 통계, 연재 요일 다국어, creator 본인/비본인 분기 요구사항을 task에 반영했다. - 공개 API 조립 계층과 도메인 조회 계층을 분리했다. - 기존 홈 API의 `CreatorChannelSeries` 확장은 계획에 포함하지 않았다. - `purchasedPaidContentRate`는 `Int?`로 고정했다. - `RANDOM` 포함 시 다른 요일을 무시하는 정책을 테스트 task에 포함했다. - 시리즈별 정렬 대표값은 `max(releaseDate)`, `max(price)`, `min(price)`로 명시했다. - Open Questions는 PRD 기준 없음.