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

16 KiB

PRD: 메인 콘텐츠 추천 탭 API

1. Overview

메인 콘텐츠 탭의 내부 추천 탭에서 사용할 배너, 오리지널 시리즈, 신규/추천/무료/포인트 오디오, New & Hot, 최근 댓글 많은 오디오 섹션을 한 번에 조회하는 v2 API를 제공한다.


2. Problem

  • 기존 content.main.tab.home API는 콘텐츠 홈 화면 전체 구성을 조립하지만, 신규 내부 추천 탭의 섹션 구성과 응답 필드가 다르다.
  • 신규 추천 탭은 실시간 최신순/랜덤 조회와 일 단위 스냅샷 기반 점수 섹션이 섞여 있어, API 조립 계층과 도메인 조회 계층의 책임을 분리해야 한다.
  • 기존 v2 패키지에 홈 추천 API, 스냅샷, 배너 조회, 오디오 응답 DTO와 유사한 코드가 있으므로 구현 전 재사용 범위를 명확히 해야 한다.
  • New & Hot, 최근 댓글 많은 오디오처럼 매일 갱신되는 섹션은 데이터가 없을 때 표시/스케줄 보강 정책이 필요하다.

3. Goals

  • 메인 콘텐츠 추천 탭 조회 API를 kr.co.vividnext.sodalive.v2 하위 신규 코드로 제공한다.
  • 기존 패턴과 동일하게 공개 API 조립 계층과 도메인 조회 계층을 분리한다.
  • 메인 배너는 메인 홈 추천 배너와 동일한 데이터를 응답한다.
  • 오리지널 시리즈, 최신 오디오, 무료 오디오, 포인트 오디오는 요청 시점 기준으로 조회한다.
  • New & Hot, 최근 댓글 많은 오디오, 추천 오디오는 KST 매일 00:00에 전날 23:59:59 KST까지의 데이터를 반영한 스냅샷을 사용한다.
  • New & Hot 스냅샷 데이터가 없으면 조회 시점에 lazy로 스케줄/집계 보강을 요청할 수 있어야 한다.
  • 최근 댓글 많은 오디오는 스냅샷 데이터가 없으면 섹션을 빈 배열로 내려주어 앱에서 표시하지 않게 한다.
  • PRD에 API endpoint와 Response data class 초안을 포함한다.

4. Non-Goals

  • 기존 content.main.tab.home 공개 API 스키마를 변경하지 않는다.
  • 기존 메인 홈 추천 API의 공개 스키마를 변경하지 않는다.
  • 관리자 화면, 수동 편집 기능, 추천 결과 강제 고정 기능은 포함하지 않는다.
  • 개인화 추천 모델, A/B 테스트, 머신러닝 기반 추천은 포함하지 않는다.
  • 전체보기/페이징 API는 이번 요구사항에 포함하지 않는다.

5. Target Users

  • 회원: 콘텐츠 메인 탭에서 추천 오디오와 오리지널 시리즈를 탐색하는 사용자
  • 비회원: 인증 없이 조회 가능한 추천 콘텐츠를 탐색하는 사용자
  • 앱 클라이언트: 추천 탭 첫 화면 섹션을 한 API 응답으로 구성하는 클라이언트

6. User Stories

  • 사용자는 추천 탭 진입 시 메인 홈 추천 배너와 동일한 배너를 보고 싶다.
  • 사용자는 오직 보이스 온에서만 볼 수 있는 오리지널 시리즈를 최신순으로 보고 싶다.
  • 사용자는 새로 올라온 오디오를 최신순으로 확인하고 싶다.
  • 사용자는 최근 반응이 좋은 New & Hot 오디오를 보고 싶다.
  • 사용자는 무료 오디오와 포인트 사용 가능 오디오를 빠르게 탐색하고 싶다.
  • 사용자는 최근 댓글이 많은 오디오와 해당 오디오의 최신 댓글, 최신 댓글 작성자 프로필 이미지를 보고 싶다.
  • 사용자는 서버 추천 점수 기반의 추천 오디오를 보고 싶다.

7. Core Features

Feature A. 메인 콘텐츠 추천 탭 통합 조회

Requirements

  • 신규 API endpoint는 GET /api/v2/audio/recommendations로 정의한다.
  • 응답 wrapper는 기존 패턴과 동일하게 ApiResponse.ok(...)를 사용한다.
  • 인증 회원이면 회원의 콘텐츠 조회 설정과 19금 노출 가능 여부를 반영한다.
  • 비회원이면 19금 콘텐츠를 노출하지 않는다.
  • 회원이 차단했거나 회원을 차단한 크리에이터의 시리즈/오디오는 노출하지 않는다.
  • 섹션별 기본 노출 수는 아래와 같다.
    • banners: 메인 홈 추천 배너와 동일
    • originalSeries: 최신순 12개
    • latestAudios: 최신순 12개
    • newAndHotAudios: 최대 12개
    • freeAudios: 최대 10개 랜덤
    • pointAudios: 최대 10개 랜덤
    • mostCommentedAudios: 최대 5개
    • recommendedAudios: 최대 10개
  • 특정 섹션 데이터가 부족하면 가능한 개수만 내려주고 전체 API는 성공 처리한다.
  • 무료/포인트/추천 오디오 섹션 사이에는 같은 오디오가 중복 노출될 수 있다.

Edge Cases

  • 한 섹션 조회 실패가 전체 API 실패로 이어질지는 구현 계획 단계에서 기존 v2 통합 조회 API의 로깅/실패 정책과 비교해 결정한다.
  • 예약 공개 콘텐츠는 공개 전에는 노출하지 않는다.
  • 비활성 콘텐츠, duration이 없는 콘텐츠, 비활성 크리에이터의 콘텐츠는 노출하지 않는다.

Feature B. 메인 배너

Requirements

  • 메인 홈 추천 배너와 동일한 데이터를 사용한다.
  • 기존 v2 홈 추천 API의 배너 응답 구조를 공통 DTO로 분리해 재사용한다.
  • 배너 응답 필드는 imageUrl, eventItem, creatorId, seriesId, link를 유지한다.
  • 배너 대상 엔티티가 비활성 처리되었거나 차단 관계에 있으면 기존 홈 추천 배너 정책과 동일하게 제외한다.

Feature C. 오직 보이스 온에서만

Requirements

  • 오리지널 시리즈를 최신순으로 12개 조회한다.
  • series.isOriginal = true인 시리즈만 대상으로 한다.
  • 활성 시리즈와 활성 크리에이터만 노출한다.
  • Response 필드는 seriesId, coverImageUrl만 포함하고, 최상위 응답 필드명은 originalSeries로 한다.

Feature D. 새로 올라온 오디오

Requirements

  • 공개된 오디오 콘텐츠를 최신순으로 12개 조회한다.
  • 최신순 기준은 releaseDate desc, 동률이면 audioContentId desc로 한다.
  • Response는 공통 오디오 카드 응답을 사용한다.

Feature E. New & Hot

Requirements

  • 최대 12개를 표시한다.
  • KST 매일 00:00에 전날 23:59:59 KST까지의 데이터를 반영해 스냅샷을 갱신한다.
  • 최근 3일 데이터를 기반으로 최종 점수를 산출한다.
  • 최종 점수는 최신성 35% + 조회수 35% + 좋아요 15% + 댓글 수 15%로 계산한다.
  • 조회수는 creator_content_view_history의 상세 페이지 조회 이력을 기준으로 최근 3일 content_id별 count를 사용한다.
  • 조회수, 좋아요 수, 댓글 수는 후보 내 정규화 없이 원본 count를 그대로 사용한다.
  • 최신성 배수는 공개 3일 이내 1.3, 7일 이내 1.15, 14일 이내 1.0, 그 외 0.8을 적용한다.
  • 19금 노출 정책은 스냅샷 variant로 분리한다.
    • SAFE: 19금이 아닌 콘텐츠만 포함한다.
    • ALL: 19금 콘텐츠와 19금이 아닌 콘텐츠를 모두 포함한다.
  • 19금 노출이 불가능한 사용자와 비회원은 SAFE 스냅샷을 조회한다.
  • 19금 노출이 가능한 회원은 ALL 스냅샷을 조회한다.
  • 산출된 스냅샷 데이터가 없으면 lazy로 스케줄/집계 보강을 추가한다.
  • Response는 공통 오디오 카드 응답을 사용한다.

Edge Cases

  • lazy 보강 중에도 즉시 산출 가능한 결과가 없으면 빈 배열로 내려준다.

Feature F. 무료 오디오

Requirements

  • 무료 오디오 중 랜덤으로 최대 10개 조회한다.
  • 무료 오디오는 price = 0인 공개 오디오로 정의한다.
  • Response는 공통 오디오 카드 응답을 사용한다.

Feature G. 포인트 오디오

Requirements

  • 포인트 사용 가능 오디오 중 랜덤으로 최대 10개 조회한다.
  • 포인트 오디오는 isPointAvailable = true인 공개 오디오로 정의한다.
  • Response는 공통 오디오 카드 응답을 사용한다.

Feature H. 최근 댓글이 많은 오디오

Requirements

  • 댓글 점수는 댓글 수 80% + 댓글 최신성 20%로 계산한다.
  • 댓글 최신성 점수는 댓글 작성 3일 이내 1.3, 7일 이내 1.15, 14일 이내 1.0, 그 이상 0을 적용한다.
  • KST 매일 00:00에 전날 23:59:59 KST까지의 데이터를 반영해 스냅샷을 갱신한다.
  • 최근 7일 댓글 데이터를 기반으로 최종 점수를 산출한다.
  • 데이터가 없으면 섹션을 표시하지 않도록 빈 배열로 내려준다.
  • 최대 5개를 표시한다.
  • 오디오별 가장 최신 댓글 1개의 본문과 글쓴이 프로필 이미지를 함께 내려준다.

Edge Cases

  • 비활성 댓글, 삭제된 댓글, 차단 관계의 댓글 작성자 프로필은 노출하지 않는다.
  • 최신 댓글 작성자 프로필 이미지가 없으면 기본 프로필 이미지 URL 정책을 적용한다.

Feature I. 추천 오디오

Requirements

  • 최대 10개를 표시한다.
  • KST 매일 00:00에 전날 23:59:59 KST까지의 데이터를 반영해 스냅샷을 갱신한다.
  • 추천 점수는 조회수 45% + 좋아요 25% + 댓글 수 20% + 최신성 10%로 계산한다.
  • 조회수는 creator_content_view_history의 상세 페이지 조회 이력을 기준으로 스냅샷 집계 기간 내 content_id별 count를 사용한다.
  • 조회수, 좋아요 수, 댓글 수는 후보 내 정규화 없이 원본 count를 그대로 사용한다.
  • 최신성 배수는 공개 3일 이내 1.3, 7일 이내 1.15, 30일 이내 1.1, 그 외 1.0을 적용한다.
  • 19금 노출 정책은 New & Hot과 동일하게 SAFE, ALL 스냅샷 variant로 분리한다.
  • Response는 공통 오디오 카드 응답을 사용한다.

8. API Endpoint

GET /api/v2/audio/recommendations
Authorization: Bearer {accessToken} (optional)
  • 비회원 조회를 허용한다.
  • 회원 조회 시 @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? 패턴을 사용한다.
  • 별도 request query parameter는 정의하지 않는다.

9. Response Data Class

data class AudioRecommendationsResponse(
    val banners: List<AudioBannerResponse>,
    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>
)

data class AudioBannerResponse(
    val imageUrl: String,
    val eventItem: EventItem?,
    val creatorId: Long?,
    val seriesId: Long?,
    val link: String?
)

data class OriginalSeriesResponse(
    val seriesId: Long,
    val coverImageUrl: String?
)

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
)

data class CommentedAudioResponse(
    val audioContentId: Long,
    val title: String,
    val imageUrl: String?,
    val latestComment: String,
    val latestCommentWriterProfileImageUrl: String
)

10. Technical Constraints

패키지 구조

  • 공개 API 조립 계층은 kr.co.vividnext.sodalive.v2.api.content.recommendation 하위에 둔다.
    • Controller: ...adapter.in.web
    • Facade: ...application
    • Response DTO: ...dto
  • 도메인 조회 계층은 kr.co.vividnext.sodalive.v2.content.recommendation 하위에 둔다.
    • Query service: ...application
    • 점수 정책/domain model: ...domain
    • 조회 port: ...port.out
    • QueryDSL/JPA 구현: ...adapter.out.persistence
    • scheduler: ...adapter.out.scheduler
  • content 패키지는 오디오 콘텐츠뿐 아니라 오리지널 시리즈 등 추천 탭에 포함될 수 있는 콘텐츠 범주를 포괄하기 위한 명칭이다.
  • 의존 방향은 v2.api.content.recommendation -> v2.content.recommendation만 허용한다.

V2 공통화/재사용 대상

  • HomeBannerItem은 메인 홈 전용 DTO가 아니라 여러 추천 화면에서 사용할 배너 응답 구조이므로 v2.api.common.dto 계열 공통 DTO로 분리한다.
  • v2.recommendation.adapter.out.persistence.RecommendationSnapshot: 일 단위 추천 스냅샷 저장 구조
  • v2.recommendation.adapter.out.scheduler.RecommendationSnapshotScheduler: Redisson 분산 lock이 적용된 스케줄러 패턴
  • v2.recommendation.adapter.out.persistence.CreatorContentViewHistory: 오디오 상세 페이지 조회 이력 저장 구조
  • v2.recommendation.application.CreatorContentViewHistoryService: AudioContentService.getDetail(...)에서 상세 조회 이력을 기록하는 서비스
  • v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponse: 오디오 카드 응답 필드와 JsonProperty 네이밍 패턴
  • v2.common.domain.CdnUrlExtensions: 이미지 URL 변환 공통 함수

참고할 기존 패턴

  • v2.api.home.adapter.in.web.HomeRecommendationControllerv2.api.home.application.HomeRecommendationFacade는 메인 페이지 Home 탭 전용이므로 직접 재사용하지 않는다.
  • 신규 API도 controller가 인증/요청 경계를 담당하고 facade가 도메인 조회 결과를 공개 응답 DTO로 변환하는 계층 분리 방식만 참고한다.

스냅샷/스케줄

  • New & Hot, 최근 댓글 많은 오디오, 추천 오디오는 스냅샷 기반 조회를 우선한다.
  • 스케줄러는 @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") 기준으로 설계한다.
  • 다중 서버 환경에서 중복 실행을 막기 위해 기존 Redisson lock 패턴을 따른다.
  • 스냅샷 기준 시각은 KST 전날 23:59:59를 UTC 변환 없이 KST-local LocalDateTime으로 저장한다.
  • 스냅샷 집계 window도 KST-local 00:00:00부터 KST-local 23:59:59까지를 기준으로 계산한다.
  • 19금 노출 영향을 받는 스냅샷 섹션은 visibility variant를 저장한다.
    • SAFE: 19금이 아닌 콘텐츠만 포함한다.
    • ALL: 19금 콘텐츠와 19금이 아닌 콘텐츠를 모두 포함한다.
  • SAFEALL을 분리하는 이유는 스냅샷 조회 후 19금 콘텐츠를 필터링할 경우 비회원/19금 노출 불가 회원에게 최대 노출 개수를 안정적으로 채우기 어렵기 때문이다.
  • 기존 recommendation_snapshot을 확장 재사용할지, 콘텐츠 추천 전용 스냅샷 테이블을 만들지는 구현 계획에서 DDL 영향과 enum 확장 범위를 비교해 결정한다.

조회 정책

  • 모든 오디오 섹션은 활성 콘텐츠, 활성 크리에이터, duration is not null, releaseDate <= now 조건을 기본으로 한다.
  • 성인 콘텐츠는 회원의 MemberContentPreference와 본인인증 정책을 반영한다.
  • 비회원은 성인 콘텐츠를 제외한다.
  • 차단 관계 필터는 기존 v2 홈/크리에이터 채널 조회 패턴을 따른다.
  • 조회수 점수의 조회수는 AudioContent.playCount가 아니라 creator_content_view_history의 상세 페이지 조회 이력 count를 사용한다.
  • 최신성 점수의 일수는 날짜 경계가 아니라 시간까지 포함한 24시간 경과 일수 기준으로 계산한다.
  • New & Hot lazy 보강은 스냅샷 row가 없을 때 Redis marker 기준 KST 날짜별 1회만 시도하고, 보강 후 후보가 0개인 정상 상황에서는 같은 날짜의 다음 조회가 전체 refresh를 반복하지 않는다.
  • 공통 오디오 카드 응답의 isOriginalSeries는 시리즈 미소속 오디오이면 클라이언트 편의를 위해 false로 내려준다.
  • 무료/포인트/추천 오디오처럼 서로 다른 추천 섹션에 같은 콘텐츠가 동시에 포함되어도 서버에서 중복 제거하지 않는다.

11. Metrics

  • API 성공/실패 로그
  • 섹션별 응답 개수
  • 스냅샷 갱신 성공/실패 로그
  • 스냅샷 갱신 대상 개수
  • lazy 보강 발생 횟수
  • 빈 섹션 목록

12. Open Questions

  • 없음