test #436

Merged
klaus merged 6 commits from test into main 2026-07-12 04:18:00 +00:00
13 changed files with 102 additions and 130 deletions

View File

@@ -167,7 +167,7 @@
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/RecommendationSnapshotPort.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/RecommendationSnapshotPort.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
- RED: QueryDSL 집계 통합 테스트를 추가해 AI 캐릭터 최근 채팅 수/활성 사용자 수, 최근 응원 `CHANNEL_DONATION` 후원 금액/후원 수와 팬 Talk 수, 인기 커뮤니티 좋아요/댓글/팔로워 수가 Phase 2 요구와 일치하는지 검증한다. - RED: QueryDSL 집계 통합 테스트를 추가해 AI 캐릭터 최근 채팅 수/활성 사용자 수, 최근 응원 `CHANNEL_DONATION` 후원 금액/후원 수와 팬 Talk 수, 인기 커뮤니티 좋아요/댓글 수가 Phase 2 요구와 일치하는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest` - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`
- GREEN: 스케줄러 cron을 KST 06:00:00 `Asia/Seoul` zone으로 수정하고, 최근 응원 후원 금액/후원 수는 `CanUsage.CHANNEL_DONATION`만 집계한다. - GREEN: 스케줄러 cron을 KST 06:00:00 `Asia/Seoul` zone으로 수정하고, 최근 응원 후원 금액/후원 수는 `CanUsage.CHANNEL_DONATION`만 집계한다.
- REFACTOR: `RecommendationSnapshotPort`가 persistence entity를 직접 노출하지 않도록 application/domain 경계 DTO 또는 모델을 도입해 `port.out` 의존 경계를 정리한다. - REFACTOR: `RecommendationSnapshotPort`가 persistence entity를 직접 노출하지 않도록 application/domain 경계 DTO 또는 모델을 도입해 `port.out` 의존 경계를 정리한다.

View File

@@ -241,7 +241,7 @@
- 핀으로 고정한 커뮤니티 게시글은 제외한다. - 핀으로 고정한 커뮤니티 게시글은 제외한다.
- 성인 속성을 가진 커뮤니티 게시글은 기존 노출 조건과 동일하게 `MemberContentPreference.isAdultContentVisible == true`인 회원에게만 노출한다. - 성인 속성을 가진 커뮤니티 게시글은 기존 노출 조건과 동일하게 `MemberContentPreference.isAdultContentVisible == true`인 회원에게만 노출한다.
- 동일 점수의 경우 스냅샷 생성 시 저장한 랜덤 tie-breaker 기준으로 노출한다. - 동일 점수의 경우 스냅샷 생성 시 저장한 랜덤 tie-breaker 기준으로 노출한다.
- 커뮤니티 인기 점수는 `((0.5 * 좋아요 수) + (0.5 * 댓글 수) + (0.1 * 팔로우 수)) * 신규 부스트`로 계산한다. - 커뮤니티 인기 점수는 `((0.5 * 좋아요 수) + (0.5 * 댓글 수)) * 신규 부스트`로 계산한다.
- 점수는 최근 7일 데이터를 기반으로 계산한다. - 점수는 최근 7일 데이터를 기반으로 계산한다.
- 신규 부스트는 데뷔 후 10일 이내 1.5, 20일 이내 1.3, 30일 이내 1.2, 그 외 1을 적용한다. - 신규 부스트는 데뷔 후 10일 이내 1.5, 20일 이내 1.3, 30일 이내 1.2, 그 외 1을 적용한다.
- 신규 부스트의 데뷔일은 `Member.createdAt`이 아니라 콘텐츠를 처음 공개한 날과 라이브를 한 날 중 빠른 날짜로 계산한다. - 신규 부스트의 데뷔일은 `Member.createdAt`이 아니라 콘텐츠를 처음 공개한 날과 라이브를 한 날 중 빠른 날짜로 계산한다.

View File

@@ -1,8 +1,8 @@
# 메인 홈 추천 응원 크리에이터 스냅샷 수정 Plan/Task # 메인 홈 추천 응원 크리에이터 스냅샷 수정 Plan/Task
## 시나리오 계약 ## 시나리오 계약
- Happy path: KST 전날 하루 데이터를 UTC half-open 범위로 변환해 `CHEER_CREATOR` 점수를 계산하고, 점수순 상위 16개 스냅샷을 저장한다. Real surface: `DefaultHomeRecommendationQueryRepositoryTest`, `RecommendationSnapshotRefreshServiceTest`. - Happy path: 인기 커뮤니티와 동일한 최근 7일 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`. - Score: 응원 점수는 `((donationAmount * 0.45) + (fanTalkCount * 0.30) + (donationCount * 0.10)) * newBoost`다. 후원 금액은 `CHANNEL_DONATION``DONATION``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`. - 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`. - 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`. - Empty marker: `CHEER_CREATOR` refresh 결과가 0건이면 `targetId = 0` marker를 저장해 정상 refresh 완료 상태를 남기고, 조회 응답에서는 marker를 제외한다. Real surface: `RecommendationSnapshotPersistenceAdapterTest`, `HomeRecommendationQueryServiceTest`.
@@ -13,7 +13,7 @@
- 신규 공개 API, 신규 응답 필드, 운영 DDL 추가는 범위에 포함하지 않는다. - 신규 공개 API, 신규 응답 필드, 운영 DDL 추가는 범위에 포함하지 않는다.
- 기존 `recommendation_snapshot` 테이블과 `RecommendedSectionType.CHEER_CREATOR`를 재사용한다. - 기존 `recommendation_snapshot` 테이블과 `RecommendedSectionType.CHEER_CREATOR`를 재사용한다.
- `CHEER_CREATOR` 집계는 현재 구조와 성능 특성을 유지해 DB-side exact scoring을 기본으로 한다. Kotlin 단에는 산식/부스트 근거 테스트용 정책 함수를 둔다. - `CHEER_CREATOR` 집계는 현재 구조와 성능 특성을 유지해 DB-side exact scoring을 기본으로 한다. Kotlin 단에는 산식/부스트 근거 테스트용 정책 함수를 둔다.
- 전날KST 전날 00:00:00 이상, 다음날 00:00:00 미만이고, DB 조회에는 UTC half-open window를 사용한다. - 집계인기 커뮤니티와 동일하게 KST 전날을 포함한 최근 7일이며, DB 조회에는 UTC half-open window를 사용한다.
- fallback orchestration은 AI 캐릭터 전용 구현을 그대로 복사하지 않고, 섹션별 lock key와 refresh action을 받을 수 있는 최소 공통 runner를 우선 적용한다. - fallback orchestration은 AI 캐릭터 전용 구현을 그대로 복사하지 않고, 섹션별 lock key와 refresh action을 받을 수 있는 최소 공통 runner를 우선 적용한다.
- 다른 스냅샷 섹션으로 empty marker를 확장하는 작업은 이번 구현 범위에서 제외한다. 단, `CHEER_CREATOR`에 적용할 때 이후 공통화가 가능하도록 조건문/상수명을 명확히 둔다. - 다른 스냅샷 섹션으로 empty marker를 확장하는 작업은 이번 구현 범위에서 제외한다. 단, `CHEER_CREATOR`에 적용할 때 이후 공통화가 가능하도록 조건문/상수명을 명확히 둔다.
@@ -22,11 +22,11 @@
- 유지: 홈 응원 크리에이터 응답 필드인 `creatorId`, `creatorNickname`, `creatorProfileImage`는 변경하지 않는다. - 유지: 홈 응원 크리에이터 응답 필드인 `creatorId`, `creatorNickname`, `creatorProfileImage`는 변경하지 않는다.
- 유지: 홈 첫 화면 응답은 최대 8명, 스냅샷 후보 조회는 최대 16개를 사용한다. - 유지: 홈 첫 화면 응답은 최대 8명, 스냅샷 후보 조회는 최대 16개를 사용한다.
- 유지: 상세 조회 시점의 활성 크리에이터 필터와 차단 필터는 유지한다. - 유지: 상세 조회 시점의 활성 크리에이터 필터와 차단 필터는 유지한다.
- 유지: 채널 후원 금액은 `use_can_calculate.can` 값을 그대로 합산한다. - 유지: 후원 금액은 `use_can_calculate.can` 값을 그대로 합산한다.
- 변경: 점수 가중치는 후원 금액 45%, 팬Talk 수 30%, 채널 후원 수 10%로 바꾼다. - 변경: 점수 가중치는 후원 금액 45%, 팬Talk 수 30%, 후원 수 10%로 바꾼다.
- 변경: 채널 후원 수는 `UseCanCalculate.useCan` 기준 distinct count로 계산한다. - 변경: 후원 수는 `UseCanCalculate.useCan` 기준 distinct count로 계산한다.
- 변경: 팬Talk 수는 `CreatorCheers.isActive == true` row 수로 계산한다. - 변경: 팬Talk 수는 `CreatorCheers.isActive == true` row 수로 계산한다.
- 변경: 집계 기간은 최근 7일에서 KST 전날 하루로 바꾼다. - 변경: 집계 기간은 인기 커뮤니티와 동일한 최근 7일로 한다.
- 변경: 신규 부스트는 기존 크리에이터 공통 부스트 `1.5/1.3/1.2`가 아니라 `CHEER_CREATOR` 전용 `1.15/1.10/1.05`를 사용한다. - 변경: 신규 부스트는 기존 크리에이터 공통 부스트 `1.5/1.3/1.2`가 아니라 `CHEER_CREATOR` 전용 `1.15/1.10/1.05`를 사용한다.
- 추가: `CHEER_CREATOR` 최신 스냅샷이 없을 때 fallback refresh를 실행한다. - 추가: `CHEER_CREATOR` 최신 스냅샷이 없을 때 fallback refresh를 실행한다.
- 추가: `CHEER_CREATOR` refresh 결과 0건이면 empty snapshot marker를 저장한다. - 추가: `CHEER_CREATOR` refresh 결과 0건이면 empty snapshot marker를 저장한다.
@@ -51,7 +51,7 @@
- Verify: `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md` - Verify: `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md`
- Create: `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md` - Create: `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md`
- RED: 문서 작업은 TDD 예외. TDD 예외 사유: 코드 동작 변경 전 요구사항과 구현 순서를 고정하는 작업이다. - RED: 문서 작업은 TDD 예외. TDD 예외 사유: 코드 동작 변경 전 요구사항과 구현 순서를 고정하는 작업이다.
- GREEN: PRD의 산식, KST 전날 UTC half-open 범위, 후원 수 distinct 기준, empty marker, fallback timeout/lock 정책을 task로 분해한다. - GREEN: PRD의 산식, 최근 7일 UTC half-open 범위, 후원 수 distinct 기준, empty marker, fallback timeout/lock 정책을 task로 분해한다.
- REFACTOR: 기존 홈 추천 구현 파일과 테스트 파일 기준으로 task별 수정/검증 경로를 맞춘다. - REFACTOR: 기존 홈 추천 구현 파일과 테스트 파일 기준으로 task별 수정/검증 경로를 맞춘다.
- 기대 결과: 구현 시작 전에 PRD와 plan-task가 같은 디렉터리에 준비된다. - 기대 결과: 구현 시작 전에 PRD와 plan-task가 같은 디렉터리에 준비된다.
@@ -90,39 +90,39 @@
--- ---
### Phase 3: 전날 집계 window와 DB 스냅샷 query ### Phase 3: 최근 7일 집계 window와 DB 스냅샷 query
- [x] **Task 3.1: `CHEER_CREATOR` refresh window를 KST 전날 하루로 변경** - [x] **Task 3.1: `CHEER_CREATOR` refresh window를 인기 커뮤니티와 동일한 최근 7일로 변경**
- 파일 경로: - 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt` - 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/port/out/HomeRecommendationQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.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` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
- RED: `refreshCheerCreatorSnapshots(nowUtc)``RecommendationSnapshotWindowPolicy.previousKstDayUtcWindow(nowUtc)`로 얻은 `windowStartUtc`, `windowEndExclusiveUtc`, `snapshotAt`을 사용하도록 실패 테스트를 작성한다. - RED: `refreshCheerCreatorSnapshots(nowUtc)``RecommendationSnapshotWindowPolicy.previousKstSevenDayUtcWindow(nowUtc)`로 얻은 `windowStartUtc`, `windowEndExclusiveUtc`, `snapshotAt`을 사용하도록 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest` - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`
- GREEN: `CHEER_CREATOR` 단일 refresh 경로를 분리하고, 기존 일괄 refresh에서 최근 7일 window 대신 전날 UTC half-open window를 넘긴다. - GREEN: `CHEER_CREATOR` 단일 refresh 경로를 분리하고, 기존 일괄 refresh에서 인기 커뮤니티와 같은 최근 7일 UTC half-open window를 넘긴다.
- REFACTOR: `POPULAR_COMMUNITY`의 기존 7일 window는 변경하지 않는다. `HomeRecommendationQueryPort.findCheerCreatorSnapshots(...)` 시그니처는 `windowEndExclusiveUtc` 의미가 드러나도록 정리한다. - REFACTOR: `POPULAR_COMMUNITY`의 기존 7일 window와 동일한 window를 사용한다. `HomeRecommendationQueryPort.findCheerCreatorSnapshots(...)` 시그니처는 `windowEndExclusiveUtc` 의미가 드러나도록 정리한다.
- 기대 결과: 스케줄러와 fallback이 같은 `CHEER_CREATOR` 전날 refresh 경로를 호출할 수 있다. - 기대 결과: 스케줄러와 fallback이 같은 `CHEER_CREATOR` 최근 7일 refresh 경로를 호출할 수 있다.
- [x] **Task 3.2: 채널 후원 금액/후원 수 집계 기준 변경** - [x] **Task 3.2: 채널 후원과 일반 후원 금액/후원 수 집계 기준 변경**
- 파일 경로: - 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.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/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.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건으로 계산되는 실패 테스트를 작성한다. - RED: 같은 `UseCanCalculate.useCan`을 참조하는 여러 row가 있을 때 `donationAmount``use_can_calculate.can` 값을 그대로 합산하고, `donationCount`는 distinct `use_can` 기준 1건으로 계산되는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` - 실패 확인: `./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)` 또는 엔티티 매핑에 맞는 동일 의미 컬럼으로 변경한다. - GREEN: donation stats query에서 금액은 기존 `sum(ucc.can)`을 유지하고, 후원 수는 `count(distinct ucc.use_can_id)` 또는 엔티티 매핑에 맞는 동일 의미 컬럼으로 변경한다.
- REFACTOR: `CanUsage.CHANNEL_DONATION`, `status = RECEIVED`, `is_refund = false` 등 기존 제외 조건은 유지한다. - REFACTOR: `CanUsage.CHANNEL_DONATION``CanUsage.DONATION`을 포함하고, `status = RECEIVED`, `is_refund = false` 등 기존 제외 조건은 유지한다.
- 기대 결과: 후원 이벤트 단위 중복 제거가 점수의 후원 수 항목에만 적용된다. - 기대 결과: 후원 이벤트 단위 중복 제거가 점수의 후원 수 항목에만 적용된다.
- [x] **Task 3.3: 팬Talk 수와 half-open 시간 조건 적용** - [x] **Task 3.3: 팬Talk 수와 half-open 시간 조건 적용**
- 파일 경로: - 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.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/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.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는 제외되어야 한다. - 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` - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: `creator_cheers` 집계 조건을 active row와 half-open time range로 맞춘다. - GREEN: `creator_cheers` 집계 조건을 active row와 half-open time range로 맞춘다.
- REFACTOR: 후원 집계 조건도 같은 half-open range를 사용해 `<= :snapshotAt` 방식이 남지 않게 정리한다. - REFACTOR: 후원 집계 조건도 같은 half-open range를 사용해 `<= :snapshotAt` 방식이 남지 않게 정리한다.
- 기대 결과: 전날 KST 하루 경계가 후원과 팬Talk 집계에 동일하게 적용된다. - 기대 결과: 최근 7일 KST 경계가 후원과 팬Talk 집계에 동일하게 적용된다.
- [x] **Task 3.4: 응원 크리에이터 DB-side 점수와 부스트 적용** - [x] **Task 3.4: 응원 크리에이터 DB-side 점수와 부스트 적용**
- 파일 경로: - 파일 경로:
@@ -266,7 +266,7 @@
## Coverage Check ## Coverage Check
- Feature A: Task 2.1, Task 3.2, Task 3.4에서 후원 금액 45%, 팬Talk 30%, 후원 수 10%, 후원 수 distinct 기준을 검증한다. - 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 B: Task 3.1, Task 3.3에서 최근 7일 KST 범위를 UTC half-open 조회 범위로 변환하고 `windowEndExclusiveUtc` 경계를 검증한다.
- Feature C: Task 2.2, Task 3.4에서 데뷔일 기준 응원 전용 신규 부스트와 경계값을 검증한다. - Feature C: Task 2.2, Task 3.4에서 데뷔일 기준 응원 전용 신규 부스트와 경계값을 검증한다.
- Feature D: Task 3.5, Task 5.4, Task 6.1에서 최신 `CHEER_CREATOR` 스냅샷 순서, 후보 16개/응답 8개, 기존 응답 스키마 유지를 검증한다. - 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 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 방지를 검증한다.

View File

@@ -1,12 +1,13 @@
# PRD: 메인 홈 추천 응원 크리에이터 스냅샷 수정 # PRD: 메인 홈 추천 응원 크리에이터 스냅샷 수정
## 1. Overview ## 1. Overview
메인 홈 추천 탭의 `CHEER_CREATOR` 스냅샷 생성과 조회를 전날 데이터 기반의 응원 점수로 수정하고, 스냅샷이 없을 때 홈 API가 동일 refresh 로직을 안전하게 재사용하도록 fallback 흐름을 보강한다. 메인 홈 추천 탭의 `CHEER_CREATOR` 스냅샷 생성과 조회를 최근 7일 데이터 기반의 응원 점수로 수정하고, 스냅샷이 없을 때 홈 API가 동일 refresh 로직을 안전하게 재사용하도록 fallback 흐름을 보강한다.
--- ---
## 2. Problem ## 2. Problem
- 기존 `CHEER_CREATOR` 산식은 최근 7일 데이터를 기반으로 하며, 후원 금액 가중치와 신규 부스트 값이 이번 요구사항과 다르다. - 기존 `CHEER_CREATOR` 스냅샷은 전날 KST 하루 데이터만 사용해, 인기 커뮤니티와 동일한 최근 7일 집계 기준과 다르다.
- 기존 후원 집계는 `CanUsage.CHANNEL_DONATION`만 대상으로 하며, 일반 후원 `CanUsage.DONATION`을 함께 반영하지 않는다.
- 현재 일괄 refresh와 홈 API fallback refresh가 섹션별로 동일한 생성 로직을 공유하지 않으면 산식 drift가 발생할 수 있다. - 현재 일괄 refresh와 홈 API fallback refresh가 섹션별로 동일한 생성 로직을 공유하지 않으면 산식 drift가 발생할 수 있다.
- 스냅샷이 없는 초기 배포, 운영 데이터 삭제, 배치 실패 상황에서 홈 조회가 매 요청마다 무거운 집계를 중복 실행하면 API 지연과 DB 부하가 커질 수 있다. - 스냅샷이 없는 초기 배포, 운영 데이터 삭제, 배치 실패 상황에서 홈 조회가 매 요청마다 무거운 집계를 중복 실행하면 API 지연과 DB 부하가 커질 수 있다.
- 집계 산식이 추천 노출 순서를 직접 바꾸므로 DB-side 계산과 Kotlin-side 계산 중 어떤 방식을 선택하더라도 산식/부스트 경계값 테스트가 필요하다. - 집계 산식이 추천 노출 순서를 직접 바꾸므로 DB-side 계산과 Kotlin-side 계산 중 어떤 방식을 선택하더라도 산식/부스트 경계값 테스트가 필요하다.
@@ -14,8 +15,8 @@
--- ---
## 3. Goals ## 3. Goals
- `CHEER_CREATOR` 스냅샷은 전날 KST 하루 데이터를 기반으로 생성한다. - `CHEER_CREATOR` 스냅샷은 인기 커뮤니티와 동일하게 스냅샷 생성 시점 기준 최근 7일 데이터를 기반으로 생성한다.
- 응원 점수 산식을 `((채널 후원 금액 * 0.45) + (팬Talk 수 * 0.30) + (채널 후원 수 * 0.10)) * 신규 부스트`로 변경한다. - 응원 점수 산식을 `((후원 금액 * 0.45) + (팬Talk 수 * 0.30) + (후원 수 * 0.10)) * 신규 부스트`로 변경한다.
- 신규 부스트는 크리에이터 데뷔일 기준 10일 이내 1.15, 20일 이내 1.10, 30일 이내 1.05, 그 외 1.0을 적용한다. - 신규 부스트는 크리에이터 데뷔일 기준 10일 이내 1.15, 20일 이내 1.10, 30일 이내 1.05, 그 외 1.0을 적용한다.
- 스케줄러 refresh와 fallback refresh는 동일한 `CHEER_CREATOR` refresh 로직을 재사용한다. - 스케줄러 refresh와 fallback refresh는 동일한 `CHEER_CREATOR` refresh 로직을 재사용한다.
- 스냅샷이 없을 때 fallback refresh는 lock, double-check, single-flight 대기로 중복 refresh를 방지한다. - 스냅샷이 없을 때 fallback refresh는 lock, double-check, single-flight 대기로 중복 refresh를 방지한다.
@@ -35,14 +36,14 @@
--- ---
## 5. Target Users ## 5. Target Users
- 회원/비회원: 메인 홈 추천 탭에서 전날 응원 반응이 많았던 크리에이터를 발견하는 사용자 - 회원/비회원: 메인 홈 추천 탭에서 최근 7일 응원 반응이 많았던 크리에이터를 발견하는 사용자
- 앱 클라이언트: 기존 응답 계약을 유지한 채 `CHEER_CREATOR` 추천 순서만 변경된 결과를 받는 클라이언트 - 앱 클라이언트: 기존 응답 계약을 유지한 채 `CHEER_CREATOR` 추천 순서만 변경된 결과를 받는 클라이언트
- 운영자: 전날 후원/팬Talk 반응이 추천 노출에 반영되는지 확인해야 하는 운영 담당자 - 운영자: 최근 7일 후원/팬Talk 반응이 추천 노출에 반영되는지 확인해야 하는 운영 담당자
--- ---
## 6. User Stories ## 6. User Stories
- 사용자는 메인 홈 추천 탭에서 전날 응원이 많았던 크리에이터를 우선 보고 싶다. - 사용자는 메인 홈 추천 탭에서 최근 7일 응원이 많았던 크리에이터를 우선 보고 싶다.
- 사용자는 후원 금액뿐 아니라 팬Talk와 후원 참여 횟수도 함께 반영된 추천을 보고 싶다. - 사용자는 후원 금액뿐 아니라 팬Talk와 후원 참여 횟수도 함께 반영된 추천을 보고 싶다.
- 사용자는 신규 크리에이터가 일정 기간 동안 적절한 노출 기회를 받기를 기대한다. - 사용자는 신규 크리에이터가 일정 기간 동안 적절한 노출 기회를 받기를 기대한다.
- 앱 클라이언트는 스냅샷이 없는 상황에서도 홈 API가 실패하지 않고 안정적으로 빈 배열 또는 생성된 스냅샷을 받기를 원한다. - 앱 클라이언트는 스냅샷이 없는 상황에서도 홈 API가 실패하지 않고 안정적으로 빈 배열 또는 생성된 스냅샷을 받기를 원한다.
@@ -56,11 +57,11 @@
#### Requirements #### Requirements
- `CHEER_CREATOR` 점수는 아래 산식으로 계산한다. - `CHEER_CREATOR` 점수는 아래 산식으로 계산한다.
- `score = ((channelDonationAmount * 0.45) + (fanTalkCount * 0.30) + (channelDonationCount * 0.10)) * newBoost` - `score = ((donationAmount * 0.45) + (fanTalkCount * 0.30) + (donationCount * 0.10)) * newBoost`
- `channelDonationAmount`는 집계 기간 안에 발생한 채널 후원 금액 합계다. - `donationAmount`는 집계 기간 안에 발생한 채널 후원과 일반 후원 금액 합계다.
- `channelDonationCount`는 집계 기간 안에 발생한 채널 후원 건수다. - `donationCount`는 집계 기간 안에 발생한 채널 후원과 일반 후원 건수다.
- 채널 후원 수는 `UseCanCalculate.useCan`이 같은 row를 1개 후원 이벤트로 보고 중복 제거해 계산한다. - 후원 수는 `UseCanCalculate.useCan`이 같은 row를 1개 후원 이벤트로 보고 중복 제거해 계산한다.
- 채널 후원은 기존 요구사항과 동일하게 `CanUsage.CHANNEL_DONATION`며, 환불/미수령/비정상 상태는 기존 채널 후원 집계 제외 조건을 유지한다. - 후원은 `CanUsage.CHANNEL_DONATION`과 일반 후원 `CanUsage.DONATION`을 포함하며, 환불/미수령/비정상 상태는 기존 후원 집계 제외 조건을 유지한다.
- `fanTalkCount`는 집계 기간 안에 생성된 활성 팬Talk 수다. - `fanTalkCount`는 집계 기간 안에 생성된 활성 팬Talk 수다.
- 팬Talk 수는 `CreatorCheers.isActive == true`인 row 수로 계산한다. - 팬Talk 수는 `CreatorCheers.isActive == true`인 row 수로 계산한다.
- 팬Talk는 기존 `CreatorCheers` 또는 현재 구현의 `creator_cheers` 기반 데이터를 의미한다. - 팬Talk는 기존 `CreatorCheers` 또는 현재 구현의 `creator_cheers` 기반 데이터를 의미한다.
@@ -69,20 +70,20 @@
- 최종 저장 수는 기존 홈 노출 안정성을 위해 `CHEER_CREATOR` 최대 16개를 유지한다. - 최종 저장 수는 기존 홈 노출 안정성을 위해 `CHEER_CREATOR` 최대 16개를 유지한다.
#### Edge Cases #### Edge Cases
- `channelDonationAmount`, `fanTalkCount`, `channelDonationCount`가 모두 0인 크리에이터는 스냅샷 후보에서 제외한다. - `donationAmount`, `fanTalkCount`, `donationCount`가 모두 0인 크리에이터는 스냅샷 후보에서 제외한다.
- 후원 금액은 없지만 팬Talk가 있으면 산식에 따라 점수를 계산한다. - 후원 금액은 없지만 팬Talk가 있으면 산식에 따라 점수를 계산한다.
- 팬Talk는 없지만 채널 후원이 있으면 산식에 따라 점수를 계산한다. - 팬Talk는 없지만 채널 후원 또는 일반 후원이 있으면 산식에 따라 점수를 계산한다.
- 비활성 크리에이터, 차단 필터에 의해 조회 시 제외되는 크리에이터는 최종 홈 응답에서 제외한다. - 비활성 크리에이터, 차단 필터에 의해 조회 시 제외되는 크리에이터는 최종 홈 응답에서 제외한다.
- 스냅샷에는 존재하지만 조회 시점에 크리에이터가 비활성화된 경우 응답에서 제외한다. - 스냅샷에는 존재하지만 조회 시점에 크리에이터가 비활성화된 경우 응답에서 제외한다.
### Feature B. 전날 데이터 기반 집계 기간 ### Feature B. 최근 7일 데이터 기반 집계 기간
#### Requirements #### Requirements
- 점수 입력값은 전날 KST 하루 데이터 사용한다. - 점수 입력값은 인기 커뮤니티와 동일하게 스냅샷 생성 시점 기준 최근 7일 데이터 사용한다.
- 전날 기준은 KST 기준 전일 00:00:00 이상, 다음날 00:00:00 미만의 half-open 범위로 정의한다. - 최근 7일 기준은 KST 기준 전날을 포함한 7일의 시작 시각 이상, 다음날 00:00:00 미만의 half-open 범위로 정의한다.
- 운영 DB 시간이 UTC 기준이면, 실제 조회는 `windowStartUtc <= createdAt < windowEndExclusiveUtc`로 변환해 사용한다. - 운영 DB 시간이 UTC 기준이면, 실제 조회는 `windowStartUtc <= createdAt < windowEndExclusiveUtc`로 변환해 사용한다.
- `snapshotAt`은 해당 KST 전날의 종료 시각을 UTC로 변환한 `windowEndExclusiveUtc.minusSeconds(1)`을 사용한다. - `snapshotAt`은 해당 KST 전날의 종료 시각을 UTC로 변환한 `windowEndExclusiveUtc.minusSeconds(1)`을 사용한다.
- 스케줄러가 KST 06:00에 실행되면 실행일 전날 KST 일자를 집계 대상으로 삼는다. - 스케줄러가 KST 06:00에 실행되면 실행일 전날 KST 일자를 포함한 최근 7일을 집계 대상으로 삼는다.
- 기존 코드에 남아 있는 `created_at <= :snapshotAt` 방식은 이번 범위에서 half-open end-exclusive 조건으로 맞춘다. - 기존 코드에 남아 있는 `created_at <= :snapshotAt` 방식은 이번 범위에서 half-open end-exclusive 조건으로 맞춘다.
#### Edge Cases #### Edge Cases
@@ -180,7 +181,7 @@
- DB-side scoring을 유지하는 경우 Kotlin 단에는 산식 parity 검증용 정책 함수를 두고, DB expression과 같은 상수를 공유한다. - DB-side scoring을 유지하는 경우 Kotlin 단에는 산식 parity 검증용 정책 함수를 두고, DB expression과 같은 상수를 공유한다.
- Kotlin-side scoring으로 변경하는 경우 DB에서 필요한 원천 metric을 정확히 집계하고, service에서 최종 점수/정렬/limit을 적용한다. 이 경우 후보 전체를 메모리에 올리는 비용과 데이터량을 구현 계획에서 검토한다. - Kotlin-side scoring으로 변경하는 경우 DB에서 필요한 원천 metric을 정확히 집계하고, service에서 최종 점수/정렬/limit을 적용한다. 이 경우 후보 전체를 메모리에 올리는 비용과 데이터량을 구현 계획에서 검토한다.
- 기본 권장안은 현재 구조와 성능 특성을 유지하는 DB-side exact scoring이다. 다만 산식/부스트 계산은 Kotlin 정책 테스트로 검증 가능한 형태를 둔다. - 기본 권장안은 현재 구조와 성능 특성을 유지하는 DB-side exact scoring이다. 다만 산식/부스트 계산은 Kotlin 정책 테스트로 검증 가능한 형태를 둔다.
- 시간 범위는 KST 전날을 UTC half-open window로 변환하는 `RecommendationSnapshotWindowPolicy`를 재사용한다. - 시간 범위는 인기 커뮤니티와 동일한 최근 7일 UTC half-open window를 반환하는 `RecommendationSnapshotWindowPolicy`를 재사용한다.
- 신규 DDL은 만들지 않는 것을 기본으로 한다. 불가피하게 필요하면 운영 DB 반영용 SQL 문서는 MySQL 기준으로 별도 작성한다. - 신규 DDL은 만들지 않는 것을 기본으로 한다. 불가피하게 필요하면 운영 DB 반영용 SQL 문서는 MySQL 기준으로 별도 작성한다.
--- ---
@@ -190,7 +191,7 @@
- `CHEER_CREATOR` 스냅샷 최종 저장 수 - `CHEER_CREATOR` 스냅샷 최종 저장 수
- fallback refresh 실행/성공/실패/timeout 로그 - fallback refresh 실행/성공/실패/timeout 로그
- fallback refresh lock 획득 성공/실패 로그 - fallback refresh lock 획득 성공/실패 로그
- `channelDonationAmount`, `fanTalkCount`, `channelDonationCount` 입력값 분포 - `donationAmount`, `fanTalkCount`, `donationCount` 입력값 분포
- empty snapshot marker 저장 횟수 - empty snapshot marker 저장 횟수
- 홈 API `CHEER_CREATOR` 섹션 빈 응답 비율 - 홈 API `CHEER_CREATOR` 섹션 빈 응답 비율
- 홈 API fallback refresh 대기 시간 - 홈 API fallback refresh 대기 시간
@@ -203,8 +204,8 @@
--- ---
## 11. Decisions ## 11. Decisions
- 채널 후원 금액은 `use_can_calculate.can` 값을 그대로 사용한다. - 후원 금액은 `use_can_calculate.can` 값을 그대로 사용한다.
- 채널 후원 수는 `UseCanCalculate.useCan`이 같은 row를 1개 후원 이벤트로 보고 중복 제거한다. - 후원 수는 `UseCanCalculate.useCan`이 같은 row를 1개 후원 이벤트로 보고 중복 제거한다.
- 팬Talk 수는 `CreatorCheers.isActive == true`인 row 수로 계산한다. - 팬Talk 수는 `CreatorCheers.isActive == true`인 row 수로 계산한다.
- 빈 결과 marker 정책은 다른 스냅샷 섹션에도 확장하는 것이 맞지만, 이번 구현 범위에서는 `CHEER_CREATOR`에만 적용한다. - 빈 결과 marker 정책은 다른 스냅샷 섹션에도 확장하는 것이 맞지만, 이번 구현 범위에서는 `CHEER_CREATOR`에만 적용한다.

View File

@@ -2,7 +2,7 @@
## 시나리오 계약 ## 시나리오 계약
- Happy path: 홈 API 호출 시각 기준 KST 전날 대상일 `snapshotAt`을 계산하고, 대상일 포함 최근 7일 데이터를 UTC half-open 범위로 변환해 `POPULAR_COMMUNITY` 점수를 계산한 뒤 점수순 상위 20개 스냅샷을 저장한다. Real surface: `RecommendationSnapshotWindowPolicyTest`, `DefaultHomeRecommendationQueryRepositoryTest`, `RecommendationSnapshotRefreshServiceTest`. - Happy path: 홈 API 호출 시각 기준 KST 전날 대상일 `snapshotAt`을 계산하고, 대상일 포함 최근 7일 데이터를 UTC half-open 범위로 변환해 `POPULAR_COMMUNITY` 점수를 계산한 뒤 점수순 상위 20개 스냅샷을 저장한다. Real surface: `RecommendationSnapshotWindowPolicyTest`, `DefaultHomeRecommendationQueryRepositoryTest`, `RecommendationSnapshotRefreshServiceTest`.
- Score: 인기 커뮤니티 점수는 `((likeCount * 0.50) + (commentCount * 0.40) + (creatorFollowerCount * 0.10)) * newBoost`다. `creatorFollowerCount`는 스냅샷 생성 시점의 활성 팔로워 총수다. Real surface: `RecommendationScorePolicyTest`, `DefaultHomeRecommendationQueryRepositoryTest`. - Score: 인기 커뮤니티 점수는 `((likeCount * 0.50) + (commentCount * 0.50)) * newBoost`다. 후보는 좋아요가 있거나 산식에 반영 가능한 댓글이 있는 게시글이다. Real surface: `RecommendationScorePolicyTest`, `DefaultHomeRecommendationQueryRepositoryTest`.
- Exclusion: 공지/고정 게시글(`CreatorCommunity.isFixed`/`is_fixed = true`)과 유료 게시글(`price > 0`)은 스냅샷 후보와 상세 조회 결과에서 제외한다. Real surface: `DefaultHomeRecommendationQueryRepositoryTest`, `HomeRecommendationQueryServiceTest`. - Exclusion: 공지/고정 게시글(`CreatorCommunity.isFixed`/`is_fixed = true`)과 유료 게시글(`price > 0`)은 스냅샷 후보와 상세 조회 결과에서 제외한다. Real surface: `DefaultHomeRecommendationQueryRepositoryTest`, `HomeRecommendationQueryServiceTest`.
- Boost: 신규 부스트는 크리에이터 데뷔일 기준 0~10일 `1.15`, 11~20일 `1.10`, 21~30일 `1.05`, 31일 이상 `1.0`이다. Real surface: `RecommendationScorePolicyTest`, `DefaultHomeRecommendationQueryRepositoryTest`. - Boost: 신규 부스트는 크리에이터 데뷔일 기준 0~10일 `1.15`, 11~20일 `1.10`, 21~30일 `1.05`, 31일 이상 `1.0`이다. Real surface: `RecommendationScorePolicyTest`, `DefaultHomeRecommendationQueryRepositoryTest`.
- Daily freshness: 과거 최신 `POPULAR_COMMUNITY` 스냅샷이 있어도 홈 API 호출 기준 대상일 `snapshotAt` 스냅샷이 없으면 fallback refresh를 시도한다. Real surface: `HomeRecommendationQueryServiceTest`, `RecommendationSnapshotFallbackServiceTest`. - Daily freshness: 과거 최신 `POPULAR_COMMUNITY` 스냅샷이 있어도 홈 API 호출 기준 대상일 `snapshotAt` 스냅샷이 없으면 fallback refresh를 시도한다. Real surface: `HomeRecommendationQueryServiceTest`, `RecommendationSnapshotFallbackServiceTest`.
@@ -26,7 +26,7 @@
- 유지: 스냅샷 후보 저장 수 최대 20개, 홈 첫 화면 반환 수 최대 10개를 유지한다. - 유지: 스냅샷 후보 저장 수 최대 20개, 홈 첫 화면 반환 수 최대 10개를 유지한다.
- 유지: 상세 조회 시점의 활성 게시글/크리에이터 필터, 성인 필터, 차단 필터, 크리에이터 중복 제거 정책을 유지한다. - 유지: 상세 조회 시점의 활성 게시글/크리에이터 필터, 성인 필터, 차단 필터, 크리에이터 중복 제거 정책을 유지한다.
- 유지: 댓글 불가 게시글은 댓글 수를 0으로 계산한다. - 유지: 댓글 불가 게시글은 댓글 수를 0으로 계산한다.
- 변경: 댓글 가중치를 `0.50`에서 `0.40`으로 바꾼다. - 변경: 기존 팔로워 가중치를 제거하고 댓글 가중치에 합산한다.
- 변경: 신규 부스트는 기존 크리에이터 공통 부스트 `1.5/1.3/1.2`가 아니라 `POPULAR_COMMUNITY` 전용 `1.15/1.10/1.05`를 사용한다. - 변경: 신규 부스트는 기존 크리에이터 공통 부스트 `1.5/1.3/1.2`가 아니라 `POPULAR_COMMUNITY` 전용 `1.15/1.10/1.05`를 사용한다.
- 변경: 좋아요/댓글 집계 기간은 UTC half-open 최근 7일 window를 사용한다. - 변경: 좋아요/댓글 집계 기간은 UTC half-open 최근 7일 window를 사용한다.
- 변경: 게시글 자체 생성 시각 조건도 대상일 `windowEndExclusiveUtc` 이전으로 맞춘다. - 변경: 게시글 자체 생성 시각 조건도 대상일 `windowEndExclusiveUtc` 이전으로 맞춘다.
@@ -65,17 +65,16 @@
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScoreSpec.kt` - 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` - 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` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicyTest.kt`
- RED: `COMMUNITY_LIKE_WEIGHT = 0.50`, `COMMUNITY_COMMENT_WEIGHT = 0.40`, `COMMUNITY_FOLLOWER_WEIGHT = 0.10`을 기대하는 실패 테스트를 작성한다. `calculateCommunityScore(...)` 입력값과 부스트를 PRD 산식대로 계산하는지도 검증한다. - RED: `COMMUNITY_LIKE_WEIGHT = 0.50`, `COMMUNITY_COMMENT_WEIGHT = 0.50`을 기대하는 실패 테스트를 작성한다. `calculateCommunityScore(...)`좋아요/댓글 입력값과 부스트를 PRD 산식대로 계산하는지도 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest` - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest`
- GREEN: 인기 커뮤니티 점수 상수를 PRD 값으로 변경하고 기존 `calculateCommunityScore(...)`가 같은 상수를 사용하게 한다. - GREEN: 인기 커뮤니티 점수 상수를 PRD 값으로 변경하고 기존 `calculateCommunityScore(...)`가 같은 상수를 사용하게 한다.
- REFACTOR: 기존 AI/최근 데뷔/응원 크리에이터 상수와 함수 값은 변경하지 않았는지 같은 테스트 안에서 회귀 assertion을 유지한다. - REFACTOR: 기존 AI/최근 데뷔/응원 크리에이터 상수와 함수 값은 변경하지 않았는지 같은 테스트 안에서 회귀 assertion을 유지한다.
- 계산식 테스트 케이스: - 계산식 테스트 케이스:
- `likeCount=0`, `commentCount=0`, `followerCount=0`, `newBoost=1.0`이면 `0.0` - `likeCount=0`, `commentCount=0`, `newBoost=1.0`이면 `0.0`
- `likeCount=10`, `commentCount=0`, `followerCount=0`, `newBoost=1.0`이면 `5.0` - `likeCount=10`, `commentCount=0`, `newBoost=1.0`이면 `5.0`
- `likeCount=0`, `commentCount=10`, `followerCount=0`, `newBoost=1.0`이면 `4.0` - `likeCount=0`, `commentCount=10`, `newBoost=1.0`이면 `5.0`
- `likeCount=0`, `commentCount=0`, `followerCount=10`, `newBoost=1.0`이면 `1.0` - `likeCount=40`, `commentCount=20`, `newBoost=1.0`이면 `30.0`
- `likeCount=40`, `commentCount=20`, `followerCount=100`, `newBoost=1.0`이면 `38.0` - `likeCount=40`, `commentCount=20`, `newBoost=1.15`이면 `34.5`
- `likeCount=40`, `commentCount=20`, `followerCount=100`, `newBoost=1.15`이면 `43.7`
- 기대 결과: 인기 커뮤니티 점수 산식 근거가 Kotlin 단위 테스트로 고정된다. - 기대 결과: 인기 커뮤니티 점수 산식 근거가 Kotlin 단위 테스트로 고정된다.
- [x] **Task 2.2: 인기 커뮤니티 신규 부스트 경계값 테스트 추가** - [x] **Task 2.2: 인기 커뮤니티 신규 부스트 경계값 테스트 추가**
@@ -115,15 +114,15 @@
- REFACTOR: `HomeRecommendationQueryPort.findPopularCommunitySnapshots(...)` 시그니처는 `windowEndExclusiveUtc` 의미가 드러나도록 정리한다. - REFACTOR: `HomeRecommendationQueryPort.findPopularCommunitySnapshots(...)` 시그니처는 `windowEndExclusiveUtc` 의미가 드러나도록 정리한다.
- 기대 결과: 스케줄러와 fallback이 같은 `POPULAR_COMMUNITY` 최근 7일 refresh 경로를 호출할 수 있다. - 기대 결과: 스케줄러와 fallback이 같은 `POPULAR_COMMUNITY` 최근 7일 refresh 경로를 호출할 수 있다.
- [x] **Task 3.2: 좋아요/댓글/팔로워 집계 기준 변경** - [x] **Task 3.2: 좋아요/댓글 집계 기준 변경**
- 파일 경로: - 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.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/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- RED: 활성 좋아요/댓글만 최근 7일 half-open window 안에서 distinct id로 집계하고, `creatorFollowerCount`는 기간 조건 없이 스냅샷 생성 시점의 활성 팔로워 총수로 집계하는 실패 테스트를 작성한다. - RED: 활성 좋아요/댓글만 최근 7일 half-open window 안에서 distinct id로 집계하고, 팔로워 수는 스냅샷 후보와 점수에 반영하지 않는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: `like_stats`, `comment_stats`, `follower_stats` native query를 PRD 기준으로 수정한다. 좋아요/댓글은 `created_at >= :windowStartUtc and created_at < :windowEndExclusiveUtc`, 팔로워는 `is_active = true` 총수로 계산한다. - GREEN: `like_stats`, `comment_stats` native query를 PRD 기준으로 수정한다. 좋아요/댓글은 `created_at >= :windowStartUtc and created_at < :windowEndExclusiveUtc`로 계산하고, follower CTE와 점수 항은 두지 않는다.
- REFACTOR: 댓글 불가 게시글은 기존처럼 `commentCount = 0`으로 계산하는 조건을 유지한다. - REFACTOR: 댓글 불가 게시글은 기존처럼 `commentCount = 0`으로 계산하는 조건을 유지한다.
- 기대 결과: metric의 기간/활성 조건이 서로 다른 의도대로 고정된다. - 기대 결과: 좋아요/댓글 metric의 기간/활성 조건이 의도대로 고정되고 follower-only 게시글은 제외된다.
- [x] **Task 3.3: 공지/고정 및 유료 게시글 제외 조건 고정** - [x] **Task 3.3: 공지/고정 및 유료 게시글 제외 조건 고정**
- 파일 경로: - 파일 경로:
@@ -149,7 +148,7 @@
- 파일 경로: - 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.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/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- RED: 좋아요/댓글/팔로워 수가 모두 0인 게시글 제외, 데뷔일이 없는 크리에이터 제외, `created_at >= windowEndExclusiveUtc` 게시글 제외, 점수 내림차순/`randomTieBreaker` 오름차순/limit 20 동작을 검증하는 실패 테스트를 작성한다. - RED: 좋아요와 산식 반영 가능 댓글 수가 모두 0인 게시글 제외, 데뷔일이 없는 크리에이터 제외, `created_at >= windowEndExclusiveUtc` 게시글 제외, 점수 내림차순/`randomTieBreaker` 오름차순/limit 20 동작을 검증하는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: 기존 후보 제외 조건과 정렬/limit을 PRD 기준에 맞게 유지 또는 보정한다. - GREEN: 기존 후보 제외 조건과 정렬/limit을 PRD 기준에 맞게 유지 또는 보정한다.
- REFACTOR: AI 캐릭터와 응원 크리에이터 스냅샷 query가 영향받지 않았는지 focused test로 확인한다. - REFACTOR: AI 캐릭터와 응원 크리에이터 스냅샷 query가 영향받지 않았는지 focused test로 확인한다.
@@ -309,7 +308,7 @@
- 2026-07-10: 리뷰 게이트 후 차단 이슈 수정 및 재검증 통과. - 2026-07-10: 리뷰 게이트 후 차단 이슈 수정 및 재검증 통과.
- 리뷰 지적: `POPULAR_COMMUNITY` 0점 후보 제외 누락, 최근 데뷔 크리에이터 query에 커뮤니티 전용 부스트 상수 침투. - 리뷰 지적: `POPULAR_COMMUNITY` 0점 후보 제외 누락, 최근 데뷔 크리에이터 query에 커뮤니티 전용 부스트 상수 침투.
- 수정: 인기 커뮤니티 후보에 좋아요/댓글/팔로워 중 하나 이상 존재 조건 추가, 최근 데뷔 크리에이터는 기존 `NEW_BOOST_*` 상수로 복원. - 수정: 인기 커뮤니티 후보에 좋아요/댓글 중 하나 이상 존재 조건 추가, 최근 데뷔 크리에이터는 기존 `NEW_BOOST_*` 상수로 복원.
- 회귀 테스트 추가: 0점 인기 커뮤니티 후보 제외, 최근 데뷔 크리에이터 기존 신규 부스트 유지. - 회귀 테스트 추가: 0점 인기 커뮤니티 후보 제외, 최근 데뷔 크리에이터 기존 신규 부스트 유지.
- `./gradlew ktlintCheck` 성공. - `./gradlew ktlintCheck` 성공.
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` 성공. - `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` 성공.

View File

@@ -16,7 +16,7 @@
## 3. Goals ## 3. Goals
- `POPULAR_COMMUNITY` 스냅샷은 최근 7일 데이터를 기반으로 생성한다. - `POPULAR_COMMUNITY` 스냅샷은 최근 7일 데이터를 기반으로 생성한다.
- 인기 커뮤니티 점수 산식을 `((좋아요 수 * 0.50) + (댓글 수 * 0.40) + (크리에이터 팔로우 수 * 0.10)) * 신규 부스트`로 변경한다. - 인기 커뮤니티 점수 산식을 `((좋아요 수 * 0.50) + (댓글 수 * 0.50)) * 신규 부스트`로 변경한다.
- 신규 부스트는 크리에이터 데뷔일 기준 10일 이내 1.15, 20일 이내 1.10, 30일 이내 1.05, 그 외 1.0을 적용한다. - 신규 부스트는 크리에이터 데뷔일 기준 10일 이내 1.15, 20일 이내 1.10, 30일 이내 1.05, 그 외 1.0을 적용한다.
- 공지/고정 게시글과 유료 게시글은 스냅샷 후보와 조회 결과에서 제외한다. - 공지/고정 게시글과 유료 게시글은 스냅샷 후보와 조회 결과에서 제외한다.
- 스케줄러 refresh와 fallback refresh는 동일한 `POPULAR_COMMUNITY` refresh 로직을 재사용한다. - 스케줄러 refresh와 fallback refresh는 동일한 `POPULAR_COMMUNITY` refresh 로직을 재사용한다.
@@ -58,23 +58,20 @@
#### Requirements #### Requirements
- `POPULAR_COMMUNITY` 점수는 아래 산식으로 계산한다. - `POPULAR_COMMUNITY` 점수는 아래 산식으로 계산한다.
- `score = ((likeCount * 0.50) + (commentCount * 0.40) + (creatorFollowerCount * 0.10)) * newBoost` - `score = ((likeCount * 0.50) + (commentCount * 0.50)) * newBoost`
- `likeCount`는 집계 기간 안에 생성된 활성 좋아요 수다. - `likeCount`는 집계 기간 안에 생성된 활성 좋아요 수다.
- 좋아요 수는 `creator_community_like.is_active = true`인 row를 기준으로 `distinct id` 집계한다. - 좋아요 수는 `creator_community_like.is_active = true`인 row를 기준으로 `distinct id` 집계한다.
- `commentCount`는 집계 기간 안에 생성된 활성 댓글 수다. - `commentCount`는 집계 기간 안에 생성된 활성 댓글 수다.
- 댓글 수는 `creator_community_comment.is_active = true`인 row를 기준으로 `distinct id` 집계한다. - 댓글 수는 `creator_community_comment.is_active = true`인 row를 기준으로 `distinct id` 집계한다.
- 댓글 불가 게시글은 댓글 row가 있어도 산식의 `commentCount`를 0으로 계산한다. - 댓글 불가 게시글은 댓글 row가 있어도 산식의 `commentCount`를 0으로 계산한다.
- `creatorFollowerCount`는 스냅샷 생성 시점 기준 크리에이터의 활성 팔로워 총수다.
- `creatorFollowerCount`는 최근 7일 신규 팔로우 수가 아니며, 스냅샷 생성 시점까지 활성 상태인 전체 팔로워 수를 의미한다.
- 팔로워 수는 `creator_following.creator_id = creator_community.member_id`이고 `is_active = true`인 row를 기준으로 `distinct id` 집계한다.
- 점수 산식 상수는 `RecommendationScoreSpec` 등 기존 점수 정책 위치에 모아 DB expression과 Kotlin 정책 테스트가 같은 값을 참조하도록 한다. - 점수 산식 상수는 `RecommendationScoreSpec` 등 기존 점수 정책 위치에 모아 DB expression과 Kotlin 정책 테스트가 같은 값을 참조하도록 한다.
- 스냅샷 정렬은 점수 내림차순, 동점이면 스냅샷 생성 시 저장한 `randomTieBreaker` 오름차순을 유지한다. - 스냅샷 정렬은 점수 내림차순, 동점이면 스냅샷 생성 시 저장한 `randomTieBreaker` 오름차순을 유지한다.
- 최종 저장 수는 기존 홈 노출 안정성을 위해 `POPULAR_COMMUNITY` 최대 20개를 유지한다. - 최종 저장 수는 기존 홈 노출 안정성을 위해 `POPULAR_COMMUNITY` 최대 20개를 유지한다.
#### Edge Cases #### Edge Cases
- `likeCount`, `commentCount`, `creatorFollowerCount`가 모두 0인 게시글은 스냅샷 후보에서 제외한다. - `likeCount`, 산식에 반영 가능한 `commentCount`가 모두 0인 게시글은 스냅샷 후보에서 제외한다.
- 좋아요는 없지만 댓글 또는 팔로워 수가 있으면 산식에 따라 점수를 계산한다. - 좋아요는 없지만 산식에 반영 가능한 댓글 수가 있으면 산식에 따라 점수를 계산한다.
- 댓글은 없지만 좋아요 또는 팔로워 수가 있으면 산식에 따라 점수를 계산한다. - 댓글은 없지만 좋아요 수가 있으면 산식에 따라 점수를 계산한다.
- 비활성 좋아요, 비활성 댓글, 비활성 게시글, 비활성 크리에이터는 집계에서 제외한다. - 비활성 좋아요, 비활성 댓글, 비활성 게시글, 비활성 크리에이터는 집계에서 제외한다.
- 스냅샷에는 존재하지만 조회 시점에 게시글 또는 크리에이터가 비활성화된 경우 응답에서 제외한다. - 스냅샷에는 존재하지만 조회 시점에 게시글 또는 크리에이터가 비활성화된 경우 응답에서 제외한다.
@@ -89,7 +86,7 @@
- 기존 코드에 남아 있는 `created_at <= :snapshotAt` 방식은 이번 범위에서 half-open end-exclusive 조건으로 맞춘다. - 기존 코드에 남아 있는 `created_at <= :snapshotAt` 방식은 이번 범위에서 half-open end-exclusive 조건으로 맞춘다.
#### Edge Cases #### Edge Cases
- 집계 기간에 좋아요/댓글 데이터가 없어도 팔로워 수만으로 추천 후보가 될 수 다. - 집계 기간에 좋아요와 산식에 반영 가능한 댓글 데이터가 모두 없으면 추천 후보가 될 수 다.
- 집계 기간과 무관하게 게시글 자체는 스냅샷 대상 시점 이전에 생성된 활성 무료/비공지 게시글이어야 한다. - 집계 기간과 무관하게 게시글 자체는 스냅샷 대상 시점 이전에 생성된 활성 무료/비공지 게시글이어야 한다.
- 같은 `sectionType`, `snapshotAt`에 대해 재실행하면 기존 스냅샷을 대체하는 정책을 유지한다. - 같은 `sectionType`, `snapshotAt`에 대해 재실행하면 기존 스냅샷을 대체하는 정책을 유지한다.
@@ -222,7 +219,7 @@
- `POPULAR_COMMUNITY` 스냅샷 최종 저장 수 - `POPULAR_COMMUNITY` 스냅샷 최종 저장 수
- fallback refresh 실행/성공/실패/timeout 로그 - fallback refresh 실행/성공/실패/timeout 로그
- fallback refresh lock 획득 성공/실패 로그 - fallback refresh lock 획득 성공/실패 로그
- `likeCount`, `commentCount`, `creatorFollowerCount` 입력값 분포 - `likeCount`, `commentCount` 입력값 분포
- empty snapshot marker 저장 횟수 - empty snapshot marker 저장 횟수
- 홈 API `popularCommunityPosts` 빈 응답 비율 - 홈 API `popularCommunityPosts` 빈 응답 비율
- 홈 API fallback refresh 대기 시간 - 홈 API fallback refresh 대기 시간
@@ -237,7 +234,6 @@
## 11. Decisions ## 11. Decisions
- 요구사항의 `is_pin`은 현재 코드의 `CreatorCommunity.isFixed` 및 DB 컬럼 `is_fixed`로 매핑한다. - 요구사항의 `is_pin`은 현재 코드의 `CreatorCommunity.isFixed` 및 DB 컬럼 `is_fixed`로 매핑한다.
- 유료 게시글 제외는 `price > 0` 제외로 정의한다. - 유료 게시글 제외는 `price > 0` 제외로 정의한다.
- `creatorFollowerCount`는 스냅샷 생성 시점의 활성 팔로워 총수로 확정한다.
- 일 단위 최신성을 강제하며, fallback 조건은 최신 스냅샷 부재가 아니라 대상일 `snapshotAt` 스냅샷 부재로 확정한다. - 일 단위 최신성을 강제하며, fallback 조건은 최신 스냅샷 부재가 아니라 대상일 `snapshotAt` 스냅샷 부재로 확정한다.
- fallback 방식은 사용자 제안의 동일 refresh 로직 재사용, lock, double-check 원칙을 채택한다. - fallback 방식은 사용자 제안의 동일 refresh 로직 재사용, lock, double-check 원칙을 채택한다.
- 더 나은 구현 방향은 기존 `RecommendationSnapshotFallbackService``POPULAR_COMMUNITY`를 추가해 AI 캐릭터/응원 크리에이터와 같은 lock, timeout, single-flight 흐름을 공유하는 것이다. - 더 나은 구현 방향은 기존 `RecommendationSnapshotFallbackService``POPULAR_COMMUNITY`를 추가해 AI 캐릭터/응원 크리에이터와 같은 lock, timeout, single-flight 흐름을 공유하는 것이다.

View File

@@ -601,7 +601,7 @@ class DefaultHomeRecommendationQueryRepository(
join use_can uc on uc.id = ucc.use_can_id join use_can uc on uc.id = ucc.use_can_id
where ucc.status = 'RECEIVED' where ucc.status = 'RECEIVED'
and uc.is_refund = false and uc.is_refund = false
and uc.can_usage = 'CHANNEL_DONATION' and uc.can_usage in ('CHANNEL_DONATION', 'DONATION')
and ucc.created_at >= :windowStart and ucc.created_at >= :windowStart
and ucc.created_at < :windowEndExclusive and ucc.created_at < :windowEndExclusive
group by ucc.recipient_creator_id group by ucc.recipient_creator_id
@@ -686,17 +686,10 @@ class DefaultHomeRecommendationQueryRepository(
and ccc.created_at >= :windowStart and ccc.created_at >= :windowStart
and ccc.created_at < :windowEndExclusive and ccc.created_at < :windowEndExclusive
group by ccc.creator_community_id group by ccc.creator_community_id
),
follower_stats as (
select cf.creator_id as creator_id, count(distinct cf.id) as follower_count
from creator_following cf
where cf.is_active = true
group by cf.creator_id
) )
select cc.id as target_id, select cc.id as target_id,
((coalesce(ls.like_count, 0) * ${RecommendationScoreSpec.COMMUNITY_LIKE_WEIGHT} + ((coalesce(ls.like_count, 0) * ${RecommendationScoreSpec.COMMUNITY_LIKE_WEIGHT} +
(case when cc.is_comment_available = true then coalesce(cs.comment_count, 0) else 0 end) * ${RecommendationScoreSpec.COMMUNITY_COMMENT_WEIGHT} + (case when cc.is_comment_available = true then coalesce(cs.comment_count, 0) else 0 end) * ${RecommendationScoreSpec.COMMUNITY_COMMENT_WEIGHT}) *
coalesce(fs.follower_count, 0) * ${RecommendationScoreSpec.COMMUNITY_FOLLOWER_WEIGHT}) *
case case
when cd.debut_at >= :boost10Start then ${RecommendationScoreSpec.COMMUNITY_NEW_BOOST_10_DAYS} when cd.debut_at >= :boost10Start then ${RecommendationScoreSpec.COMMUNITY_NEW_BOOST_10_DAYS}
when cd.debut_at >= :boost20Start then ${RecommendationScoreSpec.COMMUNITY_NEW_BOOST_20_DAYS} when cd.debut_at >= :boost20Start then ${RecommendationScoreSpec.COMMUNITY_NEW_BOOST_20_DAYS}
@@ -709,7 +702,6 @@ class DefaultHomeRecommendationQueryRepository(
join creator_debut cd on cd.creator_id = cc.member_id join creator_debut cd on cd.creator_id = cc.member_id
left join like_stats ls on ls.community_id = cc.id left join like_stats ls on ls.community_id = cc.id
left join comment_stats cs on cs.community_id = cc.id left join comment_stats cs on cs.community_id = cc.id
left join follower_stats fs on fs.creator_id = cc.member_id
where cc.is_active = true where cc.is_active = true
and m.is_active = true and m.is_active = true
and cc.price <= 0 and cc.price <= 0
@@ -719,7 +711,6 @@ class DefaultHomeRecommendationQueryRepository(
and ( and (
coalesce(ls.like_count, 0) > 0 coalesce(ls.like_count, 0) > 0
or (cc.is_comment_available = true and coalesce(cs.comment_count, 0) > 0) or (cc.is_comment_available = true and coalesce(cs.comment_count, 0) > 0)
or coalesce(fs.follower_count, 0) > 0
) )
order by score desc, random_tie_breaker asc order by score desc, random_tie_breaker asc
limit :limit limit :limit

View File

@@ -91,7 +91,7 @@ open class RecommendationSnapshotRefreshService(
@Transactional @Transactional
open fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)): Int { open fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)): Int {
val startedAt = System.currentTimeMillis() val startedAt = System.currentTimeMillis()
val window = windowPolicy.previousKstDayUtcWindow(nowUtc) val window = windowPolicy.previousKstSevenDayUtcWindow(nowUtc)
val snapshots = queryPort.findCheerCreatorSnapshots( val snapshots = queryPort.findCheerCreatorSnapshots(
window.startUtc, window.startUtc,
window.endExclusiveUtc, window.endExclusiveUtc,

View File

@@ -80,13 +80,11 @@ class RecommendationScorePolicy {
fun calculateCommunityScore( fun calculateCommunityScore(
likeCount: Long, likeCount: Long,
commentCount: Long, commentCount: Long,
followerCount: Long,
newBoost: Double newBoost: Double
): Double { ): Double {
return ( return (
(RecommendationScoreSpec.COMMUNITY_LIKE_WEIGHT * likeCount) + (RecommendationScoreSpec.COMMUNITY_LIKE_WEIGHT * likeCount) +
(RecommendationScoreSpec.COMMUNITY_COMMENT_WEIGHT * commentCount) + (RecommendationScoreSpec.COMMUNITY_COMMENT_WEIGHT * commentCount)
(RecommendationScoreSpec.COMMUNITY_FOLLOWER_WEIGHT * followerCount)
) * newBoost ) * newBoost
} }

View File

@@ -18,8 +18,7 @@ object RecommendationScoreSpec {
const val CHEER_DONATION_COUNT_WEIGHT = 0.1 const val CHEER_DONATION_COUNT_WEIGHT = 0.1
const val COMMUNITY_LIKE_WEIGHT = 0.5 const val COMMUNITY_LIKE_WEIGHT = 0.5
const val COMMUNITY_COMMENT_WEIGHT = 0.4 const val COMMUNITY_COMMENT_WEIGHT = 0.5
const val COMMUNITY_FOLLOWER_WEIGHT = 0.1
const val NEW_BOOST_10_DAYS = 1.5 const val NEW_BOOST_10_DAYS = 1.5
const val NEW_BOOST_20_DAYS = 1.3 const val NEW_BOOST_20_DAYS = 1.3

View File

@@ -773,8 +773,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
} }
@Test @Test
@DisplayName("최근 응원 스냅샷은 전날 half-open 범위의 CHANNEL_DONATION 금액 distinct 후원 수만 집계한다") @DisplayName("최근 응원 스냅샷은 half-open 범위의 CHANNEL_DONATION과 DONATION 금액 distinct 후원 수만 집계한다")
fun shouldFindCheerCreatorSnapshotsWithChannelDonationOnly() { fun shouldFindCheerCreatorSnapshotsWithChannelAndGeneralDonation() {
val windowStart = LocalDateTime.of(2026, 5, 28, 15, 0) val windowStart = LocalDateTime.of(2026, 5, 28, 15, 0)
val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0) val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0)
val snapshotAt = windowEndExclusive.minusSeconds(1) val snapshotAt = windowEndExclusive.minusSeconds(1)
@@ -877,9 +877,9 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val snapshots = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 10) val snapshots = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 10)
val expectedScore = scorePolicy.calculateCheerScore( val expectedScore = scorePolicy.calculateCheerScore(
donationAmount = 150, donationAmount = 1_150,
fanTalkCount = 1, fanTalkCount = 1,
donationCount = 1, donationCount = 2,
newBoost = scorePolicy.calculateCheerCreatorNewBoost(LocalDateTime.of(2026, 5, 10, 12, 0), snapshotAt) newBoost = scorePolicy.calculateCheerCreatorNewBoost(LocalDateTime.of(2026, 5, 10, 12, 0), snapshotAt)
) )
assertEquals(1, snapshots.size) assertEquals(1, snapshots.size)
@@ -936,7 +936,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
} }
@Test @Test
@DisplayName("인기 커뮤니티 스냅샷은 좋아요 댓글 팔로워 수를 distinct로 집계하고 댓글 불가 게시글은 댓글 수 0으로 계산한다") @DisplayName("인기 커뮤니티 스냅샷은 좋아요 댓글 수를 distinct로 집계하고 follower-only 게시글은 제외한다")
fun shouldFindPopularCommunitySnapshotsWithDistinctCounts() { fun shouldFindPopularCommunitySnapshotsWithDistinctCounts() {
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
@@ -977,17 +977,10 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val expectedPostScore = scorePolicy.calculateCommunityScore( val expectedPostScore = scorePolicy.calculateCommunityScore(
likeCount = 2, likeCount = 2,
commentCount = 2, commentCount = 2,
followerCount = 2,
newBoost = scorePolicy.calculateCommunityCreatorNewBoost(LocalDateTime.of(2026, 4, 1, 12, 0), snapshotAt)
)
val expectedCommentDisabledScore = scorePolicy.calculateCommunityScore(
likeCount = 0,
commentCount = 0,
followerCount = 2,
newBoost = scorePolicy.calculateCommunityCreatorNewBoost(LocalDateTime.of(2026, 4, 1, 12, 0), snapshotAt) newBoost = scorePolicy.calculateCommunityCreatorNewBoost(LocalDateTime.of(2026, 4, 1, 12, 0), snapshotAt)
) )
assertEquals(expectedPostScore, snapshots[post.id]!!.score, 0.0001) assertEquals(expectedPostScore, snapshots[post.id]!!.score, 0.0001)
assertEquals(expectedCommentDisabledScore, snapshots[commentDisabledPost.id]!!.score, 0.0001) assertEquals(false, snapshots.containsKey(commentDisabledPost.id))
} }
@Test @Test
@@ -1040,7 +1033,6 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val expectedScore = scorePolicy.calculateCommunityScore( val expectedScore = scorePolicy.calculateCommunityScore(
likeCount = 1, likeCount = 1,
commentCount = 0, commentCount = 0,
followerCount = 0,
newBoost = scorePolicy.calculateCommunityCreatorNewBoost(LocalDateTime.of(2026, 5, 20, 0, 0), snapshotAt) newBoost = scorePolicy.calculateCommunityCreatorNewBoost(LocalDateTime.of(2026, 5, 20, 0, 0), snapshotAt)
) )
assertEquals(1, snapshots.size) assertEquals(1, snapshots.size)
@@ -1050,17 +1042,19 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
} }
@Test @Test
@DisplayName("인기 커뮤니티 스냅샷은 좋아요 댓글 팔로워가 모두 0인 게시글을 제외한다") @DisplayName("인기 커뮤니티 스냅샷은 좋아요 댓글 모두 0인 게시글을 제외한다")
fun shouldExcludeZeroMetricPopularCommunitySnapshots() { fun shouldExcludeZeroMetricPopularCommunitySnapshots() {
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
val windowEndExclusive = snapshotAt.plusSeconds(1) val windowEndExclusive = snapshotAt.plusSeconds(1)
val creator = saveMember("zero-community-creator", MemberRole.CREATOR) val creator = saveMember("zero-community-creator", MemberRole.CREATOR)
val liker = saveMember("zero-community-liker", MemberRole.USER) val liker = saveMember("zero-community-liker", MemberRole.USER)
val follower = saveMember("zero-community-follower", MemberRole.USER)
saveLiveRoom(creator, LocalDateTime.of(2026, 5, 10, 12, 0), channelName = "zero-community-live") saveLiveRoom(creator, LocalDateTime.of(2026, 5, 10, 12, 0), channelName = "zero-community-live")
val zeroPost = saveCommunity(creator, isCommentAvailable = true) val zeroPost = saveCommunity(creator, isCommentAvailable = true)
val scoredPost = saveCommunity(creator, isCommentAvailable = true) val scoredPost = saveCommunity(creator, isCommentAvailable = true)
val like = saveCommunityLike(liker, scoredPost, isActive = true) val like = saveCommunityLike(liker, scoredPost, isActive = true)
saveFollowing(follower, creator, isActive = true)
updateCreatedAt("CreatorCommunity", zeroPost.id!!, windowStart.plusDays(1)) updateCreatedAt("CreatorCommunity", zeroPost.id!!, windowStart.plusDays(1))
updateCreatedAt("CreatorCommunity", scoredPost.id!!, windowStart.plusDays(1)) updateCreatedAt("CreatorCommunity", scoredPost.id!!, windowStart.plusDays(1))
updateCreatedAt("CreatorCommunityLike", like.id!!, windowStart.plusDays(1)) updateCreatedAt("CreatorCommunityLike", like.id!!, windowStart.plusDays(1))
@@ -1170,7 +1164,6 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val expectedCommunityScore = scorePolicy.calculateCommunityScore( val expectedCommunityScore = scorePolicy.calculateCommunityScore(
likeCount = 1, likeCount = 1,
commentCount = 0, commentCount = 0,
followerCount = 0,
newBoost = scorePolicy.calculateCommunityCreatorNewBoost(firstLiveAt, snapshotAt) newBoost = scorePolicy.calculateCommunityCreatorNewBoost(firstLiveAt, snapshotAt)
) )
assertEquals(expectedCheerScore, cheerSnapshot.score, 0.0001) assertEquals(expectedCheerScore, cheerSnapshot.score, 0.0001)
@@ -1217,7 +1210,6 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val expectedCommunityScore = scorePolicy.calculateCommunityScore( val expectedCommunityScore = scorePolicy.calculateCommunityScore(
likeCount = 1, likeCount = 1,
commentCount = 0, commentCount = 0,
followerCount = 0,
newBoost = scorePolicy.calculateCommunityCreatorNewBoost(contentDebutAt, snapshotAt) newBoost = scorePolicy.calculateCommunityCreatorNewBoost(contentDebutAt, snapshotAt)
) )
assertEquals(expectedCheerScore, cheerSnapshot.score, 0.0001) assertEquals(expectedCheerScore, cheerSnapshot.score, 0.0001)

View File

@@ -47,7 +47,7 @@ class RecommendationSnapshotRefreshServiceTest {
} }
@Test @Test
@DisplayName("일 스냅샷 갱신은 전날 23시 59분 59초 기준으로 DB 점수 계산 스냅샷을 저장한다") @DisplayName("일 스냅샷 갱신은 응원 크리에이터를 인기 커뮤니티와 같은 7일 window로 저장한다")
fun shouldRefreshDailySnapshotsWithPreviousDayEndSnapshotAt(output: CapturedOutput) { fun shouldRefreshDailySnapshotsWithPreviousDayEndSnapshotAt(output: CapturedOutput) {
val snapshotPort = FakeRecommendationSnapshotPort() val snapshotPort = FakeRecommendationSnapshotPort()
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
@@ -71,7 +71,7 @@ class RecommendationSnapshotRefreshServiceTest {
) )
) )
) )
Mockito.`when`(queryPort.findCheerCreatorSnapshots(aiWindowStart, aiWindowEndExclusive, 16)).thenReturn( Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, windowEndExclusive, 16)).thenReturn(
listOf( listOf(
RecommendationSnapshotRecord( RecommendationSnapshotRecord(
sectionType = RecommendedSectionType.CHEER_CREATOR, sectionType = RecommendedSectionType.CHEER_CREATOR,
@@ -112,7 +112,7 @@ class RecommendationSnapshotRefreshServiceTest {
assertEquals(40.0, communitySnapshots.single().score, 0.0001) assertEquals(40.0, communitySnapshots.single().score, 0.0001)
Mockito.verify(queryPort).findAiCharacterSnapshots(aiWindowStart, aiWindowEndExclusive, 20) Mockito.verify(queryPort).findAiCharacterSnapshots(aiWindowStart, aiWindowEndExclusive, 20)
Mockito.verify(queryPort).findCheerCreatorSnapshots(aiWindowStart, aiWindowEndExclusive, 16) Mockito.verify(queryPort).findCheerCreatorSnapshots(windowStart, windowEndExclusive, 16)
Mockito.verify(queryPort).findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20) Mockito.verify(queryPort).findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)
assertEquals(true, output.out.contains("event=recommendation_snapshot_refresh_success")) assertEquals(true, output.out.contains("event=recommendation_snapshot_refresh_success"))
assertEquals(true, output.out.contains("aiCharacterCount=1")) assertEquals(true, output.out.contains("aiCharacterCount=1"))
@@ -135,8 +135,8 @@ class RecommendationSnapshotRefreshServiceTest {
).thenReturn(emptyList()) ).thenReturn(emptyList())
Mockito.`when`( Mockito.`when`(
queryPort.findCheerCreatorSnapshots( queryPort.findCheerCreatorSnapshots(
LocalDateTime.of(2026, 5, 28, 15, 0, 0), windowStart,
LocalDateTime.of(2026, 5, 29, 15, 0, 0), windowEndExclusive,
16 16
) )
).thenReturn(emptyList()) ).thenReturn(emptyList())
@@ -179,8 +179,8 @@ class RecommendationSnapshotRefreshServiceTest {
) )
Mockito.`when`( Mockito.`when`(
queryPort.findCheerCreatorSnapshots( queryPort.findCheerCreatorSnapshots(
LocalDateTime.of(2026, 5, 28, 15, 0, 0), windowStart,
aiWindowEndExclusive, windowEndExclusive,
16 16
) )
).thenReturn( ).thenReturn(
@@ -229,8 +229,8 @@ class RecommendationSnapshotRefreshServiceTest {
).thenReturn(emptyList()) ).thenReturn(emptyList())
Mockito.`when`( Mockito.`when`(
queryPort.findCheerCreatorSnapshots( queryPort.findCheerCreatorSnapshots(
LocalDateTime.of(2026, 5, 28, 15, 0, 0), windowStart,
LocalDateTime.of(2026, 5, 29, 15, 0, 0), windowEndExclusive,
16 16
) )
).thenReturn(emptyList()) ).thenReturn(emptyList())
@@ -264,8 +264,6 @@ class RecommendationSnapshotRefreshServiceTest {
val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0) val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0)
val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0) val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59) val snapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59)
val cheerWindowStart = LocalDateTime.of(2026, 5, 28, 15, 0, 0)
val cheerWindowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(aiLock) Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(aiLock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(cheerLock) Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(cheerLock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.POPULAR_COMMUNITY_LOCK_KEY)) Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.POPULAR_COMMUNITY_LOCK_KEY))
@@ -276,7 +274,7 @@ class RecommendationSnapshotRefreshServiceTest {
Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(true) Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(popularLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true) Mockito.`when`(popularLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(popularLock.isHeldByCurrentThread).thenReturn(true) Mockito.`when`(popularLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(queryPort.findCheerCreatorSnapshots(cheerWindowStart, cheerWindowEndExclusive, 16)).thenReturn( Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, windowEndExclusive, 16)).thenReturn(
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 10L, score = 10.0, snapshotAt = snapshotAt)) listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 10L, score = 10.0, snapshotAt = snapshotAt))
) )
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(emptyList()) Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(emptyList())
@@ -285,7 +283,7 @@ class RecommendationSnapshotRefreshServiceTest {
Mockito.verify(redissonClient).getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY) Mockito.verify(redissonClient).getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)
Mockito.verify(cheerLock).tryLock(0, -1, TimeUnit.MILLISECONDS) Mockito.verify(cheerLock).tryLock(0, -1, TimeUnit.MILLISECONDS)
Mockito.verify(queryPort).findCheerCreatorSnapshots(cheerWindowStart, cheerWindowEndExclusive, 16) Mockito.verify(queryPort).findCheerCreatorSnapshots(windowStart, windowEndExclusive, 16)
assertEquals(listOf(10L), snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId }) assertEquals(listOf(10L), snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId })
Mockito.verify(cheerLock).unlock() Mockito.verify(cheerLock).unlock()
Mockito.verify(popularLock).unlock() Mockito.verify(popularLock).unlock()
@@ -322,8 +320,8 @@ class RecommendationSnapshotRefreshServiceTest {
service.refreshDailySnapshots(now) service.refreshDailySnapshots(now)
Mockito.verify(queryPort, Mockito.never()).findCheerCreatorSnapshots( Mockito.verify(queryPort, Mockito.never()).findCheerCreatorSnapshots(
LocalDateTime.of(2026, 5, 28, 15, 0, 0), windowStart,
LocalDateTime.of(2026, 5, 29, 15, 0, 0), windowEndExclusive,
16 16
) )
assertEquals( assertEquals(
@@ -361,8 +359,8 @@ class RecommendationSnapshotRefreshServiceTest {
Mockito.`when`(popularLock.isHeldByCurrentThread).thenReturn(true) Mockito.`when`(popularLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`( Mockito.`when`(
queryPort.findCheerCreatorSnapshots( queryPort.findCheerCreatorSnapshots(
LocalDateTime.of(2026, 5, 28, 15, 0, 0), windowStart,
LocalDateTime.of(2026, 5, 29, 15, 0, 0), windowEndExclusive,
16 16
) )
).thenReturn( ).thenReturn(

View File

@@ -112,18 +112,16 @@ class RecommendationScorePolicyTest {
} }
@Test @Test
@DisplayName("인기 커뮤니티 점수는 좋아요 수, 댓글 수, 팔로우 수에 가중치를 적용한다") @DisplayName("인기 커뮤니티 점수는 좋아요 수 댓글 수에 가중치를 적용한다")
fun shouldCalculateCommunityScore() { fun shouldCalculateCommunityScore() {
assertEquals(0.5, RecommendationScoreSpec.COMMUNITY_LIKE_WEIGHT, 0.0001) assertEquals(0.5, RecommendationScoreSpec.COMMUNITY_LIKE_WEIGHT, 0.0001)
assertEquals(0.4, RecommendationScoreSpec.COMMUNITY_COMMENT_WEIGHT, 0.0001) assertEquals(0.5, RecommendationScoreSpec.COMMUNITY_COMMENT_WEIGHT, 0.0001)
assertEquals(0.1, RecommendationScoreSpec.COMMUNITY_FOLLOWER_WEIGHT, 0.0001)
assertEquals(0.0, policy.calculateCommunityScore(0, 0, 0, 1.0), 0.0001) assertEquals(0.0, policy.calculateCommunityScore(0, 0, 1.0), 0.0001)
assertEquals(5.0, policy.calculateCommunityScore(10, 0, 0, 1.0), 0.0001) assertEquals(5.0, policy.calculateCommunityScore(10, 0, 1.0), 0.0001)
assertEquals(4.0, policy.calculateCommunityScore(0, 10, 0, 1.0), 0.0001) assertEquals(5.0, policy.calculateCommunityScore(0, 10, 1.0), 0.0001)
assertEquals(1.0, policy.calculateCommunityScore(0, 0, 10, 1.0), 0.0001) assertEquals(30.0, policy.calculateCommunityScore(40, 20, 1.0), 0.0001)
assertEquals(38.0, policy.calculateCommunityScore(40, 20, 100, 1.0), 0.0001) assertEquals(34.5, policy.calculateCommunityScore(40, 20, 1.15), 0.0001)
assertEquals(43.7, policy.calculateCommunityScore(40, 20, 100, 1.15), 0.0001)
} }
@Test @Test