diff --git a/docs/20260710_메인_홈_추천_인기커뮤니티_스냅샷/plan-task.md b/docs/20260710_메인_홈_추천_인기커뮤니티_스냅샷/plan-task.md index 79590413..c511bba1 100644 --- a/docs/20260710_메인_홈_추천_인기커뮤니티_스냅샷/plan-task.md +++ b/docs/20260710_메인_홈_추천_인기커뮤니티_스냅샷/plan-task.md @@ -2,7 +2,7 @@ ## 시나리오 계약 - 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`. - 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`. @@ -26,7 +26,7 @@ - 유지: 스냅샷 후보 저장 수 최대 20개, 홈 첫 화면 반환 수 최대 10개를 유지한다. - 유지: 상세 조회 시점의 활성 게시글/크리에이터 필터, 성인 필터, 차단 필터, 크리에이터 중복 제거 정책을 유지한다. - 유지: 댓글 불가 게시글은 댓글 수를 0으로 계산한다. -- 변경: 댓글 가중치를 `0.50`에서 `0.40`으로 바꾼다. +- 변경: 기존 팔로워 가중치를 제거하고 댓글 가중치에 합산한다. - 변경: 신규 부스트는 기존 크리에이터 공통 부스트 `1.5/1.3/1.2`가 아니라 `POPULAR_COMMUNITY` 전용 `1.15/1.10/1.05`를 사용한다. - 변경: 좋아요/댓글 집계 기간은 UTC half-open 최근 7일 window를 사용한다. - 변경: 게시글 자체 생성 시각 조건도 대상일 `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/RecommendationScorePolicy.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` - GREEN: 인기 커뮤니티 점수 상수를 PRD 값으로 변경하고 기존 `calculateCommunityScore(...)`가 같은 상수를 사용하게 한다. - REFACTOR: 기존 AI/최근 데뷔/응원 크리에이터 상수와 함수 값은 변경하지 않았는지 같은 테스트 안에서 회귀 assertion을 유지한다. - 계산식 테스트 케이스: - - `likeCount=0`, `commentCount=0`, `followerCount=0`, `newBoost=1.0`이면 `0.0` - - `likeCount=10`, `commentCount=0`, `followerCount=0`, `newBoost=1.0`이면 `5.0` - - `likeCount=0`, `commentCount=10`, `followerCount=0`, `newBoost=1.0`이면 `4.0` - - `likeCount=0`, `commentCount=0`, `followerCount=10`, `newBoost=1.0`이면 `1.0` - - `likeCount=40`, `commentCount=20`, `followerCount=100`, `newBoost=1.0`이면 `38.0` - - `likeCount=40`, `commentCount=20`, `followerCount=100`, `newBoost=1.15`이면 `43.7` + - `likeCount=0`, `commentCount=0`, `newBoost=1.0`이면 `0.0` + - `likeCount=10`, `commentCount=0`, `newBoost=1.0`이면 `5.0` + - `likeCount=0`, `commentCount=10`, `newBoost=1.0`이면 `5.0` + - `likeCount=40`, `commentCount=20`, `newBoost=1.0`이면 `30.0` + - `likeCount=40`, `commentCount=20`, `newBoost=1.15`이면 `34.5` - 기대 결과: 인기 커뮤니티 점수 산식 근거가 Kotlin 단위 테스트로 고정된다. - [x] **Task 2.2: 인기 커뮤니티 신규 부스트 경계값 테스트 추가** @@ -115,15 +114,15 @@ - REFACTOR: `HomeRecommendationQueryPort.findPopularCommunitySnapshots(...)` 시그니처는 `windowEndExclusiveUtc` 의미가 드러나도록 정리한다. - 기대 결과: 스케줄러와 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` - 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` - - 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`으로 계산하는 조건을 유지한다. - - 기대 결과: 세 metric의 기간/활성 조건이 서로 다른 의도대로 고정된다. + - 기대 결과: 좋아요/댓글 metric의 기간/활성 조건이 의도대로 고정되고 follower-only 게시글은 제외된다. - [x] **Task 3.3: 공지/고정 및 유료 게시글 제외 조건 고정** - 파일 경로: @@ -149,7 +148,7 @@ - 파일 경로: - 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: 좋아요/댓글/팔로워 수가 모두 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` - GREEN: 기존 후보 제외 조건과 정렬/limit을 PRD 기준에 맞게 유지 또는 보정한다. - REFACTOR: AI 캐릭터와 응원 크리에이터 스냅샷 query가 영향받지 않았는지 focused test로 확인한다. @@ -309,7 +308,7 @@ - 2026-07-10: 리뷰 게이트 후 차단 이슈 수정 및 재검증 통과. - 리뷰 지적: `POPULAR_COMMUNITY` 0점 후보 제외 누락, 최근 데뷔 크리에이터 query에 커뮤니티 전용 부스트 상수 침투. - - 수정: 인기 커뮤니티 후보에 좋아요/댓글/팔로워 중 하나 이상 존재 조건 추가, 최근 데뷔 크리에이터는 기존 `NEW_BOOST_*` 상수로 복원. + - 수정: 인기 커뮤니티 후보에 좋아요/댓글 중 하나 이상 존재 조건 추가, 최근 데뷔 크리에이터는 기존 `NEW_BOOST_*` 상수로 복원. - 회귀 테스트 추가: 0점 인기 커뮤니티 후보 제외, 최근 데뷔 크리에이터 기존 신규 부스트 유지. - `./gradlew ktlintCheck` 성공. - `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` 성공. diff --git a/docs/20260710_메인_홈_추천_인기커뮤니티_스냅샷/prd.md b/docs/20260710_메인_홈_추천_인기커뮤니티_스냅샷/prd.md index eab1d8da..c147c00b 100644 --- a/docs/20260710_메인_홈_추천_인기커뮤니티_스냅샷/prd.md +++ b/docs/20260710_메인_홈_추천_인기커뮤니티_스냅샷/prd.md @@ -16,7 +16,7 @@ ## 3. Goals - `POPULAR_COMMUNITY` 스냅샷은 최근 7일 데이터를 기반으로 생성한다. -- 인기 커뮤니티 점수 산식을 `((좋아요 수 * 0.50) + (댓글 수 * 0.40) + (크리에이터 팔로우 수 * 0.10)) * 신규 부스트`로 변경한다. +- 인기 커뮤니티 점수 산식을 `((좋아요 수 * 0.50) + (댓글 수 * 0.50)) * 신규 부스트`로 변경한다. - 신규 부스트는 크리에이터 데뷔일 기준 10일 이내 1.15, 20일 이내 1.10, 30일 이내 1.05, 그 외 1.0을 적용한다. - 공지/고정 게시글과 유료 게시글은 스냅샷 후보와 조회 결과에서 제외한다. - 스케줄러 refresh와 fallback refresh는 동일한 `POPULAR_COMMUNITY` refresh 로직을 재사용한다. @@ -58,23 +58,20 @@ #### Requirements - `POPULAR_COMMUNITY` 점수는 아래 산식으로 계산한다. - - `score = ((likeCount * 0.50) + (commentCount * 0.40) + (creatorFollowerCount * 0.10)) * newBoost` + - `score = ((likeCount * 0.50) + (commentCount * 0.50)) * newBoost` - `likeCount`는 집계 기간 안에 생성된 활성 좋아요 수다. - 좋아요 수는 `creator_community_like.is_active = true`인 row를 기준으로 `distinct id` 집계한다. - `commentCount`는 집계 기간 안에 생성된 활성 댓글 수다. - 댓글 수는 `creator_community_comment.is_active = true`인 row를 기준으로 `distinct id` 집계한다. - 댓글 불가 게시글은 댓글 row가 있어도 산식의 `commentCount`를 0으로 계산한다. -- `creatorFollowerCount`는 스냅샷 생성 시점 기준 크리에이터의 활성 팔로워 총수다. -- `creatorFollowerCount`는 최근 7일 신규 팔로우 수가 아니며, 스냅샷 생성 시점까지 활성 상태인 전체 팔로워 수를 의미한다. -- 팔로워 수는 `creator_following.creator_id = creator_community.member_id`이고 `is_active = true`인 row를 기준으로 `distinct id` 집계한다. - 점수 산식 상수는 `RecommendationScoreSpec` 등 기존 점수 정책 위치에 모아 DB expression과 Kotlin 정책 테스트가 같은 값을 참조하도록 한다. - 스냅샷 정렬은 점수 내림차순, 동점이면 스냅샷 생성 시 저장한 `randomTieBreaker` 오름차순을 유지한다. - 최종 저장 수는 기존 홈 노출 안정성을 위해 `POPULAR_COMMUNITY` 최대 20개를 유지한다. #### Edge Cases -- `likeCount`, `commentCount`, `creatorFollowerCount`가 모두 0인 게시글은 스냅샷 후보에서 제외한다. -- 좋아요는 없지만 댓글 또는 팔로워 수가 있으면 산식에 따라 점수를 계산한다. -- 댓글은 없지만 좋아요 또는 팔로워 수가 있으면 산식에 따라 점수를 계산한다. +- `likeCount`, 산식에 반영 가능한 `commentCount`가 모두 0인 게시글은 스냅샷 후보에서 제외한다. +- 좋아요는 없지만 산식에 반영 가능한 댓글 수가 있으면 산식에 따라 점수를 계산한다. +- 댓글은 없지만 좋아요 수가 있으면 산식에 따라 점수를 계산한다. - 비활성 좋아요, 비활성 댓글, 비활성 게시글, 비활성 크리에이터는 집계에서 제외한다. - 스냅샷에는 존재하지만 조회 시점에 게시글 또는 크리에이터가 비활성화된 경우 응답에서 제외한다. @@ -89,7 +86,7 @@ - 기존 코드에 남아 있는 `created_at <= :snapshotAt` 방식은 이번 범위에서 half-open end-exclusive 조건으로 맞춘다. #### Edge Cases -- 집계 기간에 좋아요/댓글 데이터가 없어도 팔로워 수만으로 추천 후보가 될 수 있다. +- 집계 기간에 좋아요와 산식에 반영 가능한 댓글 데이터가 모두 없으면 추천 후보가 될 수 없다. - 집계 기간과 무관하게 게시글 자체는 스냅샷 대상 시점 이전에 생성된 활성 무료/비공지 게시글이어야 한다. - 같은 `sectionType`, `snapshotAt`에 대해 재실행하면 기존 스냅샷을 대체하는 정책을 유지한다. @@ -222,7 +219,7 @@ - `POPULAR_COMMUNITY` 스냅샷 최종 저장 수 - fallback refresh 실행/성공/실패/timeout 로그 - fallback refresh lock 획득 성공/실패 로그 -- `likeCount`, `commentCount`, `creatorFollowerCount` 입력값 분포 +- `likeCount`, `commentCount` 입력값 분포 - empty snapshot marker 저장 횟수 - 홈 API `popularCommunityPosts` 빈 응답 비율 - 홈 API fallback refresh 대기 시간 @@ -237,7 +234,6 @@ ## 11. Decisions - 요구사항의 `is_pin`은 현재 코드의 `CreatorCommunity.isFixed` 및 DB 컬럼 `is_fixed`로 매핑한다. - 유료 게시글 제외는 `price > 0` 제외로 정의한다. -- `creatorFollowerCount`는 스냅샷 생성 시점의 활성 팔로워 총수로 확정한다. - 일 단위 최신성을 강제하며, fallback 조건은 최신 스냅샷 부재가 아니라 대상일 `snapshotAt` 스냅샷 부재로 확정한다. - fallback 방식은 사용자 제안의 동일 refresh 로직 재사용, lock, double-check 원칙을 채택한다. - 더 나은 구현 방향은 기존 `RecommendationSnapshotFallbackService`에 `POPULAR_COMMUNITY`를 추가해 AI 캐릭터/응원 크리에이터와 같은 lock, timeout, single-flight 흐름을 공유하는 것이다.