Files
sodalive-backend-spring-boot/docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md

40 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.audio.recommendation 조립 계층에 둔다. 추천 조회 service, 점수 정책, 조회 domain model, port, QueryDSL/native SQL repository, scheduler는 kr.co.vividnext.sodalive.v2.audio.recommendation 하위에 두고 v2.api.*에 의존하지 않는다. 배너 값 모델은 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
  • 인증 정책: 비회원 조회 가능. 인증 회원이면 회원의 콘텐츠 조회 설정과 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까지의 데이터 반영.
  • 스냅샷 저장 방식: 기존 recommendation_snapshot 테이블을 재사용하고 RecommendedSectionType enum에 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_historycontent_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: src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationController.kt
  • Create: src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacade.kt
  • Create: src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/dto/AudioRecommendationsResponse.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/application/AudioRecommendationFacadeTest.kt

신규 도메인 조회 계층

  • Create: src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryService.kt
  • Create: src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationSnapshotRefreshService.kt
  • 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/AudioRecommendationScorePolicy.kt
  • Create: src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationVisibility.kt
  • 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
  • Create: src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/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/audio/recommendation/domain/AudioRecommendationScorePolicyTest.kt
  • Test: src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryServiceTest.kt
  • Test: src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt
  • Test: src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt
  • Test: src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/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/audio/recommendation/dto/AudioRecommendationsResponse.kt에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.

package kr.co.vividnext.sodalive.v2.api.audio.recommendation.dto

import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendations
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.CommentedAudio
import kr.co.vividnext.sodalive.v2.audio.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.audio.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.audio.recommendation.port.out

import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationVisibility
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.CommentedAudio
import kr.co.vividnext.sodalive.v2.audio.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
    • RED: HomeRecommendationResponsebanners가 공통 RecommendationBannerResponse 타입을 사용하고 기존 JSON 필드 imageUrl, eventItem, creatorId, seriesId, link를 유지하는 테스트를 작성한다.
    • 실패 확인: ./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest
    • GREEN: HomeBannerItem 필드 구조를 RecommendationBanner domain model과 RecommendationBannerResponse DTO로 분리하고 홈 추천 DTO/facade import를 갱신한다.
    • REFACTOR: 홈 탭 전용 controller/facade 로직은 이동하지 않고 DTO 타입만 공통화한다.
    • 기대 결과: 기존 홈 추천 배너 JSON 계약은 유지되고 신규 오디오 추천 API가 같은 DTO를 재사용할 수 있다.
  • 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
    • RED: facade가 도메인 AudioRecommendationsAudioRecommendationsResponse로 변환하고 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: isOriginalSeriesBoolean으로 유지하고 nullable 변환을 만들지 않는다.
    • 기대 결과: API 조립 계층은 도메인 조회 계층에만 의존하고, 도메인 조회 계층은 API DTO에 의존하지 않는다.
  • 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
    • RED: GET /api/v2/audio/recommendations가 비회원과 인증 회원 모두 200 OK를 반환하고 ApiResponse.ok wrapper를 사용하는 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 테스트로 고정된다.

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
    • RED: AudioRecommendationVisibility.SAFENEW_AND_HOT_AUDIO_SAFE, ALLNEW_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.bannersv2.common.domain.RecommendationBanner만 사용한다.
    • 기대 결과: SAFE/ALL 선택이 문자열이 아니라 enum으로 고정된다.
  • 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
    • 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 산식과 최신성 경계가 순수 단위 테스트로 고정된다.
  • 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
    • 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 이름이 들어가는지 확인한다.
    • 기대 결과: 신규 테이블 없이 기존 스냅샷 저장 구조를 재사용한다.

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
    • RED: 배너는 기존 홈 추천 배너와 동일 필드/활성/차단 정책을 적용하고, 오리지널 시리즈는 isOriginal = true 최신순 12개, 최신 오디오는 releaseDate desc, audioContentId desc 12개를 반환하는 repository 테스트를 작성한다.
    • 실패 확인: ./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest
    • GREEN: QueryDSL로 findBanners, findOriginalSeries, findLatestAudios를 구현한다. 이미지 경로는 toCdnUrl(cloudFrontHost)를 사용한다.
    • REFACTOR: 공개 오디오 조건, 성인 콘텐츠 조건, 차단 관계 조건을 private 조건 함수로 분리한다.
    • 기대 결과: 비회원은 성인 콘텐츠를 제외하고, 인증 회원은 성인 노출 가능 여부에 따라 결과가 달라진다.
  • 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
    • 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 함수를 사용하게 정리한다.
    • 기대 결과: 랜덤 섹션도 공개/성인/차단 조건을 동일하게 적용한다.
  • 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
    • RED: AudioCardaudioContentId, 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 변환을 사용한다.
    • 기대 결과: 섹션별 오디오 카드 필드 의미가 동일하게 유지된다.

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
    • RED: 최근 3일 creator_content_view_history count, content_like active count, audio_content_comment active 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 후보가 정확한 점수순으로 산출된다.
  • 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
    • 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는 빈 배열이다.
  • 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
    • RED: 상세 조회수 45%, 좋아요 25%, 댓글 수 20%, 최신성 10% 점수를 계산하고 SAFE/ALL visibility별 최대 10개 후보를 반환하는 테스트를 작성한다.
    • 실패 확인: ./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest
    • GREEN: findRecommendedAudioSnapshots(...)를 구현한다. 상세 조회수는 creator_content_view_history count를 사용하고 AudioContent.playCount를 사용하지 않는다.
    • REFACTOR: New & Hot과 공유 가능한 조회수/좋아요/댓글 aggregate CTE를 private SQL fragment 또는 QueryDSL helper로 정리한다.
    • 기대 결과: RECOMMENDED_AUDIO_SAFE/ALL에 저장할 top 10 후보가 정확한 점수순으로 산출된다.
  • 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
    • 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 함수로 분리하고 UTC/KST 변환 테스트를 유지한다.
    • 기대 결과: 일 배치와 lazy 보강 모두 같은 산정 함수를 사용한다.
  • 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
    • 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 호출만 남기고 집계 로직을 두지 않는다.
    • 기대 결과: 다중 서버에서 하루 한 번만 오디오 추천 스냅샷을 갱신한다.

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
    • RED: 비회원은 SAFE visibility와 19금 제외 조건을 사용하고, 19금 노출 가능 회원은 ALL visibility를 사용하며, 각 섹션 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로 조립된다.
  • 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
    • 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만 호출한다.
  • 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
    • 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 호출이 성공한다.

Phase 6: 회귀 검증과 문서 기록

  • Task 6.1: 전체 관련 테스트와 ktlint 실행

    • Files:
      • Verify: docs/20260623_메인_콘텐츠_추천_탭_API/prd.md
      • Verify: docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md
    • TDD 예외 사유: 구현 완료 후 회귀 검증과 문서 기록 task이므로 신규 실패 테스트 작성 대상이 아니다.
    • 대체 검증 방법:
      • Run: ./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.*
      • Run: ./gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.*
      • Run: ./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest
      • Run: ./gradlew ktlintCheck
    • 기대 결과: 모든 관련 테스트와 ktlint가 BUILD SUCCESSFUL이다.
    • 검증 기록: 구현 완료 시 실행 명령, 결과, 실패 시 원인과 수정 내용을 이 task 아래에 한국어로 누적 기록한다.
  • Task 6.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
    • 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: ./gradlew tasks --all
    • 기대 결과: 공개 API endpoint와 응답 필드명이 문서/코드/테스트에서 일치하고, 신규 DB 테이블 DDL이 필요하지 않음이 확인된다.
    • 검증 기록: 구현 완료 시 문서와 코드 검색 결과를 이 task 아래에 한국어로 누적 기록한다.

전체 검증 기록

  • 계획 문서 생성 시점에는 구현 코드를 변경하지 않았으므로 테스트 실행 대상은 없다.
  • 문서 변경 후 명령 유효성 확인은 ./gradlew tasks --all로 수행한다.

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 SecurityConfigGET /api/v2/audio/recommendations 비회원 허용 추가, controller 테스트의 테스트 전용 permitAll 보안 체인 제거, 오디오 추천 배너의 성인 배너 필터 제거로 홈 추천 배너와 동일 정책 유지, 오리지널 시리즈 최신순/12개 limit 및 무료/포인트 10개 limit 테스트 보강.
  • 동일 targeted test 명령과 ./gradlew ktlintCheck를 재실행했고 모두 BUILD SUCCESSFUL.