41 KiB
메인 콘텐츠 전체 탭 API Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use
superpowers:subagent-driven-development또는superpowers:executing-plans로 task 단위 구현을 진행한다. 각 단계는 체크박스(- [ ])로 진행 상태를 갱신한다.
Goal: GET /api/v2/audio/contents로 메인 콘텐츠 전체 탭의 오디오, 시리즈, 오리지널, 무료, 포인트 목록을 정렬/요일/페이징 조건에 맞춰 조회한다.
Architecture: 공개 API controller/facade/response DTO는 kr.co.vividnext.sodalive.v2.api.content.all 조립 계층에 둔다. 전체 탭 조회 service, 요청 보정 정책, domain model, port, QueryDSL repository는 kr.co.vividnext.sodalive.v2.content.all 하위에 두고 v2.api.*에 의존하지 않는다. 기존 ContentSort, SeriesPublishedDaysOfWeek, 콘텐츠 추천/채널 오디오/채널 시리즈 조회 패턴을 재사용하되 공개 응답 DTO는 전체 탭 전용으로 최소 필드만 둔다.
Tech Stack: Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Security, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
0. 구현 전 확정 사항
- API endpoint:
GET /api/v2/audio/contents - 인증 정책: 비회원 조회 가능. 인증 회원이면
MemberContentPreferenceService의 성인 콘텐츠 노출 가능 여부를 반영한다. - 응답 wrapper:
ApiResponse.ok(...) - 요청 query parameter:
type:AUDIO,SERIES,ORIGINAL,FREE,POINT; 기본값AUDIOsort:LATEST,POPULAR,PRICE_HIGH,PRICE_LOW; 기본값LATESTdayOfWeek:type=SERIES에서만 적용.SeriesPublishedDaysOfWeek값SUN,MON,TUE,WED,THU,FRI,SAT,RANDOMpage: 0부터 시작. 기본값0size: 기본값20, 최소20, 최대50
sort가 invalid이거나OWNED이면LATEST로 fallback한다.dayOfWeek가 invalid이면 요일 조건을 적용하지 않고dayOfWeek = null로 fallback한다.type != SERIES이면dayOfWeek는 조회 조건에 적용하지 않고 응답에서null로 내려준다.type=ORIGINAL에는dayOfWeek를 적용하지 않는다.- 전체 응답은
totalCount,audios,series,sort,dayOfWeek,page,size,hasNext를 포함한다. AUDIO,FREE,POINT는audios만 채우고series는 빈 배열로 내려준다.SERIES,ORIGINAL은series만 채우고audios는 빈 배열로 내려준다.- 공개 오디오 조건:
audioContent.isActive == true,duration != null,releaseDate != null,releaseDate <= now, 활성 테마, 활성 크리에이터. - 공개 시리즈 조건:
series.isActive == true, 활성 크리에이터. 성인 콘텐츠 노출 불가이면series.isAdult == false. - 회원이 차단했거나 회원을 차단한 크리에이터의 콘텐츠/시리즈는 제외한다.
- 신규 Entity와 DDL은 작성하지 않는다.
SecurityConfig에는GET /api/v2/audio/contentspermitAll 설정을 추가한다.
1. 파일 구조 계획
신규 API 조립 계층
- Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllController.kt - Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacade.kt - Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponse.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllControllerTest.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacadeTest.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt
신규 도메인 조회 계층
- Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAll.kt - Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllType.kt - Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllQueryPolicy.kt - Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentPage.kt - Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt - Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/port/out/MainContentAllQueryPort.kt - Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/MainContentAllQueryRepository.kt - Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllQueryPolicyTest.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt
기존 설정/회귀
- Modify:
src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt - Verify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSort.kt - Verify:
src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/Series.kt - Verify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.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/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt
2. Response data class 초안
구현 시 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponse.kt에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
package kr.co.vividnext.sodalive.v2.api.content.all.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAll
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllAudio
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllSeries
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllType
data class MainContentAllTabResponse(
val type: MainContentAllType,
val totalCount: Int,
val audios: List<MainContentAudioResponse>,
val series: List<MainContentSeriesResponse>,
val sort: ContentSort,
val dayOfWeek: SeriesPublishedDaysOfWeek?,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(tab: MainContentAll): MainContentAllTabResponse {
return MainContentAllTabResponse(
type = tab.type,
totalCount = tab.totalCount,
audios = tab.audios.map(MainContentAudioResponse::from),
series = tab.series.map(MainContentSeriesResponse::from),
sort = tab.sort,
dayOfWeek = tab.dayOfWeek,
page = tab.page.page,
size = tab.page.size,
hasNext = tab.hasNext
)
}
}
}
data class MainContentAudioResponse(
val audioContentId: Long,
val title: String,
val imageUrl: String?,
val price: Int,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isPointAvailable")
val isPointAvailable: Boolean,
@JsonProperty("isFirstContent")
val isFirstContent: Boolean,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean,
val creatorNickname: String
) {
companion object {
fun from(audio: MainContentAllAudio): MainContentAudioResponse {
return MainContentAudioResponse(
audioContentId = audio.audioContentId,
title = audio.title,
imageUrl = audio.imageUrl,
price = audio.price,
isAdult = audio.isAdult,
isPointAvailable = audio.isPointAvailable,
isFirstContent = audio.isFirstContent,
isOriginalSeries = audio.isOriginalSeries,
creatorNickname = audio.creatorNickname
)
}
}
}
data class MainContentSeriesResponse(
val seriesId: Long,
val title: String,
val coverImageUrl: String?,
val creatorNickname: String,
@JsonProperty("isOriginal")
val isOriginal: Boolean,
@JsonProperty("isAdult")
val isAdult: Boolean
) {
companion object {
fun from(series: MainContentAllSeries): MainContentSeriesResponse {
return MainContentSeriesResponse(
seriesId = series.seriesId,
title = series.title,
coverImageUrl = series.coverImageUrl,
creatorNickname = series.creatorNickname,
isOriginal = series.isOriginal,
isAdult = series.isAdult
)
}
}
}
3. Domain / Port 초안
package kr.co.vividnext.sodalive.v2.content.all.domain
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
enum class MainContentAllType {
AUDIO,
SERIES,
ORIGINAL,
FREE,
POINT
}
data class MainContentAll(
val type: MainContentAllType,
val totalCount: Int,
val audios: List<MainContentAllAudio>,
val series: List<MainContentAllSeries>,
val sort: ContentSort,
val dayOfWeek: SeriesPublishedDaysOfWeek?,
val page: MainContentPage,
val hasNext: Boolean
)
data class MainContentAllAudio(
val audioContentId: Long,
val title: String,
val imageUrl: String?,
val price: Int,
val isAdult: Boolean,
val isPointAvailable: Boolean,
val isFirstContent: Boolean,
val isOriginalSeries: Boolean,
val creatorNickname: String
)
data class MainContentAllSeries(
val seriesId: Long,
val title: String,
val coverImageUrl: String?,
val creatorNickname: String,
val isOriginal: Boolean,
val isAdult: Boolean
)
data class MainContentPage(
val page: Int,
val size: Int
) {
val offset: Long = page.toLong() * size
}
package kr.co.vividnext.sodalive.v2.content.all.port.out
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllAudio
import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllSeries
import java.time.LocalDateTime
interface MainContentAllQueryPort {
fun countAudios(
memberId: Long?,
canViewAdultContent: Boolean,
now: LocalDateTime,
onlyFree: Boolean = false,
onlyPointAvailable: Boolean = false
): Int
fun findAudios(
memberId: Long?,
canViewAdultContent: Boolean,
now: LocalDateTime,
sort: ContentSort,
offset: Long,
limit: Int,
onlyFree: Boolean = false,
onlyPointAvailable: Boolean = false
): List<MainContentAllAudio>
fun countSeries(
memberId: Long?,
canViewAdultContent: Boolean,
now: LocalDateTime,
onlyOriginal: Boolean = false,
dayOfWeek: SeriesPublishedDaysOfWeek? = null
): Int
fun findSeries(
memberId: Long?,
canViewAdultContent: Boolean,
now: LocalDateTime,
sort: ContentSort,
offset: Long,
limit: Int,
onlyOriginal: Boolean = false,
dayOfWeek: SeriesPublishedDaysOfWeek? = null,
locale: String
): List<MainContentAllSeries>
}
Phase 1: 요청 보정 정책과 도메인 모델
-
Task 1.1: 전체 탭 타입, page, 요청 보정 policy 추가
- Files:
- Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllType.kt - Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentPage.kt - Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllQueryPolicy.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllQueryPolicyTest.kt
- Create:
- RED: 다음 테스트를 먼저 작성한다.
@Test fun shouldResolveDefaultsAndFallbacks() { val policy = MainContentAllQueryPolicy() assertEquals(MainContentAllType.AUDIO, policy.resolveType(null)) assertEquals(MainContentAllType.AUDIO, policy.resolveType("UNKNOWN")) assertEquals(ContentSort.LATEST, policy.resolveSort(null)) assertEquals(ContentSort.LATEST, policy.resolveSort("UNKNOWN")) assertEquals(ContentSort.LATEST, policy.resolveSort("OWNED")) assertEquals(ContentSort.POPULAR, policy.resolveSort("POPULAR")) assertEquals(MainContentPage(page = 0, size = 20), policy.createPage(page = null, size = null)) assertEquals(MainContentPage(page = 0, size = 20), policy.createPage(page = -1, size = 1)) assertEquals(MainContentPage(page = 2, size = 50), policy.createPage(page = 2, size = 100)) } - RED:
type=SERIES일 때만 요일이 적용되는 테스트를 작성한다.@Test fun shouldResolveDayOfWeekOnlyForSeriesType() { val policy = MainContentAllQueryPolicy() assertEquals(SeriesPublishedDaysOfWeek.MON, policy.resolveDayOfWeek(MainContentAllType.SERIES, "MON")) assertEquals(SeriesPublishedDaysOfWeek.RANDOM, policy.resolveDayOfWeek(MainContentAllType.SERIES, "RANDOM")) assertNull(policy.resolveDayOfWeek(MainContentAllType.SERIES, "INVALID")) assertNull(policy.resolveDayOfWeek(MainContentAllType.ORIGINAL, "MON")) assertNull(policy.resolveDayOfWeek(MainContentAllType.AUDIO, "MON")) } - 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicyTest - GREEN:
resolveType(sort: String?),resolveSort(sort: String?),resolveDayOfWeek(type, dayOfWeek),createPage(page, size),limitItems,hasNext를 최소 구현한다. - REFACTOR:
OWNEDfallback과 invaliddayOfWeekfallback이 400으로 흐르지 않도록 controller에서 enum 직접 binding을 사용하지 않는 설계를 확인한다. - 기대 결과: 요청 보정 정책이 순수 단위 테스트로 고정된다.
- 검증 기록:
- RED:
./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicyTest실행 시MainContentAllQueryPolicy,MainContentAllType,MainContentPage미구현 컴파일 실패를 확인했다. - GREEN:
./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicyTest성공으로 기본값/fallback/page/hasNext 정책을 확인했다.
- RED:
- Files:
-
Task 1.2: 전체 탭 domain model 추가
- Files:
- Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAll.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt
- Create:
- RED:
MainContentAllTabResponse.from(...)이 최소 필드만 변환하는 테스트를 작성한다.@Test fun shouldMapDomainToResponseWithMinimalFields() { val response = MainContentAllTabResponse.from( MainContentAll( type = MainContentAllType.SERIES, totalCount = 1, audios = emptyList(), series = listOf( MainContentAllSeries( seriesId = 10L, title = "시리즈", coverImageUrl = "https://cdn/series.jpg", creatorNickname = "creator", isOriginal = true, isAdult = false ) ), sort = ContentSort.LATEST, dayOfWeek = SeriesPublishedDaysOfWeek.MON, page = MainContentPage(0, 20), hasNext = false ) ) assertEquals(MainContentAllType.SERIES, response.type) assertEquals(1, response.totalCount) assertTrue(response.audios.isEmpty()) assertEquals("creator", response.series.first().creatorNickname) assertEquals(SeriesPublishedDaysOfWeek.MON, response.dayOfWeek) } - 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest - GREEN:
MainContentAll,MainContentAllAudio,MainContentAllSeries, response DTO를 최소 구현한다. - REFACTOR:
MainContentAudioResponse에duration,MainContentSeriesResponse에publishedDaysOfWeek,isProceeding,contentCount,paidContentCount가 없는지 소스와 테스트에서 확인한다. - 기대 결과: 공개 응답 계약이 PRD와 일치한다.
- 검증 기록:
- RED:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest실행 시MainContentAllTabResponse,MainContentAll계열 도메인 모델 미구현 컴파일 실패를 확인했다. - GREEN:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest성공으로 도메인→응답 DTO 변환과 booleanis*JSON 필드명을 확인했다.
- RED:
- Files:
Phase 2: API 조립 계층
-
Task 2.1: facade 작성
- Files:
- Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacade.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacadeTest.kt
- Create:
- RED: facade가 문자열 query parameter를 그대로 query service에 넘기고 응답 DTO로 변환하는 테스트를 작성한다.
@Test fun shouldDelegateToQueryServiceAndMapResponse() { val service = FakeMainContentAllQueryService() val facade = MainContentAllFacade(service) val response = facade.getContents( type = "FREE", sort = "PRICE_LOW", dayOfWeek = "MON", page = 1, size = 30, member = null ) assertEquals("FREE", service.requestedType) assertEquals("PRICE_LOW", service.requestedSort) assertEquals("MON", service.requestedDayOfWeek) assertEquals(MainContentAllType.FREE, response.type) } - 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest - GREEN: facade는 query service 호출과
MainContentAllTabResponse.from(...)변환만 담당한다. - REFACTOR: facade에 정렬, 요일, DB 조회 정책이 들어가지 않도록 확인한다.
- 기대 결과: API 조립 계층과 도메인 조회 계층의 책임이 분리된다.
- 검증 기록:
- RED:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest실행 시MainContentAllFacade,MainContentAllQueryService미구현 컴파일 실패를 확인했다. - GREEN: 동일 명령 성공으로 facade가 문자열 query parameter와
Member?를 query service에 그대로 전달하고 응답 DTO로 변환함을 확인했다.
- RED:
- Files:
-
Task 2.2: controller와 보안 설정 추가
- Files:
- Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllController.kt - Modify:
src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllControllerTest.kt
- Create:
- RED:
GET /api/v2/audio/contents가 비회원에게200 OK를 반환하고type기본값을 service까지 전달하는 MockMvc 테스트를 작성한다.@Test fun shouldAllowAnonymousAndUseDefaultType() { mockMvc.get("/api/v2/audio/contents") .andExpect { status { isOk() } jsonPath("$.data.type") { value("AUDIO") } jsonPath("$.data.sort") { value("LATEST") } } } - RED:
GET /api/v2/audio/contents?type=SERIES&dayOfWeek=MON&sort=POPULAR&page=1&size=30이 query parameter를 facade로 전달하는 테스트를 작성한다. - 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest - GREEN:
@RequestMapping("/api/v2/audio/contents"),@RequestParam type: String?,sort: String?,dayOfWeek: String?,page: Int?,size: Int?, optionalmember: Member?로 controller를 구현한다. - GREEN:
SecurityConfig에antMatchers(HttpMethod.GET, "/api/v2/audio/contents").permitAll()을 추가한다. - REFACTOR:
ContentSort와SeriesPublishedDaysOfWeek를 controller parameter에 직접 binding하지 않는지 확인한다. - 기대 결과: 공개 endpoint, 비회원 허용, invalid parameter fallback을 위한 controller 계약이 고정된다.
- 검증 기록:
- RED:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest실행 시MainContentAllController미구현 컴파일 실패를 확인했다. - GREEN: 동일 명령 성공으로 비회원
GET /api/v2/audio/contents200 OK, query parameter/member 전달,SecurityConfigpermitAll 설정을 확인했다.
- RED:
- Files:
Phase 3: 조회 service와 port
-
Task 3.1: query port와 service 분기 작성
- Files:
- Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/port/out/MainContentAllQueryPort.kt - Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt
- Create:
- RED:
AUDIO,FREE,POINTtype이 audio count/list port를 올바른 필터로 호출하는 fake port 테스트를 작성한다.@Test fun shouldQueryAudiosByType() { val port = FakeMainContentAllQueryPort() val service = createService(port) service.getContents(type = "FREE", sort = "LATEST", dayOfWeek = null, page = 0, size = 20, member = null) assertEquals("audio", port.lastListKind) assertTrue(port.lastOnlyFree) assertFalse(port.lastOnlyPointAvailable) } - RED:
SERIEStype이dayOfWeek=MON을 series count/list port에 전달하고ORIGINALtype은onlyOriginal=true,dayOfWeek=null로 호출하는 테스트를 작성한다. - 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest - GREEN: service는 policy로 type/sort/day/page를 보정하고,
type에 따라 port 메서드를 호출한다. - GREEN:
limit = page.size + 1로 조회한 뒤policy.limitItems(...)와policy.hasNext(...)를 적용한다. - REFACTOR: service에는 QueryDSL 조건식이나 response DTO 변환을 두지 않는다.
- 기대 결과: type별 조회 분기, 전체 개수,
hasNext, fallback 정책이 service 단위 테스트로 고정된다. - 검증 기록:
- RED:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest실행 시MainContentAllQueryPort,MainContentAllQueryService미구현 컴파일 실패를 확인했다. - GREEN: 동일 명령 성공으로
AUDIO/FREE/POINTaudio 분기,SERIES/ORIGINALseries 분기,limit = size + 1,hasNext처리를 확인했다.
- RED:
- Files:
-
Task 3.2: 성인 콘텐츠 노출 정책 연결
- Files:
- Modify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt
- Modify:
- RED: 비회원이면
canViewAdultContent=false, 회원이면MemberContentPreferenceService.canViewAdultContent(member)결과를 port에 전달하는 테스트를 작성한다. - 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest - GREEN: 기존
AudioRecommendationQueryService와 같은 방식으로 성인 콘텐츠 노출 가능 여부를 계산한다. - REFACTOR: 회원 id는
member?.id만 port에 전달하고, port/repository에서 차단 관계 제외 조건을 처리하게 둔다. - 기대 결과: 비회원/회원 성인 콘텐츠 정책이 기존 v2 추천 탭과 일치한다.
- 검증 기록:
- RED: service 테스트 추가 후
MainContentAllQueryService미구현 컴파일 실패를 확인했다. - GREEN:
./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest포함 Phase 2-3 테스트 명령 성공으로 비회원canViewAdultContent=false, 회원MemberContentPreferenceService.canViewAdultContent(member)결과 전달을 확인했다.
- RED: service 테스트 추가 후
- Files:
Phase 4: QueryDSL repository
-
Task 4.1: audio count/list repository 구현
- Files:
- Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/MainContentAllQueryRepository.kt - Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt
- Create:
- RED: 공개 오디오만 조회하고 비회원은 성인 오디오를 제외하며 차단 관계 크리에이터의 오디오를 제외하는 repository 테스트를 작성한다.
- RED:
FREE조회는price == 0,POINT조회는isPointAvailable == true필터가 적용되는 테스트를 작성한다. - RED:
LATEST,POPULAR,PRICE_HIGH,PRICE_LOW정렬 테스트를 작성한다. - 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest - GREEN:
DefaultAudioRecommendationQueryRepository.audioRows(...),DefaultCreatorChannelAudioQueryRepository.findAudioContentRows(...)패턴을 참고해 audio count/list를 구현한다. - GREEN: 인기순은
orders.isActive == true인 주문의orders.can.sum().coalesce(0)만 사용하고orders.point는 더하지 않는다. - GREEN:
isFirstContent는 크리에이터별 전체 공개 오디오 중 가장 먼저 공개된 콘텐츠인지로 계산한다. - GREEN:
isOriginalSeries는 해당 오디오가 속한 시리즈의isOriginal기준으로 계산하고 시리즈 미소속이면false로 내려준다. - REFACTOR: CDN URL 변환은
toCdnUrl(cloudFrontHost)패턴을 사용한다. - 기대 결과: 오디오/무료/포인트 조회의 필터, count, 정렬, 카드 필드가 repository 테스트로 고정된다.
- 검증 기록:
- RED:
./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest실행 시DefaultMainContentAllQueryRepository미구현 컴파일 실패를 확인했다. - GREEN: 동일 명령 성공으로 공개 오디오 조건, 성인/차단 제외, 무료/포인트 필터, 가격/인기 정렬, CDN URL, 첫 콘텐츠, 오리지널 시리즈 여부를 확인했다.
- RED:
- Files:
-
Task 4.2: series count/list repository 구현
- Files:
- Modify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt
- Modify:
- RED:
SERIES조회가 활성 시리즈와 활성 크리에이터만 반환하고, 비회원은 성인 시리즈를 제외하며, 차단 관계 크리에이터의 시리즈를 제외하는 테스트를 작성한다. - RED:
dayOfWeek=MON이면series.publishedDaysOfWeek에MON이 포함된 시리즈만 반환하고dayOfWeek=RANDOM이면RANDOM포함 시리즈만 반환하는 테스트를 작성한다. - RED:
ORIGINAL조회가series.isOriginal == true만 반환하고dayOfWeek는 적용하지 않는 테스트를 작성한다. - RED:
LATEST,POPULAR,PRICE_HIGH,PRICE_LOW시리즈 정렬 테스트를 작성한다. - 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest - GREEN:
DefaultCreatorChannelSeriesQueryRepository.findSeriesIds(...)패턴을 참고해 시리즈 id 선조회 후 row를 원래 정렬 순서대로 조립한다. - GREEN: 시리즈 정렬 대표값은 공개 오디오 기준
max(releaseDate),max(price),min(price),orders.can.sum()을 사용한다. - GREEN: 시리즈 응답 필드는
seriesId,title,coverImageUrl,creatorNickname,isOriginal,isAdult만 조립한다. - REFACTOR:
MainContentSeriesResponse에서 제외된 연재 요일/연재 상태/콘텐츠 통계 필드를 조회 응답 조립용으로 불필요하게 projection하지 않는다. - 기대 결과: 시리즈/오리지널 조회의 요일 필터, count, 정렬, 최소 응답 필드가 repository 테스트로 고정된다.
- 검증 기록:
- RED:
./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest실행 시 repository 미구현 컴파일 실패를 확인했다. - GREEN: 동일 명령 성공으로 활성 시리즈/크리에이터 조건, 성인/차단 제외, 요일 필터, ORIGINAL 필터, 대표 공개 오디오 기준 정렬, 최소 시리즈 응답 필드를 확인했다.
- RED:
- Files:
Phase 5: 공개 API 통합 검증
-
Task 5.1: controller-to-repository 통합 테스트 작성
- Files:
- Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt
- Test:
- RED: Spring context 기반으로
GET /api/v2/audio/contents?type=AUDIO&sort=LATEST&page=0&size=20가audios,totalCount,sort,page,size,hasNext를 반환하고series는 빈 배열인 테스트를 작성한다. - RED:
GET /api/v2/audio/contents?type=SERIES&dayOfWeek=MON&sort=POPULAR&page=0&size=20가series,dayOfWeek=MON,audios=[]를 반환하는 테스트를 작성한다. - RED:
GET /api/v2/audio/contents?type=ORIGINAL&dayOfWeek=MON이dayOfWeek=null로 응답하고 오리지널 시리즈만 반환하는 테스트를 작성한다. - 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest - GREEN: 테스트 fixture에 공개/비공개/성인/무료/포인트/요일별 시리즈/오리지널 시리즈/차단 관계 데이터를 구성하고 end-to-end 응답을 통과시킨다.
- REFACTOR: controller, facade, service, repository 경계가 단방향 의존을 유지하는지 import를 확인한다.
- 기대 결과: 실제 HTTP 경로에서 PRD의 주요 응답 계약이 검증된다.
- 검증 기록:
- GREEN:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest성공으로AUDIO,SERIES dayOfWeek=MON,ORIGINAL dayOfWeek 무시HTTP 통합 경로를 확인했다. - 참고: Phase 1-4 구현이 이미 존재해 신규 E2E 추가 직후 타깃 테스트가 GREEN으로 통과했으므로, 별도 production 수정은 없었다.
- GREEN:
- Files:
-
Task 5.2: 회귀 테스트와 포맷 검증
- Files:
- Verify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/** - Verify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/** - Verify:
src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt - Modify:
docs/20260624_메인_콘텐츠_전체_탭_API/plan-task.md
- Verify:
- RED: 이 task는 신규 동작 추가가 아니라 전체 회귀 검증 task이므로 별도 실패 테스트를 만들지 않는다.
- TDD 예외 사유: 앞선 task에서 기능별 실패 테스트를 작성했고, 이 task는 전체 suite와 문서 검증 기록 누적이 목적이다.
- 대체 검증 방법:
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*'./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'./gradlew ktlintCheckgit diff --checkrg -n "duration|publishedDaysOfWeek|isProceeding|contentCount|paidContentCount" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all
- GREEN: 위 명령이 모두 성공하고, 응답 DTO에 제거 대상 필드가 남아 있지 않음을 확인한다.
- REFACTOR: 검증 결과를 이 문서 하단
검증 기록에 누적한다. - 기대 결과: 신규 API 패키지 테스트와 포맷 검증이 완료된다.
- 검증 기록:
- GREEN:
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*'성공. - GREEN:
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'성공. - GREEN:
./gradlew ktlintCheck성공. - GREEN:
git diff --check성공. - 확인:
rg -n "duration|publishedDaysOfWeek|isProceeding|contentCount|paidContentCount" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all실행 시 공개 DTO 소스에는 제거 대상 필드가 없고, E2E fixture의 공개 조건 설정과 DTO 테스트의 부재 검증만 검색되었다.
- GREEN:
- Files:
4. 실행 명령
- 정책 테스트:
./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicyTest - DTO 테스트:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest - Facade 테스트:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest - Controller 테스트:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest - Service 테스트:
./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest - Repository 테스트:
./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest - End-to-end 테스트:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest - 전체 신규 패키지 테스트:
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*' - 포맷 검증:
./gradlew ktlintCheck - 문서 변경 후 명령 유효성 확인:
./gradlew tasks --all
5. 검증 기록
- 2026-06-25 Phase 1-3 RED/GREEN 검증
- RED: Phase 1 정책/DTO 테스트 추가 후
MainContentAllQueryPolicy,MainContentAllType,MainContentPage,MainContentAllTabResponse,MainContentAll계열 모델 미구현 컴파일 실패를 확인했다. - GREEN:
./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicyTest성공. - GREEN:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest성공. - RED: Phase 2-3 facade/controller/service 테스트 추가 후
MainContentAllFacade,MainContentAllController,MainContentAllQueryPort,MainContentAllQueryService미구현 컴파일 실패를 확인했다. - GREEN:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest성공. - 보강:
MainContentAllQueryServiceTest에서AUDIO,FREE,POINTaudio 분기를 각각 독립 테스트로 검증하도록 분리했다. - 참고: Phase 4 repository 구현 전이므로 Spring 전체 context에서
MainContentAllQueryPort실제 bean 연결은 아직 범위 밖이다. - 참고: 실제 머지/배포 전에는 Phase 4 repository adapter bean과 Phase 5 end-to-end 테스트를 구현한 뒤 Spring 전체 context 검증을 다시 수행해야 한다.
- RED: Phase 1 정책/DTO 테스트 추가 후
- 2026-06-25 Phase 4 RED/GREEN 검증
- RED:
./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest실행 시DefaultMainContentAllQueryRepository미구현 컴파일 실패를 확인했다. - GREEN: 동일 명령 성공으로 audio/series count/list repository의 공개 조건, 성인/차단 제외, FREE/POINT/ORIGINAL/dayOfWeek 필터, 정렬, CDN URL, 최소 응답 필드를 확인했다.
- RED:
- 2026-06-25 Phase 4 코드 리뷰 및 검증
- 리뷰:
DefaultMainContentAllQueryRepository.findSeries(...)가locale파라미터를 받지만SeriesTranslation을 조회하지 않아, PRD의 언어코드 기반 시리즈 제목 fallback 요구사항을 충족하지 못하는 것을 확인했다. - 리뷰:
ContentSort.LATEST의 오디오/시리즈 정렬에price대표값이 보조 정렬로 포함되어 있어, PRD의releaseDate desc, id desc기준과 다른 순서가 나올 수 있음을 확인했다. - RED:
shouldSortAudiosByLatestReleaseDateAndIdOnly추가 후expected: <[2, 1]> but was: <[1, 2]>실패로 audioLATEST가 같은 공개일에서 price desc를 우선하는 문제를 재현했다. - RED:
shouldFindSeriesWithTranslatedTitleFallback추가 후expected: <Translated Series> but was: <origin-translated-series>실패로 series locale 번역 미적용 문제를 재현했다. - RED:
shouldSortSeriesByPublicAudioRepresentatives보강 후expected: <[6, 5, 4]> but was: <[5, 4, 6]>실패로 seriesLATEST가 같은 대표 공개일에서 highestPrice desc를 우선하는 문제를 재현했다. - GREEN:
findSeries(...)에SeriesTranslationleft join과 blank fallback을 추가하고, audio/seriesLATEST보조 정렬에서 price 대표값을 제거했다. - GREEN:
./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest성공. - GREEN:
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'성공. - GREEN:
./gradlew ktlintCheck성공. - GREEN:
git diff --check성공. - 확인:
rg -n "duration|publishedDaysOfWeek|isProceeding|contentCount|paidContentCount" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all실행 시 공개 DTO 소스에는 제거 대상 필드가 없고, DTO 테스트의 부재 검증만 검색되었다. - 확인: 위 리뷰 항목 2건은 보강 테스트와 구현 수정으로 해결했다.
- 리뷰:
- 2026-06-25 Phase 5 공개 API 통합 검증
- GREEN:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest성공으로 실제 HTTP 경로에서AUDIO는audios와 빈series,SERIES dayOfWeek=MON은series와 빈audios,ORIGINAL dayOfWeek=MON은dayOfWeek=null과 오리지널 시리즈만 반환함을 확인했다. - 참고: Phase 1-4 구현이 이미 존재해 신규 E2E 추가 직후 타깃 테스트가 GREEN으로 통과했으며, Phase 5에서 production 코드는 변경하지 않았다.
- 참고:
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*'와./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'를 동시에 실행했을 때 test result XML 파일 쓰기 충돌이 한 번 발생했다. 동일 명령을 순차 재실행해 두 테스트 모두 성공함을 확인했다. - GREEN:
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*'성공. - GREEN:
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'성공. - GREEN:
./gradlew ktlintCheck성공. - GREEN:
git diff --check성공. - 확인:
rg -n "duration|publishedDaysOfWeek|isProceeding|contentCount|paidContentCount" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all실행 시 공개 DTO 소스에는 제거 대상 필드가 없고, E2E fixture의 공개 조건 설정과 DTO 테스트의 부재 검증만 검색되었다.
- GREEN: