test #436
@@ -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` 성공.
|
||||
|
||||
@@ -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 흐름을 공유하는 것이다.
|
||||
|
||||
Reference in New Issue
Block a user