docs(home): 인기 커뮤니티 스냅샷 계획을 추가한다
This commit is contained in:
322
docs/20260710_메인_홈_추천_인기커뮤니티_스냅샷/plan-task.md
Normal file
322
docs/20260710_메인_홈_추천_인기커뮤니티_스냅샷/plan-task.md
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
# 메인 홈 추천 인기 커뮤니티 스냅샷 수정 Plan/Task
|
||||||
|
|
||||||
|
## 시나리오 계약
|
||||||
|
- 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`.
|
||||||
|
- 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`.
|
||||||
|
- Fallback: 대상일 `POPULAR_COMMUNITY` 스냅샷이 없으면 lock, double-check, 동일 refresh 로직 재사용, refresh 후 재조회 순서로 fallback을 실행한다. lock 대기는 최대 300ms, 홈 API refresh 완료 대기는 최대 1,500ms다. Real surface: fallback service test, `HomeRecommendationQueryServiceTest`.
|
||||||
|
- Empty marker: `POPULAR_COMMUNITY` refresh 결과가 0건이면 `targetId = 0` marker를 저장해 정상 refresh 완료 상태를 남기고, 조회 응답에서는 marker를 제외한다. Real surface: `RecommendationSnapshotPersistenceAdapterTest`, `HomeRecommendationQueryServiceTest`.
|
||||||
|
- Adjacent regression: 메인 홈 추천 API URL과 `popularCommunityPosts` 응답 필드는 변경하지 않는다. AI 캐릭터, 응원 크리에이터, 최근 데뷔 등 다른 섹션 산식과 공개 스키마는 이번 변경으로 바꾸지 않는다. Real surface: 기존 focused tests, `HomeRecommendationControllerTest`.
|
||||||
|
|
||||||
|
## 범위와 전제
|
||||||
|
- 이번 문서는 `docs/20260710_메인_홈_추천_인기커뮤니티_스냅샷/prd.md`의 구현 계획이다.
|
||||||
|
- 신규 공개 API, 신규 응답 필드, 운영 DDL 추가는 범위에 포함하지 않는다.
|
||||||
|
- 기존 `recommendation_snapshot` 테이블과 `RecommendedSectionType.POPULAR_COMMUNITY`를 재사용한다.
|
||||||
|
- `POPULAR_COMMUNITY` 집계는 현재 구조와 성능 특성을 유지해 DB-side exact scoring을 기본으로 한다. Kotlin 단에는 산식/부스트 근거 테스트용 정책 함수를 둔다.
|
||||||
|
- 최근 7일 기준은 KST 대상일 포함 7일 00:00:00 이상, 대상일 다음날 00:00:00 미만이며, DB 조회에는 UTC half-open window를 사용한다.
|
||||||
|
- 홈 조회는 대상일 `snapshotAt` 스냅샷을 우선 조회한다. 최신 스냅샷이 대상일보다 과거이면 fallback refresh 대상으로 본다.
|
||||||
|
- fallback orchestration은 기존 `RecommendationSnapshotFallbackService`에 `POPULAR_COMMUNITY` target을 추가하는 최소 변경을 우선한다.
|
||||||
|
- empty marker는 이번 범위에서 `POPULAR_COMMUNITY`에만 추가한다. 다른 섹션 공통화는 별도 후속 작업으로 둔다.
|
||||||
|
|
||||||
|
## 기존 POPULAR_COMMUNITY 로직 유지/변경 경계
|
||||||
|
- 유지: `RecommendedSectionType.POPULAR_COMMUNITY` enum 값과 code는 변경하지 않는다.
|
||||||
|
- 유지: 홈 인기 커뮤니티 응답 필드는 변경하지 않는다.
|
||||||
|
- 유지: 스냅샷 후보 저장 수 최대 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` 이전으로 맞춘다.
|
||||||
|
- 추가: `POPULAR_COMMUNITY` 대상일 스냅샷이 없을 때 fallback refresh를 실행한다.
|
||||||
|
- 추가: `POPULAR_COMMUNITY` refresh 결과 0건이면 empty snapshot marker를 저장한다.
|
||||||
|
|
||||||
|
## 실행 명령
|
||||||
|
- 문서 명령 확인: `./gradlew tasks --all`
|
||||||
|
- 산식/부스트/window 단위 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicyTest`
|
||||||
|
- 스냅샷 저장 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.RecommendationSnapshotFallbackServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
|
||||||
|
- 홈 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의 산식, 최근 7일 KST/UTC half-open 범위, 공지/유료 제외, 대상일 `snapshotAt` 조회, empty marker, fallback timeout/lock 정책을 task로 분해한다.
|
||||||
|
- REFACTOR: 기존 홈 추천 구현 파일과 테스트 파일 기준으로 task별 수정/검증 경로를 맞춘다.
|
||||||
|
- 기대 결과: 구현 시작 전에 PRD와 plan-task가 같은 디렉터리에 준비된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: 산식, 부스트, window 정책
|
||||||
|
|
||||||
|
- [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: `COMMUNITY_LIKE_WEIGHT = 0.50`, `COMMUNITY_COMMENT_WEIGHT = 0.40`, `COMMUNITY_FOLLOWER_WEIGHT = 0.10`을 기대하는 실패 테스트를 작성한다. `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`
|
||||||
|
- 기대 결과: 인기 커뮤니티 점수 산식 근거가 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: `calculateCommunityCreatorNewBoost(debutAt, now)` 또는 동등한 전용 함수를 추가하고 `COMMUNITY_NEW_BOOST_*` 상수를 사용한다.
|
||||||
|
- REFACTOR: 기존 `calculateCreatorNewBoost(...)`는 최근 데뷔용 기존 값 `1.5/1.3/1.2`를 유지한다. `CHEER_CREATOR` 전용 부스트와 같은 값이더라도 이름은 섹션별 의도를 드러낸다.
|
||||||
|
- 기대 결과: `POPULAR_COMMUNITY`만 낮아진 신규 부스트 값을 사용하고 다른 크리에이터 기반 섹션은 기존 부스트를 유지한다.
|
||||||
|
|
||||||
|
- [x] **Task 2.3: 최근 7일 KST window 정책 추가**
|
||||||
|
- 파일 경로:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationSnapshotWindowPolicy.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationSnapshotWindowPolicyTest.kt`
|
||||||
|
- RED: `nowUtc = 2026-07-10T03:00:00`이면 KST 기준 대상일이 `2026-07-09`이고, 최근 7일 window가 UTC `2026-07-02T15:00:00 <= t < 2026-07-09T15:00:00`, `snapshotAt = 2026-07-09T14:59:59`로 계산되는 실패 테스트를 작성한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicyTest`
|
||||||
|
- GREEN: `previousKstSevenDayUtcWindow(nowUtc)` 또는 의미가 명확한 함수를 추가한다.
|
||||||
|
- REFACTOR: 기존 `previousKstDayUtcWindow(...)`는 AI 캐릭터/응원 크리에이터 전날 하루 집계에서 계속 사용하므로 변경하지 않는다.
|
||||||
|
- 기대 결과: 인기 커뮤니티만 최근 7일 window를 사용하고, 대상일 `snapshotAt` 계산은 다른 섹션과 일관된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: DB 스냅샷 query와 상세 조회
|
||||||
|
|
||||||
|
- [x] **Task 3.1: `POPULAR_COMMUNITY` refresh 입력을 최근 7일 UTC half-open window로 변경**
|
||||||
|
- 파일 경로:
|
||||||
|
- 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: `refreshPopularCommunitySnapshots(nowUtc)`가 최근 7일 window의 `windowStartUtc`, `windowEndExclusiveUtc`, `snapshotAt`을 사용해 `queryPort.findPopularCommunitySnapshots(...)`와 `snapshotPort.replaceSnapshots(...)`를 호출하는 실패 테스트를 작성한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`
|
||||||
|
- GREEN: `POPULAR_COMMUNITY` 단일 refresh 경로를 분리하고, 기존 일괄 refresh에서 같은 경로를 호출하게 한다.
|
||||||
|
- REFACTOR: `HomeRecommendationQueryPort.findPopularCommunitySnapshots(...)` 시그니처는 `windowEndExclusiveUtc` 의미가 드러나도록 정리한다.
|
||||||
|
- 기대 결과: 스케줄러와 fallback이 같은 `POPULAR_COMMUNITY` 최근 7일 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: 활성 좋아요/댓글만 최근 7일 half-open window 안에서 distinct id로 집계하고, `creatorFollowerCount`는 기간 조건 없이 스냅샷 생성 시점의 활성 팔로워 총수로 집계하는 실패 테스트를 작성한다.
|
||||||
|
- 실패 확인: `./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` 총수로 계산한다.
|
||||||
|
- REFACTOR: 댓글 불가 게시글은 기존처럼 `commentCount = 0`으로 계산하는 조건을 유지한다.
|
||||||
|
- 기대 결과: 세 metric의 기간/활성 조건이 서로 다른 의도대로 고정된다.
|
||||||
|
|
||||||
|
- [x] **Task 3.3: 공지/고정 및 유료 게시글 제외 조건 고정**
|
||||||
|
- 파일 경로:
|
||||||
|
- 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: `is_fixed = true` 게시글, `price > 0` 게시글, 비활성 게시글, 비활성 크리에이터가 스냅샷 후보에서 제외되는 실패 테스트를 작성한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
|
||||||
|
- GREEN: 스냅샷 query의 후보 조건을 `cc.is_active = true`, `m.is_active = true`, `cc.is_fixed = false`, `cc.price <= 0` 기준으로 맞춘다.
|
||||||
|
- REFACTOR: 기존 `cc.price = 0` 조건이 도메인상 무료 판정과 다르면 `price <= 0`로 통일한다.
|
||||||
|
- 기대 결과: 공지/유료 게시글이 추천 스냅샷에 저장되지 않는다.
|
||||||
|
|
||||||
|
- [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.calculateCommunityScore(...)`와 동일하고, 데뷔일 기준 신규 부스트 `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: 좋아요/댓글/팔로워 수가 모두 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로 확인한다.
|
||||||
|
- 기대 결과: `POPULAR_COMMUNITY` 스냅샷 저장 후보만 정확히 변경된다.
|
||||||
|
|
||||||
|
- [x] **Task 3.6: 인기 커뮤니티 상세 조회에도 공지/유료 제외 적용**
|
||||||
|
- 파일 경로:
|
||||||
|
- 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: 스냅샷 저장 이후 게시글이 `isFixed = true` 또는 `price > 0`로 변경되면 `findPopularCommunityRecommendationDetails(...)` 결과에서 제외되는 실패 테스트를 작성한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
|
||||||
|
- GREEN: 상세 조회 where 조건에 `creatorCommunity.isFixed.isFalse`, 무료 조건을 명시한다.
|
||||||
|
- REFACTOR: 성인 필터, 차단 필터, 좋아요 여부, 구매 여부 계산은 기존 동작을 유지한다.
|
||||||
|
- 기대 결과: 스냅샷 생성 이후 상태가 바뀐 공지/유료 게시글도 홈 응답에 노출되지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: refresh 저장과 empty marker
|
||||||
|
|
||||||
|
- [x] **Task 4.1: `POPULAR_COMMUNITY` 빈 결과 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.POPULAR_COMMUNITY, snapshotAt, emptyList())` 호출 시 `targetId = 0` marker가 저장되고, `findSnapshots(POPULAR_COMMUNITY, snapshotAt)`는 빈 배열이며, `existsSnapshot(POPULAR_COMMUNITY, snapshotAt)`는 true인 실패 테스트를 작성한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest`
|
||||||
|
- GREEN: empty marker 적용 대상에 `POPULAR_COMMUNITY`를 추가한다.
|
||||||
|
- REFACTOR: marker 대상 조건은 `supportsEmptySnapshotMarker(...)` 같은 명시적 함수에 유지하고, 조회 쿼리의 `target_id <> 0` 조건은 변경하지 않는다.
|
||||||
|
- 기대 결과: 데이터가 없는 날에도 `POPULAR_COMMUNITY` 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: `AI_CHARACTER`, `CHEER_CREATOR` marker 동작 회귀 테스트도 함께 유지한다.
|
||||||
|
- 기대 결과: 빈 결과 refresh 후 재실행으로 실제 추천 row가 생겨도 marker가 응답/존재 상태를 오염시키지 않는다.
|
||||||
|
|
||||||
|
- [x] **Task 4.3: refresh service가 `POPULAR_COMMUNITY` 저장 수와 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: `refreshPopularCommunitySnapshots(nowUtc)` 성공 시 `event=popular_community_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를 사용한다.
|
||||||
|
- 기대 결과: 운영에서 `POPULAR_COMMUNITY` refresh 결과 0건과 실패를 구분할 수 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: 대상일 조회와 fallback refresh
|
||||||
|
|
||||||
|
- [x] **Task 5.1: 홈 인기 커뮤니티 조회를 대상일 `snapshotAt` 기준으로 변경**
|
||||||
|
- 파일 경로:
|
||||||
|
- 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: 과거 최신 `POPULAR_COMMUNITY` 스냅샷이 있어도 홈 API 호출 기준 대상일 `snapshotAt` 스냅샷이 없으면 과거 스냅샷을 반환하지 않고 fallback을 호출하는 실패 테스트를 작성한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
|
||||||
|
- GREEN: `RecommendationSnapshotWindowPolicy`로 대상일 `snapshotAt`을 계산하고 `snapshotPort.findSnapshots(POPULAR_COMMUNITY, snapshotAt, ...)`를 사용한다.
|
||||||
|
- REFACTOR: 스냅샷 후보 20개 조회, 상세 조회 후 최대 10개 반환, 크리에이터 중복 제거, 차단/성인 필터 전달은 기존 동작을 유지한다.
|
||||||
|
- 기대 결과: 인기 커뮤니티 홈 조회가 일 단위 최신성을 강제한다.
|
||||||
|
|
||||||
|
- [x] **Task 5.2: `POPULAR_COMMUNITY` 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: 대상일 `POPULAR_COMMUNITY` 스냅샷이 없을 때 lock key `lock:recommendation-snapshot-refresh:POPULAR_COMMUNITY`로 lock을 얻고, `refreshPopularCommunitySnapshots(nowUtc)`를 호출한 뒤 대상일 스냅샷을 재조회하는 실패 테스트를 작성한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest`
|
||||||
|
- GREEN: `POPULAR_COMMUNITY` fallback target을 등록하고 lock 대기 300ms, 홈 대기 1,500ms 상수를 PRD 값으로 유지한다.
|
||||||
|
- REFACTOR: `AI_CHARACTER`, `CHEER_CREATOR` fallback lock key와 상호 간섭하지 않도록 section별 key를 분리한다.
|
||||||
|
- 기대 결과: 스냅샷 없음 상황에서 홈 API가 스케줄러와 동일한 `POPULAR_COMMUNITY` refresh 로직을 재사용한다.
|
||||||
|
|
||||||
|
- [x] **Task 5.3: fallback double-check를 대상일 `snapshotAt` 기준으로 검증**
|
||||||
|
- 파일 경로:
|
||||||
|
- 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 획득 후 대상일 `snapshotAt` 스냅샷이 이미 존재하면 refresh를 실행하지 않고 해당 스냅샷을 반환하는 실패 테스트를 작성한다. 과거 최신 스냅샷만 있는 경우에는 refresh를 실행해야 한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest`
|
||||||
|
- GREEN: fallback service의 조회/존재 확인을 `findSnapshots(sectionType, snapshotAt, ...)`, `existsSnapshot(sectionType, snapshotAt)` 기준으로 수행한다.
|
||||||
|
- REFACTOR: AI 캐릭터/응원 크리에이터는 기존 요구사항과 충돌하지 않도록 필요한 경우 대상일 snapshot 조회 방식으로 함께 정리하되, 산식/window는 변경하지 않는다.
|
||||||
|
- 기대 결과: fallback double-check가 일 단위 최신성 요구와 일치한다.
|
||||||
|
|
||||||
|
- [x] **Task 5.4: 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 처리, existsSnapshot double-check 흐름을 구현한다.
|
||||||
|
- REFACTOR: fallback service는 점수 계산이나 상세 DTO 조립을 직접 하지 않고 snapshot 조회와 refresh orchestration만 담당한다.
|
||||||
|
- 기대 결과: 홈 조회 경로에서 refresh 중복, 장시간 대기, 전체 API 실패가 발생하지 않는다.
|
||||||
|
|
||||||
|
- [x] **Task 5.5: 스케줄러와 fallback refresh lock key 공유**
|
||||||
|
- 파일 경로:
|
||||||
|
- 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: 일괄 scheduler refresh가 `POPULAR_COMMUNITY` 갱신 시 `lock:recommendation-snapshot-refresh:POPULAR_COMMUNITY`를 사용하고, lock miss 시 중복 refresh를 실행하지 않는 실패 테스트를 작성한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`
|
||||||
|
- GREEN: 기존 AI 캐릭터/응원 크리에이터 section lock 패턴에 `POPULAR_COMMUNITY`를 추가한다.
|
||||||
|
- REFACTOR: 세 섹션 lock 처리 중복이 커지면 section type과 lock key를 받는 최소 helper로 정리한다.
|
||||||
|
- 기대 결과: 스케줄러와 홈 fallback이 같은 섹션 lock으로 중복 refresh를 막는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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: 홈 통합 조회의 `popularCommunityPosts` 응답이 기존 필드만 유지하고 신규 필드를 추가하지 않는 회귀 테스트를 확인/보강한다.
|
||||||
|
- 실패 확인: `./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 --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicyTest`
|
||||||
|
- `./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`
|
||||||
|
- 기대 결과: 산식/window/집계/marker/fallback/API 회귀가 최소 명령으로 검증된다.
|
||||||
|
|
||||||
|
- [x] **Task 6.3: 전체 회귀와 문서 검증**
|
||||||
|
- 파일 경로:
|
||||||
|
- Modify: `docs/20260710_메인_홈_추천_인기커뮤니티_스냅샷/plan-task.md`
|
||||||
|
- RED: 구현 완료 후 전체 회귀 명령 실행 전에는 검증 기록이 구현 전 상태여야 한다.
|
||||||
|
- 실패 확인: 해당 없음. TDD 예외 사유: 검증 기록 문서화 task다.
|
||||||
|
- GREEN: `./gradlew ktlintCheck`, `./gradlew test`, `./gradlew tasks --all`을 실행하고 결과를 문서 하단 검증 기록에 누적한다.
|
||||||
|
- REFACTOR: PRD와 plan-task가 구현 결과와 어긋나면 먼저 문서를 갱신하고 필요한 focused test를 재실행한다.
|
||||||
|
- 기대 결과: 포맷, 전체 테스트, 문서 명령 유효성을 모두 확인한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
- 2026-07-10: 구현 계획 문서 작성. 코드 변경은 아직 수행하지 않았다.
|
||||||
|
- 2026-07-10: focused regression 통과.
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicyTest` 성공.
|
||||||
|
- `./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` 성공.
|
||||||
|
- 2026-07-10: 전체 회귀와 문서 명령 검증 통과.
|
||||||
|
- `./gradlew ktlintCheck` 성공.
|
||||||
|
- `./gradlew test` 성공.
|
||||||
|
- `./gradlew tasks --all` 성공.
|
||||||
|
|
||||||
|
- 2026-07-10: 리뷰 게이트 후 차단 이슈 수정 및 재검증 통과.
|
||||||
|
- 리뷰 지적: `POPULAR_COMMUNITY` 0점 후보 제외 누락, 최근 데뷔 크리에이터 query에 커뮤니티 전용 부스트 상수 침투.
|
||||||
|
- 수정: 인기 커뮤니티 후보에 좋아요/댓글/팔로워 중 하나 이상 존재 조건 추가, 최근 데뷔 크리에이터는 기존 `NEW_BOOST_*` 상수로 복원.
|
||||||
|
- 회귀 테스트 추가: 0점 인기 커뮤니티 후보 제외, 최근 데뷔 크리에이터 기존 신규 부스트 유지.
|
||||||
|
- `./gradlew ktlintCheck` 성공.
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` 성공.
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicyTest --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` 성공.
|
||||||
|
- `./gradlew cleanTest test` 성공.
|
||||||
|
|
||||||
|
- 2026-07-10: `POPULAR_COMMUNITY` snapshot 후보 paid/fixed 제외 직접 통합 테스트 보강.
|
||||||
|
- 추가: `shouldExcludePaidAndFixedPostsFromPopularCommunitySnapshots`에서 점수가 있는 유료/고정 게시글도 스냅샷 후보에서 제외됨을 검증.
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` 성공.
|
||||||
|
- `./gradlew ktlintCheck` 성공.
|
||||||
254
docs/20260710_메인_홈_추천_인기커뮤니티_스냅샷/prd.md
Normal file
254
docs/20260710_메인_홈_추천_인기커뮤니티_스냅샷/prd.md
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
# PRD: 메인 홈 추천 인기 커뮤니티 스냅샷 수정
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
메인 홈 추천 탭의 `POPULAR_COMMUNITY` 스냅샷 생성과 조회를 최근 7일 데이터 기반 인기 점수로 수정하고, 스냅샷이 없을 때 홈 API가 동일 refresh 로직을 안전하게 재사용하도록 fallback 흐름을 보강한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problem
|
||||||
|
- 기존 `POPULAR_COMMUNITY` 산식은 댓글 가중치와 신규 부스트 값이 이번 요구사항과 다르다.
|
||||||
|
- 기존 구현은 공지/고정 게시글과 유료 게시글 제외 조건을 일부 갖고 있으나, 요구사항의 제외 조건을 테스트로 명확히 고정해야 한다.
|
||||||
|
- 현재 `POPULAR_COMMUNITY` 조회는 대상일 `snapshotAt` 스냅샷이 없으면 별도 fallback 없이 빈 결과가 될 수 있다.
|
||||||
|
- 스케줄러 refresh와 홈 API fallback refresh가 같은 생성 로직을 공유하지 않으면 산식 drift가 발생할 수 있다.
|
||||||
|
- 집계 산식이 추천 노출 순서를 직접 바꾸므로 DB-side 계산과 Kotlin-side 계산 중 어떤 방식을 선택하더라도 산식/부스트 경계값 테스트가 필요하다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
- `POPULAR_COMMUNITY` 스냅샷은 최근 7일 데이터를 기반으로 생성한다.
|
||||||
|
- 인기 커뮤니티 점수 산식을 `((좋아요 수 * 0.50) + (댓글 수 * 0.40) + (크리에이터 팔로우 수 * 0.10)) * 신규 부스트`로 변경한다.
|
||||||
|
- 신규 부스트는 크리에이터 데뷔일 기준 10일 이내 1.15, 20일 이내 1.10, 30일 이내 1.05, 그 외 1.0을 적용한다.
|
||||||
|
- 공지/고정 게시글과 유료 게시글은 스냅샷 후보와 조회 결과에서 제외한다.
|
||||||
|
- 스케줄러 refresh와 fallback refresh는 동일한 `POPULAR_COMMUNITY` refresh 로직을 재사용한다.
|
||||||
|
- 대상일 `snapshotAt` 스냅샷이 없을 때 fallback refresh는 lock, double-check, single-flight 대기로 중복 refresh를 방지한다.
|
||||||
|
- 홈 API는 fallback refresh 완료를 최대 1,500ms까지만 기다리고, lock 대기는 최대 300ms로 제한한다.
|
||||||
|
- fallback refresh 실패, timeout, refresh 결과 없음은 홈 API 전체 실패로 전파하지 않고 `popularCommunityPosts` 빈 배열로 처리한다.
|
||||||
|
- 산식과 신규 부스트는 단위 테스트에서 경계값과 가중치 계산을 촘촘히 검증한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
- 메인 홈 추천 API URL, 응답 필드, 응답 JSON 스키마는 변경하지 않는다.
|
||||||
|
- `POPULAR_COMMUNITY` 이외 추천 섹션의 산식과 조회 정책은 변경하지 않는다.
|
||||||
|
- 커뮤니티 게시글 작성/수정/삭제, 좋아요, 댓글, 구매 동작 자체는 변경하지 않는다.
|
||||||
|
- 관리자 화면, 수동 추천 편집, A/B 테스트, 개인화 추천은 이번 범위에 포함하지 않는다.
|
||||||
|
- 신규 추천 스냅샷 테이블을 만들지 않고, 기존 `recommendation_snapshot` 구조를 우선 재사용한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Target Users
|
||||||
|
- 회원/비회원: 메인 홈 추천 탭에서 최근 반응이 좋은 무료 커뮤니티 게시글을 발견하는 사용자
|
||||||
|
- 앱 클라이언트: 기존 응답 계약을 유지한 채 `POPULAR_COMMUNITY` 추천 순서만 변경된 결과를 받는 클라이언트
|
||||||
|
- 운영자: 최근 7일 커뮤니티 반응이 추천 노출에 반영되는지 확인해야 하는 운영 담당자
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. User Stories
|
||||||
|
- 사용자는 메인 홈 추천 탭에서 최근 좋아요와 댓글 반응이 많은 커뮤니티 게시글을 우선 보고 싶다.
|
||||||
|
- 사용자는 유료 게시글이나 공지 게시글이 인기 추천 영역에 섞이지 않기를 기대한다.
|
||||||
|
- 사용자는 신규 크리에이터의 커뮤니티 게시글도 일정 기간 동안 적절한 노출 기회를 받기를 기대한다.
|
||||||
|
- 앱 클라이언트는 스냅샷이 없는 상황에서도 홈 API가 실패하지 않고 안정적으로 빈 배열 또는 생성된 스냅샷을 받기를 원한다.
|
||||||
|
- 운영자는 배치 실패 후 첫 홈 조회가 스케줄러와 동일한 로직으로 스냅샷을 복구하기를 원한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Core Features
|
||||||
|
|
||||||
|
### Feature A. 인기 커뮤니티 스냅샷 산식 변경
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- `POPULAR_COMMUNITY` 점수는 아래 산식으로 계산한다.
|
||||||
|
- `score = ((likeCount * 0.50) + (commentCount * 0.40) + (creatorFollowerCount * 0.10)) * 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인 게시글은 스냅샷 후보에서 제외한다.
|
||||||
|
- 좋아요는 없지만 댓글 또는 팔로워 수가 있으면 산식에 따라 점수를 계산한다.
|
||||||
|
- 댓글은 없지만 좋아요 또는 팔로워 수가 있으면 산식에 따라 점수를 계산한다.
|
||||||
|
- 비활성 좋아요, 비활성 댓글, 비활성 게시글, 비활성 크리에이터는 집계에서 제외한다.
|
||||||
|
- 스냅샷에는 존재하지만 조회 시점에 게시글 또는 크리에이터가 비활성화된 경우 응답에서 제외한다.
|
||||||
|
|
||||||
|
### Feature B. 최근 7일 데이터 기반 집계 기간
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 좋아요와 댓글 입력값은 최근 7일 데이터만 사용한다.
|
||||||
|
- 최근 7일 기준은 KST 기준 스냅샷 대상일을 포함한 7일의 00:00:00 이상, 대상일 다음날 00:00:00 미만의 half-open 범위로 정의한다.
|
||||||
|
- 예를 들어 스케줄러가 2026-07-10 KST에 2026-07-09 대상 스냅샷을 만들면, 집계 기간은 2026-07-03 00:00:00 KST 이상 2026-07-10 00:00:00 KST 미만이다.
|
||||||
|
- 운영 DB 시간이 UTC 기준이면, 실제 조회는 `windowStartUtc <= createdAt < windowEndExclusiveUtc`로 변환해 사용한다.
|
||||||
|
- `snapshotAt`은 해당 KST 대상일의 종료 시각을 UTC로 변환한 `windowEndExclusiveUtc.minusSeconds(1)`을 사용한다.
|
||||||
|
- 기존 코드에 남아 있는 `created_at <= :snapshotAt` 방식은 이번 범위에서 half-open end-exclusive 조건으로 맞춘다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 집계 기간에 좋아요/댓글 데이터가 없어도 팔로워 수만으로 추천 후보가 될 수 있다.
|
||||||
|
- 집계 기간과 무관하게 게시글 자체는 스냅샷 대상 시점 이전에 생성된 활성 무료/비공지 게시글이어야 한다.
|
||||||
|
- 같은 `sectionType`, `snapshotAt`에 대해 재실행하면 기존 스냅샷을 대체하는 정책을 유지한다.
|
||||||
|
|
||||||
|
### Feature C. 공지/유료 게시글 제외
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 공지 게시글은 스냅샷 후보에서 제외한다.
|
||||||
|
- 요구사항의 `is_pin == true`는 현재 코드의 `CreatorCommunity.isFixed` 및 DB 컬럼 `is_fixed = true`와 동일한 의미로 해석한다.
|
||||||
|
- 유료 게시글은 스냅샷 후보에서 제외한다.
|
||||||
|
- 유료 게시글 제외 조건은 `creator_community.price > 0`이며, 추천 후보는 `price <= 0` 또는 현재 도메인의 무료 판정과 일치해야 한다.
|
||||||
|
- 상세 조회 시점에도 공지/고정 게시글과 유료 게시글을 다시 제외해 스냅샷 생성 이후 상태 변경이 응답에 노출되지 않도록 한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 스냅샷 생성 당시 무료였지만 조회 시점에 유료로 변경된 게시글은 응답에서 제외한다.
|
||||||
|
- 스냅샷 생성 당시 비공지였지만 조회 시점에 공지/고정으로 변경된 게시글은 응답에서 제외한다.
|
||||||
|
- 스냅샷 생성 당시 활성 상태였지만 조회 시점에 비활성화된 게시글은 응답에서 제외한다.
|
||||||
|
|
||||||
|
### Feature D. 신규 부스트 변경
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 신규 부스트 기준일은 크리에이터 데뷔일이다.
|
||||||
|
- 크리에이터 데뷔일은 기존 홈 추천 PRD와 동일하게 콘텐츠를 처음 공개한 날과 라이브를 한 날 중 빠른 날짜로 계산한다.
|
||||||
|
- 신규 부스트 구간은 다음과 같다.
|
||||||
|
- 데뷔 후 10일 이내: `1.15`
|
||||||
|
- 데뷔 후 20일 이내: `1.10`
|
||||||
|
- 데뷔 후 30일 이내: `1.05`
|
||||||
|
- 그 외: `1.0`
|
||||||
|
- 경계일 계산은 기존 추천 점수 정책의 날짜 단위 계산과 일관되게 한다.
|
||||||
|
- 부스트 기준 시각은 스냅샷의 `snapshotAt`이다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 공개 콘텐츠와 라이브 이력이 모두 없어 데뷔일을 계산할 수 없는 크리에이터는 스냅샷 후보에서 제외한다.
|
||||||
|
- 데뷔일이 `snapshotAt`보다 미래인 데이터는 데이터 오류로 보고 스냅샷 후보에서 제외한다.
|
||||||
|
- 10일/20일/30일 경계값은 테스트에서 명확히 검증한다.
|
||||||
|
|
||||||
|
### Feature E. `POPULAR_COMMUNITY` 스냅샷 조회
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 홈 통합 조회 `GET /api/v2/home/recommendations`의 인기 커뮤니티 섹션은 대상일 `snapshotAt`의 `POPULAR_COMMUNITY` 스냅샷 순서를 사용한다.
|
||||||
|
- 대상일 `snapshotAt`은 홈 API 호출 시각 기준 KST 전날의 종료 시각을 UTC로 변환한 값이다.
|
||||||
|
- 일 단위 최신성을 강제하므로, 과거 최신 스냅샷이 존재하더라도 대상일 `snapshotAt` 스냅샷이 없으면 fallback refresh를 시도한다.
|
||||||
|
- 기존 노출 정보는 유지한다.
|
||||||
|
- `communityId`
|
||||||
|
- `creatorId`
|
||||||
|
- 크리에이터 닉네임
|
||||||
|
- 크리에이터 프로필 이미지
|
||||||
|
- 이미지 경로
|
||||||
|
- 오디오 경로
|
||||||
|
- 본문
|
||||||
|
- 가격
|
||||||
|
- 생성 시각
|
||||||
|
- 전체 좋아요 수
|
||||||
|
- 전체 댓글 수
|
||||||
|
- 구매 여부
|
||||||
|
- 좋아요 여부
|
||||||
|
- 조회 시점에도 기존 차단 필터, 성인 필터, 활성 게시글/크리에이터 필터를 적용한다.
|
||||||
|
- 스냅샷 후보는 최대 20개까지 조회하고, 상세 조회/필터링/크리에이터 중복 제거 후 홈 첫 화면에는 최대 10개를 반환한다.
|
||||||
|
- 동일 크리에이터의 게시글이 여러 개 스냅샷에 포함된 경우 기존 정책처럼 점수가 가장 높은 1개만 홈 응답에 노출한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 대상일 `snapshotAt` 스냅샷 row가 존재하지만 조회 필터로 모두 제외되면 빈 배열을 반환한다.
|
||||||
|
- 상세 조회 결과가 스냅샷 저장 개수보다 적어도 홈 조회 전체는 성공 처리한다.
|
||||||
|
- 스냅샷 정렬 순서와 응답 순서는 일치해야 한다.
|
||||||
|
|
||||||
|
### Feature F. 스냅샷 없음 fallback refresh
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 대상일 `snapshotAt`의 `POPULAR_COMMUNITY` 스냅샷이 없으면 fallback refresh를 시도한다.
|
||||||
|
- fallback refresh는 홈 API에서 직접 계산 결과를 응답하지 않고, 스케줄러와 동일한 refresh 로직으로 `recommendation_snapshot`에 저장한 뒤 저장된 스냅샷을 다시 조회한다.
|
||||||
|
- fallback refresh 흐름은 다음 순서를 따른다.
|
||||||
|
- 홈 API 호출 시각 기준 대상일 `snapshotAt` 계산
|
||||||
|
- 대상일 `snapshotAt`의 `POPULAR_COMMUNITY` 스냅샷 조회
|
||||||
|
- 대상일 스냅샷이 없으면 fallback refresh 진입
|
||||||
|
- refresh 전 섹션 전용 lock 획득 시도
|
||||||
|
- lock 안에서 대상일 `snapshotAt` 스냅샷을 한 번 더 조회
|
||||||
|
- 여전히 없으면 스케줄러와 동일한 `POPULAR_COMMUNITY` refresh 로직 실행
|
||||||
|
- 대상일 `snapshotAt`에 저장된 스냅샷을 다시 조회해 반환
|
||||||
|
- refresh 후에도 노출 가능한 스냅샷이 없으면 빈 배열 반환
|
||||||
|
- lock key는 섹션 단위로 분리한다.
|
||||||
|
- 권장: `lock:recommendation-snapshot-refresh:POPULAR_COMMUNITY`
|
||||||
|
- lock 대기 시간은 최대 300ms로 제한한다.
|
||||||
|
- 홈 API가 fallback refresh 완료를 기다리는 시간은 최대 1,500ms로 제한한다.
|
||||||
|
- timeout은 홈 API 대기 timeout이며, 이미 시작된 refresh 작업을 반드시 중단한다는 의미가 아니다.
|
||||||
|
- 동일 JVM에서는 single-flight 상태를 유지해 동시에 들어온 홈 요청이 중복 refresh를 시작하지 않도록 한다.
|
||||||
|
- 다중 인스턴스에서는 Redisson 기반 분산 lock으로 인스턴스 간 중복 refresh를 방지한다.
|
||||||
|
- lock 획득 실패 또는 refresh 진행 중인 요청은 짧게 대기한 뒤 대상일 `snapshotAt` 스냅샷을 다시 조회하고, 없으면 빈 배열을 반환한다.
|
||||||
|
- fallback refresh 실패는 로그를 남기고 홈 API 전체 실패로 전파하지 않는다.
|
||||||
|
- 구현은 기존 `RecommendationSnapshotFallbackService`를 섹션 단위로 확장하는 방식을 우선 검토한다. 이는 AI 캐릭터/응원 크리에이터 fallback과 lock, timeout, double-check 동작을 공유할 수 있어 사용자 제안 흐름보다 중복이 적다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- lock 획득 직전에 다른 요청 또는 스케줄러가 스냅샷을 만들 수 있으므로 double-check가 필요하다.
|
||||||
|
- fallback refresh가 성공했지만 집계 대상 데이터가 없으면 빈 배열을 반환한다.
|
||||||
|
- 집계 대상 데이터가 없는 정상 refresh와 아직 한 번도 refresh되지 않은 상태를 구분해야 반복 fallback을 막을 수 있다.
|
||||||
|
- 홈 API 대기 시간이 초과되어 빈 배열을 반환한 뒤에도 백그라운드 refresh가 완료되면 이후 요청은 저장된 대상일 `snapshotAt` 스냅샷을 사용한다.
|
||||||
|
|
||||||
|
### Feature G. 빈 결과 refresh 마커
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- `POPULAR_COMMUNITY`도 AI 캐릭터/응원 크리에이터와 같은 방식으로 빈 결과 refresh 마커를 저장하는 방안을 우선 적용한다.
|
||||||
|
- 권장 방식은 `targetId = 0` empty snapshot marker를 `POPULAR_COMMUNITY`에도 적용하는 것이다.
|
||||||
|
- 조회 쿼리는 기존처럼 `target_id <> 0`을 유지해 marker가 사용자 응답에 노출되지 않게 한다.
|
||||||
|
- 대상일 `snapshotAt` 존재 여부 확인은 marker를 포함해 판단하여, 집계 결과가 없는 날 매 홈 요청마다 fallback refresh가 반복되지 않도록 한다.
|
||||||
|
- 이 방식은 별도 DDL 없이 기존 `recommendation_snapshot` 구조를 재사용할 수 있어 이번 요구사항에 가장 단순하다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- marker가 있더라도 실제 추천 row가 없으면 홈 응답은 빈 배열이다.
|
||||||
|
- 같은 `sectionType`, `snapshotAt`에 실제 row가 생기는 재실행이 있으면 marker는 대체되어야 한다.
|
||||||
|
- marker 저장 정책을 모든 스냅샷 섹션으로 공통화하는 작업은 이번 범위에서는 `POPULAR_COMMUNITY`에 필요한 최소 변경만 수행한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 캐릭터/응원 크리에이터 구현을 복사하기보다 섹션별로 재사용 가능한 형태를 우선 검토한다. 단, 과도한 일반화가 필요하면 `POPULAR_COMMUNITY`에 필요한 최소 추상화만 적용한다.
|
||||||
|
- `POPULAR_COMMUNITY` 집계는 정확한 top 후보를 위해 최종 점수 계산 전 candidate pre-limit를 두지 않는다.
|
||||||
|
- DB-side scoring을 유지하는 경우 Kotlin 단에는 산식 parity 검증용 정책 함수를 두고, DB expression과 같은 상수를 공유한다.
|
||||||
|
- Kotlin-side scoring으로 변경하는 경우 DB에서 필요한 원천 metric을 정확히 집계하고, service에서 최종 점수/정렬/limit을 적용한다. 이 경우 후보 전체를 메모리에 올리는 비용과 데이터량을 구현 계획에서 검토한다.
|
||||||
|
- 기본 권장안은 현재 구조와 성능 특성을 유지하는 DB-side exact scoring이다. 다만 산식/부스트 계산은 Kotlin 정책 테스트로 검증 가능한 형태를 둔다.
|
||||||
|
- 시간 범위는 KST 기준 최근 7일을 UTC half-open window로 변환하는 정책 함수를 사용한다. 기존 `RecommendationSnapshotWindowPolicy`에 7일 window 함수를 추가하는 방식을 우선 검토한다.
|
||||||
|
- 신규 DDL은 만들지 않는 것을 기본으로 한다. 불가피하게 필요하면 운영 DB 반영용 SQL 문서는 MySQL 기준으로 별도 작성한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Metrics
|
||||||
|
- `POPULAR_COMMUNITY` 스냅샷 생성 성공/실패 로그
|
||||||
|
- `POPULAR_COMMUNITY` 스냅샷 최종 저장 수
|
||||||
|
- fallback refresh 실행/성공/실패/timeout 로그
|
||||||
|
- fallback refresh lock 획득 성공/실패 로그
|
||||||
|
- `likeCount`, `commentCount`, `creatorFollowerCount` 입력값 분포
|
||||||
|
- empty snapshot marker 저장 횟수
|
||||||
|
- 홈 API `popularCommunityPosts` 빈 응답 비율
|
||||||
|
- 홈 API fallback refresh 대기 시간
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Open Questions
|
||||||
|
- 없음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 흐름을 공유하는 것이다.
|
||||||
|
- 점수 계산은 기본적으로 DB-side exact scoring을 유지하고, Kotlin 정책 함수와 테스트로 가중치/부스트의 근거를 고정한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 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`
|
||||||
|
- `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md`
|
||||||
Reference in New Issue
Block a user