# 콘텐츠 전체보기 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한다. - `hasNext`는 `size + 1`개 조회 후 응답 item은 최대 `size`개만 내려주는 방식으로 계산한다. - `NEW_AND_HOT_AUDIO`는 `AudioRecommendationQueryService`에 페이징 조회 메서드를 추가해 조회한다. - New & Hot 첫 화면 노출 수는 `12`로 유지한다. - New & Hot 스냅샷 저장 수는 `SAFE`, `ALL` 각각 `100`으로 확장한다. - `FIRST_AUDIO_CONTENT`는 `HomeRecommendationQueryService.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` ```kotlin 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, 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` ```kotlin 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 pageItems(items: List, page: ContentOverviewPage): List { return items.take(page.size) } fun hasNext(items: List, 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()`를 사용한다. ```kotlin 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: 콘텐츠 전체보기 응답/요청 정책 작성 - [x] **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: `ContentOverviewPageResponse`와 `ContentOverviewItemResponse`의 `JsonProperty` 필드명을 검증하는 실패 테스트를 작성한다. - 테스트 코드 기준: ```kotlin 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`. - [x] **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 보정 정책 실패 테스트를 작성한다. - 테스트 코드 기준: ```kotlin 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`을 전달하는 실패 테스트를 추가한다. - 테스트 코드 기준: ```kotlin @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 저장 조회에 사용한다. - 구현 기준: ```kotlin 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`으로 정리하고 같은 테스트를 재실행한다. - [ ] **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개만 요청하는 회귀 테스트를 추가한다. - 테스트 코드 기준: ```kotlin @Test @DisplayName("추천 탭 첫 화면은 New & Hot 스냅샷을 12개만 조회한다") fun shouldKeepNewAndHotHomeLimitAtTwelve() { val member = member(id = 10L) Mockito.doReturn(true).`when`(memberContentPreferenceService).canViewAdultContent(member) Mockito.doReturn(emptyList()).`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: `AudioRecommendationQueryService`에 `NEW_AND_HOT_HOME_LIMIT = 12`를 추가하고 첫 화면 조회와 lazy refresh 재조회에 사용한다. - 구현 기준: ```kotlin 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/상수명을 정리하고 같은 테스트를 재실행한다. - [ ] **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을 반영하고 상세 조회 순서를 유지하는 실패 테스트를 작성한다. - 테스트 코드 기준: ```kotlin @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)`를 추가한다. - 구현 기준: ```kotlin fun findNewAndHotAudios(member: Member, offset: Long, limit: Int): List { 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 함수로 정리하고 같은 테스트를 재실행한다. --- ### 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_AUDIO`와 `FIRST_AUDIO_CONTENT`를 각각 조회해 `ContentOverviewPageResponse`로 변환하는 실패 테스트를 작성한다. - 테스트 코드 기준: ```kotlin 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: `HomeFirstAudioContentRecord`에 `isAdult: Boolean`, `isOriginalSeries: Boolean` 필드를 추가하고, `DefaultHomeRecommendationQueryRepository.findFirstAudioContents(...)`가 해당 값을 조회해 채우도록 보강한다. - 구현 기준: ```kotlin 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를 전달하는 실패 테스트를 작성한다. - 테스트 코드 기준: ```kotlin @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`를 추가한다. - 구현 기준: ```kotlin @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가 아님을 확인하는 테스트로 갱신한다. - 테스트 코드 기준: ```kotlin @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_CONTENT`가 `ApiResponse.ok`와 `items/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 검증 범위에 반영했다.