Files
sodalive-backend-spring-boot/docs/20260620_크리에이터_채널_시리즈_탭_API/plan-task.md

40 KiB

크리에이터 채널 시리즈 탭 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, purchasedPaidContentRatenull이다.
  • 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.
  • coverImageUrlSeries.coverImageString?.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와 이 문서를 갱신한다.

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<CreatorChannelSeriesResponse>,
    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를 참조하지 않는다.

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<CreatorChannelSeries>,
    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?
)
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<CreatorChannelSeriesRecord>
}

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<SeriesPublishedDaysOfWeek>,
    val isOriginal: Boolean,
    val isAdult: Boolean,
    val state: SeriesState,
    val contentCount: Int,
    val purchasedContentCount: Int?,
    val paidContentCount: Int?
)

4. 작업 계획

Phase 1: 순수 정책과 도메인 모델 추가

  • 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, UNKNOWNContentSort.LATEST로 fallback한다.
      • page = -1, size = 10page=0, size=20, fetchLimit=21이 된다.
      • page = 2, size = 100page=2, size=50, offset=100, fetchLimit=51이 된다.
      • limitItemssize만큼만 남기고 hasNextfetched.size > size로 계산한다.
      • 구매율은 paidContentCount == 0이면 0, paid=4, purchased=3이면 75, paid=3, purchased=2이면 66이다.
      • publishedDaysOfWeekRANDOM 포함 시 다른 요일을 무시하고 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 정책을 구현하되 purchaseRateInt를 반환한다. 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을 확인했다.
  • 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 조립 계층 추가

  • 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을 확인했다.
  • 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을 확인했다.
  • 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: sortString?으로 받고 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: 도메인 조회 서비스 추가

  • 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: 아래 서비스 테스트를 작성한다.
      • findCreatornull이면 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<CreatorChannelSeriesQueryPort>, 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의 coverImagePathString?.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을 확인했다.
  • 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, purchasedPaidContentRatenull이다.
      • 조회자가 creator가 아니면 paidContentCount, purchasedContentCountpurchasedPaidContentRate 정수값을 계산한다.
      • 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 추가

  • 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다.
      • countSeriesseries.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을 확인했다.
  • 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을 반환한다.
      • coverImagePathSeries.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: seriesContentaudioContent를 기준으로 시리즈 목록을 조회하고, 목록의 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을 확인했다.
  • 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 통합 검증

  • 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 수정은 필요하지 않았다.
  • 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 ExecutorJava heap space 실패가 발생했다. build.gradle.ktstasks.withType<Test>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. 전체 검증 명령

구현 완료 후 아래 순서로 실행한다.

./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 확장은 계획에 포함하지 않았다.
  • purchasedPaidContentRateInt?로 고정했다.
  • RANDOM 포함 시 다른 요일을 무시하는 정책을 테스트 task에 포함했다.
  • 시리즈별 정렬 대표값은 max(releaseDate), max(price), min(price)로 명시했다.
  • Open Questions는 PRD 기준 없음.