test #433

Merged
klaus merged 41 commits from test into main 2026-07-10 07:20:07 +00:00
2 changed files with 535 additions and 0 deletions
Showing only changes of commit 5d612ffcdb - Show all commits

View File

@@ -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` 또는 출력 없음으로 통과했다.

View 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`