Files
sodalive-backend-spring-boot/docs/20260624_메인_콘텐츠_전체_탭_API/plan-task.md

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; 기본값 AUDIO
    • sort: LATEST, POPULAR, PRICE_HIGH, PRICE_LOW; 기본값 LATEST
    • dayOfWeek: type=SERIES에서만 적용. SeriesPublishedDaysOfWeekSUN, MON, TUE, WED, THU, FRI, SAT, RANDOM
    • page: 0부터 시작. 기본값 0
    • size: 기본값 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, POINTaudios만 채우고 series는 빈 배열로 내려준다.
  • SERIES, ORIGINALseries만 채우고 audios는 빈 배열로 내려준다.
  • 공개 오디오 조건: audioContent.isActive == true, duration != null, releaseDate != null, releaseDate <= now, 활성 테마, 활성 크리에이터.
  • 공개 시리즈 조건: series.isActive == true, 활성 크리에이터. 성인 콘텐츠 노출 불가이면 series.isAdult == false.
  • 회원이 차단했거나 회원을 차단한 크리에이터의 콘텐츠/시리즈는 제외한다.
  • 신규 Entity와 DDL은 작성하지 않는다.
  • SecurityConfig에는 GET /api/v2/audio/contents permitAll 설정을 추가한다.

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
    • 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: OWNED fallback과 invalid dayOfWeek fallback이 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 정책을 확인했다.
  • 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
    • 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: MainContentAudioResponseduration, MainContentSeriesResponsepublishedDaysOfWeek, 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 변환과 boolean is* JSON 필드명을 확인했다.

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
    • 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로 변환함을 확인했다.
  • 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
    • 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?, optional member: Member?로 controller를 구현한다.
    • GREEN: SecurityConfigantMatchers(HttpMethod.GET, "/api/v2/audio/contents").permitAll()을 추가한다.
    • REFACTOR: ContentSortSeriesPublishedDaysOfWeek를 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/contents 200 OK, query parameter/member 전달, SecurityConfig permitAll 설정을 확인했다.

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
    • RED: AUDIO, FREE, POINT type이 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: SERIES type이 dayOfWeek=MON을 series count/list port에 전달하고 ORIGINAL type은 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/POINT audio 분기, SERIES/ORIGINAL series 분기, limit = size + 1, hasNext 처리를 확인했다.
  • 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
    • 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) 결과 전달을 확인했다.

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
    • 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, 첫 콘텐츠, 오리지널 시리즈 여부를 확인했다.
  • 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
    • RED: SERIES 조회가 활성 시리즈와 활성 크리에이터만 반환하고, 비회원은 성인 시리즈를 제외하며, 차단 관계 크리에이터의 시리즈를 제외하는 테스트를 작성한다.
    • RED: dayOfWeek=MON이면 series.publishedDaysOfWeekMON이 포함된 시리즈만 반환하고 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 필터, 대표 공개 오디오 기준 정렬, 최소 시리즈 응답 필드를 확인했다.

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
    • RED: Spring context 기반으로 GET /api/v2/audio/contents?type=AUDIO&sort=LATEST&page=0&size=20audios, totalCount, sort, page, size, hasNext를 반환하고 series는 빈 배열인 테스트를 작성한다.
    • RED: GET /api/v2/audio/contents?type=SERIES&dayOfWeek=MON&sort=POPULAR&page=0&size=20series, dayOfWeek=MON, audios=[]를 반환하는 테스트를 작성한다.
    • RED: GET /api/v2/audio/contents?type=ORIGINAL&dayOfWeek=MONdayOfWeek=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 수정은 없었다.
  • 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
    • 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 ktlintCheck
      • 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
    • 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 테스트의 부재 검증만 검색되었다.

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, POINT audio 분기를 각각 독립 테스트로 검증하도록 분리했다.
    • 참고: Phase 4 repository 구현 전이므로 Spring 전체 context에서 MainContentAllQueryPort 실제 bean 연결은 아직 범위 밖이다.
    • 참고: 실제 머지/배포 전에는 Phase 4 repository adapter bean과 Phase 5 end-to-end 테스트를 구현한 뒤 Spring 전체 context 검증을 다시 수행해야 한다.
  • 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, 최소 응답 필드를 확인했다.
  • 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]> 실패로 audio LATEST가 같은 공개일에서 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]> 실패로 series LATEST가 같은 대표 공개일에서 highestPrice desc를 우선하는 문제를 재현했다.
    • GREEN: findSeries(...)SeriesTranslation left join과 blank fallback을 추가하고, audio/series LATEST 보조 정렬에서 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 경로에서 AUDIOaudios와 빈 series, SERIES dayOfWeek=MONseries와 빈 audios, ORIGINAL dayOfWeek=MONdayOfWeek=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 테스트의 부재 검증만 검색되었다.