50 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_AUDIOpage: 0부터 시작. 기본값0size: 기본값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
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
- Create:
- RED:
ContentOverviewPageResponse와ContentOverviewItemResponse의JsonProperty필드명을 검증하는 실패 테스트를 작성한다. - 테스트 코드 기준:
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.
- RED:
- Files:
-
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
- Create:
- 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가 기본 size20으로 보정되는 테스트를 추가하고,MIN_SIZE = 20정책을 반영했다. 보완 후 같은 policy 테스트가BUILD SUCCESSFUL. - REVIEW 보완: 큰
page입력에서offset이 Int overflow 되지 않도록offset: Long = page.toLong() * size로 변경했다. 보완 RED는Int.MAX_VALUE, size = 50offset 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 로직 실패로 보지 않는다.
- RED:
- Files:
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
- Modify:
- 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.
- RED:
- Files:
-
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
- Modify:
- 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:
AudioRecommendationQueryService에NEW_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.
- RED:
- Files:
-
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
- Modify:
- 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.
- RED:
- Files:
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
- Create:
- RED:
NEW_AND_HOT_AUDIO와FIRST_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조회, itemtake(size),hasNext계산을 구현한다. - GREEN:
HomeFirstAudioContentRecord에isAdult: 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:
coverImageCDN URL 변환은String?.toCdnUrl(cloudFrontHost)를 사용하고, 타입별 전용 필드 없이ContentOverviewItemResponse의 동일 필드만 채우는지 확인한 뒤 같은 테스트를 재실행한다. - 검증 기록:
- RED:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewFacadeTest실행 시ContentOverviewFacade미구현 및HomeFirstAudioContentRecord의isAdult,isOriginalSeries필드 미구현으로compileTestKotlin실패. - GREEN:
ContentOverviewFacade추가,HomeFirstAudioContentRecord플래그 확장,DefaultHomeRecommendationQueryRepository.findFirstAudioContents(...)플래그 조회 보강 후 같은 명령 재실행,BUILD SUCCESSFUL. - REFACTOR: Kotlin Mockito matcher 보정과 Phase 1의
MIN_SIZE = 20정책에 맞춰 테스트 기대값을 정렬했고,String?.toCdnUrl(cloudFrontHost)로 coverImage CDN 변환을 유지했다. - REVIEW 보완:
findFirstAudioContents(...)native SQL의 오리지널 시리즈 subquery가 실제SeriesContent/Series테이블(series_content,series)과 FK(series_id)를 참조하는지 검증하는 repository 테스트를 추가했다. 보완 RED는 존재하지 않는content_series_content테이블 참조로SQLGrammarException실패였고, 테이블/FK명을 실제 스키마에 맞춘 뒤DefaultHomeRecommendationQueryRepositoryTest가BUILD SUCCESSFUL.
- RED:
- Files:
-
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
- Create:
- 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/contentspermitAll을 추가하지 않았는지 확인하고 같은 테스트를 재실행한다. - 검증 기록:
- RED:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewControllerTest실행 시ContentOverviewController미구현으로compileTestKotlin실패. - GREEN:
ContentOverviewController추가 후 인증 회원 query parameter 전달 테스트 통과. 비회원 401 검증은 slice test에서 실제JwtAuthenticationEntryPoint,JwtAccessDeniedHandler를 import하고.with(anonymous())를 명시하도록 보정한 뒤 같은 명령 재실행,BUILD SUCCESSFUL. - REFACTOR:
SecurityConfig에/api/v2/contentspermitAll을 추가하지 않았음을 확인했다./api/v2/contents는 기존anyRequest().authenticated()정책으로 인증 필수다. - Phase 3 묶음:
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.*'실행,BUILD SUCCESSFUL. - 회귀:
./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest실행,BUILD SUCCESSFUL. - 회귀:
./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest실행,BUILD SUCCESSFUL. - Lint:
./gradlew ktlintCheck실행,BUILD SUCCESSFUL. - 코드 리뷰:
ContentOverviewFacade,ContentOverviewController,HomeFirstAudioContentRecord확장,DefaultHomeRecommendationQueryRepository.findFirstAudioContents(...)변경을 Phase 3 요구사항과 대조했고 차단 이슈는 발견하지 않았다. - 리뷰 검증:
git diff --check실행, 공백 오류 없음. - 리뷰 검증:
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.*'재실행,BUILD SUCCESSFUL. - 리뷰 회귀:
./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest재실행,BUILD SUCCESSFUL. - 리뷰 회귀:
./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest재실행,BUILD SUCCESSFUL. - 리뷰 wiring:
./gradlew test --tests kr.co.vividnext.sodalive.support.SpringBootIntegrationSampleTest실행,BUILD SUCCESSFUL. - 리뷰 Lint:
./gradlew ktlintCheck재실행,BUILD SUCCESSFUL. 최초 sandbox 실행은 Gradle wrapper lock 파일 접근 권한 문제로 중단되어 sandbox 밖에서 재실행했다.
- RED:
- Files:
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
- Modify:
- 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에서 재사용하므로 제거하지 않았는지 확인하고 같은 테스트를 재실행한다. - 검증 기록:
- RED:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest실행 시shouldNotExposeDeprecatedFirstAudioContentsEndpoint가 기존 endpoint 200 응답으로 실패. - GREEN:
HomeRecommendationController.getFirstAudioContents(...)와HomeRecommendationFacade.getFirstAudioContents(...)제거 후 같은 명령 재실행,BUILD SUCCESSFUL. - REFACTOR:
rg -n "findFirstAudioContents|firstAudioContents|HOME_FIRST_AUDIO_CONTENT_LIMIT" ...로 홈 메인firstAudioContents,HOME_FIRST_AUDIO_CONTENT_LIMIT,HomeRecommendationQueryService.findFirstAudioContents(...), 새ContentOverviewFacade재사용 경로가 유지됨을 확인했다.
- RED:
- Files:
-
Task 4.2: 홈 전체보기 인증 경로 목록 회귀 테스트 정리
- Files:
- Modify:
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt
- Modify:
- 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는 유지되어야 하므로 삭제하지 않았는지 확인한다. - 검증 기록:
- 테스트 정리:
HomeRecommendationControllerTest의 성공 응답 반복 경로와 비회원 거부 반복 경로에서/first-audio-contents를 제거하고, facade page failure 로그 검증에서FIRST_AUDIO_CONTENTsection 검증을 제거했다. - 회귀:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewFacadeTest실행,BUILD SUCCESSFUL. - 회귀:
./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest실행,BUILD SUCCESSFUL. - Lint:
./gradlew ktlintCheck실행,BUILD SUCCESSFUL. - 코드 리뷰:
HomeRecommendationController,HomeRecommendationFacade,HomeRecommendationControllerTest변경을 Phase 4 요구사항과 대조했고 차단 이슈는 발견하지 않았다. - 리뷰 확인:
rg -n "first-audio-contents|getFirstAudioContents|FIRST_AUDIO_CONTENT|findFirstAudioContents|HOME_FIRST_AUDIO_CONTENT_LIMIT|firstAudioContents" ...실행으로 제거 endpoint는 문서와 404 테스트에만 남고, 홈 메인firstAudioContents와 새 콘텐츠 전체보기의findFirstAudioContents(...)재사용 경로가 유지됨을 확인했다. - 리뷰 검증:
git diff --check실행, 공백 오류 없음. - 리뷰 검증:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest재실행,BUILD SUCCESSFUL. - 리뷰 회귀:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.application.ContentOverviewFacadeTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest실행,BUILD SUCCESSFUL. - 리뷰 Lint:
./gradlew ktlintCheck재실행,BUILD SUCCESSFUL. 최초 sandbox 실행은 Gradle wrapper lock 파일 접근 권한 문제로 중단되어 sandbox 밖에서 재실행했다.
- 테스트 정리:
- Files:
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
- Create:
- 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: 테스트 데이터가 다른 추천 테스트와 충돌하지 않도록 각 테스트에서 저장한 데이터만 사용하고, 같은 테스트를 재실행한다.
- 검증 기록:
- E2E 테스트 작성:
ContentOverviewEndToEndTest를 추가해 비회원 401, 인증 회원NEW_AND_HOT_AUDIO200, 인증 회원FIRST_AUDIO_CONTENT200, invalid type의NEW_AND_HOT_AUDIOfallback을 검증했다. - GREEN:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.overview.adapter.in.web.ContentOverviewEndToEndTest실행,BUILD SUCCESSFUL.
- E2E 테스트 작성:
- Files:
-
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/**
- Verify:
- 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 test --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.*'실행,BUILD SUCCESSFUL. - 회귀:
./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest실행,BUILD SUCCESSFUL. - 회귀:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest실행,BUILD SUCCESSFUL. - Lint:
./gradlew ktlintCheck실행,BUILD SUCCESSFUL.
- 관련 테스트 묶음:
- Files:
검증 기록
- 구현 전 문서 생성 단계에서는 코드 변경이 없으므로 단위 테스트를 실행하지 않는다.
- 문서 변경 후 명령 유효성은
./gradlew tasks --all로 확인한다. - 구현 중 각 task 완료 즉시 해당 task 아래에 실행 명령, 결과, 실패 원인과 수정 내용을 한국어로 누적 기록한다.
- Phase 3 코드 리뷰 및 검증 기록 추가 후
git diff --check실행, 공백 오류 없음. - Phase 3 코드 리뷰 및 검증 기록 추가 후
./gradlew tasks --all실행,BUILD SUCCESSFUL. 최초 sandbox 실행은 Gradle wrapper lock 파일 접근 권한 문제로 중단되어 sandbox 밖에서 재실행했다.
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 검증 범위에 반영했다.