Files
sodalive-backend-spring-boot/docs/20260627_콘텐츠_전체보기_API/plan-task.md

42 KiB

콘텐츠 전체보기 API Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development 또는 superpowers:executing-plans로 task 단위 구현을 진행한다. 각 단계는 체크박스(- [ ])로 진행 상태를 갱신한다.

Goal: GET /api/v2/contents로 인증 회원이 NEW_AND_HOT_AUDIO, FIRST_AUDIO_CONTENT 콘텐츠 전체보기 목록을 동일한 페이징 계약으로 조회할 수 있게 한다.

Architecture: 공개 API controller/facade/response DTO는 kr.co.vividnext.sodalive.v2.api.content.overview 조립 계층에 둔다. New & Hot 조회는 기존 v2.content.recommendation 도메인 조회 계층을 확장해 재사용하고, 첫 번째 오디오 콘텐츠 조회는 기존 v2.recommendation.application.HomeRecommendationQueryService.findFirstAudioContents(...)를 재사용한다. 기존 홈 하위 전체보기 endpoint는 배포 전 기능이므로 제거하고, 새 콘텐츠 전체보기 API로 책임을 이동한다.

Tech Stack: Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Security, Spring Data JPA, QueryDSL/native SQL, Redisson, JUnit 5, MockMvc, Gradle Wrapper


0. 구현 전 확정 사항

  • API endpoint: GET /api/v2/contents
  • 인증 정책: 비회원 조회 불가. 인증 회원만 호출할 수 있다.
  • 응답 wrapper: ApiResponse.ok(...)
  • 요청 query parameter:
    • type: NEW_AND_HOT_AUDIO, FIRST_AUDIO_CONTENT; 기본값 NEW_AND_HOT_AUDIO
    • page: 0부터 시작. 기본값 0
    • size: 기본값 20, 최소값보다 작으면 20, 최대 50
  • invalid type은 400 오류 대신 NEW_AND_HOT_AUDIO로 fallback한다.
  • hasNextsize + 1개 조회 후 응답 item은 최대 size개만 내려주는 방식으로 계산한다.
  • NEW_AND_HOT_AUDIOAudioRecommendationQueryService에 페이징 조회 메서드를 추가해 조회한다.
  • New & Hot 첫 화면 노출 수는 12로 유지한다.
  • New & Hot 스냅샷 저장 수는 SAFE, ALL 각각 100으로 확장한다.
  • FIRST_AUDIO_CONTENTHomeRecommendationQueryService.findFirstAudioContents(...)를 새 콘텐츠 전체보기 Facade에서 직접 호출한다.
  • GET /api/v2/home/recommendations/first-audio-contents는 제거한다.
  • 신규 DB 테이블과 DDL은 작성하지 않는다. New & Hot 전체보기용 스냅샷은 기존 recommendation_snapshot 테이블을 재사용하고, 저장 개수만 visibility별 100개로 확장한다.

1. 파일 구조 계획

신규 API 조립 계층

  • Create: src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewController.kt
  • Create: src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacade.kt
  • Create: src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewQueryPolicy.kt
  • Create: src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt
  • Test: src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewControllerTest.kt
  • Test: src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacadeTest.kt
  • Test: src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewQueryPolicyTest.kt
  • Test: src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt

기존 도메인 조회 계층 확장

  • Modify: src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt
  • Modify: src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt
  • Modify: src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt
  • Modify: src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt
  • Test: src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt
  • Test: src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt
  • Test: src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt

미배포 홈 하위 endpoint 제거

  • Modify: src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt
  • Modify: src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt
  • Modify: src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt

통합 검증

  • Test: src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt
  • Verify: src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt
  • Verify: src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/RecommendationSnapshotPort.kt
  • Verify: src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt
  • Verify: src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt

2. 공개 응답 및 정책 초안

src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt

package kr.co.vividnext.sodalive.v2.api.content.overview.dto

import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeFirstAudioContentRecord

data class ContentOverviewPageResponse(
    val type: ContentOverviewType,
    val items: List<ContentOverviewItemResponse>,
    val page: Int,
    val size: Int,
    @JsonProperty("hasNext")
    val hasNext: Boolean
)

enum class ContentOverviewType {
    NEW_AND_HOT_AUDIO,
    FIRST_AUDIO_CONTENT;

    companion object {
        fun from(value: String?): ContentOverviewType {
            return values().firstOrNull { it.name == value } ?: NEW_AND_HOT_AUDIO
        }
    }
}

data class ContentOverviewItemResponse(
    val contentId: Long,
    val title: String,
    val coverImage: String?,
    val price: Int,
    @JsonProperty("isPointAvailable")
    val isPointAvailable: Boolean,
    val creatorNickname: String,
    @JsonProperty("isAdult")
    val isAdult: Boolean,
    @JsonProperty("isFirstContent")
    val isFirstContent: Boolean,
    @JsonProperty("isOriginalSeries")
    val isOriginalSeries: Boolean
) {
    companion object {
        fun fromNewAndHot(audio: AudioCard): ContentOverviewItemResponse {
            return ContentOverviewItemResponse(
                contentId = audio.audioContentId,
                title = audio.title,
                coverImage = audio.imageUrl,
                price = audio.price,
                isPointAvailable = audio.isPointAvailable,
                creatorNickname = audio.creatorNickname,
                isAdult = audio.isAdult,
                isFirstContent = audio.isFirstContent,
                isOriginalSeries = audio.isOriginalSeries
            )
        }

        fun fromFirstAudioContent(
            audio: HomeFirstAudioContentRecord,
            coverImage: String?
        ): ContentOverviewItemResponse {
            return ContentOverviewItemResponse(
                contentId = audio.contentId,
                title = audio.title,
                coverImage = coverImage,
                price = audio.price,
                isPointAvailable = audio.isPointAvailable,
                creatorNickname = audio.creatorNickname,
                isAdult = audio.isAdult,
                isFirstContent = true,
                isOriginalSeries = audio.isOriginalSeries
            )
        }
    }
}

src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewQueryPolicy.kt

package kr.co.vividnext.sodalive.v2.api.content.overview.application

import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewType

data class ContentOverviewPage(
    val page: Int,
    val size: Int
) {
    val offset: Long = page.toLong() * size
}

class ContentOverviewQueryPolicy {
    fun resolveType(type: String?): ContentOverviewType {
        return ContentOverviewType.from(type)
    }

    fun createPage(page: Int?, size: Int?): ContentOverviewPage {
        val resolvedPage = (page ?: DEFAULT_PAGE).coerceAtLeast(DEFAULT_PAGE)
        val requestedSize = size ?: DEFAULT_SIZE
        val resolvedSize = if (requestedSize < 1) DEFAULT_SIZE else minOf(requestedSize, MAX_SIZE)
        return ContentOverviewPage(page = resolvedPage, size = resolvedSize)
    }

    fun <T> pageItems(items: List<T>, page: ContentOverviewPage): List<T> {
        return items.take(page.size)
    }

    fun <T> hasNext(items: List<T>, page: ContentOverviewPage): Boolean {
        return items.size > page.size
    }

    companion object {
        const val DEFAULT_PAGE = 0
        const val DEFAULT_SIZE = 20
        const val MAX_SIZE = 50
    }
}

3. 테스트 helper 기준

아래 helper는 각 테스트 파일에서 필요한 범위만 복사해 사용한다. Kotlin 1.6.21을 사용하므로 enum 변환 구현에는 entries가 아니라 values()를 사용한다.

private fun member(id: Long): Member {
    return Member(
        email = "viewer$id@test.com",
        password = "password",
        nickname = "viewer$id",
        role = MemberRole.USER
    ).apply {
        this.id = id
    }
}

private fun audioCard(id: Long): AudioCard {
    return AudioCard(
        audioContentId = id,
        title = "audio$id",
        duration = "00:01",
        imageUrl = "https://cdn.test/audio$id.png",
        price = id.toInt(),
        isAdult = false,
        isPointAvailable = true,
        isFirstContent = true,
        isOriginalSeries = false,
        creatorNickname = "creator$id"
    )
}

private fun firstAudio(id: Long): HomeFirstAudioContentRecord {
    return HomeFirstAudioContentRecord(
        contentId = id,
        creatorId = id + 100,
        creatorNickname = "creator$id",
        creatorProfileImage = null,
        title = "first audio$id",
        price = id.toInt(),
        coverImage = "cover/audio$id.png",
        isPointAvailable = true,
        isAdult = false,
        isOriginalSeries = false
    )
}

private fun snapshot(
    sectionType: RecommendedSectionType,
    targetId: Long,
    score: Double = 100.0 - targetId,
    snapshotAt: LocalDateTime = LocalDateTime.of(2026, 6, 26, 23, 59, 59)
): RecommendationSnapshotRecord {
    return RecommendationSnapshotRecord(
        sectionType = sectionType,
        targetId = targetId,
        score = score,
        snapshotAt = snapshotAt,
        randomTieBreaker = targetId.toDouble() / 1000
    )
}

private fun anyLocalDateTime(): LocalDateTime {
    return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.of(2026, 6, 27, 0, 0)
}

private fun emptyResponse(type: ContentOverviewType): ContentOverviewPageResponse {
    return ContentOverviewPageResponse(
        type = type,
        items = emptyList(),
        page = 0,
        size = 20,
        hasNext = false
    )
}

Phase 1: 콘텐츠 전체보기 응답/요청 정책 작성

  • Task 1.1: ContentOverview DTO 직렬화 테스트 작성

    • Files:
      • Create: src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt
      • Create: src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt
    • RED: ContentOverviewPageResponseContentOverviewItemResponseJsonProperty 필드명을 검증하는 실패 테스트를 작성한다.
    • 테스트 코드 기준:
      class ContentOverviewPageResponseTest {
          private val objectMapper = jacksonObjectMapper()
      
          @Test
          fun shouldSerializeContentOverviewPageResponse() {
              val response = ContentOverviewPageResponse(
                  type = ContentOverviewType.NEW_AND_HOT_AUDIO,
                  items = listOf(
                      ContentOverviewItemResponse(
                          contentId = 1L,
                          title = "audio",
                          coverImage = "https://cdn.test/audio.png",
                          price = 10,
                          isPointAvailable = true,
                          creatorNickname = "creator",
                          isAdult = false,
                          isFirstContent = true,
                          isOriginalSeries = false
                      )
                  ),
                  page = 0,
                  size = 20,
                  hasNext = true
              )
      
              val json = objectMapper.readTree(objectMapper.writeValueAsString(response))
      
              assertEquals("NEW_AND_HOT_AUDIO", json["type"].asText())
              assertEquals(true, json["hasNext"].asBoolean())
              assertEquals(1L, json["items"][0]["contentId"].asLong())
              assertEquals("https://cdn.test/audio.png", json["items"][0]["coverImage"].asText())
              assertEquals(true, json["items"][0]["isPointAvailable"].asBoolean())
              assertEquals(false, json["items"][0]["isAdult"].asBoolean())
              assertEquals(true, json["items"][0]["isFirstContent"].asBoolean())
              assertEquals(false, json["items"][0]["isOriginalSeries"].asBoolean())
              assertEquals(false, json["items"][0].has("audioContentId"))
              assertEquals(false, json["items"][0].has("imageUrl"))
              assertEquals(false, json["items"][0].has("duration"))
              assertEquals(false, json["items"][0].has("creatorId"))
              assertEquals(false, json["items"][0].has("creatorProfileImage"))
              assertEquals(false, json["items"][0].has("pointAvailable"))
              assertEquals(false, json["items"][0].has("adult"))
              assertEquals(false, json["items"][0].has("firstContent"))
              assertEquals(false, json["items"][0].has("originalSeries"))
          }
      }
      
    • 실패 확인 명령: ./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponseTest
    • 기대 결과: DTO 파일이 없어서 compileTestKotlin 실패.
    • GREEN: 위 DTO 초안을 추가하고 테스트를 통과시킨다.
    • 통과 확인 명령: ./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponseTest
    • REFACTOR: import 정리 후 같은 테스트를 재실행한다.
    • 검증 기록:
      • RED: ./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponseTest 실행 시 ContentOverviewPageResponse, ContentOverviewType, ContentOverviewItemResponse 미구현으로 compileTestKotlin 실패.
      • GREEN: DTO 구현 후 같은 명령 재실행, BUILD SUCCESSFUL.
      • REVIEW 보완: fromFirstAudioContent(...)가 성인/오리지널 플래그를 전달하는 테스트를 추가했다. 보완 RED는 isAdult, isOriginalSeries 파라미터 미존재로 compileTestKotlin 실패했고, 시그니처 보강 후 같은 DTO 테스트가 BUILD SUCCESSFUL.
  • Task 1.2: ContentOverviewQueryPolicy 테스트와 구현 작성

    • Files:
      • Create: src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewQueryPolicyTest.kt
      • Create: src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewQueryPolicy.kt
    • RED: type/page/size 보정 정책 실패 테스트를 작성한다.
    • 테스트 코드 기준:
      class ContentOverviewQueryPolicyTest {
          private val policy = ContentOverviewQueryPolicy()
      
          @Test
          fun shouldResolveTypeWithDefaultFallback() {
              assertEquals(ContentOverviewType.NEW_AND_HOT_AUDIO, policy.resolveType(null))
              assertEquals(ContentOverviewType.NEW_AND_HOT_AUDIO, policy.resolveType("UNKNOWN"))
              assertEquals(ContentOverviewType.FIRST_AUDIO_CONTENT, policy.resolveType("FIRST_AUDIO_CONTENT"))
          }
      
          @Test
          fun shouldNormalizePageAndSize() {
              assertEquals(ContentOverviewPage(page = 0, size = 20), policy.createPage(null, null))
              assertEquals(ContentOverviewPage(page = 0, size = 20), policy.createPage(-1, 0))
              assertEquals(ContentOverviewPage(page = 2, size = 50), policy.createPage(2, 100))
          }
      
          @Test
          fun shouldCalculatePageItemsAndHasNext() {
              val page = ContentOverviewPage(page = 0, size = 2)
              val items = listOf(1, 2, 3)
      
              assertEquals(listOf(1, 2), policy.pageItems(items, page))
              assertEquals(true, policy.hasNext(items, page))
          }
      }
      
    • 실패 확인 명령: ./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewQueryPolicyTest
    • 기대 결과: policy 파일이 없어서 compileTestKotlin 실패.
    • GREEN: 위 policy 초안을 추가하고 테스트를 통과시킨다.
    • 통과 확인 명령: ./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewQueryPolicyTest
    • REFACTOR: ContentOverviewType.from(...)와 page 보정 로직이 DTO/Facade에 중복되지 않게 유지하고 같은 테스트를 재실행한다.
    • 검증 기록:
      • RED: ./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewQueryPolicyTest 실행 시 ContentOverviewQueryPolicy, ContentOverviewPage 미구현으로 compileTestKotlin 실패.
      • GREEN: policy 구현 후 같은 명령 재실행, BUILD SUCCESSFUL.
      • REVIEW 보완: size = 19가 기본 size 20으로 보정되는 테스트를 추가하고, MIN_SIZE = 20 정책을 반영했다. 보완 후 같은 policy 테스트가 BUILD SUCCESSFUL.
      • REVIEW 보완: 큰 page 입력에서 offset이 Int overflow 되지 않도록 offset: Long = page.toLong() * size로 변경했다. 보완 RED는 Int.MAX_VALUE, size = 50 offset assertion 실패였고, 수정 후 같은 policy 테스트가 BUILD SUCCESSFUL.
      • REVIEW 보완: 후속 Phase에서 ContentOverviewPage.offset을 그대로 넘길 수 있도록 RecommendationSnapshotPort, HomeRecommendationQueryPort, 관련 service/adapter/repository offset 계약과 문서 예시를 Long으로 정렬했다.
      • Phase 1 묶음: ./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.*' 실행, BUILD SUCCESSFUL.
      • Lint: ./gradlew ktlintCheck 실행, BUILD SUCCESSFUL.
      • 참고: ./gradlew test 전체 실행은 다수 테스트의 XML 결과 파일 write 실패로 중단되어 Phase 1 로직 실패로 보지 않는다.

Phase 2: New & Hot 스냅샷 저장 수와 페이징 조회 분리

  • Task 2.1: New & Hot 스냅샷 저장 limit 100 테스트 작성

    • Files:
      • Modify: src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt
      • Modify: src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt
    • RED: refreshDailySnapshots(now)가 New & Hot 후보 조회 시 limit = 100을 전달하는 실패 테스트를 추가한다.
    • 테스트 코드 기준:
      @Test
      @DisplayName("New & Hot 스냅샷은 visibility별 100개 후보를 저장한다")
      fun shouldRequestOneHundredNewAndHotSnapshotsPerVisibility() {
          val snapshotPort = FakeRecommendationSnapshotPort()
          val queryPort = Mockito.mock(AudioRecommendationQueryPort::class.java)
          val service = AudioRecommendationSnapshotRefreshService(snapshotPort, queryPort)
          val now = LocalDateTime.of(2026, 6, 27, 0, 0, 0)
          val snapshotAt = LocalDateTime.of(2026, 6, 26, 23, 59, 59)
          val windowStart = LocalDateTime.of(2026, 6, 24, 0, 0, 0)
      
          service.refreshDailySnapshots(now)
      
          Mockito.verify(queryPort).findNewAndHotSnapshots(windowStart, snapshotAt, AudioRecommendationVisibility.SAFE, 100)
          Mockito.verify(queryPort).findNewAndHotSnapshots(windowStart, snapshotAt, AudioRecommendationVisibility.ALL, 100)
      }
      
    • 실패 확인 명령: ./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest
    • 기대 결과: 현재 구현이 NEW_AND_HOT_LIMIT = 12를 사용하므로 verify가 실패.
    • GREEN: AudioRecommendationSnapshotRefreshService에서 NEW_AND_HOT_SNAPSHOT_LIMIT = 100을 추가하고 New & Hot 저장 조회에 사용한다.
    • 구현 기준:
      companion object {
          const val NEW_AND_HOT_SNAPSHOT_LIMIT = 100
          const val MOST_COMMENTED_LIMIT = 5
          const val RECOMMENDED_AUDIO_LIMIT = 10
          private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul")
      }
      
    • 통과 확인 명령: ./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest
    • REFACTOR: 기존 NEW_AND_HOT_LIMIT 이름이 남아 있으면 저장 limit 의미가 드러나는 NEW_AND_HOT_SNAPSHOT_LIMIT으로 정리하고 같은 테스트를 재실행한다.
    • 검증 기록:
      • RED: ./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest 실행 시 shouldRequestOneHundredNewAndHotSnapshotsPerVisibility가 기존 limit = 12 호출과 기대 100 차이로 ArgumentsAreDifferent 실패.
      • GREEN: NEW_AND_HOT_SNAPSHOT_LIMIT = 100으로 저장 조회 limit을 분리한 뒤 같은 명령 재실행, BUILD SUCCESSFUL.
  • Task 2.2: AudioRecommendationQueryService의 첫 화면 12개 조회 회귀 테스트 작성

    • Files:
      • Modify: src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt
      • Modify: src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt
    • RED: getRecommendations(member)는 New & Hot 첫 화면 조회 시 여전히 12개만 요청하는 회귀 테스트를 추가한다.
    • 테스트 코드 기준:
      @Test
      @DisplayName("추천 탭 첫 화면은 New & Hot 스냅샷을 12개만 조회한다")
      fun shouldKeepNewAndHotHomeLimitAtTwelve() {
          val member = member(id = 10L)
          Mockito.doReturn(true).`when`(memberContentPreferenceService).canViewAdultContent(member)
          Mockito.doReturn(emptyList<RecommendationSnapshotRecord>()).`when`(snapshotPort)
              .findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, limit = 12)
      
          queryService.getRecommendations(member)
      
          Mockito.verify(snapshotPort).findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, limit = 12)
      }
      
    • 실패 확인 명령: ./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest
    • 기대 결과: 상수명을 아직 분리하지 않았거나 test helper가 없으면 컴파일 또는 verify 실패.
    • GREEN: AudioRecommendationQueryServiceNEW_AND_HOT_HOME_LIMIT = 12를 추가하고 첫 화면 조회와 lazy refresh 재조회에 사용한다.
    • 구현 기준:
      companion object {
          const val NEW_AND_HOT_HOME_LIMIT = 12
          // 기존 NEW_AND_HOT_AUDIO_LIMIT 사용처는 첫 화면 의미이면 NEW_AND_HOT_HOME_LIMIT로 교체한다.
      }
      
    • 통과 확인 명령: ./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest
    • REFACTOR: 첫 화면 limit과 스냅샷 저장 limit 이름이 섞이지 않게 import/상수명을 정리하고 같은 테스트를 재실행한다.
    • 검증 기록:
      • RED: ./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest 실행 시 NEW_AND_HOT_HOME_LIMIT 미구현으로 compileTestKotlin 실패.
      • GREEN: NEW_AND_HOT_HOME_LIMIT = 12를 추가하고 홈 첫 화면 조회와 lazy refresh 재조회에서 사용하도록 정리한 뒤 같은 명령 재실행, BUILD SUCCESSFUL.
  • Task 2.3: New & Hot 전체보기 페이징 조회 테스트 작성

    • Files:
      • Modify: src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt
      • Modify: src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt
    • RED: findNewAndHotAudios(member, offset, limit)가 visibility, offset, limit을 반영하고 상세 조회 순서를 유지하는 실패 테스트를 작성한다.
    • 테스트 코드 기준:
      @Test
      @DisplayName("New & Hot 전체보기는 스냅샷 offset과 limit으로 오디오 카드를 조회한다")
      fun shouldFindNewAndHotAudiosWithOffsetAndLimit() {
          val member = member(id = 10L)
          val nowSnapshots = listOf(
              snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, targetId = 3L),
              snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, targetId = 4L),
              snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, targetId = 5L)
          )
          Mockito.doReturn(true).`when`(memberContentPreferenceService).canViewAdultContent(member)
          Mockito.doReturn(nowSnapshots).`when`(snapshotPort)
              .findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, offset = 20L, limit = 21)
          Mockito.doReturn(listOf(audioCard(3L), audioCard(4L), audioCard(5L))).`when`(queryPort)
              .findAudioCardsByIds(listOf(3L, 4L, 5L), member.id, true, anyLocalDateTime())
      
          val result = queryService.findNewAndHotAudios(member, offset = 20L, limit = 21)
      
          assertEquals(listOf(3L, 4L, 5L), result.map { it.audioContentId })
          Mockito.verify(snapshotPort).findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, offset = 20L, limit = 21)
      }
      
    • 실패 확인 명령: ./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest
    • 기대 결과: findNewAndHotAudios 메서드가 없어 compileTestKotlin 실패.
    • GREEN: AudioRecommendationQueryService.findNewAndHotAudios(member, offset, limit)를 추가한다.
    • 구현 기준:
      fun findNewAndHotAudios(member: Member, offset: Long, limit: Int): List<AudioCard> {
          val now = LocalDateTime.now()
          val canViewAdultContent = canViewAdultContent(member)
          val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE
          val sectionType = newAndHotSectionType(visibility)
          val snapshots = findNewAndHotSnapshotsWithLazyRefresh(sectionType, offset, limit)
      
          return queryPort.findAudioCardsByIds(
              snapshots.map { it.targetId },
              member.id,
              canViewAdultContent,
              now
          )
      }
      
    • 통과 확인 명령: ./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest
    • REFACTOR: 기존 refreshMissingNewAndHotSnapshots(...)는 첫 화면과 전체보기에서 공통 사용 가능한 private 함수로 정리하고 같은 테스트를 재실행한다.
    • 검증 기록:
      • RED: ./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest 실행 시 findNewAndHotAudios 미구현으로 compileTestKotlin 실패.
      • GREEN: findNewAndHotAudios(member, offset, limit)를 추가하고 기존 lazy refresh 재조회가 동일 offset, limit을 사용하도록 보강한 뒤 같은 명령 재실행, BUILD SUCCESSFUL.

Phase 3: 콘텐츠 전체보기 API 조립 계층 작성

  • Task 3.1: ContentOverviewFacade 테스트 작성

    • Files:
      • Create: src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacadeTest.kt
      • Create: src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacade.kt
      • Modify: src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt
      • Modify: src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt
    • RED: NEW_AND_HOT_AUDIOFIRST_AUDIO_CONTENT를 각각 조회해 ContentOverviewPageResponse로 변환하는 실패 테스트를 작성한다.
    • 테스트 코드 기준:
      class ContentOverviewFacadeTest {
          private val audioRecommendationQueryService = Mockito.mock(AudioRecommendationQueryService::class.java)
          private val homeRecommendationQueryService = Mockito.mock(HomeRecommendationQueryService::class.java)
          private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
          private val facade = ContentOverviewFacade(
              audioRecommendationQueryService = audioRecommendationQueryService,
              homeRecommendationQueryService = homeRecommendationQueryService,
              memberContentPreferenceService = memberContentPreferenceService,
              cloudFrontHost = "https://cdn.test",
              queryPolicy = ContentOverviewQueryPolicy()
          )
      
          @Test
          fun shouldReturnNewAndHotPage() {
              val member = member(id = 10L)
              Mockito.doReturn(listOf(audioCard(1L), audioCard(2L), audioCard(3L))).`when`(audioRecommendationQueryService)
                  .findNewAndHotAudios(member, offset = 0L, limit = 3)
      
              val response = facade.getContents("NEW_AND_HOT_AUDIO", page = 0, size = 2, member = member)
      
              assertEquals(ContentOverviewType.NEW_AND_HOT_AUDIO, response.type)
              assertEquals(listOf(1L, 2L), response.items.map { it.contentId })
              assertEquals(listOf("https://cdn.test/audio1.png", "https://cdn.test/audio2.png"), response.items.map { it.coverImage })
              assertEquals(true, response.hasNext)
          }
      
          @Test
          fun shouldReturnFirstAudioContentPage() {
              val member = member(id = 10L)
              Mockito.doReturn(true).`when`(memberContentPreferenceService).canViewAdultContent(member)
              Mockito.doReturn(listOf(firstAudio(1L), firstAudio(2L))).`when`(homeRecommendationQueryService)
                  .findFirstAudioContents(anyLocalDateTime(), offset = 20L, limit = 21, memberId = member.id, includeAdultContents = true)
      
              val response = facade.getContents("FIRST_AUDIO_CONTENT", page = 1, size = 20, member = member)
      
              assertEquals(ContentOverviewType.FIRST_AUDIO_CONTENT, response.type)
              assertEquals(listOf(1L, 2L), response.items.map { it.contentId })
              assertEquals("https://cdn.test/cover/audio1.png", response.items[0].coverImage)
              assertEquals(true, response.items[0].isFirstContent)
          }
      }
      
    • 실패 확인 명령: ./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewFacadeTest
    • 기대 결과: Facade 파일이 없어 compileTestKotlin 실패.
    • GREEN: ContentOverviewFacade를 추가하고 size + 1 조회, item take(size), hasNext 계산을 구현한다.
    • GREEN: HomeFirstAudioContentRecordisAdult: Boolean, isOriginalSeries: Boolean 필드를 추가하고, DefaultHomeRecommendationQueryRepository.findFirstAudioContents(...)가 해당 값을 조회해 채우도록 보강한다.
    • 구현 기준:
      fun getContents(type: String?, page: Int?, size: Int?, member: Member): ContentOverviewPageResponse {
          val resolvedType = queryPolicy.resolveType(type)
          val resolvedPage = queryPolicy.createPage(page, size)
      
          return when (resolvedType) {
              ContentOverviewType.NEW_AND_HOT_AUDIO -> getNewAndHotContents(member, resolvedPage)
              ContentOverviewType.FIRST_AUDIO_CONTENT -> getFirstAudioContents(member, resolvedPage)
          }
      }
      
    • 통과 확인 명령: ./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewFacadeTest
    • REFACTOR: coverImage CDN URL 변환은 String?.toCdnUrl(cloudFrontHost)를 사용하고, 타입별 전용 필드 없이 ContentOverviewItemResponse의 동일 필드만 채우는지 확인한 뒤 같은 테스트를 재실행한다.
  • Task 3.2: ContentOverviewController 인증/파라미터 테스트 작성

    • Files:
      • Create: src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewControllerTest.kt
      • Create: src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewController.kt
    • RED: 비회원 요청은 401, 인증 회원 요청은 facade에 type/page/size/member를 전달하는 실패 테스트를 작성한다.
    • 테스트 코드 기준:
      @WebMvcTest(ContentOverviewController::class)
      @Import(SecurityConfig::class)
      class ContentOverviewControllerTest @Autowired constructor(
          private val mockMvc: MockMvc
      ) {
          @MockBean
          private lateinit var facade: ContentOverviewFacade
      
          @Test
          fun shouldRejectAnonymousRequest() {
              mockMvc.perform(get("/api/v2/contents"))
                  .andExpect(status().isUnauthorized)
          }
      
          @Test
          fun shouldPassAuthenticatedMemberAndQueryParameters() {
              val member = member(id = 10L)
              Mockito.doReturn(emptyResponse(ContentOverviewType.FIRST_AUDIO_CONTENT)).`when`(facade)
                  .getContents("FIRST_AUDIO_CONTENT", 1, 30, member)
      
              mockMvc.perform(
                  get("/api/v2/contents")
                      .param("type", "FIRST_AUDIO_CONTENT")
                      .param("page", "1")
                      .param("size", "30")
                      .with(user(MemberAdapter(member)))
              )
                  .andExpect(status().isOk)
                  .andExpect(jsonPath("$.success").value(true))
                  .andExpect(jsonPath("$.data.type").value("FIRST_AUDIO_CONTENT"))
      
              Mockito.verify(facade).getContents("FIRST_AUDIO_CONTENT", 1, 30, member)
          }
      }
      
    • 실패 확인 명령: ./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewControllerTest
    • 기대 결과: Controller 파일이 없어 compileTestKotlin 실패.
    • GREEN: ContentOverviewController를 추가한다.
    • 구현 기준:
      @RestController
      @RequestMapping("/api/v2/contents")
      class ContentOverviewController(
          private val facade: ContentOverviewFacade
      ) {
          @GetMapping
          fun getContents(
              @RequestParam(required = false) type: String?,
              @RequestParam(required = false) page: Int?,
              @RequestParam(required = false) size: Int?,
              @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
          ) = run {
              ApiResponse.ok(facade.getContents(type, page, size, requireMember(member)))
          }
      
          private fun requireMember(member: Member?): Member {
              return member ?: throw SodaException(messageKey = "common.error.bad_credentials")
          }
      }
      
    • 통과 확인 명령: ./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewControllerTest
    • REFACTOR: SecurityConfig/api/v2/contents permitAll을 추가하지 않았는지 확인하고 같은 테스트를 재실행한다.

Phase 4: 미배포 홈 하위 전체보기 endpoint 제거

  • Task 4.1: 홈 하위 first-audio-contents 제거 테스트 갱신

    • Files:
      • Modify: src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt
      • Modify: src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt
      • Modify: src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt
    • RED: /api/v2/home/recommendations/first-audio-contents가 더 이상 성공 endpoint가 아님을 확인하는 테스트로 갱신한다.
    • 테스트 코드 기준:
      @Test
      @DisplayName("미배포 first-audio-contents 홈 하위 endpoint는 제거된다")
      fun shouldNotExposeDeprecatedFirstAudioContentsEndpoint() {
          val member = saveMember("home-viewer", MemberRole.USER)
          entityManager.flush()
          entityManager.clear()
      
          mockMvc.perform(
              get("/api/v2/home/recommendations/first-audio-contents")
                  .with(user(MemberAdapter(member)))
          )
              .andExpect(status().isNotFound)
      }
      
    • 실패 확인 명령: ./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest
    • 기대 결과: 기존 endpoint가 남아 있으면 200 OK로 응답해 테스트 실패.
    • GREEN: HomeRecommendationController.getFirstAudioContents(...)를 제거하고, HomeRecommendationFacade.getFirstAudioContents(...)와 관련 로그 section 처리만 제거한다.
    • 통과 확인 명령: ./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest
    • REFACTOR: HomeRecommendationQueryService.findFirstAudioContents(...)는 새 API에서 재사용하므로 제거하지 않았는지 확인하고 같은 테스트를 재실행한다.
  • Task 4.2: 홈 전체보기 인증 경로 목록 회귀 테스트 정리

    • Files:
      • Modify: src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt
    • RED: 기존 테스트의 경로 목록에서 /first-audio-contents를 제거하고 /lives, /debut-creators, /ai-characters만 홈 하위 전체보기 endpoint로 검증하도록 갱신한다.
    • 실패 확인 명령: ./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest
    • 기대 결과: controller 제거와 테스트 기대값이 어긋나면 실패.
    • GREEN: 홈 추천 controller 테스트에서 first-audio-contents 성공 응답, facade 실패 로그 검증, 경로 반복 목록을 모두 새 정책에 맞춰 정리한다.
    • 통과 확인 명령: ./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest
    • REFACTOR: 홈 추천 첫 화면의 firstAudioContents 필드와 HOME_FIRST_AUDIO_CONTENT_LIMIT는 유지되어야 하므로 삭제하지 않았는지 확인한다.

Phase 5: End-to-End 검증

  • Task 5.1: 콘텐츠 전체보기 E2E 테스트 작성

    • Files:
      • Create: src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt
    • RED: 인증 회원 기준 NEW_AND_HOT_AUDIO, FIRST_AUDIO_CONTENTApiResponse.okitems/page/size/hasNext를 반환하는 E2E 실패 테스트를 작성한다.
    • 테스트 범위:
      • 비회원 GET /api/v2/contents는 401
      • 인증 회원 GET /api/v2/contents?type=NEW_AND_HOT_AUDIO는 200, data.type = NEW_AND_HOT_AUDIO
      • 인증 회원 GET /api/v2/contents?type=FIRST_AUDIO_CONTENT는 200, data.type = FIRST_AUDIO_CONTENT
      • invalid type은 NEW_AND_HOT_AUDIO로 fallback
    • 실패 확인 명령: ./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewEndToEndTest
    • 기대 결과: API 구현 전에는 endpoint 미존재 또는 bean 미구성으로 실패.
    • GREEN: Phase 1~4 구현을 통합해 E2E 테스트를 통과시킨다.
    • 통과 확인 명령: ./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewEndToEndTest
    • REFACTOR: 테스트 데이터가 다른 추천 테스트와 충돌하지 않도록 각 테스트에서 저장한 데이터만 사용하고, 같은 테스트를 재실행한다.
  • Task 5.2: 전체 관련 테스트와 ktlint 검증

    • Files:
      • Verify: src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/**
      • Verify: src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt
      • Verify: src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt
      • Verify: src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/**
    • RED: 신규/수정 테스트를 한 번에 실행해 남은 컴파일 오류나 회귀를 확인한다.
    • 실행 명령:
      • ./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.*'
      • ./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest
      • ./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest
    • 기대 결과: 모든 명령 BUILD SUCCESSFUL.
    • GREEN: 실패가 있으면 해당 task 문서 체크박스를 되돌리고 RED/GREEN 단계로 돌아가 수정한다.
    • REFACTOR: ./gradlew ktlintCheck를 실행하고 BUILD SUCCESSFUL을 확인한다.

검증 기록

  • 구현 전 문서 생성 단계에서는 코드 변경이 없으므로 단위 테스트를 실행하지 않는다.
  • 문서 변경 후 명령 유효성은 ./gradlew tasks --all로 확인한다.
  • 구현 중 각 task 완료 즉시 해당 task 아래에 실행 명령, 결과, 실패 원인과 수정 내용을 한국어로 누적 기록한다.

Self-Review Checklist

  • PRD의 endpoint GET /api/v2/contents는 Phase 3과 Phase 5에서 구현/검증한다.
  • 비회원 조회 불가는 Phase 3 controller 테스트와 Phase 5 E2E 테스트에서 검증한다.
  • NEW_AND_HOT_AUDIO 스냅샷 저장 수 100개는 Phase 2에서 검증한다.
  • New & Hot 첫 화면 12개 유지 회귀는 Phase 2에서 검증한다.
  • FIRST_AUDIO_CONTENT 조회 재사용은 Phase 3 Facade 테스트와 Phase 5 E2E 테스트에서 검증한다.
  • 미배포 홈 하위 endpoint 제거는 Phase 4에서 검증한다.
  • 신규 DB 테이블이 없다는 제약은 파일 구조 계획과 Phase 5 검증 범위에 반영했다.