docs(home): 응원 크리에이터 스냅샷 계획을 추가한다
This commit is contained in:
250
docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md
Normal file
250
docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# PRD: 메인 홈 추천 응원 크리에이터 스냅샷 수정
|
||||
|
||||
## 1. Overview
|
||||
메인 홈 추천 탭의 `CHEER_CREATOR` 스냅샷 생성과 조회를 전날 데이터 기반의 응원 점수로 수정하고, 스냅샷이 없을 때 홈 API가 동일 refresh 로직을 안전하게 재사용하도록 fallback 흐름을 보강한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. Problem
|
||||
- 기존 `CHEER_CREATOR` 산식은 최근 7일 데이터를 기반으로 하며, 후원 금액 가중치와 신규 부스트 값이 이번 요구사항과 다르다.
|
||||
- 현재 일괄 refresh와 홈 API fallback refresh가 섹션별로 동일한 생성 로직을 공유하지 않으면 산식 drift가 발생할 수 있다.
|
||||
- 스냅샷이 없는 초기 배포, 운영 데이터 삭제, 배치 실패 상황에서 홈 조회가 매 요청마다 무거운 집계를 중복 실행하면 API 지연과 DB 부하가 커질 수 있다.
|
||||
- 집계 산식이 추천 노출 순서를 직접 바꾸므로 DB-side 계산과 Kotlin-side 계산 중 어떤 방식을 선택하더라도 산식/부스트 경계값 테스트가 필요하다.
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals
|
||||
- `CHEER_CREATOR` 스냅샷은 전날 KST 하루 데이터를 기반으로 생성한다.
|
||||
- 응원 점수 산식을 `((채널 후원 금액 * 0.45) + (팬Talk 수 * 0.30) + (채널 후원 수 * 0.10)) * 신규 부스트`로 변경한다.
|
||||
- 신규 부스트는 크리에이터 데뷔일 기준 10일 이내 1.15, 20일 이내 1.10, 30일 이내 1.05, 그 외 1.0을 적용한다.
|
||||
- 스케줄러 refresh와 fallback refresh는 동일한 `CHEER_CREATOR` refresh 로직을 재사용한다.
|
||||
- 스냅샷이 없을 때 fallback refresh는 lock, double-check, single-flight 대기로 중복 refresh를 방지한다.
|
||||
- 홈 API는 fallback refresh 완료를 최대 1,500ms까지만 기다리고, lock 대기는 최대 300ms로 제한한다.
|
||||
- fallback refresh 실패, timeout, refresh 결과 없음은 홈 API 전체 실패로 전파하지 않고 `CHEER_CREATOR` 섹션 빈 배열로 처리한다.
|
||||
- 산식과 신규 부스트는 단위 테스트에서 경계값과 가중치 계산을 촘촘히 검증한다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Non-Goals
|
||||
- 메인 홈 추천 API URL, 응답 필드, 응답 JSON 스키마는 변경하지 않는다.
|
||||
- `CHEER_CREATOR` 이외 추천 섹션의 산식과 조회 정책은 변경하지 않는다.
|
||||
- 관리자 화면, 수동 추천 편집, A/B 테스트, 개인화 추천은 이번 범위에 포함하지 않는다.
|
||||
- 후원, 팬Talk 생성/수정/삭제 자체의 도메인 동작은 변경하지 않는다.
|
||||
- 신규 추천 스냅샷 테이블을 만들지 않고, 기존 `recommendation_snapshot` 구조를 우선 재사용한다.
|
||||
|
||||
---
|
||||
|
||||
## 5. Target Users
|
||||
- 회원/비회원: 메인 홈 추천 탭에서 전날 응원 반응이 많았던 크리에이터를 발견하는 사용자
|
||||
- 앱 클라이언트: 기존 응답 계약을 유지한 채 `CHEER_CREATOR` 추천 순서만 변경된 결과를 받는 클라이언트
|
||||
- 운영자: 전날 후원/팬Talk 반응이 추천 노출에 반영되는지 확인해야 하는 운영 담당자
|
||||
|
||||
---
|
||||
|
||||
## 6. User Stories
|
||||
- 사용자는 메인 홈 추천 탭에서 전날 응원이 많았던 크리에이터를 우선 보고 싶다.
|
||||
- 사용자는 후원 금액뿐 아니라 팬Talk와 후원 참여 횟수도 함께 반영된 추천을 보고 싶다.
|
||||
- 사용자는 신규 크리에이터가 일정 기간 동안 적절한 노출 기회를 받기를 기대한다.
|
||||
- 앱 클라이언트는 스냅샷이 없는 상황에서도 홈 API가 실패하지 않고 안정적으로 빈 배열 또는 생성된 스냅샷을 받기를 원한다.
|
||||
- 운영자는 배치 실패 후 첫 홈 조회가 스케줄러와 동일한 로직으로 스냅샷을 복구하기를 원한다.
|
||||
|
||||
---
|
||||
|
||||
## 7. Core Features
|
||||
|
||||
### Feature A. 응원 크리에이터 스냅샷 산식 변경
|
||||
|
||||
#### Requirements
|
||||
- `CHEER_CREATOR` 점수는 아래 산식으로 계산한다.
|
||||
- `score = ((channelDonationAmount * 0.45) + (fanTalkCount * 0.30) + (channelDonationCount * 0.10)) * newBoost`
|
||||
- `channelDonationAmount`는 집계 기간 안에 발생한 채널 후원 금액 합계다.
|
||||
- `channelDonationCount`는 집계 기간 안에 발생한 채널 후원 건수다.
|
||||
- 채널 후원 수는 `UseCanCalculate.useCan`이 같은 row를 1개 후원 이벤트로 보고 중복 제거해 계산한다.
|
||||
- 채널 후원은 기존 요구사항과 동일하게 `CanUsage.CHANNEL_DONATION`이며, 환불/미수령/비정상 상태는 기존 채널 후원 집계 제외 조건을 유지한다.
|
||||
- `fanTalkCount`는 집계 기간 안에 생성된 활성 팬Talk 수다.
|
||||
- 팬Talk 수는 `CreatorCheers.isActive == true`인 row 수로 계산한다.
|
||||
- 팬Talk는 기존 `CreatorCheers` 또는 현재 구현의 `creator_cheers` 기반 데이터를 의미한다.
|
||||
- 점수 산식 상수는 `RecommendationScoreSpec` 등 기존 점수 정책 위치에 모아 DB expression과 Kotlin 정책 테스트가 같은 값을 참조하도록 한다.
|
||||
- 스냅샷 정렬은 점수 내림차순, 동점이면 스냅샷 생성 시 저장한 `randomTieBreaker` 오름차순을 유지한다.
|
||||
- 최종 저장 수는 기존 홈 노출 안정성을 위해 `CHEER_CREATOR` 최대 16개를 유지한다.
|
||||
|
||||
#### Edge Cases
|
||||
- `channelDonationAmount`, `fanTalkCount`, `channelDonationCount`가 모두 0인 크리에이터는 스냅샷 후보에서 제외한다.
|
||||
- 후원 금액은 없지만 팬Talk가 있으면 산식에 따라 점수를 계산한다.
|
||||
- 팬Talk는 없지만 채널 후원이 있으면 산식에 따라 점수를 계산한다.
|
||||
- 비활성 크리에이터, 차단 필터에 의해 조회 시 제외되는 크리에이터는 최종 홈 응답에서 제외한다.
|
||||
- 스냅샷에는 존재하지만 조회 시점에 크리에이터가 비활성화된 경우 응답에서 제외한다.
|
||||
|
||||
### Feature B. 전날 데이터 기반 집계 기간
|
||||
|
||||
#### Requirements
|
||||
- 점수 입력값은 전날 KST 하루 데이터만 사용한다.
|
||||
- 전날 기준은 KST 기준 전일 00:00:00 이상, 다음날 00:00:00 미만의 half-open 범위로 정의한다.
|
||||
- 운영 DB 시간이 UTC 기준이면, 실제 조회는 `windowStartUtc <= createdAt < windowEndExclusiveUtc`로 변환해 사용한다.
|
||||
- `snapshotAt`은 해당 KST 전날의 종료 시각을 UTC로 변환한 `windowEndExclusiveUtc.minusSeconds(1)`을 사용한다.
|
||||
- 스케줄러가 KST 06:00에 실행되면 실행일 전날 KST 일자를 집계 대상으로 삼는다.
|
||||
- 기존 코드에 남아 있는 `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`
|
||||
- 경계일 계산은 기존 추천 점수 정책의 날짜 단위 계산과 일관되게 한다.
|
||||
- 부스트 기준 시각은 스냅샷의 `snapshotAt`이다.
|
||||
|
||||
#### Edge Cases
|
||||
- 공개 콘텐츠와 라이브 이력이 모두 없어 데뷔일을 계산할 수 없는 크리에이터는 스냅샷 후보에서 제외한다.
|
||||
- 데뷔일이 `snapshotAt`보다 미래인 데이터는 데이터 오류로 보고 스냅샷 후보에서 제외한다.
|
||||
- 10일/20일/30일 경계값은 테스트에서 명확히 검증한다.
|
||||
|
||||
### Feature D. `CHEER_CREATOR` 스냅샷 조회
|
||||
|
||||
#### Requirements
|
||||
- 홈 통합 조회 `GET /api/v2/home/recommendations`의 최근 응원이 많은 크리에이터 섹션은 최신 `CHEER_CREATOR` 스냅샷 순서를 사용한다.
|
||||
- 기존 노출 정보는 유지한다.
|
||||
- `creatorId`
|
||||
- 크리에이터 닉네임
|
||||
- 크리에이터 프로필 이미지
|
||||
- 조회 시점에도 기존 차단 필터와 활성 크리에이터 필터를 적용한다.
|
||||
- 스냅샷 후보는 최대 16개까지 조회하고, 상세 조회/필터링 후 홈 첫 화면에는 최대 8명을 반환한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 최신 스냅샷 row가 존재하지만 조회 필터로 모두 제외되면 빈 배열을 반환한다.
|
||||
- 상세 조회 결과가 스냅샷 저장 개수보다 적어도 홈 조회 전체는 성공 처리한다.
|
||||
- 스냅샷 정렬 순서와 응답 순서는 일치해야 한다.
|
||||
|
||||
### 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_CREATOR` refresh 로직 실행
|
||||
- 저장된 스냅샷을 다시 조회해 반환
|
||||
- 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 = 0` empty 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는 필드 추가 없이 유지한다.
|
||||
- 스케줄러 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 정책 테스트로 검증 가능한 형태를 둔다.
|
||||
- 시간 범위는 KST 전날을 UTC half-open window로 변환하는 `RecommendationSnapshotWindowPolicy`를 재사용한다.
|
||||
- 신규 DDL은 만들지 않는 것을 기본으로 한다. 불가피하게 필요하면 운영 DB 반영용 SQL 문서는 MySQL 기준으로 별도 작성한다.
|
||||
|
||||
---
|
||||
|
||||
## 9. Metrics
|
||||
- `CHEER_CREATOR` 스냅샷 생성 성공/실패 로그
|
||||
- `CHEER_CREATOR` 스냅샷 최종 저장 수
|
||||
- fallback refresh 실행/성공/실패/timeout 로그
|
||||
- fallback refresh lock 획득 성공/실패 로그
|
||||
- `channelDonationAmount`, `fanTalkCount`, `channelDonationCount` 입력값 분포
|
||||
- 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`에만 적용한다.
|
||||
|
||||
---
|
||||
|
||||
## 12. Future Prompt: 빈 결과 marker 정책 공통화
|
||||
아래 프롬프트는 `CHEER_CREATOR` 구현 이후 다른 추천 스냅샷 섹션에 empty snapshot marker 정책을 확장할 때 사용한다.
|
||||
|
||||
```text
|
||||
추천 스냅샷의 빈 결과 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/prd/sample-prd.md`
|
||||
- `docs/agent-guides/작업절차.md`
|
||||
- `docs/agent-guides/문서유지보수.md`
|
||||
- `docs/20260529_메인_홈_추천_API/prd.md`
|
||||
- `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.md`
|
||||
Reference in New Issue
Block a user