18 KiB
18 KiB
PRD: 메인 홈 추천 인기 커뮤니티 스냅샷 수정
1. Overview
메인 홈 추천 탭의 POPULAR_COMMUNITY 스냅샷 생성과 조회를 최근 7일 데이터 기반 인기 점수로 수정하고, 스냅샷이 없을 때 홈 API가 동일 refresh 로직을 안전하게 재사용하도록 fallback 흐름을 보강한다.
2. Problem
- 기존
POPULAR_COMMUNITY산식은 댓글 가중치와 신규 부스트 값이 이번 요구사항과 다르다. - 기존 구현은 공지/고정 게시글과 유료 게시글 제외 조건을 일부 갖고 있으나, 요구사항의 제외 조건을 테스트로 명확히 고정해야 한다.
- 현재
POPULAR_COMMUNITY조회는 대상일snapshotAt스냅샷이 없으면 별도 fallback 없이 빈 결과가 될 수 있다. - 스케줄러 refresh와 홈 API fallback refresh가 같은 생성 로직을 공유하지 않으면 산식 drift가 발생할 수 있다.
- 집계 산식이 추천 노출 순서를 직접 바꾸므로 DB-side 계산과 Kotlin-side 계산 중 어떤 방식을 선택하더라도 산식/부스트 경계값 테스트가 필요하다.
3. Goals
POPULAR_COMMUNITY스냅샷은 최근 7일 데이터를 기반으로 생성한다.- 인기 커뮤니티 점수 산식을
((좋아요 수 * 0.50) + (댓글 수 * 0.40) + (크리에이터 팔로우 수 * 0.10)) * 신규 부스트로 변경한다. - 신규 부스트는 크리에이터 데뷔일 기준 10일 이내 1.15, 20일 이내 1.10, 30일 이내 1.05, 그 외 1.0을 적용한다.
- 공지/고정 게시글과 유료 게시글은 스냅샷 후보와 조회 결과에서 제외한다.
- 스케줄러 refresh와 fallback refresh는 동일한
POPULAR_COMMUNITYrefresh 로직을 재사용한다. - 대상일
snapshotAt스냅샷이 없을 때 fallback refresh는 lock, double-check, single-flight 대기로 중복 refresh를 방지한다. - 홈 API는 fallback refresh 완료를 최대 1,500ms까지만 기다리고, lock 대기는 최대 300ms로 제한한다.
- fallback refresh 실패, timeout, refresh 결과 없음은 홈 API 전체 실패로 전파하지 않고
popularCommunityPosts빈 배열로 처리한다. - 산식과 신규 부스트는 단위 테스트에서 경계값과 가중치 계산을 촘촘히 검증한다.
4. Non-Goals
- 메인 홈 추천 API URL, 응답 필드, 응답 JSON 스키마는 변경하지 않는다.
POPULAR_COMMUNITY이외 추천 섹션의 산식과 조회 정책은 변경하지 않는다.- 커뮤니티 게시글 작성/수정/삭제, 좋아요, 댓글, 구매 동작 자체는 변경하지 않는다.
- 관리자 화면, 수동 추천 편집, A/B 테스트, 개인화 추천은 이번 범위에 포함하지 않는다.
- 신규 추천 스냅샷 테이블을 만들지 않고, 기존
recommendation_snapshot구조를 우선 재사용한다.
5. Target Users
- 회원/비회원: 메인 홈 추천 탭에서 최근 반응이 좋은 무료 커뮤니티 게시글을 발견하는 사용자
- 앱 클라이언트: 기존 응답 계약을 유지한 채
POPULAR_COMMUNITY추천 순서만 변경된 결과를 받는 클라이언트 - 운영자: 최근 7일 커뮤니티 반응이 추천 노출에 반영되는지 확인해야 하는 운영 담당자
6. User Stories
- 사용자는 메인 홈 추천 탭에서 최근 좋아요와 댓글 반응이 많은 커뮤니티 게시글을 우선 보고 싶다.
- 사용자는 유료 게시글이나 공지 게시글이 인기 추천 영역에 섞이지 않기를 기대한다.
- 사용자는 신규 크리에이터의 커뮤니티 게시글도 일정 기간 동안 적절한 노출 기회를 받기를 기대한다.
- 앱 클라이언트는 스냅샷이 없는 상황에서도 홈 API가 실패하지 않고 안정적으로 빈 배열 또는 생성된 스냅샷을 받기를 원한다.
- 운영자는 배치 실패 후 첫 홈 조회가 스케줄러와 동일한 로직으로 스냅샷을 복구하기를 원한다.
7. Core Features
Feature A. 인기 커뮤니티 스냅샷 산식 변경
Requirements
POPULAR_COMMUNITY점수는 아래 산식으로 계산한다.score = ((likeCount * 0.50) + (commentCount * 0.40) + (creatorFollowerCount * 0.10)) * newBoost
likeCount는 집계 기간 안에 생성된 활성 좋아요 수다.- 좋아요 수는
creator_community_like.is_active = true인 row를 기준으로distinct id집계한다. commentCount는 집계 기간 안에 생성된 활성 댓글 수다.- 댓글 수는
creator_community_comment.is_active = true인 row를 기준으로distinct id집계한다. - 댓글 불가 게시글은 댓글 row가 있어도 산식의
commentCount를 0으로 계산한다. creatorFollowerCount는 스냅샷 생성 시점 기준 크리에이터의 활성 팔로워 총수다.creatorFollowerCount는 최근 7일 신규 팔로우 수가 아니며, 스냅샷 생성 시점까지 활성 상태인 전체 팔로워 수를 의미한다.- 팔로워 수는
creator_following.creator_id = creator_community.member_id이고is_active = true인 row를 기준으로distinct id집계한다. - 점수 산식 상수는
RecommendationScoreSpec등 기존 점수 정책 위치에 모아 DB expression과 Kotlin 정책 테스트가 같은 값을 참조하도록 한다. - 스냅샷 정렬은 점수 내림차순, 동점이면 스냅샷 생성 시 저장한
randomTieBreaker오름차순을 유지한다. - 최종 저장 수는 기존 홈 노출 안정성을 위해
POPULAR_COMMUNITY최대 20개를 유지한다.
Edge Cases
likeCount,commentCount,creatorFollowerCount가 모두 0인 게시글은 스냅샷 후보에서 제외한다.- 좋아요는 없지만 댓글 또는 팔로워 수가 있으면 산식에 따라 점수를 계산한다.
- 댓글은 없지만 좋아요 또는 팔로워 수가 있으면 산식에 따라 점수를 계산한다.
- 비활성 좋아요, 비활성 댓글, 비활성 게시글, 비활성 크리에이터는 집계에서 제외한다.
- 스냅샷에는 존재하지만 조회 시점에 게시글 또는 크리에이터가 비활성화된 경우 응답에서 제외한다.
Feature B. 최근 7일 데이터 기반 집계 기간
Requirements
- 좋아요와 댓글 입력값은 최근 7일 데이터만 사용한다.
- 최근 7일 기준은 KST 기준 스냅샷 대상일을 포함한 7일의 00:00:00 이상, 대상일 다음날 00:00:00 미만의 half-open 범위로 정의한다.
- 예를 들어 스케줄러가 2026-07-10 KST에 2026-07-09 대상 스냅샷을 만들면, 집계 기간은 2026-07-03 00:00:00 KST 이상 2026-07-10 00:00:00 KST 미만이다.
- 운영 DB 시간이 UTC 기준이면, 실제 조회는
windowStartUtc <= createdAt < windowEndExclusiveUtc로 변환해 사용한다. snapshotAt은 해당 KST 대상일의 종료 시각을 UTC로 변환한windowEndExclusiveUtc.minusSeconds(1)을 사용한다.- 기존 코드에 남아 있는
created_at <= :snapshotAt방식은 이번 범위에서 half-open end-exclusive 조건으로 맞춘다.
Edge Cases
- 집계 기간에 좋아요/댓글 데이터가 없어도 팔로워 수만으로 추천 후보가 될 수 있다.
- 집계 기간과 무관하게 게시글 자체는 스냅샷 대상 시점 이전에 생성된 활성 무료/비공지 게시글이어야 한다.
- 같은
sectionType,snapshotAt에 대해 재실행하면 기존 스냅샷을 대체하는 정책을 유지한다.
Feature C. 공지/유료 게시글 제외
Requirements
- 공지 게시글은 스냅샷 후보에서 제외한다.
- 요구사항의
is_pin == true는 현재 코드의CreatorCommunity.isFixed및 DB 컬럼is_fixed = true와 동일한 의미로 해석한다. - 유료 게시글은 스냅샷 후보에서 제외한다.
- 유료 게시글 제외 조건은
creator_community.price > 0이며, 추천 후보는price <= 0또는 현재 도메인의 무료 판정과 일치해야 한다. - 상세 조회 시점에도 공지/고정 게시글과 유료 게시글을 다시 제외해 스냅샷 생성 이후 상태 변경이 응답에 노출되지 않도록 한다.
Edge Cases
- 스냅샷 생성 당시 무료였지만 조회 시점에 유료로 변경된 게시글은 응답에서 제외한다.
- 스냅샷 생성 당시 비공지였지만 조회 시점에 공지/고정으로 변경된 게시글은 응답에서 제외한다.
- 스냅샷 생성 당시 활성 상태였지만 조회 시점에 비활성화된 게시글은 응답에서 제외한다.
Feature D. 신규 부스트 변경
Requirements
- 신규 부스트 기준일은 크리에이터 데뷔일이다.
- 크리에이터 데뷔일은 기존 홈 추천 PRD와 동일하게 콘텐츠를 처음 공개한 날과 라이브를 한 날 중 빠른 날짜로 계산한다.
- 신규 부스트 구간은 다음과 같다.
- 데뷔 후 10일 이내:
1.15 - 데뷔 후 20일 이내:
1.10 - 데뷔 후 30일 이내:
1.05 - 그 외:
1.0
- 데뷔 후 10일 이내:
- 경계일 계산은 기존 추천 점수 정책의 날짜 단위 계산과 일관되게 한다.
- 부스트 기준 시각은 스냅샷의
snapshotAt이다.
Edge Cases
- 공개 콘텐츠와 라이브 이력이 모두 없어 데뷔일을 계산할 수 없는 크리에이터는 스냅샷 후보에서 제외한다.
- 데뷔일이
snapshotAt보다 미래인 데이터는 데이터 오류로 보고 스냅샷 후보에서 제외한다. - 10일/20일/30일 경계값은 테스트에서 명확히 검증한다.
Feature E. POPULAR_COMMUNITY 스냅샷 조회
Requirements
- 홈 통합 조회
GET /api/v2/home/recommendations의 인기 커뮤니티 섹션은 대상일snapshotAt의POPULAR_COMMUNITY스냅샷 순서를 사용한다. - 대상일
snapshotAt은 홈 API 호출 시각 기준 KST 전날의 종료 시각을 UTC로 변환한 값이다. - 일 단위 최신성을 강제하므로, 과거 최신 스냅샷이 존재하더라도 대상일
snapshotAt스냅샷이 없으면 fallback refresh를 시도한다. - 기존 노출 정보는 유지한다.
communityIdcreatorId- 크리에이터 닉네임
- 크리에이터 프로필 이미지
- 이미지 경로
- 오디오 경로
- 본문
- 가격
- 생성 시각
- 전체 좋아요 수
- 전체 댓글 수
- 구매 여부
- 좋아요 여부
- 조회 시점에도 기존 차단 필터, 성인 필터, 활성 게시글/크리에이터 필터를 적용한다.
- 스냅샷 후보는 최대 20개까지 조회하고, 상세 조회/필터링/크리에이터 중복 제거 후 홈 첫 화면에는 최대 10개를 반환한다.
- 동일 크리에이터의 게시글이 여러 개 스냅샷에 포함된 경우 기존 정책처럼 점수가 가장 높은 1개만 홈 응답에 노출한다.
Edge Cases
- 대상일
snapshotAt스냅샷 row가 존재하지만 조회 필터로 모두 제외되면 빈 배열을 반환한다. - 상세 조회 결과가 스냅샷 저장 개수보다 적어도 홈 조회 전체는 성공 처리한다.
- 스냅샷 정렬 순서와 응답 순서는 일치해야 한다.
Feature F. 스냅샷 없음 fallback refresh
Requirements
- 대상일
snapshotAt의POPULAR_COMMUNITY스냅샷이 없으면 fallback refresh를 시도한다. - fallback refresh는 홈 API에서 직접 계산 결과를 응답하지 않고, 스케줄러와 동일한 refresh 로직으로
recommendation_snapshot에 저장한 뒤 저장된 스냅샷을 다시 조회한다. - fallback refresh 흐름은 다음 순서를 따른다.
- 홈 API 호출 시각 기준 대상일
snapshotAt계산 - 대상일
snapshotAt의POPULAR_COMMUNITY스냅샷 조회 - 대상일 스냅샷이 없으면 fallback refresh 진입
- refresh 전 섹션 전용 lock 획득 시도
- lock 안에서 대상일
snapshotAt스냅샷을 한 번 더 조회 - 여전히 없으면 스케줄러와 동일한
POPULAR_COMMUNITYrefresh 로직 실행 - 대상일
snapshotAt에 저장된 스냅샷을 다시 조회해 반환 - refresh 후에도 노출 가능한 스냅샷이 없으면 빈 배열 반환
- 홈 API 호출 시각 기준 대상일
- lock key는 섹션 단위로 분리한다.
- 권장:
lock:recommendation-snapshot-refresh:POPULAR_COMMUNITY
- 권장:
- lock 대기 시간은 최대 300ms로 제한한다.
- 홈 API가 fallback refresh 완료를 기다리는 시간은 최대 1,500ms로 제한한다.
- timeout은 홈 API 대기 timeout이며, 이미 시작된 refresh 작업을 반드시 중단한다는 의미가 아니다.
- 동일 JVM에서는 single-flight 상태를 유지해 동시에 들어온 홈 요청이 중복 refresh를 시작하지 않도록 한다.
- 다중 인스턴스에서는 Redisson 기반 분산 lock으로 인스턴스 간 중복 refresh를 방지한다.
- lock 획득 실패 또는 refresh 진행 중인 요청은 짧게 대기한 뒤 대상일
snapshotAt스냅샷을 다시 조회하고, 없으면 빈 배열을 반환한다. - fallback refresh 실패는 로그를 남기고 홈 API 전체 실패로 전파하지 않는다.
- 구현은 기존
RecommendationSnapshotFallbackService를 섹션 단위로 확장하는 방식을 우선 검토한다. 이는 AI 캐릭터/응원 크리에이터 fallback과 lock, timeout, double-check 동작을 공유할 수 있어 사용자 제안 흐름보다 중복이 적다.
Edge Cases
- lock 획득 직전에 다른 요청 또는 스케줄러가 스냅샷을 만들 수 있으므로 double-check가 필요하다.
- fallback refresh가 성공했지만 집계 대상 데이터가 없으면 빈 배열을 반환한다.
- 집계 대상 데이터가 없는 정상 refresh와 아직 한 번도 refresh되지 않은 상태를 구분해야 반복 fallback을 막을 수 있다.
- 홈 API 대기 시간이 초과되어 빈 배열을 반환한 뒤에도 백그라운드 refresh가 완료되면 이후 요청은 저장된 대상일
snapshotAt스냅샷을 사용한다.
Feature G. 빈 결과 refresh 마커
Requirements
POPULAR_COMMUNITY도 AI 캐릭터/응원 크리에이터와 같은 방식으로 빈 결과 refresh 마커를 저장하는 방안을 우선 적용한다.- 권장 방식은
targetId = 0empty snapshot marker를POPULAR_COMMUNITY에도 적용하는 것이다. - 조회 쿼리는 기존처럼
target_id <> 0을 유지해 marker가 사용자 응답에 노출되지 않게 한다. - 대상일
snapshotAt존재 여부 확인은 marker를 포함해 판단하여, 집계 결과가 없는 날 매 홈 요청마다 fallback refresh가 반복되지 않도록 한다. - 이 방식은 별도 DDL 없이 기존
recommendation_snapshot구조를 재사용할 수 있어 이번 요구사항에 가장 단순하다.
Edge Cases
- marker가 있더라도 실제 추천 row가 없으면 홈 응답은 빈 배열이다.
- 같은
sectionType,snapshotAt에 실제 row가 생기는 재실행이 있으면 marker는 대체되어야 한다. - marker 저장 정책을 모든 스냅샷 섹션으로 공통화하는 작업은 이번 범위에서는
POPULAR_COMMUNITY에 필요한 최소 변경만 수행한다.
8. Technical Constraints
- Kotlin, Spring Boot 2.7.14, Java 17, Gradle Wrapper 구조를 유지한다.
- 기존
kr.co.vividnext.sodalive.v2.recommendation패키지 경계와v2.api.home에서v2.recommendation을 호출하는 의존 방향을 유지한다. - 기존
RecommendationSnapshot,RecommendationSnapshotPort,HomeRecommendationQueryPort기반 저장/조회 구조를 재사용한다. - 공개 API 응답 DTO는 필드 추가 없이 유지한다.
- 스케줄러 refresh와 fallback refresh는 산식, 기간, 저장 limit, 정렬 기준이 갈라지지 않도록 같은 application service 경로를 사용한다.
- fallback refresh 기능은 기존 AI 캐릭터/응원 크리에이터 구현을 복사하기보다 섹션별로 재사용 가능한 형태를 우선 검토한다. 단, 과도한 일반화가 필요하면
POPULAR_COMMUNITY에 필요한 최소 추상화만 적용한다. POPULAR_COMMUNITY집계는 정확한 top 후보를 위해 최종 점수 계산 전 candidate pre-limit를 두지 않는다.- DB-side scoring을 유지하는 경우 Kotlin 단에는 산식 parity 검증용 정책 함수를 두고, DB expression과 같은 상수를 공유한다.
- Kotlin-side scoring으로 변경하는 경우 DB에서 필요한 원천 metric을 정확히 집계하고, service에서 최종 점수/정렬/limit을 적용한다. 이 경우 후보 전체를 메모리에 올리는 비용과 데이터량을 구현 계획에서 검토한다.
- 기본 권장안은 현재 구조와 성능 특성을 유지하는 DB-side exact scoring이다. 다만 산식/부스트 계산은 Kotlin 정책 테스트로 검증 가능한 형태를 둔다.
- 시간 범위는 KST 기준 최근 7일을 UTC half-open window로 변환하는 정책 함수를 사용한다. 기존
RecommendationSnapshotWindowPolicy에 7일 window 함수를 추가하는 방식을 우선 검토한다. - 신규 DDL은 만들지 않는 것을 기본으로 한다. 불가피하게 필요하면 운영 DB 반영용 SQL 문서는 MySQL 기준으로 별도 작성한다.
9. Metrics
POPULAR_COMMUNITY스냅샷 생성 성공/실패 로그POPULAR_COMMUNITY스냅샷 최종 저장 수- fallback refresh 실행/성공/실패/timeout 로그
- fallback refresh lock 획득 성공/실패 로그
likeCount,commentCount,creatorFollowerCount입력값 분포- empty snapshot marker 저장 횟수
- 홈 API
popularCommunityPosts빈 응답 비율 - 홈 API fallback refresh 대기 시간
10. Open Questions
- 없음.
11. Decisions
- 요구사항의
is_pin은 현재 코드의CreatorCommunity.isFixed및 DB 컬럼is_fixed로 매핑한다. - 유료 게시글 제외는
price > 0제외로 정의한다. creatorFollowerCount는 스냅샷 생성 시점의 활성 팔로워 총수로 확정한다.- 일 단위 최신성을 강제하며, fallback 조건은 최신 스냅샷 부재가 아니라 대상일
snapshotAt스냅샷 부재로 확정한다. - fallback 방식은 사용자 제안의 동일 refresh 로직 재사용, lock, double-check 원칙을 채택한다.
- 더 나은 구현 방향은 기존
RecommendationSnapshotFallbackService에POPULAR_COMMUNITY를 추가해 AI 캐릭터/응원 크리에이터와 같은 lock, timeout, single-flight 흐름을 공유하는 것이다. - 점수 계산은 기본적으로 DB-side exact scoring을 유지하고, Kotlin 정책 함수와 테스트로 가중치/부스트의 근거를 고정한다.
12. Related Documents
docs/prd/sample-prd.mddocs/agent-guides/작업절차.mddocs/agent-guides/문서유지보수.mddocs/20260529_메인_홈_추천_API/prd.mddocs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.mddocs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md