16 KiB
16 KiB
PRD: 메인 콘텐츠 추천 탭 API
1. Overview
메인 콘텐츠 탭의 내부 추천 탭에서 사용할 배너, 오리지널 시리즈, 신규/추천/무료/포인트 오디오, New & Hot, 최근 댓글 많은 오디오 섹션을 한 번에 조회하는 v2 API를 제공한다.
2. Problem
- 기존
content.main.tab.homeAPI는 콘텐츠 홈 화면 전체 구성을 조립하지만, 신규 내부 추천 탭의 섹션 구성과 응답 필드가 다르다. - 신규 추천 탭은 실시간 최신순/랜덤 조회와 일 단위 스냅샷 기반 점수 섹션이 섞여 있어, 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: 최신순 15개newAndHotAudios: 최대 12개freeAudios: 최대 15개 랜덤pointAudios: 최대 15개 랜덤mostCommentedAudios: 최대 5개recommendedAudios: 최대 20개
- 특정 섹션 데이터가 부족하면 가능한 개수만 내려주고 전체 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
- 공개된 오디오 콘텐츠를 최신순으로 15개 조회한다.
- 최신순 기준은
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
- 무료 오디오 중 랜덤으로 최대 15개 조회한다.
- 무료 오디오는
price = 0인 공개 오디오로 정의한다. - Response는 공통 오디오 카드 응답을 사용한다.
Feature G. 포인트 오디오
Requirements
- 포인트 사용 가능 오디오 중 랜덤으로 최대 15개 조회한다.
- 포인트 오디오는
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개만 저장한다.
- 오디오별 가장 최신 댓글 1개의 본문과 글쓴이 프로필 이미지를 함께 내려준다.
Edge Cases
- 비활성 댓글, 삭제된 댓글, 차단 관계의 댓글 작성자 프로필은 노출하지 않는다.
- 최신 댓글 작성자 프로필 이미지가 없으면 기본 프로필 이미지 URL 정책을 적용한다.
Feature I. 추천 오디오
Requirements
- 최대 20개를 표시한다.
- 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
- Controller:
- 도메인 조회 계층은
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
- Query service:
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.HomeRecommendationController와v2.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-localLocalDateTime으로 저장한다. - 스냅샷 집계 window도 KST-local
00:00:00부터 KST-local23:59:59까지를 기준으로 계산한다. - 19금 노출 영향을 받는 스냅샷 섹션은 visibility variant를 저장한다.
SAFE: 19금이 아닌 콘텐츠만 포함한다.ALL: 19금 콘텐츠와 19금이 아닌 콘텐츠를 모두 포함한다.
SAFE와ALL을 분리하는 이유는 스냅샷 조회 후 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로 내려준다. - 무료/포인트/추천 오디오처럼 서로 다른 추천 섹션에 같은 콘텐츠가 동시에 포함되어도 서버에서 중복 제거하지 않는다.
mostCommentedAudios스냅샷 저장은 같은 크리에이터의 오디오를 최대 1개만 포함한다. 같은 크리에이터 후보가 여러 개이면findMostCommentedSnapshots의 최근 댓글 많은 오디오 점수가 가장 높은 후보를 남기고,AudioRecommendationSnapshotRefreshService.replaceMostCommentedSnapshots는 그 결과만 저장한다.
11. Metrics
- API 성공/실패 로그
- 섹션별 응답 개수
- 스냅샷 갱신 성공/실패 로그
- 스냅샷 갱신 대상 개수
- lazy 보강 발생 횟수
- 빈 섹션 목록
12. Open Questions
- 없음