20 KiB
20 KiB
PRD: 메인 홈 추천 응원 크리에이터 스냅샷 수정
1. Overview
메인 홈 추천 탭의 CHEER_CREATOR 스냅샷 생성과 조회를 최근 7일 데이터 기반의 응원 점수로 수정하고, 스냅샷이 없을 때 홈 API가 동일 refresh 로직을 안전하게 재사용하도록 fallback 흐름을 보강한다. 인증 회원에게는 조회자 본인과 현재 활성 팔로우 중인 크리에이터를 cheerCreators 응답에서 제외한다.
2. Problem
- 기존
CHEER_CREATOR스냅샷은 전날 KST 하루 데이터만 사용해, 인기 커뮤니티와 동일한 최근 7일 집계 기준과 다르다. - 기존 후원 집계는
CanUsage.CHANNEL_DONATION만 대상으로 하며, 일반 후원CanUsage.DONATION을 함께 반영하지 않는다. - 현재 일괄 refresh와 홈 API fallback refresh가 섹션별로 동일한 생성 로직을 공유하지 않으면 산식 drift가 발생할 수 있다.
- 스냅샷이 없는 초기 배포, 운영 데이터 삭제, 배치 실패 상황에서 홈 조회가 매 요청마다 무거운 집계를 중복 실행하면 API 지연과 DB 부하가 커질 수 있다.
- 집계 산식이 추천 노출 순서를 직접 바꾸므로 DB-side 계산과 Kotlin-side 계산 중 어떤 방식을 선택하더라도 산식/부스트 경계값 테스트가 필요하다.
- 현재
CHEER_CREATOR상세 조회는 활성 크리에이터와 양방향 차단 조건만 적용하여, 인증 회원 본인이나 이미 팔로우 중인 크리에이터가 추천에 노출될 수 있다.
3. Goals
CHEER_CREATOR스냅샷은 인기 커뮤니티와 동일하게 스냅샷 생성 시점 기준 최근 7일 데이터를 기반으로 생성한다.- 응원 점수 산식을
((후원 금액 * 0.45) + (팬Talk 수 * 0.30) + (후원 수 * 0.10)) * 신규 부스트로 변경한다. - 신규 부스트는 크리에이터 데뷔일 기준 10일 이내 1.15, 20일 이내 1.10, 30일 이내 1.05, 그 외 1.0을 적용한다.
- 스케줄러 refresh와 fallback refresh는 동일한
CHEER_CREATORrefresh 로직을 재사용한다. - 스냅샷이 없을 때 fallback refresh는 lock, double-check, single-flight 대기로 중복 refresh를 방지한다.
- 홈 API는 fallback refresh 완료를 최대 1,500ms까지만 기다리고, lock 대기는 최대 300ms로 제한한다.
- fallback refresh 실패, timeout, refresh 결과 없음은 홈 API 전체 실패로 전파하지 않고
CHEER_CREATOR섹션 빈 배열로 처리한다. - 산식과 신규 부스트는 단위 테스트에서 경계값과 가중치 계산을 촘촘히 검증한다.
- 인증 회원의
cheerCreators상세 조회에서 조회자 본인과CreatorFollowing.isActive == true인 팔로우 크리에이터를 제외한다. - 비활성 팔로우 이력과 비회원 조회는 기존 조회 정책을 유지한다.
4. Non-Goals
- 메인 홈 추천 API URL, 응답 필드, 응답 JSON 스키마는 변경하지 않는다.
CHEER_CREATOR이외 추천 섹션의 산식과 조회 정책은 변경하지 않는다.- 관리자 화면, 수동 추천 편집, A/B 테스트, 사용자별 응원 점수·스냅샷 순서 산정은 이번 범위에 포함하지 않는다.
- 후원, 팬Talk 생성/수정/삭제 자체의 도메인 동작은 변경하지 않는다.
- 신규 추천 스냅샷 테이블을 만들지 않고, 기존
recommendation_snapshot구조를 우선 재사용한다. - 팔로우/본인 필터링으로 8명이 채워지지 않을 때 스냅샷 저장 수나 조회 후보를 16명 이상으로 늘리는 작업은 범위에 포함하지 않는다.
5. Target Users
- 회원/비회원: 메인 홈 추천 탭에서 최근 7일 응원 반응이 많았던 크리에이터를 발견하는 사용자
- 앱 클라이언트: 기존 응답 계약을 유지한 채
CHEER_CREATOR점수 순서와 인증 회원 조회 필터를 반영한 결과를 받는 클라이언트 - 운영자: 최근 7일 후원/팬Talk 반응이 추천 노출에 반영되는지 확인해야 하는 운영 담당자
6. User Stories
- 사용자는 메인 홈 추천 탭에서 최근 7일 응원이 많았던 크리에이터를 우선 보고 싶다.
- 사용자는 후원 금액뿐 아니라 팬Talk와 후원 참여 횟수도 함께 반영된 추천을 보고 싶다.
- 사용자는 신규 크리에이터가 일정 기간 동안 적절한 노출 기회를 받기를 기대한다.
- 인증 회원은 자신과 이미 팔로우 중인 크리에이터를 제외한 새로운 응원 크리에이터를 보고 싶다.
- 앱 클라이언트는 스냅샷이 없는 상황에서도 홈 API가 실패하지 않고 안정적으로 빈 배열 또는 생성된 스냅샷을 받기를 원한다.
- 운영자는 배치 실패 후 첫 홈 조회가 스케줄러와 동일한 로직으로 스냅샷을 복구하기를 원한다.
7. Core Features
Feature A. 응원 크리에이터 스냅샷 산식 변경
Requirements
CHEER_CREATOR점수는 아래 산식으로 계산한다.score = ((donationAmount * 0.45) + (fanTalkCount * 0.30) + (donationCount * 0.10)) * newBoost
donationAmount는 집계 기간 안에 발생한 채널 후원과 일반 후원 금액 합계다.donationCount는 집계 기간 안에 발생한 채널 후원과 일반 후원 건수다.- 후원 수는
UseCanCalculate.useCan이 같은 row를 1개 후원 이벤트로 보고 중복 제거해 계산한다. - 후원은
CanUsage.CHANNEL_DONATION과 일반 후원CanUsage.DONATION을 포함하며, 환불/미수령/비정상 상태는 기존 후원 집계 제외 조건을 유지한다. fanTalkCount는 집계 기간 안에 생성된 활성 팬Talk 수다.- 팬Talk 수는
CreatorCheers.isActive == true인 row 수로 계산한다. - 팬Talk는 기존
CreatorCheers또는 현재 구현의creator_cheers기반 데이터를 의미한다. - 점수 산식 상수는
RecommendationScoreSpec등 기존 점수 정책 위치에 모아 DB expression과 Kotlin 정책 테스트가 같은 값을 참조하도록 한다. - 스냅샷 정렬은 점수 내림차순, 동점이면 스냅샷 생성 시 저장한
randomTieBreaker오름차순을 유지한다. - 최종 저장 수는 기존 홈 노출 안정성을 위해
CHEER_CREATOR최대 16개를 유지한다.
Edge Cases
donationAmount,fanTalkCount,donationCount가 모두 0인 크리에이터는 스냅샷 후보에서 제외한다.- 후원 금액은 없지만 팬Talk가 있으면 산식에 따라 점수를 계산한다.
- 팬Talk는 없지만 채널 후원 또는 일반 후원이 있으면 산식에 따라 점수를 계산한다.
- 비활성 크리에이터, 차단 필터에 의해 조회 시 제외되는 크리에이터는 최종 홈 응답에서 제외한다.
- 스냅샷에는 존재하지만 조회 시점에 크리에이터가 비활성화된 경우 응답에서 제외한다.
Feature B. 최근 7일 데이터 기반 집계 기간
Requirements
- 점수 입력값은 인기 커뮤니티와 동일하게 스냅샷 생성 시점 기준 최근 7일 데이터를 사용한다.
- 최근 7일 기준은 KST 기준 전날을 포함한 7일의 시작 시각 이상, 다음날 00:00:00 미만의 half-open 범위로 정의한다.
- 운영 DB 시간이 UTC 기준이면, 실제 조회는
windowStartUtc <= createdAt < windowEndExclusiveUtc로 변환해 사용한다. snapshotAt은 해당 KST 전날의 종료 시각을 UTC로 변환한windowEndExclusiveUtc.minusSeconds(1)을 사용한다.- 스케줄러가 KST 06:00에 실행되면 실행일 전날 KST 일자를 포함한 최근 7일을 집계 대상으로 삼는다.
- 기존 코드에 남아 있는
created_at <= :snapshotAt방식은 이번 범위에서 half-open end-exclusive 조건으로 맞춘다.
Edge Cases
- 집계 기간에 대상 데이터가 없으면 실제 추천 row는 0개일 수 있다.
- 데이터가 0개인 날도 refresh가 정상 수행되었음을 구분할 수 있어야 한다.
- 같은
sectionType,snapshotAt에 대해 재실행하면 기존 스냅샷을 대체하는 정책을 유지한다.
Feature C. 신규 부스트 변경
Requirements
- 신규 부스트 기준일은 크리에이터 데뷔일이다.
- 크리에이터 데뷔일은 기존 홈 추천 PRD와 동일하게 콘텐츠를 처음 공개한 날과 라이브를 한 날 중 빠른 날짜로 계산한다.
- 신규 부스트 구간은 다음과 같다.
- 데뷔 후 10일 이내:
1.15 - 데뷔 후 20일 이내:
1.10 - 데뷔 후 30일 이내:
1.05 - 그 외:
1.0
- 데뷔 후 10일 이내:
- 경계일 계산은 기존 추천 점수 정책의 날짜 단위 계산과 일관되게 한다.
- 부스트 기준 시각은 스냅샷의
snapshotAt이다.
Edge Cases
- 공개 콘텐츠와 라이브 이력이 모두 없어 데뷔일을 계산할 수 없는 크리에이터는 스냅샷 후보에서 제외한다.
- 데뷔일이
snapshotAt보다 미래인 데이터는 데이터 오류로 보고 스냅샷 후보에서 제외한다. - 10일/20일/30일 경계값은 테스트에서 명확히 검증한다.
Feature D. CHEER_CREATOR 스냅샷 조회
Requirements
- 홈 통합 조회
GET /api/v2/home/recommendations의 최근 응원이 많은 크리에이터 섹션은 최신CHEER_CREATOR스냅샷 순서를 사용한다. - 기존 노출 정보는 유지한다.
creatorId- 크리에이터 닉네임
- 크리에이터 프로필 이미지
- 조회 시점에도 기존 차단 필터와 활성 크리에이터 필터를 적용한다.
- 인증 회원이 크리에이터인 경우
creatorId == memberId인 조회자 본인을 제외한다. - 인증 회원과 크리에이터 사이의
CreatorFollowing.isActive == true인 팔로우 관계가 있으면 해당 크리에이터를 제외한다. - 과거 언팔로우로
CreatorFollowing.isActive == false인 이력만 있는 크리에이터는 제외하지 않는다. - 비회원은 본인과 팔로우 관계를 판정할
memberId가 없으므로 해당 필터를 적용하지 않는다. - 스냅샷 후보는 최대 16개까지 조회하고, 상세 조회/필터링 후 홈 첫 화면에는 최대 8명을 반환한다.
Edge Cases
- 최신 스냅샷 row가 존재하지만 조회 필터로 모두 제외되면 빈 배열을 반환한다.
- 본인과 활성 팔로우 크리에이터를 제외한 결과가 8명보다 적으면 16명 스냅샷 후보 범위 안에서 조회 가능한 수만 반환하고, 16명 밖의 하위 후보로 보충하지 않는다.
- 상세 조회 결과가 스냅샷 저장 개수보다 적어도 홈 조회 전체는 성공 처리한다.
- 스냅샷 정렬 순서와 응답 순서는 일치해야 한다.
Feature E. 스냅샷 없음 fallback refresh
Requirements
- 최신
CHEER_CREATOR스냅샷이 없으면 fallback refresh를 시도한다. - fallback refresh는 홈 API에서 직접 계산 결과를 응답하지 않고, 스케줄러와 동일한 refresh 로직으로
recommendation_snapshot에 저장한 뒤 저장된 스냅샷을 다시 조회한다. - fallback refresh 흐름은 다음 순서를 따른다.
- 최신
CHEER_CREATOR스냅샷 조회 - 최신 스냅샷이 없으면 fallback refresh 진입
- refresh 전 섹션 전용 lock 획득 시도
- lock 안에서 최신 스냅샷을 한 번 더 조회
- 여전히 없으면 스케줄러와 동일한
CHEER_CREATORrefresh 로직 실행 - 저장된 스냅샷을 다시 조회해 반환
- refresh 후에도 노출 가능한 스냅샷이 없으면 빈 배열 반환
- 최신
- lock key는 섹션 단위로 분리한다.
- 권장:
lock:recommendation-snapshot-refresh:CHEER_CREATOR
- 권장:
- lock 대기 시간은 최대 300ms로 제한한다.
- 홈 API가 fallback refresh 완료를 기다리는 시간은 최대 1,500ms로 제한한다.
- timeout은 홈 API 대기 timeout이며, 이미 시작된 refresh 작업을 반드시 중단한다는 의미가 아니다.
- 동일 JVM에서는 single-flight 상태를 유지해 동시에 들어온 홈 요청이 중복 refresh를 시작하지 않도록 한다.
- 다중 인스턴스에서는 Redisson 기반 분산 lock으로 인스턴스 간 중복 refresh를 방지한다.
- lock 획득 실패 또는 refresh 진행 중인 요청은 짧게 대기한 뒤 최신 스냅샷을 다시 조회하고, 없으면 빈 배열을 반환한다.
- fallback refresh 실패는 로그를 남기고 홈 API 전체 실패로 전파하지 않는다.
Edge Cases
- lock 획득 직전에 다른 요청 또는 스케줄러가 스냅샷을 만들 수 있으므로 double-check가 필요하다.
- fallback refresh가 성공했지만 집계 대상 데이터가 없으면 빈 배열을 반환한다.
- 집계 대상 데이터가 없는 정상 refresh와 아직 한 번도 refresh되지 않은 상태를 구분해야 반복 fallback을 막을 수 있다.
- 홈 API 대기 시간이 초과되어 빈 배열을 반환한 뒤에도 백그라운드 refresh가 완료되면 이후 요청은 저장된 최신 스냅샷을 사용한다.
Feature F. 빈 결과 refresh 마커
Requirements
CHEER_CREATOR도 AI 캐릭터와 같은 방식으로 빈 결과 refresh 마커를 저장하는 방안을 우선 검토한다.- 권장 방식은
targetId = 0empty snapshot marker를CHEER_CREATOR에도 적용하는 것이다. - 조회 쿼리는 기존처럼
target_id <> 0을 유지해 marker가 사용자 응답에 노출되지 않게 한다. - 존재 여부 확인은 marker를 포함해 판단하여, 집계 결과가 없는 날 매 홈 요청마다 fallback refresh가 반복되지 않도록 한다.
- 이 방식은 별도 DDL 없이 기존
recommendation_snapshot구조를 재사용할 수 있어 이번 요구사항에 가장 단순하다.
Edge Cases
- marker가 있더라도 실제 추천 row가 없으면 홈 응답은 빈 배열이다.
- 같은
sectionType,snapshotAt에 실제 row가 생기는 재실행이 있으면 marker는 대체되어야 한다. - marker 저장 정책을
AI_CHARACTER와CHEER_CREATOR외 섹션으로 확장할지는 이번 구현 계획에서 필요한 범위만 결정한다.
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는 필드 추가 없이 유지한다.
- 본인과 활성 팔로우 제외는 기존
memberId를 사용하는findCheerCreatorRecommendationDetails(...)상세 조회 경로에서 적용하고, 스냅샷 생성 산식과 저장 데이터는 변경하지 않는다. - 스케줄러 refresh와 fallback refresh는 산식, 기간, 저장 limit, 정렬 기준이 갈라지지 않도록 같은 application service 경로를 사용한다.
- fallback refresh 기능은 AI 캐릭터 전용 구현을 복사하기보다 섹션별로 재사용 가능한 형태를 우선 검토한다. 단, 과도한 일반화가 필요하면
CHEER_CREATOR에 필요한 최소 추상화만 적용한다. CHEER_CREATOR집계는 정확한 top 후보를 위해 최종 점수 계산 전 candidate pre-limit를 두지 않는다.- DB-side scoring을 유지하는 경우 Kotlin 단에는 산식 parity 검증용 정책 함수를 두고, DB expression과 같은 상수를 공유한다.
- Kotlin-side scoring으로 변경하는 경우 DB에서 필요한 원천 metric을 정확히 집계하고, service에서 최종 점수/정렬/limit을 적용한다. 이 경우 후보 전체를 메모리에 올리는 비용과 데이터량을 구현 계획에서 검토한다.
- 기본 권장안은 현재 구조와 성능 특성을 유지하는 DB-side exact scoring이다. 다만 산식/부스트 계산은 Kotlin 정책 테스트로 검증 가능한 형태를 둔다.
- 시간 범위는 인기 커뮤니티와 동일한 최근 7일 UTC half-open window를 반환하는
RecommendationSnapshotWindowPolicy를 재사용한다. - 신규 DDL은 만들지 않는 것을 기본으로 한다. 불가피하게 필요하면 운영 DB 반영용 SQL 문서는 MySQL 기준으로 별도 작성한다.
9. Metrics
CHEER_CREATOR스냅샷 생성 성공/실패 로그CHEER_CREATOR스냅샷 최종 저장 수- fallback refresh 실행/성공/실패/timeout 로그
- fallback refresh lock 획득 성공/실패 로그
donationAmount,fanTalkCount,donationCount입력값 분포- empty snapshot marker 저장 횟수
- 홈 API
CHEER_CREATOR섹션 빈 응답 비율 - 홈 API fallback refresh 대기 시간
10. Open Questions
- 없음.
11. Decisions
- 후원 금액은
use_can_calculate.can값을 그대로 사용한다. - 후원 수는
UseCanCalculate.useCan이 같은 row를 1개 후원 이벤트로 보고 중복 제거한다. - 팬Talk 수는
CreatorCheers.isActive == true인 row 수로 계산한다. - 빈 결과 marker 정책은 다른 스냅샷 섹션에도 확장하는 것이 맞지만, 이번 구현 범위에서는
CHEER_CREATOR에만 적용한다. - 인증 회원 본인과 활성 팔로우 중인 크리에이터는
cheerCreators에서 제외하고, 비활성 팔로우 이력은 제외 근거로 사용하지 않는다. - 필터링 후 8명 미만이어도 기존 16명 스냅샷 후보 범위를 넘어서 보충하지 않는다.
- 비회원은 기존
CHEER_CREATOR조회 결과를 유지한다.
12. Future Prompt: 빈 결과 marker 정책 공통화
아래 프롬프트는 CHEER_CREATOR 구현 이후 다른 추천 스냅샷 섹션에 empty snapshot marker 정책을 확장할 때 사용한다.
추천 스냅샷의 빈 결과 marker 정책을 공통화한다.
목표:
- `recommendation_snapshot` 기반 추천 섹션에서 refresh 결과가 0건인 경우에도 "정상 refresh 완료" 상태를 저장한다.
- 조회 응답에는 marker가 절대 노출되지 않아야 한다.
- 스냅샷이 실제로 없는 상태와 refresh 결과가 빈 상태를 구분해 홈 API fallback refresh가 매 요청마다 반복 실행되지 않게 한다.
요구사항:
1. 기존 `targetId = 0` empty snapshot marker 정책을 `RecommendationSnapshot` 공통 저장 정책으로 정리한다.
2. marker 적용 대상 섹션은 구현 전에 명시한다. 기본 후보는 일 단위 refresh와 fallback refresh를 사용하는 섹션이다.
3. `findLatestSnapshots` 조회는 기존처럼 `target_id <> 0` 조건을 유지해 marker를 응답 후보에서 제외한다.
4. `existsLatestSnapshot` 또는 동등한 존재 여부 확인은 marker를 포함해 판단한다.
5. 같은 `sectionType`, `snapshotAt`에 실제 스냅샷 row가 생기는 재실행에서는 marker가 대체되어야 한다.
6. 섹션별 refresh 로직은 결과가 0건이어도 marker 저장을 통해 완료 상태를 남긴다.
7. marker 때문에 기존 API 응답 스키마, 정렬, limit, 상세 조회 로직이 바뀌면 안 된다.
8. 신규 DDL 없이 기존 `recommendation_snapshot` 테이블을 재사용한다.
검증 기준:
- marker만 있는 섹션의 최신 스냅샷 조회 결과는 빈 배열이다.
- marker만 있는 섹션의 존재 여부 확인은 true다.
- refresh 결과가 0건이면 marker가 저장된다.
- 이후 refresh 결과가 실제 row를 만들면 같은 `sectionType`, `snapshotAt`의 marker는 제거되고 실제 row만 남는다.
- fallback refresh는 marker가 있는 섹션에서 중복 refresh를 시작하지 않는다.
- 기존 실제 스냅샷 조회 정렬은 `score desc`, `randomTieBreaker asc`를 유지한다.
13. Related Documents
docs/sample/sample-prd.mddocs/agent-guides/작업절차.mddocs/agent-guides/문서유지보수.mddocs/20260529_메인_홈_추천_API/prd.mddocs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.md