70 KiB
메인 콘텐츠 추천 탭 API Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use
superpowers:subagent-driven-development또는superpowers:executing-plans로 task 단위 구현을 진행한다. 각 단계는 체크박스(- [ ])로 진행 상태를 갱신한다.
Goal: GET /api/v2/audio/recommendations로 메인 콘텐츠 추천 탭의 배너, 오리지널 시리즈, 최신/무료/포인트/추천 오디오, New & Hot, 최근 댓글 많은 오디오 섹션을 한 번에 조회할 수 있게 한다.
Architecture: 공개 API controller/facade/response DTO는 kr.co.vividnext.sodalive.v2.api.content.recommendation 조립 계층에 둔다. 추천 조회 service, 점수 정책, 조회 domain model, port, QueryDSL/native SQL repository, scheduler는 kr.co.vividnext.sodalive.v2.content.recommendation 하위에 두고 v2.api.*에 의존하지 않는다. content 패키지는 오디오 콘텐츠뿐 아니라 오리지널 시리즈 등 추천 탭에 포함될 수 있는 콘텐츠 범주를 포괄하기 위한 명칭이다. 배너 값 모델은 v2.common.domain, 배너 응답 DTO는 v2.api.common.dto로 분리하고, 기존 recommendation_snapshot은 section enum 확장 방식으로 재사용한다.
Tech Stack: Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, native SQL, Redisson, JUnit 5, MockMvc, Gradle Wrapper
0. 구현 전 확정 사항
- API endpoint:
GET /api/v2/audio/recommendations - 최종 패키지 구조: 공개 API 조립 계층은
kr.co.vividnext.sodalive.v2.api.content.recommendation, 도메인 조회 계층은kr.co.vividnext.sodalive.v2.content.recommendation을 사용한다. - 기존 Phase 1-5 구현 산출물이
audio.recommendation패키지에 있으면 Phase 6에서content.recommendation패키지로 이동한다. - 인증 정책: 비회원 조회 가능. 인증 회원이면 회원의 콘텐츠 조회 설정과 19금 노출 가능 여부를 반영한다.
- 응답 wrapper:
ApiResponse.ok(...) - 기본 노출 수:
banners: 메인 홈 추천 배너와 동일originalSeries: 최신순 12개latestAudios: 최신순 12개newAndHotAudios: 최대 12개freeAudios: 최대 10개 랜덤pointAudios: 최대 10개 랜덤mostCommentedAudios: 최대 5개recommendedAudios: 최대 10개
- 공개 오디오 공통 조건:
AudioContent.isActive == true,AudioContent.duration != null,AudioContent.releaseDate != null,AudioContent.releaseDate <= now, 크리에이터 회원 활성. - 비회원과 19금 노출 불가 회원은 성인 콘텐츠를 제외하고
SAFE스냅샷을 조회한다. - 19금 노출 가능 회원은 성인/비성인 콘텐츠를 모두 포함하는
ALL스냅샷을 조회한다. - 스냅샷 기준: KST 매일 00:00 실행, 전날 23:59:59 KST까지의 데이터를 UTC 변환 없이 KST-local
LocalDateTime으로 반영. - 스냅샷 저장 방식: 기존
recommendation_snapshot테이블을 재사용하고RecommendedSectionTypeenum에NEW_AND_HOT_AUDIO_SAFE,NEW_AND_HOT_AUDIO_ALL,MOST_COMMENTED_AUDIO_SAFE,MOST_COMMENTED_AUDIO_ALL,RECOMMENDED_AUDIO_SAFE,RECOMMENDED_AUDIO_ALL을 추가한다. 신규 테이블 DDL은 작성하지 않는다. - New & Hot 점수: 최신성 35%, 상세 조회수 35%, 좋아요 15%, 댓글 수 15%. 상세 조회수는
creator_content_view_history의content_id별 count를 사용한다. - 추천 오디오 점수: 상세 조회수 45%, 좋아요 25%, 댓글 수 20%, 최신성 10%.
- 최근 댓글 많은 오디오 점수: 댓글 수 80%, 댓글 최신성 20%.
- 조회수/좋아요/댓글 수는 후보 내 정규화 없이 원본 count를 그대로 사용한다.
- 무료/포인트/추천 오디오 섹션 사이에는 같은 콘텐츠가 중복 노출될 수 있다.
isOriginalSeries는 시리즈 미소속 오디오이면false로 내려준다.- 전체보기/페이징 API, 관리자 화면, 수동 편집 기능은 이번 범위에 포함하지 않는다.
1. 파일 구조 계획
API 공통 DTO
- Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/RecommendationBanner.kt - Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/common/dto/RecommendationBannerResponse.kt - Modify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt - Modify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt
신규 API 조립 계층
- Create/Move:
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationController.kt - Create/Move:
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt - Create/Move:
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt
신규 도메인 조회 계층
- Create/Move:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt - Create/Move:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt - Create/Move:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendation.kt - Create/Move:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendationScorePolicy.kt - Create/Move:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendationVisibility.kt - Create/Move:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt - Create/Move:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/AudioRecommendationQueryRepository.kt - Create/Move:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt - Create/Move:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt - Modify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedSectionType.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendationScorePolicyTest.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/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt
기존 재사용 파일 확인
- Verify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshot.kt - Verify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/RecommendationSnapshotPort.kt - Verify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt - Verify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/CreatorContentViewHistory.kt - Verify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt - Verify:
src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt
2. Response data class 초안
구현 시 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
package kr.co.vividnext.sodalive.v2.api.content.recommendation.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendations
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.OriginalSeries
data class AudioRecommendationsResponse(
val banners: List<RecommendationBannerResponse>,
val originalSeries: List<OriginalSeriesResponse>,
val latestAudios: List<AudioCardResponse>,
val newAndHotAudios: List<AudioCardResponse>,
val freeAudios: List<AudioCardResponse>,
val pointAudios: List<AudioCardResponse>,
val mostCommentedAudios: List<CommentedAudioResponse>,
val recommendedAudios: List<AudioCardResponse>
) {
companion object {
fun from(recommendations: AudioRecommendations): AudioRecommendationsResponse {
return AudioRecommendationsResponse(
banners = recommendations.banners.map(RecommendationBannerResponse::from),
originalSeries = recommendations.originalSeries.map(OriginalSeriesResponse::from),
latestAudios = recommendations.latestAudios.map(AudioCardResponse::from),
newAndHotAudios = recommendations.newAndHotAudios.map(AudioCardResponse::from),
freeAudios = recommendations.freeAudios.map(AudioCardResponse::from),
pointAudios = recommendations.pointAudios.map(AudioCardResponse::from),
mostCommentedAudios = recommendations.mostCommentedAudios.map(CommentedAudioResponse::from),
recommendedAudios = recommendations.recommendedAudios.map(AudioCardResponse::from)
)
}
}
}
data class OriginalSeriesResponse(
val seriesId: Long,
val coverImageUrl: String?
) {
companion object {
fun from(series: OriginalSeries): OriginalSeriesResponse {
return OriginalSeriesResponse(series.seriesId, series.coverImageUrl)
}
}
}
data class AudioCardResponse(
val audioContentId: Long,
val title: String,
val duration: String?,
val imageUrl: String?,
val price: Int,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isPointAvailable")
val isPointAvailable: Boolean,
@JsonProperty("isFirstContent")
val isFirstContent: Boolean,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean,
val creatorNickname: String
) {
companion object {
fun from(audio: AudioCard): AudioCardResponse {
return AudioCardResponse(
audioContentId = audio.audioContentId,
title = audio.title,
duration = audio.duration,
imageUrl = audio.imageUrl,
price = audio.price,
isAdult = audio.isAdult,
isPointAvailable = audio.isPointAvailable,
isFirstContent = audio.isFirstContent,
isOriginalSeries = audio.isOriginalSeries,
creatorNickname = audio.creatorNickname
)
}
}
}
data class CommentedAudioResponse(
val audioContentId: Long,
val title: String,
val imageUrl: String?,
val latestComment: String,
val latestCommentWriterProfileImageUrl: String
) {
companion object {
fun from(audio: CommentedAudio): CommentedAudioResponse {
return CommentedAudioResponse(
audioContentId = audio.audioContentId,
title = audio.title,
imageUrl = audio.imageUrl,
latestComment = audio.latestComment,
latestCommentWriterProfileImageUrl = audio.latestCommentWriterProfileImageUrl
)
}
}
}
3. Domain / Port 초안
package kr.co.vividnext.sodalive.v2.content.recommendation.domain
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
data class AudioRecommendations(
val banners: List<RecommendationBanner>,
val originalSeries: List<OriginalSeries>,
val latestAudios: List<AudioCard>,
val newAndHotAudios: List<AudioCard>,
val freeAudios: List<AudioCard>,
val pointAudios: List<AudioCard>,
val mostCommentedAudios: List<CommentedAudio>,
val recommendedAudios: List<AudioCard>
)
data class OriginalSeries(
val seriesId: Long,
val coverImageUrl: String?
)
data class AudioCard(
val audioContentId: Long,
val title: String,
val duration: String?,
val imageUrl: String?,
val price: Int,
val isAdult: Boolean,
val isPointAvailable: Boolean,
val isFirstContent: Boolean,
val isOriginalSeries: Boolean,
val creatorNickname: String
)
data class CommentedAudio(
val audioContentId: Long,
val title: String,
val imageUrl: String?,
val latestComment: String,
val latestCommentWriterProfileImageUrl: String
)
enum class AudioRecommendationVisibility {
SAFE,
ALL
}
package kr.co.vividnext.sodalive.v2.content.recommendation.port.out
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.OriginalSeries
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
import java.time.LocalDateTime
interface AudioRecommendationQueryPort {
fun findBanners(limit: Int, memberId: Long?, canViewAdultContent: Boolean): List<RecommendationBanner>
fun findOriginalSeries(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<OriginalSeries>
fun findLatestAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
fun findFreeAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
fun findPointAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
fun findAudioCardsByIds(contentIds: List<Long>, memberId: Long?, canViewAdultContent: Boolean): List<AudioCard>
fun findCommentedAudiosByIds(contentIds: List<Long>, memberId: Long?, canViewAdultContent: Boolean): List<CommentedAudio>
fun findNewAndHotSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime, visibility: AudioRecommendationVisibility, limit: Int): List<RecommendationSnapshotRecord>
fun findMostCommentedSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime, visibility: AudioRecommendationVisibility, limit: Int): List<RecommendationSnapshotRecord>
fun findRecommendedAudioSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime, visibility: AudioRecommendationVisibility, limit: Int): List<RecommendationSnapshotRecord>
}
Phase 1: 공통 DTO와 API 계약
-
Task 1.1: 배너 응답 DTO를 공통 패키지로 분리
- Files:
- Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/RecommendationBanner.kt - Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/common/dto/RecommendationBannerResponse.kt - Modify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt - Modify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt
- Create:
- RED:
HomeRecommendationResponse의banners가 공통RecommendationBannerResponse타입을 사용하고 기존 JSON 필드imageUrl,eventItem,creatorId,seriesId,link를 유지하는 테스트를 작성한다. - 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest - GREEN:
HomeBannerItem필드 구조를RecommendationBannerdomain model과RecommendationBannerResponseDTO로 분리하고 홈 추천 DTO/facade import를 갱신한다. - REFACTOR: 홈 탭 전용 controller/facade 로직은 이동하지 않고 DTO 타입만 공통화한다.
- 기대 결과: 기존 홈 추천 배너 JSON 계약은 유지되고 신규 오디오 추천 API가 같은 DTO를 재사용할 수 있다.
- Files:
-
Task 1.2: 오디오 추천 응답 DTO와 facade 변환 작성
- Files:
- Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/dto/AudioRecommendationsResponse.kt - Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacade.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacadeTest.kt
- Create:
- RED: facade가 도메인
AudioRecommendations를AudioRecommendationsResponse로 변환하고originalSeries,latestAudios,newAndHotAudios,freeAudios,pointAudios,mostCommentedAudios,recommendedAudios필드를 모두 채우는 테스트를 작성한다. - 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.application.AudioRecommendationFacadeTest - GREEN: facade는
AudioRecommendationQueryService.getRecommendations(member)만 호출하고 공개 DTO 변환만 담당한다. - REFACTOR:
isOriginalSeries는Boolean으로 유지하고 nullable 변환을 만들지 않는다. - 기대 결과: API 조립 계층은 도메인 조회 계층에만 의존하고, 도메인 조회 계층은 API DTO에 의존하지 않는다.
- Files:
-
Task 1.3: 비회원 허용 controller 작성
- Files:
- Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationController.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt
- Create:
- RED:
GET /api/v2/audio/recommendations가 비회원과 인증 회원 모두200 OK를 반환하고ApiResponse.okwrapper를 사용하는 MockMvc 테스트를 작성한다. - 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationControllerTest - GREEN:
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?패턴으로 member nullable을 facade에 전달한다. - REFACTOR: request parameter는 추가하지 않고 controller에는 인증/응답 경계만 남긴다.
- 기대 결과: 비회원 조회 가능 계약과 endpoint 경로가 controller 테스트로 고정된다.
- Files:
Phase 2: 도메인 모델과 점수 정책
-
Task 2.1: 도메인 모델과 visibility enum 작성
- Files:
- Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendation.kt - Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationVisibility.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryServiceTest.kt
- Create:
- RED:
AudioRecommendationVisibility.SAFE는NEW_AND_HOT_AUDIO_SAFE,ALL은NEW_AND_HOT_AUDIO_ALL처럼 section type을 선택해야 한다는 service 테스트를 작성한다. - 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest - GREEN:
AudioRecommendations,OriginalSeries,AudioCard,CommentedAudio,AudioRecommendationVisibility를 추가한다. - REFACTOR: domain model에는 API DTO import를 두지 않는다.
AudioRecommendations.banners는v2.common.domain.RecommendationBanner만 사용한다. - 기대 결과: SAFE/ALL 선택이 문자열이 아니라 enum으로 고정된다.
- Files:
-
Task 2.2: 오디오 추천 점수 정책 작성
- Files:
- Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicy.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicyTest.kt
- Create:
- RED: New & Hot 최신성 배수 3/7/14일/그 외, 추천 오디오 최신성 배수 3/7/30일/그 외, 최근 댓글 최신성 배수 3/7/14일/그 이상을 검증하는 테스트를 작성한다. 원본 count 가중합이 정규화 없이 계산되는 테스트도 작성한다.
- 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicyTest - GREEN:
calculateNewAndHotScore,calculateRecommendedAudioScore,calculateCommentScore와 각 recency multiplier 함수를 구현한다. - REFACTOR: 가중치와 일수 경계는
companion object상수로 모아 테스트 기대값과 용어를 맞춘다. - 기대 결과: PRD 산식과 최신성 경계가 순수 단위 테스트로 고정된다.
- Files:
-
Task 2.3: 스냅샷 section enum 확장
- Files:
- Modify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedSectionType.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryServiceTest.kt
- Modify:
- RED: visibility와 섹션 조합이
NEW_AND_HOT_AUDIO_SAFE,NEW_AND_HOT_AUDIO_ALL,MOST_COMMENTED_AUDIO_SAFE,MOST_COMMENTED_AUDIO_ALL,RECOMMENDED_AUDIO_SAFE,RECOMMENDED_AUDIO_ALL로 매핑되는 테스트를 작성한다. - 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest - GREEN: 기존
RecommendedSectionType에 오디오 추천 섹션 enum 값을 추가하고 service 내부 매핑 함수를 구현한다. - REFACTOR:
recommendation_snapshot.section_type길이 50 안에 모든 enum 이름이 들어가는지 확인한다. - 기대 결과: 신규 테이블 없이 기존 스냅샷 저장 구조를 재사용한다.
- Files:
Phase 3: 실시간 조회 섹션 repository
-
Task 3.1: 배너/오리지널 시리즈/최신 오디오 조회 구현
- Files:
- Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/port/out/AudioRecommendationQueryPort.kt - Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/AudioRecommendationQueryRepository.kt - Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt
- Create:
- RED: 배너는 기존 홈 추천 배너와 동일 필드/활성/차단 정책을 적용하고, 오리지널 시리즈는
isOriginal = true최신순 12개, 최신 오디오는releaseDate desc,audioContentId desc12개를 반환하는 repository 테스트를 작성한다. - 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest - GREEN: QueryDSL로
findBanners,findOriginalSeries,findLatestAudios를 구현한다. 이미지 경로는toCdnUrl(cloudFrontHost)를 사용한다. - REFACTOR: 공개 오디오 조건, 성인 콘텐츠 조건, 차단 관계 조건을 private 조건 함수로 분리한다.
- 기대 결과: 비회원은 성인 콘텐츠를 제외하고, 인증 회원은 성인 노출 가능 여부에 따라 결과가 달라진다.
- Files:
-
Task 3.2: 무료/포인트 랜덤 오디오 조회 구현
- Files:
- Modify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt
- Modify:
- RED: 무료 오디오는
price = 0공개 오디오 중 최대 10개, 포인트 오디오는isPointAvailable = true공개 오디오 중 최대 10개를 반환하고 두 섹션 간 중복을 제거하지 않는 테스트를 작성한다. - 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest - GREEN:
findFreeAudios,findPointAudios를 구현하고 DB 랜덤 정렬은 기존 repository 관례에 맞춰Expressions.numberTemplate(Double::class.java, "function('rand')")또는 동일 프로젝트에서 쓰는 랜덤 정렬 방식을 사용한다. - REFACTOR: 무료/포인트 조회가 같은 공통 projection 함수를 사용하게 정리한다.
- 기대 결과: 랜덤 섹션도 공개/성인/차단 조건을 동일하게 적용한다.
- Files:
-
Task 3.3: 공통 오디오 카드 enrichment 구현
- Files:
- Modify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt
- Modify:
- RED:
AudioCard가audioContentId,title,duration,imageUrl,price,isAdult,isPointAvailable,isFirstContent,isOriginalSeries,creatorNickname을 채우고, 시리즈 미소속이면isOriginalSeries = false인 테스트를 작성한다. - 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest - GREEN: first content 판정은 기존 크리에이터 채널 오디오 조회 repository의 첫 콘텐츠 계산 패턴을 참고해 구현한다. 원본 시리즈 연결이 없으면
false, 연결 시리즈가 있으면series.isOriginal을 사용한다. - REFACTOR: latest/free/point/snapshot 상세 조회 모두 같은
toAudioCard변환을 사용한다. - 기대 결과: 섹션별 오디오 카드 필드 의미가 동일하게 유지된다.
- Files:
Phase 4: 스냅샷 산정과 일 배치
-
Task 4.1: New & Hot 스냅샷 후보 산정 구현
- Files:
- Modify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/port/out/AudioRecommendationQueryPort.kt - Modify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt
- Modify:
- RED: 최근 3일
creator_content_view_historycount,content_likeactive count,audio_content_commentactive count, 최신성 배수를 원본 count 가중합으로 계산하고SAFE는 비성인만,ALL은 성인/비성인을 모두 포함하는 테스트를 작성한다. - 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest - GREEN: native SQL CTE 또는 QueryDSL aggregate로
findNewAndHotSnapshots(windowStart, snapshotAt, visibility, limit)를 구현한다. 정렬은score desc,randomTieBreaker asc로 한다. - REFACTOR: Kotlin
AudioRecommendationScorePolicy기대값과 DB score가 일치하는 parity 테스트 데이터를 유지한다. - 기대 결과:
NEW_AND_HOT_AUDIO_SAFE/ALL에 저장할 top 12 후보가 정확한 점수순으로 산출된다.
- Files:
-
Task 4.2: 최근 댓글 많은 오디오 스냅샷 후보 산정 구현
- Files:
- Modify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt
- Modify:
- RED: 최근 7일 댓글 데이터 기반으로 댓글 수 80%, 댓글 최신성 20% 점수를 계산하고 데이터가 없으면 빈 후보를 반환하는 테스트를 작성한다. 가장 최신 댓글 1개의 본문과 작성자 프로필 이미지가 상세 조회에서 내려가는 테스트도 작성한다.
- 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest - GREEN:
findMostCommentedSnapshots(...)와findCommentedAudiosByIds(...)를 구현한다. 상세 조회 결과에는 가장 최신 댓글 본문과 작성자 프로필 이미지를 포함한다. 비활성 댓글, 삭제된 댓글, 차단 관계의 댓글 작성자는 제외한다. - REFACTOR: 댓글 최신성 배수 계산은 repository SQL과
AudioRecommendationScorePolicy가 같은 경계값을 사용하도록 테스트로 고정한다. - 기대 결과: 스냅샷이 없거나 후보가 없으면
mostCommentedAudios는 빈 배열이다.
- Files:
-
Task 4.3: 추천 오디오 스냅샷 후보 산정 구현
- Files:
- Modify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt
- Modify:
- RED: 상세 조회수 45%, 좋아요 25%, 댓글 수 20%, 최신성 10% 점수를 계산하고
SAFE/ALLvisibility별 최대 10개 후보를 반환하는 테스트를 작성한다. - 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest - GREEN:
findRecommendedAudioSnapshots(...)를 구현한다. 상세 조회수는creator_content_view_historycount를 사용하고AudioContent.playCount를 사용하지 않는다. - REFACTOR: New & Hot과 공유 가능한 조회수/좋아요/댓글 aggregate CTE를 private SQL fragment 또는 QueryDSL helper로 정리한다.
- 기대 결과:
RECOMMENDED_AUDIO_SAFE/ALL에 저장할 top 10 후보가 정확한 점수순으로 산출된다.
- Files:
-
Task 4.4: 스냅샷 refresh service와 lazy 보강 구현
- Files:
- Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationSnapshotRefreshService.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt
- Create:
- RED:
refreshDailySnapshots(now)가 KST 전날 23:59:59 기준으로 여섯 section type(NEW_AND_HOT_AUDIO_SAFE/ALL,MOST_COMMENTED_AUDIO_SAFE/ALL,RECOMMENDED_AUDIO_SAFE/ALL)을 replace하고, New & Hot 조회 시 최신 스냅샷이 없으면 lazy refresh를 1회 호출하는 테스트를 작성한다. - 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest - GREEN: 기존
RecommendationSnapshotPort.replaceSnapshots(...)와findLatestSnapshots(...)를 재사용한다. lazy 보강은 New & Hot에만 적용하고, 최근 댓글 많은 오디오는 스냅샷이 없으면 빈 배열로 유지한다. - REFACTOR: 기준 시각 계산은 private 함수로 분리하고 KST-local
LocalDateTime경계 테스트를 유지한다. 보강 후에도 New & Hot 후보가 0개이면 Redis marker 기준 같은 KST 날짜에는 lazy refresh를 반복하지 않는다. - 기대 결과: 일 배치와 lazy 보강 모두 같은 산정 함수를 사용한다.
- Files:
-
Task 4.5: 00:00 KST 스케줄러와 Redisson lock 작성
- Files:
- Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt
- Create:
- RED: cron이
0 0 0 * * *, zone이Asia/Seoul, lock key가lock:audio-recommendation-snapshot-refresh이고 lock 획득 성공 시에만 refresh service를 호출하는 테스트를 작성한다. - 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.scheduler.AudioRecommendationSnapshotSchedulerTest - GREEN:
RedissonClient를 주입하고 기존 추천 스냅샷 scheduler 패턴처럼tryLock성공 시refreshDailySnapshots()를 호출한다. - REFACTOR: 스케줄러에는 lock과 service 호출만 남기고 집계 로직을 두지 않는다.
- 기대 결과: 다중 서버에서 하루 한 번만 오디오 추천 스냅샷을 갱신한다.
- Files:
Phase 5: 통합 조회 service와 API 연결
-
Task 5.1: AudioRecommendationQueryService 통합 조립
- Files:
- Create:
src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryService.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryServiceTest.kt
- Create:
- RED: 비회원은
SAFEvisibility와 19금 제외 조건을 사용하고, 19금 노출 가능 회원은ALLvisibility를 사용하며, 각 섹션 limit이 PRD와 일치하는 테스트를 작성한다. - 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest - GREEN: query service가 real-time 섹션과 snapshot 섹션을 조립해
AudioRecommendations를 반환한다.MemberContentPreferenceService는 facade가 아니라 query service 또는 별도 resolver에서 사용해 도메인 조회 조건을 만든다. - REFACTOR: 섹션 limit은 companion object 상수로 고정하고 테스트에서 같은 값을 검증한다.
- 기대 결과: 특정 섹션이 빈 배열이어도 전체 응답은 성공 가능한 domain model로 조립된다.
- Files:
-
Task 5.2: Facade 성인 정책/이미지 URL 변환 연결
- Files:
- Modify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacade.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacadeTest.kt
- Modify:
- RED: 회원/비회원별 성인 노출 정책이 query service에 전달되고, CDN URL이 포함된 domain 응답이 공개 DTO로 변환되는 테스트를 작성한다.
- 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.application.AudioRecommendationFacadeTest - GREEN: facade는 member를 그대로 query service에 전달하고
AudioRecommendationsResponse.from(...)만 수행한다. - REFACTOR: Home 탭 전용
HomeRecommendationFacade를 주입하거나 호출하지 않는지 import를 확인한다. - 기대 결과: API 조립 계층은 신규 audio recommendation use case만 호출한다.
- Files:
-
Task 5.3: Controller/E2E 통합 검증
- Files:
- Modify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationController.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt
- Modify:
- RED: MockMvc controller 테스트와 최소 E2E 테스트를 작성해 JSON path
$.data.originalSeries,$.data.latestAudios,$.data.recommendedAudios,$.data.latestAudios[0].isOriginalSeries,$.data.mostCommentedAudios[0].latestComment,$.data.mostCommentedAudios[0].latestCommentWriterProfileImageUrl가 존재하는지 검증한다. - 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationEndToEndTest - GREEN: controller와 Spring bean wiring을 완성한다.
- REFACTOR: 응답 필드명이 PRD와 plan-task의 DTO 초안과 같은지 검색으로 확인한다.
- 기대 결과: 비회원과 인증 회원 모두 endpoint 호출이 성공한다.
- Files:
Phase 6: 패키지 구조 content.recommendation 이동
-
Task 6.1: 공개 API 조립 계층 패키지 이동
- Files:
- Move:
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationController.kt->src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationController.kt - Move:
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacade.kt->src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt - Move:
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/dto/AudioRecommendationsResponse.kt->src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt - Move:
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt->src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt - Move:
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacadeTest.kt->src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt
- Move:
- TDD 예외 사유: 동작 변경 없이 패키지/디렉터리만 이동하는 구조 정리 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.* - Run:
rg -n "v2\\.api\\.audio\\.recommendation" src/main/kotlin src/test/kotlin
- Run:
- GREEN:
package선언과 import를kr.co.vividnext.sodalive.v2.api.content.recommendation기준으로 갱신한다. - REFACTOR: endpoint
GET /api/v2/audio/recommendations, response DTO class/field 이름은 공개 API 계약이므로 변경하지 않는다. - 기대 결과: 공개 API 조립 계층은
v2.api.content.recommendation아래에만 존재한다.
- Files:
-
Task 6.2: 도메인 조회 계층 패키지 이동
- Files:
- Move:
src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryService.kt->src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt - Move:
src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationSnapshotRefreshService.kt->src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt - Move:
src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendation.kt->src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendation.kt - Move:
src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicy.kt->src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendationScorePolicy.kt - Move:
src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationVisibility.kt->src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendationVisibility.kt - Move:
src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/port/out/AudioRecommendationQueryPort.kt->src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt - Move:
src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/AudioRecommendationQueryRepository.kt->src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/AudioRecommendationQueryRepository.kt - Move:
src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt->src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt - Move:
src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt->src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt - Move:
src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/**->src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/**
- Move:
- TDD 예외 사유: 동작 변경 없이 패키지/디렉터리만 이동하는 구조 정리 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run:
./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.* - Run:
rg -n "v2\\.audio\\.recommendation" src/main/kotlin src/test/kotlin
- Run:
- GREEN: 도메인 조회 계층의
package선언과 import를kr.co.vividnext.sodalive.v2.content.recommendation기준으로 갱신한다. - REFACTOR: class 이름(
AudioRecommendation*)은 현재 API/섹션 의미가 오디오 중심이므로 유지하고, 패키지명만 콘텐츠 범주로 확장한다. - 기대 결과: 도메인 조회 계층은
v2.content.recommendation아래에만 존재하고v2.api.*에 의존하지 않는다.
- Files:
-
Task 6.3: 패키지 잔여 참조와 문서 동기화 확인
- Files:
- Verify:
docs/20260623_메인_콘텐츠_추천_탭_API/prd.md - Verify:
docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md - Verify:
src/main/kotlin - Verify:
src/test/kotlin
- Verify:
- TDD 예외 사유: 문서와 package/import 잔여 참조 확인 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run:
rg -n "api\\.audio\\.recommendation|v2\\.audio\\.recommendation|api/audio/recommendation|v2/audio/recommendation" docs/20260623_메인_콘텐츠_추천_탭_API src/main/kotlin src/test/kotlin - Run:
rg -n "api\\.content\\.recommendation|v2\\.content\\.recommendation|api/content/recommendation|v2/content/recommendation" docs/20260623_메인_콘텐츠_추천_탭_API src/main/kotlin src/test/kotlin
- Run:
- 기대 결과: 잔여
audio.recommendation패키지 참조는 과거 검증 기록을 제외하고 남지 않고, PRD/plan-task의 최종 구조가content.recommendation기준으로 일치한다. - 검증 기록: 구현 완료 시 실행 명령, 결과, 잔여 참조가 남은 경우 사유를 이 task 아래에 한국어로 누적 기록한다.
- 2026-06-23 Phase 6 구현 기록:
- 공개 API 조립 계층을
kr.co.vividnext.sodalive.v2.api.content.recommendation패키지로 이동했다. endpointGET /api/v2/audio/recommendations, class 이름, response DTO field 이름은 변경하지 않았다. - 도메인 조회 계층을
kr.co.vividnext.sodalive.v2.content.recommendation패키지로 이동했다.AudioRecommendation*class 이름과 repository/query/scheduler 동작은 변경하지 않았다. rg -n "kr\.co\.vividnext\.sodalive\.v2\.(api\.)?audio\.recommendation|v2\.api\.audio\.recommendation|v2\.audio\.recommendation" src/main/kotlin src/test/kotlin: 결과 없음.rg --files src/main/kotlin src/test/kotlin | rg "/v2/(api/)?audio/recommendation/": 결과 없음.rg -n "/api/v2/audio/recommendations" src/main/kotlin src/test/kotlin: controller, controller test, E2E test,SecurityConfig에서 기존 endpoint 유지 확인../gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.*':BUILD SUCCESSFUL../gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.*': 병렬 Gradle 실행 중 XML test result 파일 쓰기 충돌로 1회 실패 후 단독 재실행해BUILD SUCCESSFUL.
- 공개 API 조립 계층을
- 2026-06-23 Phase 6 코드 리뷰 및 검증 기록:
rg -n "v2\\.api\\.audio\\.recommendation|v2\\.audio\\.recommendation|api/audio/recommendation|/v2/audio/recommendation" src/main/kotlin src/test/kotlin: endpoint 문자열을 제외하고 이전 패키지/경로 참조 없음.rg -n "api\\.audio\\.recommendation|v2\\.audio\\.recommendation|api/audio/recommendation|v2/audio/recommendation" docs/20260623_메인_콘텐츠_추천_탭_API src/main/kotlin src/test/kotlin: 문서의 Phase 1-6 과거 작업 경로/검증 기록과 endpoint 문자열만 확인됨../gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.*':BUILD SUCCESSFUL../gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.*':BUILD SUCCESSFUL../gradlew ktlintCheck: 최초 sandbox 실행은 Gradle wrapper의~/.gradlelock 파일 접근 권한으로 실패했고, 승인 후 재실행해BUILD SUCCESSFUL.
- Files:
Phase 7: 성인 콘텐츠 조회 정책 계산 경로 통일
-
Task 7.1: MemberContentPreferenceService에 성인 콘텐츠 조회 가능 여부 메서드 추가
- Files:
- Modify:
src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt
- Modify:
- RED:
canViewAdultContent(member)가 저장된isAdultContentVisible설정, 국가 정책, 성인 인증 여부를 반영해ViewerContentPreference.isAdult와 같은 값을 반환하는 테스트를 작성한다. - 실패 확인:
./gradlew test --tests kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest - GREEN:
MemberContentPreferenceService.canViewAdultContent(member: Member): Boolean을 추가하고 내부 구현은getStoredPreference(member).isAdult를 반환한다. - REFACTOR: 성인 콘텐츠 조회 가능 여부를 계산하는 신규 호출부는
isAdultVisibleByPolicy(...)를 직접 호출하지 않고 service 메서드를 사용한다. - 기대 결과: 사용자 설정(
isAdultContentVisible), 국가 정책, 성인 인증 여부가 하나의 공개 service 메서드로 일관되게 계산된다. - 검증 기록: 구현 완료 시 실행 명령, 결과, 실패 시 원인과 수정 내용을 이 task 아래에 한국어로 누적 기록한다.
- 2026-06-23 Phase 7 구현 기록:
- RED:
MemberContentPreferenceServiceTest.shouldReturnStoredPreferenceAdultPolicyForCanViewAdultContent를 추가하고./gradlew test --tests kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest --tests '*shouldReturnStoredPreferenceAdultPolicyForCanViewAdultContent'를 실행해Unresolved reference: canViewAdultContent실패를 확인했다. - GREEN:
MemberContentPreferenceService.canViewAdultContent(member: Member): Boolean을 추가해getStoredPreference(member).isAdult를 반환하도록 했고, 동일 테스트 재실행 결과BUILD SUCCESSFUL을 확인했다. ./gradlew test --tests 'kr.co.vividnext.sodalive.member.contentpreference.*': 따옴표 없이 실행한 첫 명령은 zsh glob 해석으로 실행 전 실패했고, 따옴표로 감싸 재실행해BUILD SUCCESSFUL을 확인했다.
- RED:
- Files:
-
Task 7.2: 추천 탭과 v2 조회 계층의 성인 정책 호출부를 service 메서드로 교체
- Files:
- Modify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt - Modify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt - Modify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt - Modify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt - Modify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt - Modify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt - Modify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt - Test:
src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt - Test: 기존 v2 조회 service 테스트 중 성인 콘텐츠 노출 정책을 검증하는 테스트 파일
- Modify:
- RED:
AudioRecommendationQueryServiceTest에서memberContentPreferenceService.canViewAdultContent(member)가 호출되고getStoredPreference(...)또는isAdultVisibleByPolicy(...)직접 조합을 사용하지 않는 테스트를 작성한다. 기존 v2 조회 service 테스트에는 성인 콘텐츠 노출 가능/불가 회원별 조회 조건이 유지되는 회귀 테스트를 추가한다. - 실패 확인:
- Run:
./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest - Run: 변경한 기존 v2 조회 service 테스트
- Run:
- GREEN: 각 호출부의
getStoredPreference(...)+isAdultVisibleByPolicy(...)조합 또는getStoredPreference(...).isAdult직접 사용을memberContentPreferenceService.canViewAdultContent(member)로 교체한다. - REFACTOR: 더 이상 필요 없는
isAdultVisibleByPolicyimport와 중간preference지역 변수를 제거한다. - 기대 결과: v2 조회 계층과 추천 탭 API의 성인 콘텐츠 조회 정책 계산 경로가
MemberContentPreferenceService.canViewAdultContent(...)로 통일된다. - 검증 기록: 구현 완료 시 실행 명령, 결과, 실패 시 원인과 수정 내용을 이 task 아래에 한국어로 누적 기록한다.
- 2026-06-23 Phase 7 구현 기록:
AudioRecommendationQueryService,HomeRecommendationFacade, v2 creator channel audio/community/home/live/series 조회 service의 성인 콘텐츠 조회 가능 여부 계산을memberContentPreferenceService.canViewAdultContent(...)호출로 통일했다.CreatorChannelHomeQueryService는 기존preference.contentType전달이 필요하므로getStoredPreference(viewer)는 유지하고, 성인 콘텐츠 조회 가능 여부 계산만 service 메서드로 교체했다.- 변경한 v2 service/controller 테스트 묶음 실행 결과
BUILD SUCCESSFUL을 확인했다. ./gradlew test --tests kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest:BUILD SUCCESSFUL.
- Files:
-
Task 7.3: 성인 정책 직접 호출 잔여 참조 확인
- Files:
- Verify:
src/main/kotlin - Verify:
src/test/kotlin - Verify:
docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md
- Verify:
- TDD 예외 사유: 검색 기반 잔여 참조 확인 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run:
rg -n "isAdultVisibleByPolicy|getStoredPreference\\([^\\n]*\\)\\.isAdult" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2 - Run:
rg -n "canViewAdultContent\\(" src/main/kotlin/kr/co/vividnext/sodalive src/test/kotlin/kr/co/vividnext/sodalive
- Run:
- 기대 결과: v2 조회 계층에는 성인 콘텐츠 조회 가능 여부 계산을 위한
isAdultVisibleByPolicy(...)직접 호출이나getStoredPreference(...).isAdult직접 사용이 남지 않고,canViewAdultContent(...)호출로 통일된다. - 검증 기록: 구현 완료 시 실행 명령, 결과, 잔여 참조가 남은 경우 사유를 이 task 아래에 한국어로 누적 기록한다.
- 2026-06-23 Phase 7 구현 기록:
rg -n "isAdultVisibleByPolicy|getStoredPreference\([^\n]*\)\.isAdult" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2: 결과 없음.rg -n "canViewAdultContent\(" src/main/kotlin/kr/co/vividnext/sodalive src/test/kotlin/kr/co/vividnext/sodalive:MemberContentPreferenceService와 Phase 7 변경 호출부에서 canonical 메서드 사용 확인../gradlew ktlintCheck:BUILD SUCCESSFUL.git diff --check: 출력 없음.- Phase 7 리뷰어 검토 결과:
PASS(차단 이슈 없음).
- Files:
-
Task 7.4: 중복 성인 정책 함수 정리
- Files:
- Modify/Verify:
src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicy.kt - Modify/Verify:
src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt - Verify:
src/main/kotlin - Verify:
src/test/kotlin
- Modify/Verify:
- TDD 예외 사유: 정책 계산 로직의 공개 진입점 정리와 잔여 사용처 확인 task이며, Task 7.1/7.2의 회귀 테스트가 동작 동일성을 검증한다.
- 대체 검증 방법:
- Run:
rg -n "isAdultVisibleByPolicy|resolveCountryCodeByPolicy" src/main/kotlin src/test/kotlin - Run:
rg -n "calculateIsAdultForQuery|canViewAdultContent\\(" src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference
- Run:
- GREEN:
isAdultVisibleByPolicy(...)와resolveCountryCodeByPolicy(...)의 production 사용처가 모두 없어졌으면 제거한다. 아직 v2 외부 사용처가 남아 있으면 즉시 제거하지 않고@Deprecated("Use MemberContentPreferenceService.canViewAdultContent(member)")로 표시한 뒤 별도 후속 task를 남긴다. - REFACTOR: 성인 콘텐츠 조회 가능 여부 정책의 canonical 진입점은
MemberContentPreferenceService.canViewAdultContent(member)로 문서화하고, 내부 계산은 기존calculateIsAdultForQuery(...)를 재사용한다. - 기대 결과: 동일한 정책을 중복 구현한
isAdultVisibleByPolicy(...)경로가 제거되거나 명확히 deprecated 처리되어, 신규 호출부가 다시 분산되지 않는다. - 검증 기록: 구현 완료 시 실행 명령, 결과, 제거하지 못한 사용처가 있으면 사유와 후속 task를 이 task 아래에 한국어로 누적 기록한다.
- 2026-06-23 Phase 7 구현 기록:
rg -n "isAdultVisibleByPolicy|resolveCountryCodeByPolicy" src/main/kotlin src/test/kotlin실행 결과 v2 외부 기존 production 사용처(content/main,content/series,content/theme,content/AudioContentService등)가 남아 있어 즉시 제거하지 않았다.MemberContentPreferencePolicy.resolveCountryCodeByPolicy(...)와isAdultVisibleByPolicy(...)에@Deprecated("Use MemberContentPreferenceService.canViewAdultContent(member)")를 추가했다.- 성인 콘텐츠 조회 가능 여부 정책의 신규 canonical 진입점은
MemberContentPreferenceService.canViewAdultContent(member)로 정리했다.
- 2026-06-23 Phase 7 코드 리뷰 및 추가 검증 기록:
- 코드 리뷰:
canViewAdultContent(member)가getStoredPreference(member).isAdult를 반환해 기본 preference 초기화, 국가 정책, 성인 인증 여부 계산 경로를 그대로 재사용함을 확인했다. v2 추천 탭/홈/creator channel 호출부도 해당 service 메서드로 통일되어 차단 이슈 없음. ./gradlew test --tests kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest:BUILD SUCCESSFUL.rg -n "isAdultVisibleByPolicy|getStoredPreference\([^\n]*\)\.isAdult" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2: 결과 없음.rg -n "canViewAdultContent\(" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2 src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference:MemberContentPreferenceService와 Phase 7 v2 변경 호출부에서 canonical 메서드 사용 확인.git diff --check: 출력 없음../gradlew ktlintCheck: sandbox 환경에서는 Gradle wrapper lock 파일 접근 제한으로 실패했으나, 승인 후 sandbox 밖에서 재실행해BUILD SUCCESSFUL확인.
- 코드 리뷰:
- Files:
Phase 8: 회귀 검증과 문서 기록
-
Task 8.1: 전체 관련 테스트와 ktlint 실행
- Files:
- Verify:
docs/20260623_메인_콘텐츠_추천_탭_API/prd.md - Verify:
docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md
- Verify:
- TDD 예외 사유: 구현 완료 후 회귀 검증과 문서 기록 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run:
./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.* - Run:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.* - Run:
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest - Run:
./gradlew test --tests kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest - Run:
./gradlew ktlintCheck
- Run:
- 기대 결과: 모든 관련 테스트와 ktlint가
BUILD SUCCESSFUL이다. - 검증 기록: 구현 완료 시 실행 명령, 결과, 실패 시 원인과 수정 내용을 이 task 아래에 한국어로 누적 기록한다.
- Files:
-
Task 8.2: 문서/스키마 영향 최종 확인
- Files:
- Verify:
docs/20260623_메인_콘텐츠_추천_탭_API/prd.md - Verify:
docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md - Verify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedSectionType.kt
- Verify:
- TDD 예외 사유: 문서와 enum 확장 범위 확인 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run:
rg -n "GET /api/v2/audio/recommendations|AudioRecommendationsResponse|NEW_AND_HOT_AUDIO_SAFE|RECOMMENDED_AUDIO_ALL" docs src/main/kotlin src/test/kotlin - Run:
rg -n "api\\.audio\\.recommendation|v2\\.audio\\.recommendation|api/audio/recommendation|v2/audio/recommendation" src/main/kotlin src/test/kotlin - Run:
rg -n "isAdultVisibleByPolicy|getStoredPreference\\([^\\n]*\\)\\.isAdult" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2 - Run:
rg -n "isAdultVisibleByPolicy|resolveCountryCodeByPolicy" src/main/kotlin src/test/kotlin - Run:
./gradlew tasks --all
- Run:
- 기대 결과: 공개 API endpoint와 응답 필드명이 문서/코드/테스트에서 일치하고, 신규 DB 테이블 DDL이 필요하지 않으며, 코드의 최종 패키지 구조가
content.recommendation기준이고, v2 성인 콘텐츠 조회 정책 계산 경로가 service 메서드로 통일됐음이 확인된다. - 검증 기록: 구현 완료 시 문서와 코드 검색 결과를 이 task 아래에 한국어로 누적 기록한다.
- Files:
전체 검증 기록
- 계획 문서 생성 시점에는 구현 코드를 변경하지 않았으므로 테스트 실행 대상은 없다.
- 문서 변경 후 명령 유효성 확인은
./gradlew tasks --all로 수행한다. - 패키지 구조 변경 계획 문서 수정 후
./gradlew tasks --all을 실행했다. 최초 sandbox 실행은 Gradle wrapper lock 파일의~/.gradle접근 권한 문제로 실패했고, 승인 후 재실행해BUILD SUCCESSFUL을 확인했다.
Phase 1-3 검증 기록
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest:BUILD SUCCESSFUL(홈 배너 공통 DTO 직렬화 필드 포함 확인)../gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.application.AudioRecommendationFacadeTest: 최초 실행 시 점수 정책 테스트 기대값 산식 오산으로 실패 후 기대값 수정../gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.application.AudioRecommendationFacadeTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest:BUILD SUCCESSFUL.- Phase 4 범위로 보일 수 있는 snapshot 후보 조회 stub 제거 후 동일한 6개 타깃 테스트 명령을 재실행했고
BUILD SUCCESSFUL. - reviewer 지적 사항 반영:
latestComment응답 필드 추가, PRD 기준 최신성 배수 수정, JSON boolean 필드명과 공개 오디오 필터 테스트 보강. ./gradlew test --rerun-tasks --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.application.AudioRecommendationFacadeTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest:BUILD SUCCESSFUL../gradlew ktlintCheck:BUILD SUCCESSFUL.- 추가 code review 지적 사항 반영: production
SecurityConfig에GET /api/v2/audio/recommendations비회원 허용 추가, controller 테스트의 테스트 전용 permitAll 보안 체인 제거, 오디오 추천 배너의 성인 배너 필터 제거로 홈 추천 배너와 동일 정책 유지, 오리지널 시리즈 최신순/12개 limit 및 무료/포인트 10개 limit 테스트 보강. - 동일 targeted test 명령과
./gradlew ktlintCheck를 재실행했고 모두BUILD SUCCESSFUL.
Phase 4-5 검증 기록
- RED:
DefaultAudioRecommendationQueryRepositoryTest,AudioRecommendationSnapshotRefreshServiceTest,AudioRecommendationSnapshotSchedulerTest,AudioRecommendationQueryServiceTest에 Phase 4/5 실패 테스트를 먼저 추가했다. 초기 실행에서 query service Mockito matcher 오류와 동시 Gradle 실행으로 인한 XML 결과 파일 쓰기 충돌, ktlint formatting 실패를 확인했다. - GREEN: snapshot 후보 native SQL, 최신 댓글 상세 조회, KST 기준 refresh service, 00:00 KST Redisson lock scheduler, query service snapshot 조립과 New & Hot lazy refresh를 구현하고 실패 원인을 수정했다.
./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest:BUILD SUCCESSFUL(New & Hot/최근 댓글/추천 후보 산정, 댓글 상세, 기존 실시간 섹션 회귀 포함)../gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest:BUILD SUCCESSFUL(KST 전날 23:59:59 기준과 여섯 section replace 확인)../gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.scheduler.AudioRecommendationSnapshotSchedulerTest:BUILD SUCCESSFUL(cron/zone, lock 획득/skip/unlock 확인)../gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest:BUILD SUCCESSFUL(SAFE snapshot 조회, New & Hot lazy refresh, 빈 mostCommented/recommended 허용 확인)../gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.audio.recommendation.*':BUILD SUCCESSFUL(facade DTO 변환과 controller permitAll 응답 계약 확인)../gradlew ktlintCheck:BUILD SUCCESSFUL.- 추가 점검: 댓글 상세 조회에서 차단 작성자 제외 후 최신 active 댓글을 선택하도록 보강하고
DefaultAudioRecommendationQueryRepositoryTest,./gradlew ktlintCheck를 재실행해 모두BUILD SUCCESSFUL을 확인했다. - 추가 code review 지적 사항 반영:
findCommentedAudiosByIds의 최신 댓글 상세 조회가 스냅샷 산정 SQL과 동일하게 크리에이터-댓글 작성자 간 차단 댓글을 제외하도록 보강하고, 해당 작성자의 더 최신 댓글이 이전 정상 댓글 선택을 막지 않도록newer후보에도 같은 차단 조건을 적용했다. DefaultAudioRecommendationQueryRepositoryTest에 viewer와 무관한 크리에이터-댓글 작성자 차단 회귀 테스트를 추가했다../gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest:BUILD SUCCESSFUL.- Task 5.3 보강:
AudioRecommendationEndToEndTest를 추가해@SpringBootTest+@AutoConfigureMockMvc로 production SecurityConfig, controller, facade, query service, repository, snapshot 조회 조합을 통과하는 최소 E2E를 검증했다../gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationEndToEndTest:BUILD SUCCESSFUL. - 2026-06-23 리뷰 보정: 스냅샷 기준/윈도우를 UTC 변환
LocalDateTime이 아니라 KST-localLocalDateTime으로 저장/조회하도록 보정하고, 최신성 일수는 24시간 경과 기준으로 Kotlin 정책을 맞췄다. New & Hot lazy refresh는 보강 후에도 row가 없으면 Redis marker 기준 같은 KST 날짜에 반복 실행하지 않도록 보강했다. - 2026-06-23 리뷰 보정 후 추가 보정: post-implementation review에서
getRecommendations()의 read-only transaction 안에서 lazy refresh 후 재조회하면 MySQLREPEATABLE_READread view 때문에 새 스냅샷이 같은 요청에서 보이지 않을 수 있다고 지적해, query service의 외부 read-only transaction을 제거했다. 또한 인메모리 guard는 프로세스 재시작/다중 서버에서 KST 날짜별 1회를 보장하지 못하므로RedissonClientRedis marker(audio-recommendation:new-and-hot:lazy-refresh-attempted:{yyyy-MM-dd}, TTL 2일)로 변경했다. - 2026-06-23 리뷰 보정 검증:
./gradlew --stop && ./gradlew clean test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicyTest:BUILD SUCCESSFUL. - 2026-06-23 리뷰 보정 검증: repository focused test는 병렬 Gradle 실행 중
kaptGenerateStubsTestKotlin출력 디렉터리 충돌로 1회 실패해 단독 재실행했다. H2MODE=MySQL의TIMESTAMPDIFF경계 동작이 운영 MySQL 공식 기준과 달라 신규 repository 경계 테스트는 제거하고 Kotlin 정책 테스트로 24시간 경계를 고정했다. 최종./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest:BUILD SUCCESSFUL. - 2026-06-23 리뷰 보정 검증:
./gradlew ktlintCheck:BUILD SUCCESSFUL,git diff --check: 출력 없음. - 2026-06-25 후속 보정:
DefaultAudioRecommendationQueryRepositoryTest.shouldFindNewAndHotSnapshotsWithVisibility의 score 비교 실패 원인은 repository native SQL의timestampdiff(day, c.release_date, :snapshotAt)최신성 계산이 DB 날짜 경계 기준에 의존해AudioRecommendationScorePolicy의 24시간 경과 기준ChronoUnit.DAYS계산과 어긋날 수 있는 점으로 확인했다.DefaultAudioRecommendationQueryRepository의 New & Hot/추천 오디오 공개일 최신성 계산을floor(timestampdiff(hour, c.release_date, :snapshotAt) / 24)로 변경해 Kotlin 정책과 일치시켰고,SAFE성인 콘텐츠 제외 조건은 기존(:includeAdult = true or c.is_adult = false)구현이 올바른 것으로 확인했다. 검증은./gradlew test --rerun-tasks --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest.shouldFindNewAndHotSnapshotsWithVisibility',./gradlew test --rerun-tasks --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationScorePolicyTest',./gradlew ktlintCheck모두BUILD SUCCESSFUL로 완료했다.