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, 기본값/fallbackLATEST - query parameter:
page,required = false, 기본값0,page < 0이면0으로 fallback - query parameter:
size,required = false, 기본값20,size < 20이면20,size > 50이면50으로 fallback
- path variable:
- controller는 invalid
sortfallback을 위해sort: String?으로 받고 service/facade 경계에서ContentSort로 보정한다. - response:
seriesCount: sort-bar에 표시할 조회 가능한 전체 시리즈 개수series: 시리즈 목록sort: 실제 적용한ContentSortpage: fallback 보정 후 실제 적용된 page indexsize: fallback 보정 후 실제 적용된 page sizehasNext: 다음 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 descPOPULAR: 시리즈 콘텐츠의orders.can합계 desc, 대표releaseDate desc,series.id desc;orders.is_active = true만 포함OWNED: 조회자가 유효하게 소장/대여 중인 시리즈 콘텐츠 개수 desc, 대표releaseDate desc,series.id descPRICE_HIGH: 대표price desc, 대표releaseDate desc,series.id descPRICE_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
- Create:
- 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을 확인했다.
- RED:
- Files:
-
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
- Create:
- 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 패키지 의존이 없음을 확인했다.
- 검증:
- Files:
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
- Create:
- 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을 확인했다.
- RED:
- Files:
-
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
- Create:
- 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을 확인했다.
- RED:
- Files:
-
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
- Create:
- 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?으로 받고ContentSortenum 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을 확인했다.
- RED:
- Files:
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
- Create:
- 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<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의
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을 확인했다.
- RED:
- Files:
-
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
- Modify:
- 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별 문자열로 변환된다.
- 조회자가 creator 본인이면 각 series item의
- 실패 확인:
./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을 확인했다.
- RED: 신규 조립 테스트 작성 후 query service 타입 부재로
- Files:
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
- Create:
- RED: repository 테스트를 작성한다.
- active creator를
CreatorChannelSeriesCreatorRecord로 조회한다. - viewer와 creator 사이 차단 관계가 있으면
existsBlockedBetween == true다. countSeries는series.isActive == true,series.member.id == creatorId, 성인 콘텐츠 노출 정책을 반영한다.
- active creator를
- 실패 확인:
./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을 확인했다.
- RED:
- Files:
-
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
- Modify:
- RED:
findSeries테스트를 작성한다.- locale에 맞는
SeriesTranslationtitle이 있으면 번역명을 반환하고, 없거나 빈 문자열이면 원문 title을 반환한다. coverImagePath는Series.coverImage값을 반환한다.contentCount는 공개 콘텐츠 기준으로 계산한다.paidContentCount는 공개 콘텐츠 중price > 0만 계산한다.purchasedContentCount는 viewer의 active 소장 주문과 만료되지 않은 active 대여 주문을 중복 없이 계산한다.- 예약 공개 전 콘텐츠와
releaseDate == null콘텐츠는 통계에서 제외한다.
- locale에 맞는
- 실패 확인:
./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가 시리즈 필드,SeriesTranslationtitle fallback, 공개 콘텐츠 기준contentCount/paidContentCount, 유효 KEEP/RENTAL 기반 distinctpurchasedContentCount를 반환하도록 구현했다.- RED:
./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest실행 시findSeries빈 목록으로NoSuchElementException실패를 확인했다. - GREEN: 동일 명령 재실행 결과
BUILD SUCCESSFUL을 확인했다.
- RED:
- Files:
-
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
- Modify:
- RED: 각 정렬별 순서 테스트를 작성한다.
LATEST: 시리즈별max(audioContent.releaseDate) desc,max(audioContent.price) desc,series.id descPOPULAR:sum(orders.can) desc,max(audioContent.releaseDate) desc,series.id desc; inactive order 제외OWNED: viewer의 유효 소장/대여 콘텐츠 개수 desc,max(audioContent.releaseDate) desc,series.id descPRICE_HIGH:max(audioContent.price) desc,max(audioContent.releaseDate) desc,series.id descPRICE_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을 확인했다.
- RED/GREEN: 정렬 테스트 추가 후
- Files:
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
- Create:
- 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/작은sizefallback, 비크리에이터 구매 통계 정수 비율, 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 수정은 필요하지 않았다.
- 검증 시나리오: 인증 회원의 전체 응답 필드, invalid
- Files:
-
Task 5.2: 회귀 검증과 문서 검증 기록
- Files:
- Modify:
docs/20260620_크리에이터_채널_시리즈_탭_API/plan-task.md - Verify:
docs/20260620_크리에이터_채널_시리즈_탭_API/prd.md
- Modify:
- 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<Test>에maxHeapSize = "1536m"를 명시해 test worker heap을 1.5g로 고정했다. - context 재사용 보완:
CreatorChannelSeriesEndToEndTest의 H2 datasource URL을 기존 creator-channel E2E와 같은creator-channel-live-e2e로 맞춰audio/live/seriesE2E가 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을 확인했다.
- 문서 계약 검색:
- Files:
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확장은 계획에 포함하지 않았다. purchasedPaidContentRate는Int?로 고정했다.RANDOM포함 시 다른 요일을 무시하는 정책을 테스트 task에 포함했다.- 시리즈별 정렬 대표값은
max(releaseDate),max(price),min(price)로 명시했다. - Open Questions는 PRD 기준 없음.