From 5d612ffcdb384a71056492c9515b9117e803dac5 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 10 Jul 2026 06:05:15 +0900 Subject: [PATCH] =?UTF-8?q?docs(home):=20=EC=9D=91=EC=9B=90=20=ED=81=AC?= =?UTF-8?q?=EB=A6=AC=EC=97=90=EC=9D=B4=ED=84=B0=20=EC=8A=A4=EB=83=85?= =?UTF-8?q?=EC=83=B7=20=EA=B3=84=ED=9A=8D=EC=9D=84=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 285 ++++++++++++++++++ .../prd.md | 250 +++++++++++++++ 2 files changed, 535 insertions(+) create mode 100644 docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md create mode 100644 docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md diff --git a/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md new file mode 100644 index 00000000..e1dff182 --- /dev/null +++ b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md @@ -0,0 +1,285 @@ +# 메인 홈 추천 응원 크리에이터 스냅샷 수정 Plan/Task + +## 시나리오 계약 +- Happy path: KST 전날 하루 데이터를 UTC half-open 범위로 변환해 `CHEER_CREATOR` 점수를 계산하고, 점수순 상위 16개 스냅샷을 저장한다. Real surface: `DefaultHomeRecommendationQueryRepositoryTest`, `RecommendationSnapshotRefreshServiceTest`. +- Score: 응원 점수는 `((channelDonationAmount * 0.45) + (fanTalkCount * 0.30) + (channelDonationCount * 0.10)) * newBoost`다. 채널 후원 금액은 `use_can_calculate.can` 그대로 사용하고, 채널 후원 수는 `UseCanCalculate.useCan` 기준으로 중복 제거한다. Real surface: `RecommendationScorePolicyTest`, `DefaultHomeRecommendationQueryRepositoryTest`. +- Boost: 신규 부스트는 크리에이터 데뷔일 기준 0~10일 `1.15`, 11~20일 `1.10`, 21~30일 `1.05`, 31일 이상 `1.0`이다. Real surface: `RecommendationScorePolicyTest`, `DefaultHomeRecommendationQueryRepositoryTest`. +- Fallback: 최신 `CHEER_CREATOR` 스냅샷이 없으면 lock, double-check, 동일 refresh 로직 재사용, refresh 후 재조회 순서로 fallback을 실행한다. lock 대기는 최대 300ms, 홈 API refresh 완료 대기는 최대 1,500ms다. Real surface: fallback service test, `HomeRecommendationQueryServiceTest`. +- Empty marker: `CHEER_CREATOR` refresh 결과가 0건이면 `targetId = 0` marker를 저장해 정상 refresh 완료 상태를 남기고, 조회 응답에서는 marker를 제외한다. Real surface: `RecommendationSnapshotPersistenceAdapterTest`, `HomeRecommendationQueryServiceTest`. +- Adjacent regression: 메인 홈 추천 API URL과 `CHEER_CREATOR` 응답 필드는 변경하지 않는다. AI 캐릭터, 인기 커뮤니티, 최근 데뷔 등 다른 섹션 산식과 공개 스키마는 이번 변경으로 바꾸지 않는다. Real surface: 기존 focused tests, `HomeRecommendationControllerTest`. + +## 범위와 전제 +- 이번 문서는 `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md`의 구현 계획이다. +- 신규 공개 API, 신규 응답 필드, 운영 DDL 추가는 범위에 포함하지 않는다. +- 기존 `recommendation_snapshot` 테이블과 `RecommendedSectionType.CHEER_CREATOR`를 재사용한다. +- `CHEER_CREATOR` 집계는 현재 구조와 성능 특성을 유지해 DB-side exact scoring을 기본으로 한다. Kotlin 단에는 산식/부스트 근거 테스트용 정책 함수를 둔다. +- 전날 기준은 KST 전날 00:00:00 이상, 다음날 00:00:00 미만이고, DB 조회에는 UTC half-open window를 사용한다. +- fallback orchestration은 AI 캐릭터 전용 구현을 그대로 복사하지 않고, 섹션별 lock key와 refresh action을 받을 수 있는 최소 공통 runner를 우선 적용한다. +- 다른 스냅샷 섹션으로 empty marker를 확장하는 작업은 이번 구현 범위에서 제외한다. 단, `CHEER_CREATOR`에 적용할 때 이후 공통화가 가능하도록 조건문/상수명을 명확히 둔다. + +## 기존 CHEER_CREATOR 로직 유지/변경 경계 +- 유지: `RecommendedSectionType.CHEER_CREATOR` enum 값과 code는 변경하지 않는다. +- 유지: 홈 응원 크리에이터 응답 필드인 `creatorId`, `creatorNickname`, `creatorProfileImage`는 변경하지 않는다. +- 유지: 홈 첫 화면 응답은 최대 8명, 스냅샷 후보 조회는 최대 16개를 사용한다. +- 유지: 상세 조회 시점의 활성 크리에이터 필터와 차단 필터는 유지한다. +- 유지: 채널 후원 금액은 `use_can_calculate.can` 값을 그대로 합산한다. +- 변경: 점수 가중치는 후원 금액 45%, 팬Talk 수 30%, 채널 후원 수 10%로 바꾼다. +- 변경: 채널 후원 수는 `UseCanCalculate.useCan` 기준 distinct count로 계산한다. +- 변경: 팬Talk 수는 `CreatorCheers.isActive == true` row 수로 계산한다. +- 변경: 집계 기간은 최근 7일에서 KST 전날 하루로 바꾼다. +- 변경: 신규 부스트는 기존 크리에이터 공통 부스트 `1.5/1.3/1.2`가 아니라 `CHEER_CREATOR` 전용 `1.15/1.10/1.05`를 사용한다. +- 추가: `CHEER_CREATOR` 최신 스냅샷이 없을 때 fallback refresh를 실행한다. +- 추가: `CHEER_CREATOR` refresh 결과 0건이면 empty snapshot marker를 저장한다. + +## 실행 명령 +- 문서 명령 확인: `./gradlew tasks --all` +- 산식/부스트 단위 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest` +- 스냅샷 저장 marker 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest` +- 응원 크리에이터 query 통합 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` +- refresh/fallback 조회 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest` +- fallback service 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest` +- 홈 API 회귀 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest` +- 포맷 검증: `./gradlew ktlintCheck` +- 전체 회귀: `./gradlew test` + +--- + +### Phase 1: 문서와 기준 고정 + +- [x] **Task 1.1: PRD 기반 구현 계획 문서 작성** + - 파일 경로: + - Verify: `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md` + - Create: `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md` + - RED: 문서 작업은 TDD 예외. TDD 예외 사유: 코드 동작 변경 전 요구사항과 구현 순서를 고정하는 작업이다. + - GREEN: PRD의 산식, KST 전날 UTC half-open 범위, 후원 수 distinct 기준, empty marker, fallback timeout/lock 정책을 task로 분해한다. + - REFACTOR: 기존 홈 추천 구현 파일과 테스트 파일 기준으로 task별 수정/검증 경로를 맞춘다. + - 기대 결과: 구현 시작 전에 PRD와 plan-task가 같은 디렉터리에 준비된다. + +--- + +### Phase 2: 산식과 부스트 정책 + +- [x] **Task 2.1: 응원 점수 계산식 계약 테스트 보강** + - 파일 경로: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScoreSpec.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicy.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicyTest.kt` + - RED: `CHEER_DONATION_AMOUNT_WEIGHT = 0.45`, `CHEER_FAN_TALK_WEIGHT = 0.30`, `CHEER_DONATION_COUNT_WEIGHT = 0.10`을 기대하는 실패 테스트를 작성한다. `calculateCheerScore(...)`가 세 입력값과 부스트를 PRD 산식대로 계산하는지도 검증한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest` + - GREEN: 응원 점수 상수를 PRD 값으로 변경하고 기존 `calculateCheerScore(...)`가 같은 상수를 사용하게 한다. + - REFACTOR: 기존 AI/최근 데뷔/인기 커뮤니티 상수와 함수 값은 변경하지 않았는지 같은 테스트 안에서 회귀 assertion을 유지한다. + - 계산식 테스트 케이스: + - `donationAmount=0`, `fanTalkCount=0`, `donationCount=0`, `newBoost=1.0`이면 `0.0` + - `donationAmount=100`, `fanTalkCount=0`, `donationCount=0`, `newBoost=1.0`이면 `45.0` + - `donationAmount=0`, `fanTalkCount=10`, `donationCount=0`, `newBoost=1.0`이면 `3.0` + - `donationAmount=0`, `fanTalkCount=0`, `donationCount=10`, `newBoost=1.0`이면 `1.0` + - `donationAmount=100`, `fanTalkCount=10`, `donationCount=10`, `newBoost=1.0`이면 `49.0` + - `donationAmount=100`, `fanTalkCount=10`, `donationCount=10`, `newBoost=1.15`이면 `56.35` + - 기대 결과: 응원 점수 산식 근거가 Kotlin 단위 테스트로 고정된다. + +- [x] **Task 2.2: 응원 크리에이터 신규 부스트 경계값 테스트 추가** + - 파일 경로: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScoreSpec.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicy.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicyTest.kt` + - RED: 응원 크리에이터 전용 신규 부스트가 데뷔일 기준 0일/10일 `1.15`, 11일/20일 `1.10`, 21일/30일 `1.05`, 31일 `1.0`으로 계산되는 실패 테스트를 작성한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest` + - GREEN: `calculateCheerCreatorNewBoost(debutAt, now)` 또는 동등한 전용 함수를 추가하고 `CHEER_NEW_BOOST_*` 상수를 사용한다. + - REFACTOR: 기존 `calculateCreatorNewBoost(...)`는 최근 데뷔/인기 커뮤니티용 기존 값 `1.5/1.3/1.2`를 유지한다. + - 기대 결과: `CHEER_CREATOR`만 낮아진 신규 부스트 값을 사용하고 다른 크리에이터 기반 섹션은 기존 부스트를 유지한다. + +--- + +### Phase 3: 전날 집계 window와 DB 스냅샷 query + +- [x] **Task 3.1: `CHEER_CREATOR` refresh window를 KST 전날 하루로 변경** + - 파일 경로: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt` + - RED: `refreshCheerCreatorSnapshots(nowUtc)`가 `RecommendationSnapshotWindowPolicy.previousKstDayUtcWindow(nowUtc)`로 얻은 `windowStartUtc`, `windowEndExclusiveUtc`, `snapshotAt`을 사용하도록 실패 테스트를 작성한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest` + - GREEN: `CHEER_CREATOR` 단일 refresh 경로를 분리하고, 기존 일괄 refresh에서 최근 7일 window 대신 전날 UTC half-open window를 넘긴다. + - REFACTOR: `POPULAR_COMMUNITY`의 기존 7일 window는 변경하지 않는다. `HomeRecommendationQueryPort.findCheerCreatorSnapshots(...)` 시그니처는 `windowEndExclusiveUtc` 의미가 드러나도록 정리한다. + - 기대 결과: 스케줄러와 fallback이 같은 `CHEER_CREATOR` 전날 refresh 경로를 호출할 수 있다. + +- [x] **Task 3.2: 채널 후원 금액/후원 수 집계 기준 변경** + - 파일 경로: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt` + - RED: 같은 `UseCanCalculate.useCan`을 참조하는 여러 row가 있을 때 `channelDonationAmount`는 `use_can_calculate.can` 값을 그대로 합산하고, `channelDonationCount`는 distinct `use_can` 기준 1건으로 계산되는 실패 테스트를 작성한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` + - GREEN: donation stats query에서 금액은 기존 `sum(ucc.can)`을 유지하고, 후원 수는 `count(distinct ucc.use_can_id)` 또는 엔티티 매핑에 맞는 동일 의미 컬럼으로 변경한다. + - REFACTOR: `CanUsage.CHANNEL_DONATION`, `status = RECEIVED`, `is_refund = false` 등 기존 제외 조건은 유지한다. + - 기대 결과: 후원 이벤트 단위 중복 제거가 점수의 후원 수 항목에만 적용된다. + +- [x] **Task 3.3: 팬Talk 수와 half-open 시간 조건 적용** + - 파일 경로: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt` + - RED: `CreatorCheers.isActive == true` row만 집계하고, `created_at >= windowStartUtc and created_at < windowEndExclusiveUtc` 조건으로 전날 경계를 검증하는 실패 테스트를 작성한다. `windowEndExclusiveUtc`와 같은 시각의 row는 제외되어야 한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` + - GREEN: `creator_cheers` 집계 조건을 active row와 half-open time range로 맞춘다. + - REFACTOR: 후원 집계 조건도 같은 half-open range를 사용해 `<= :snapshotAt` 방식이 남지 않게 정리한다. + - 기대 결과: 전날 KST 하루 경계가 후원과 팬Talk 집계에 동일하게 적용된다. + +- [x] **Task 3.4: 응원 크리에이터 DB-side 점수와 부스트 적용** + - 파일 경로: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt` + - RED: DB 스냅샷 query 결과 점수가 `RecommendationScorePolicy.calculateCheerScore(...)`와 동일하고, 데뷔일 기준 신규 부스트 `1.15/1.10/1.05/1.0` 경계값을 반영하는 실패 테스트를 작성한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` + - GREEN: native SQL score expression과 boost case expression을 `RecommendationScoreSpec`의 응원 전용 상수로 변경한다. + - REFACTOR: `creator_debut` 계산은 콘텐츠 첫 공개일과 라이브 첫 진행일 중 빠른 날짜라는 기존 정의를 유지한다. + - 기대 결과: DB-side exact scoring과 Kotlin 정책 테스트의 산식 값이 일치한다. + +- [x] **Task 3.5: 후보 제외, 정렬, limit 회귀 테스트 보강** + - 파일 경로: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt` + - RED: 후원/팬Talk가 모두 0인 크리에이터 제외, 데뷔일이 없는 크리에이터 제외, 비활성 크리에이터 제외, 점수 내림차순/`randomTieBreaker` 오름차순/limit 16 동작을 검증하는 실패 테스트를 작성한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` + - GREEN: 기존 후보 제외 조건과 정렬/limit을 PRD 기준에 맞게 유지 또는 보정한다. + - REFACTOR: AI 캐릭터와 인기 커뮤니티 스냅샷 query가 영향받지 않았는지 focused test로 확인한다. + - 기대 결과: `CHEER_CREATOR` 스냅샷 저장 후보만 정확히 변경된다. + +--- + +### Phase 4: refresh 저장과 empty marker + +- [x] **Task 4.1: `CHEER_CREATOR` 빈 결과 marker 저장 정책 추가** + - 파일 경로: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt` + - RED: `replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, snapshotAt, emptyList())` 호출 시 `targetId = 0` marker가 저장되고, `findLatestSnapshots(CHEER_CREATOR)`는 빈 배열이며, `existsLatestSnapshot(CHEER_CREATOR)`는 true인 실패 테스트를 작성한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest` + - GREEN: empty marker 적용 대상을 `AI_CHARACTER`와 `CHEER_CREATOR`로 제한하는 명시적 상수/함수를 추가한다. + - REFACTOR: `POPULAR_COMMUNITY` 등 다른 섹션으로 marker 정책을 확장하지 않는다. + - 기대 결과: 데이터가 없는 날에도 `CHEER_CREATOR` refresh 완료 상태가 저장된다. + +- [x] **Task 4.2: 실제 row 재실행 시 marker 대체 보장** + - 파일 경로: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt` + - RED: 같은 `sectionType`, `snapshotAt`에 marker가 있는 상태에서 실제 스냅샷 row로 `replaceSnapshots(...)`를 호출하면 marker가 제거되고 실제 row만 남는 실패 테스트를 작성한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest` + - GREEN: 기존 `deleteBySectionTypeAndSnapshotAt` 후 저장 흐름이 marker 대체를 보장하는지 확인하고, 부족하면 해당 경로만 보정한다. + - REFACTOR: 조회 쿼리의 `target_id <> 0` 조건은 유지한다. + - 기대 결과: 빈 결과 refresh 후 재실행으로 실제 추천 row가 생겨도 marker가 응답/존재 상태를 오염시키지 않는다. + +- [x] **Task 4.3: refresh service가 `CHEER_CREATOR` 저장 수와 marker 상태를 로그로 남김** + - 파일 경로: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt` + - RED: `refreshCheerCreatorSnapshots(nowUtc)` 성공 시 `event=cheer_creator_recommendation_snapshot_refresh_success`, `savedCount`, `windowStartUtc`, `windowEndExclusiveUtc`, `snapshotAt`이 로그에 남는 실패 테스트를 작성한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest` + - GREEN: 기존 구조화 로그 관례에 맞춰 section refresh 성공/실패 로그를 추가한다. + - REFACTOR: 전체 일괄 refresh 성공 로그는 유지하되, 섹션별 로그와 중복되어도 검색 가능한 event key를 사용한다. + - 기대 결과: 운영에서 `CHEER_CREATOR` refresh 결과 0건과 실패를 구분할 수 있다. + +--- + +### Phase 5: fallback refresh + +- [x] **Task 5.1: 섹션 스냅샷 fallback 공통 runner 도입** + - 파일 경로: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/AiCharacterSnapshotFallbackPort.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt` + - RED: 공통 runner가 section type, lock key, refresh action, offset/limit을 받아 double-check 조회 후 refresh를 실행하는 실패 테스트를 작성한다. 기존 AI 캐릭터 fallback 테스트도 통과해야 한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest` + - GREEN: AI 캐릭터 전용 lock/timeout/single-flight 흐름을 공통 runner로 이동하거나, AI 서비스가 공통 runner를 위임 호출하도록 최소 변경한다. + - REFACTOR: Redisson `RLock` 획득/해제는 refresh worker thread 안에서 수행한다. 테스트에서는 deterministic executor를 사용해 sleep 기반 테스트를 피한다. + - 기대 결과: `CHEER_CREATOR` fallback을 추가할 때 lock/double-check/timeout 구현을 중복 작성하지 않는다. + +- [x] **Task 5.2: `CHEER_CREATOR` fallback target 추가** + - 파일 경로: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt` + - RED: 최신 `CHEER_CREATOR` 스냅샷이 없을 때 lock key `lock:recommendation-snapshot-refresh:CHEER_CREATOR`로 lock을 얻고, `refreshCheerCreatorSnapshots(nowUtc)`를 호출한 뒤 저장된 스냅샷을 재조회하는 실패 테스트를 작성한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest` + - GREEN: `CHEER_CREATOR` fallback target을 등록하고 lock 대기 300ms, 홈 대기 1,500ms 상수를 PRD 값으로 유지한다. + - REFACTOR: `AI_CHARACTER` fallback lock key와 상호 간섭하지 않도록 section별 key를 분리한다. + - 기대 결과: 스냅샷 없음 상황에서 홈 API가 스케줄러와 동일한 `CHEER_CREATOR` refresh 로직을 재사용한다. + +- [x] **Task 5.3: lock miss, timeout, 실패, empty marker fallback 케이스 검증** + - 파일 경로: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt` + - RED: lock 획득 실패 시 중복 refresh를 시작하지 않는 테스트, 홈 대기 1,500ms timeout 시 빈 배열을 반환하되 background refresh를 cancel하지 않는 테스트, refresh 실패 시 빈 배열과 warn log를 남기는 테스트, marker가 있으면 fallback을 반복하지 않는 테스트를 작성한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest` + - GREEN: 공통 runner의 future/single-flight 상태, 예외 처리, timeout 처리, existsLatestSnapshot double-check 흐름을 구현한다. + - REFACTOR: fallback service는 점수 계산이나 상세 DTO 조립을 직접 하지 않고 snapshot 조회와 refresh orchestration만 담당한다. + - 기대 결과: 홈 조회 경로에서 refresh 중복, 장시간 대기, 전체 API 실패가 발생하지 않는다. + +- [x] **Task 5.4: 홈 응원 크리에이터 조회에 fallback 연결** + - 파일 경로: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt` + - RED: `findCheerCreatorRecommendations(...)`가 최신 스냅샷 없음이면 `CHEER_CREATOR` fallback을 호출하고, fallback 결과 스냅샷 순서대로 상세를 조립하는 실패 테스트를 작성한다. marker가 있어 `existsLatestSnapshot(CHEER_CREATOR) == true`이면 fallback을 호출하지 않는 테스트도 추가한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest` + - GREEN: 기존 `findAiCharacterSnapshotsWithFallback(...)`와 같은 패턴으로 `CHEER_CREATOR` 스냅샷 조회에 fallback을 연결한다. + - REFACTOR: 스냅샷 후보 16개 조회, 상세 조회 후 최대 8개 반환, 차단 필터 전달은 기존 동작을 유지한다. + - 기대 결과: 홈 통합 조회의 최근 응원이 많은 크리에이터 섹션이 스냅샷 없음 상황을 자체 복구할 수 있다. + +--- + +### Phase 6: API 회귀와 최종 검증 + +- [x] **Task 6.1: 홈 API 응답 스키마 회귀 검증** + - 파일 경로: + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt` + - RED: 홈 통합 조회의 `CHEER_CREATOR` 응답이 기존 `creatorId`, `creatorNickname`, `creatorProfileImage` 필드만 유지하고 신규 필드를 추가하지 않는 회귀 테스트를 확인/보강한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest` + - GREEN: DTO/Controller 변경 없이 application service 결과가 기존 response로 매핑되게 한다. + - REFACTOR: 공개 API URL과 JSON field name 변경이 없음을 assertion으로 유지한다. + - 기대 결과: 클라이언트 공개 스키마는 변경되지 않는다. + +- [x] **Task 6.2: focused regression 실행** + - 파일 경로: + - Modify: `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md` + - RED: 구현 task 완료 후 계획 문서에 기록할 focused command 목록을 확정한다. + - 실패 확인: 해당 없음. TDD 예외 사유: 검증 기록 문서화 task다. + - GREEN: 아래 명령을 실행하고 결과를 이 문서 하단 검증 기록에 누적한다. + - REFACTOR: 실패한 명령이 있으면 원인과 재실행 결과를 같은 task 아래에 기록한다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest` + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest` + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest` + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest` + - 기대 결과: 산식/집계/marker/fallback/API 회귀가 최소 명령으로 검증된다. + +- [x] **Task 6.3: 전체 회귀와 문서 검증** + - 파일 경로: + - Modify: `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md` + - RED: 구현 완료 후 전체 회귀 명령 실행 전에는 검증 기록이 구현 전 상태여야 한다. + - 실패 확인: 해당 없음. TDD 예외 사유: 검증 기록 문서화 task다. + - GREEN: `./gradlew ktlintCheck`, `./gradlew test`, `./gradlew tasks --all`, `git diff --check`를 실행하고 결과를 검증 기록에 남긴다. + - REFACTOR: 문서와 코드의 산식/timeout/window 값이 다르면 구현 또는 문서를 수정한 뒤 재검증한다. + - 기대 결과: 전체 테스트, 포맷, 문서 명령 유효성, diff 공백 검사가 모두 통과한다. + +--- + +## Coverage Check +- Feature A: Task 2.1, Task 3.2, Task 3.4에서 후원 금액 45%, 팬Talk 30%, 후원 수 10%, 후원 수 distinct 기준을 검증한다. +- Feature B: Task 3.1, Task 3.3에서 KST 전날 하루를 UTC half-open 조회 범위로 변환하고 `windowEndExclusiveUtc` 경계를 검증한다. +- Feature C: Task 2.2, Task 3.4에서 데뷔일 기준 응원 전용 신규 부스트와 경계값을 검증한다. +- Feature D: Task 3.5, Task 5.4, Task 6.1에서 최신 `CHEER_CREATOR` 스냅샷 순서, 후보 16개/응답 8개, 기존 응답 스키마 유지를 검증한다. +- Feature E: Task 5.1, Task 5.2, Task 5.3, Task 5.4에서 fallback refresh 재사용, double-check, 300ms lock 대기, 1,500ms 홈 API 대기, timeout 후 background 완료, 중복 refresh 방지를 검증한다. +- Feature F: Task 4.1, Task 4.2, Task 5.3에서 `CHEER_CREATOR` empty marker 저장, 조회 제외, 존재 여부 true, marker 기반 fallback 반복 방지를 검증한다. +- Non-Goals: Task 4.1, Task 6.1, Task 6.3에서 다른 스냅샷 섹션 marker 확장 없음, 공개 API URL/응답 필드 변경 없음, 신규 DDL 없음, 관리자/ML/A-B 제외를 확인한다. + +## 전체 검증 기록 +- 2026-07-10: PRD 기반으로 `plan-task.md`를 생성했다. 구현 전 계획 문서 작성 작업이므로 코드 테스트는 아직 실행하지 않았고, 문서 형식/명령 유효성 검증을 진행한다. +- 2026-07-10: 문서 검증으로 `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md`를 실행해 통과했다. `./gradlew tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 파일 접근 제한으로 실패했고, 권한 상승 재실행 결과 `BUILD SUCCESSFUL`로 통과했다. +- 2026-07-10: 구현 RED 확인으로 `RecommendationScorePolicyTest`는 `CHEER_NEW_BOOST_*`와 `calculateCheerCreatorNewBoost(...)` 미구현 컴파일 실패를 확인했고, `DefaultHomeRecommendationQueryRepositoryTest`는 half-open/distinct 집계 기대값 불일치 실패를 확인했다. `RecommendationSnapshotPersistenceAdapterTest`는 `CHEER_CREATOR` empty marker 미지원 실패를 확인했다. +- 2026-07-10: focused regression으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`를 실행해 `BUILD SUCCESSFUL`로 통과했다. +- 2026-07-10: 최종 검증으로 `./gradlew ktlintCheck`, `./gradlew test`, `./gradlew tasks --all`, `git diff --check`를 실행했다. `./gradlew test`는 최초 300초 timeout 후 600초 timeout으로 재실행해 `BUILD SUCCESSFUL`로 통과했고, 나머지 명령도 `BUILD SUCCESSFUL` 또는 출력 없음으로 통과했다. +- 2026-07-10: 리뷰 보강으로 `CHEER_CREATOR` 스케줄러 refresh도 section lock을 사용하도록 수정하고, 공통 fallback runner의 기본 worker를 2개 thread로 변경해 AI/응원 섹션 간 fallback 대기 간섭을 줄였다. `RecommendationSnapshotFallbackServiceTest`에 lock miss, refresh 실패, timeout 후 worker 지속, AI block 중 CHEER fallback 독립 실행 테스트를 추가해 Task 5.3 커버리지와 완료 표시를 맞췄다. 보강 RED 확인 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest` 재실행 결과 `BUILD SUCCESSFUL`로 통과했다. +- 2026-07-10: 리뷰 보강 후 순차 검증으로 `./gradlew ktlintCheck`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest`, focused recommendation/API regression 묶음, `git diff --check`를 실행했다. 모두 `BUILD SUCCESSFUL` 또는 출력 없음으로 통과했다. +- 2026-07-10: 추가 리뷰 보강으로 `CHEER_CREATOR` 홈/fallback 조회를 expected `snapshotAt` 기준으로 변경해 stale latest snapshot이 fallback을 막지 않게 했다. `RecommendationSnapshotPort.findSnapshots(...)`, `existsSnapshot(...)`와 adapter/repository exact snapshot 조회 테스트를 추가했고, `DefaultHomeRecommendationQueryRepositoryTest`의 `findCheerCreatorSnapshots(...)` 호출부는 두 번째 인자를 `windowEndExclusive`로 정리했다. 비활성 legacy `AiCharacterSnapshotFallbackServiceTest`는 삭제하고 AI 핵심 fallback coverage를 `RecommendationSnapshotFallbackServiceTest`로 이관했다. focused regression, `./gradlew test`, `./gradlew ktlintCheck`, `git diff --check`를 재실행해 `BUILD SUCCESSFUL` 또는 출력 없음으로 통과했다. +- 2026-07-10: `review-work`에서 발견한 blocking 이슈를 수정했다. `HomeRecommendationQueryService`가 expected `snapshotAt` 계산에 사용한 같은 `nowUtc`를 `refreshCheerCreatorIfMissing(...)`에 전달하도록 변경했고, `RecommendationSnapshotFallbackServiceTest`의 기본 현재 시각 의존 호출을 고정 `nowUtc`로 바꿨다. `HomeRecommendationQueryServiceTest`는 fallback에 전달된 `nowUtc`를 검증한다. 재검증으로 focused recommendation/API regression, `./gradlew ktlintCheck`, `./gradlew test`, `git diff --check`를 실행했고 모두 `BUILD SUCCESSFUL` 또는 출력 없음으로 통과했다. diff --git a/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md new file mode 100644 index 00000000..547572db --- /dev/null +++ b/docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md @@ -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`