# 메인 홈 추천 AI 캐릭터 스냅샷 산식 변경 Plan/Task ## 시나리오 계약 - Happy path: KST 전날 하루 데이터를 UTC 범위로 변환해 AI 캐릭터 점수를 계산하고, 점수순 상위 20개 스냅샷을 저장한다. Real surface: `DefaultHomeRecommendationQueryRepositoryTest`, `RecommendationSnapshotRefreshServiceTest`. - Edge: `ChatCharacter.createdAt`이 null이거나 미래인 캐릭터, 비활성 캐릭터, 비활성 메시지/participant/user, 비활성 재팔로우 row는 AI 캐릭터 스냅샷 후보에서 제외된다. Real surface: `DefaultHomeRecommendationQueryRepositoryTest`. - Fallback: 최신 `AI_CHARACTER` 스냅샷이 없으면 fallback refresh를 같은 application service 경로로 실행하고, 홈 API는 최대 1,500ms만 기다린다. timeout 이후에도 refresh는 가능한 경우 백그라운드에서 완료되어 다음 요청부터 저장된 스냅샷을 사용한다. Real surface: `HomeRecommendationQueryServiceTest`, fallback service unit test. - Adjacent regression: 메인 홈 추천 API URL과 AI 캐릭터 응답 필드는 변경하지 않는다. 최근 응원/인기 커뮤니티 스냅샷 산식과 조회 동작은 이번 변경으로 바꾸지 않는다. Real surface: 기존 `HomeRecommendationControllerTest`, `RecommendationSnapshotRefreshServiceTest`. ## 범위와 전제 - 기존 `docs/20260529_메인_홈_추천_API/prd.md`는 과거 구현 기준으로 유지한다. - 이번 문서는 `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.md`의 후속 구현 계획이다. - 신규 공개 API, 신규 응답 필드, 운영 DDL 추가는 기본 범위에 포함하지 않는다. - fallback refresh는 AI 캐릭터 섹션만 대상으로 한다. 스케줄러의 전체 일 스냅샷 갱신은 유지하되, AI 캐릭터 갱신 로직은 fallback과 같은 application service 경로를 사용하도록 분리한다. - Redisson `RLock`은 thread-bound이므로, 백그라운드 refresh를 사용할 때 lock 획득과 해제는 refresh를 실제 수행하는 같은 worker thread 안에서 처리한다. ## 기존 AI_CHARACTER 로직 유지/변경 경계 - 유지: `RecommendedSectionType.AI_CHARACTER` enum 값과 code는 변경하지 않는다. - 유지: `RecommendationSnapshot` 저장 구조와 `RecommendationSnapshotPort.findLatestSnapshots(...)` 조회 계약은 변경하지 않는다. - 유지: `HomeRecommendationQueryService.findAiCharacterRecommendations(...)`의 정상 경로는 최신 스냅샷 조회 후 상세 조립이다. fallback은 스냅샷이 없을 때만 추가된다. - 유지: `DefaultHomeRecommendationQueryRepository.findAiCharacterRecommendationDetails(...)`의 상세 조립, `characterId`, `creatorId`, 원작명, 전체 채팅 수, 활성 `creatorMember` 필터는 변경하지 않는다. - 유지: `HomeRecommendationFacade`, `HomeRecommendationController`, `HomeRecommendationResponse.HomeAiCharacterItem`의 공개 API URL과 응답 필드는 변경하지 않는다. - 유지: 최근 응원/인기 커뮤니티 스냅샷 산식, window, 저장 limit, random tie-breaker 정렬은 변경하지 않는다. - 추가: 홈 통합 조회에서 최신 스냅샷 상세 결과가 20개 미만이면 스냅샷 캐릭터 id를 제외한 활성 AI 캐릭터 랜덤 조회로 부족분을 채운다. - 변경: `findAiCharacterSnapshots(...)`의 입력 window, AI 캐릭터 점수 산식, 신규 부스트 값, 팔로우 증가 수 포함, 후보 제외 조건, top 20 동점 정렬 기준만 변경한다. - 변경: `RecommendationSnapshotRefreshService`에는 AI 캐릭터 단일 refresh 경로를 추가하되, 기존 전체 일 refresh가 최근 응원/인기 커뮤니티까지 함께 갱신하는 동작은 유지한다. - 추가: 최신 AI 캐릭터 스냅샷이 없을 때만 fallback refresh orchestration을 추가한다. ## 실행 명령 - 문서 명령 확인: `./gradlew tasks --all` - 산식/기간 단위 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicyTest` - 스냅샷 refresh 서비스 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest` - AI 캐릭터 query 통합 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` - 홈 추천 조회/fallback 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest` - 홈 API 회귀 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest` - 포맷 검증: `./gradlew ktlintCheck` - 전체 회귀: `./gradlew test` --- ### Phase 1: 문서와 기준 고정 - [x] **Task 1.1: PRD 기반 구현 계획 문서 작성** - 파일 경로: - Modify: `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.md` - Create: `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md` - RED: 문서 작업은 TDD 예외. TDD 예외 사유: 코드 동작 변경 전 요구사항과 구현 순서를 고정하는 작업이다. - GREEN: PRD의 산식, KST→UTC 전날 범위, top 20, 동점 정렬, fallback timeout/lock 정책을 task로 분해한다. - REFACTOR: 기존 홈 추천 구현 파일과 테스트 파일 기준으로 task별 수정/검증 경로를 맞춘다. - 기대 결과: 구현 시작 전에 PRD와 plan-task가 같은 디렉터리에 준비된다. --- ### Phase 2: 산식과 시간 범위 정책 - [x] **Task 2.1: AI 캐릭터 점수 계산식 계약 테스트 보강** - 파일 경로: - 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: AI 캐릭터 점수가 `aiChatCount * 0.45 + activeUserCount * 0.35 + followIncreaseCount * 0.20`에 신규 부스트를 곱하도록 실패 테스트를 추가한다. 계산을 DB에서 하든 Kotlin에서 하든 동일한 기대값을 검증할 수 있도록 계산식 자체를 독립 단위 테스트로 고정한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest` - GREEN: 기존 크리에이터 부스트 `1.5/1.3/1.2`는 유지하고, AI 캐릭터 전용 부스트 상수와 `followIncreaseCount`를 받는 AI 점수 함수를 추가/수정한다. - REFACTOR: 산식 상수명은 PRD 용어가 드러나도록 `AI_CHAT_WEIGHT`, `AI_ACTIVE_USER_WEIGHT`, `AI_FOLLOW_INCREASE_WEIGHT`, `AI_NEW_BOOST_*` 형태로 정리한다. - 계산식 테스트 케이스: - `aiChatCount=0`, `activeUserCount=0`, `followIncreaseCount=0`, `newBoost=1.0`이면 `0.0` - `aiChatCount=10`, `activeUserCount=0`, `followIncreaseCount=0`, `newBoost=1.0`이면 `4.5` - `aiChatCount=0`, `activeUserCount=10`, `followIncreaseCount=0`, `newBoost=1.0`이면 `3.5` - `aiChatCount=0`, `activeUserCount=0`, `followIncreaseCount=10`, `newBoost=1.0`이면 `2.0` - `aiChatCount=10`, `activeUserCount=10`, `followIncreaseCount=10`, `newBoost=1.0`이면 `10.0` - `aiChatCount=10`, `activeUserCount=10`, `followIncreaseCount=10`, `newBoost=1.15`이면 `11.5` - 소수 오차는 `0.0001` 이내로 검증한다. - 기대 결과: AI 캐릭터 산식만 변경되고 최근 응원/인기 커뮤니티/최근 데뷔 산식은 변경되지 않는다. - [x] **Task 2.2: AI 캐릭터 신규 부스트 경계값 계약 테스트 보강** - 파일 경로: - 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: AI 캐릭터 신규 부스트가 생성일 기준 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: AI 캐릭터 전용 부스트 계산 함수를 PRD 값으로 변경한다. - REFACTOR: 날짜 차이 계산 방식은 기존 `ChronoUnit.DAYS.between(baseAt.toLocalDate(), 기준일.toLocalDate())`와 일치하도록 유지한다. - 기대 결과: 생성일 경계값에 대한 산식 근거가 Kotlin 단위 테스트로 고정된다. - [x] **Task 2.3: KST 전날 하루를 UTC 조회 범위로 변환하는 정책 추가** - 파일 경로: - Create: `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` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt` - RED: `nowUtc = 2026-07-09T21:00:00`이면 KST 기준 전날인 `2026-07-09 00:00:00 <= t < 2026-07-10 00:00:00`이 UTC `2026-07-08T15:00:00 <= t < 2026-07-09T15:00:00` half-open 범위로 변환되고, 저장 `snapshotAt`은 `2026-07-09T14:59:59`가 되는 실패 테스트를 작성한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicyTest` - GREEN: `previousKstDayUtcWindow(nowUtc)` 정책을 추가하고, AI 캐릭터 스냅샷 계산에는 `windowStartUtc`, `windowEndExclusiveUtc`를 전달한다. - REFACTOR: 기존 최근 응원/인기 커뮤니티 7일 window 계산과 AI 캐릭터 전날 window 계산이 섞이지 않도록 함수명을 분리한다. - 기대 결과: 운영 DB 시간이 UTC여도 AI 캐릭터 집계 기준은 KST 전날 하루로 고정된다. --- ### Phase 3: AI 캐릭터 스냅샷 Query 변경 - [x] **Task 3.1: 전날 AI 답변 수/활성 사용자 수/팔로우 증가 수 집계** - 파일 경로: - 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/port/out/HomeRecommendationQueryPort.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt` - RED: `findAiCharacterSnapshots(windowStartUtc, windowEndExclusiveUtc, 20)`가 전날 범위 안의 AI 답변 수, 중복 없는 활성 사용자 수, `ChatCharacter.creatorMember.id` 대상 신규 활성 `creator_following` row 수를 반영하도록 실패 테스트를 작성한다. 범위 밖 메시지/팔로우, 비활성 메시지/participant/user/following은 제외되는 fixture를 포함한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` - GREEN: AI 캐릭터 snapshot native query 또는 QueryDSL 집계에 `creator_following` 전날 신규 row 집계를 추가하고, `score = ((chat * 0.45) + (activeUser * 0.35) + (followIncrease * 0.20)) * aiNewBoost`로 계산한다. - REFACTOR: 기존 AI 발화 수/활성 사용자 수 테스트는 전날 window 기준으로 이름과 fixture를 갱신한다. 상세 조회 로직 `findAiCharacterRecommendationDetails(...)`는 변경하지 않는다. - 기대 결과: 팔로우 증가 수가 AI 캐릭터 점수에 포함되고, 재활성화 row는 `created_at`이 전날 신규 row가 아니면 집계되지 않는다. - [x] **Task 3.2: AI 신규 부스트와 후보 제외 조건 적용** - 파일 경로: - 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: `ChatCharacter.createdAt` 기준 AI 전용 부스트가 적용되는 테스트, `createdAt` null 또는 기준 시각보다 미래인 캐릭터가 후보에서 제외되는 테스트, `creatorMember`가 없거나 비활성/비 `CREATOR`/비 `AI_CHARACTER`인 캐릭터가 제외되는 테스트를 작성한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` - GREEN: AI 스냅샷 후보 query에 `ChatCharacter.createdAt is not null`, `createdAt < windowEndExclusiveUtc`, 활성 `creatorMember` 조건을 추가하고 AI 전용 신규 부스트 값을 적용한다. - REFACTOR: 신규 부스트 계산 기준과 query의 날짜 비교 기준이 `RecommendationSnapshotWindowPolicy`의 UTC end-exclusive와 일치하는지 정리한다. - 기대 결과: PRD의 null/future 생성일 제외와 AI 전용 신규 부스트 경계가 DB 집계 테스트로 고정된다. - [x] **Task 3.3: top 20 저장 후보와 동점 생성일 정렬 적용** - 파일 경로: - 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: AI 캐릭터 후보가 21개 이상일 때 최종 점수 기준 20개만 반환되고, 점수가 동일하면 `ChatCharacter.createdAt`이 더 늦은 캐릭터가 먼저 반환되는 실패 테스트를 작성한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` - GREEN: AI 스냅샷 query의 정렬을 `score desc`, `chat_character.created_at desc` 기준으로 바꾸고 최종 limit 20을 적용한다. 기존 `RecommendationSnapshotRecord.randomTieBreaker` 필드는 schema 유지를 위해 값을 채우되 AI 정렬 기준으로 사용하지 않는다. - REFACTOR: 최근 응원/인기 커뮤니티의 random tie-breaker 정렬은 변경하지 않는다. - 기대 결과: AI 캐릭터 스냅샷만 동점 생성일 최신순 정책을 사용한다. --- ### Phase 4: Refresh Service와 Scheduler 정리 - [x] **Task 4.1: AI 캐릭터 단일 refresh 경로 분리** - 파일 경로: - 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: `refreshAiCharacterSnapshots(nowUtc)`가 KST 전날 하루 UTC window로 `queryPort.findAiCharacterSnapshots(windowStartUtc, windowEndExclusiveUtc, 20)`을 호출하고 `AI_CHARACTER` 스냅샷만 replace하는 실패 테스트를 작성한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest` - GREEN: 전체 일 refresh 내부에서 AI 캐릭터 부분을 public 또는 internal method로 분리하고, fallback도 같은 method를 호출할 수 있게 한다. - REFACTOR: 최근 응원/인기 커뮤니티 refresh는 기존 7일 window와 저장 limit을 유지한다. - 기대 결과: scheduler와 fallback이 같은 AI 스냅샷 생성 로직을 재사용할 수 있다. - [x] **Task 4.2: Scheduler가 AI section lock 경로를 재사용하도록 보강** - 파일 경로: - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/scheduler/RecommendationSnapshotScheduler.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/RecommendationSnapshotRefreshServiceTest.kt` - RED: 스케줄러 또는 refresh service 테스트에서 AI 캐릭터 refresh가 section lock key를 사용하고, lock 획득 실패 시 AI 중복 refresh를 실행하지 않는 실패 테스트를 작성한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest` - GREEN: 기존 전체 scheduler lock과 최근 응원/인기 커뮤니티 갱신 흐름은 유지하되, AI 캐릭터 refresh만 fallback과 충돌하지 않도록 공통 section lock key를 사용한다. 권장 key는 `lock:recommendation-snapshot-refresh:AI_CHARACTER`다. - REFACTOR: Redisson `RLock` 획득/해제는 같은 thread에서 수행되도록 lock 처리 위치를 정리한다. - 기대 결과: 스케줄러 실행 중 fallback이 동시에 AI 캐릭터 스냅샷을 중복 생성하지 않는다. --- ### Phase 5: 스냅샷 없음 fallback refresh - [x] **Task 5.1: AI 캐릭터 fallback refresh service 추가** - 파일 경로: - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/AiCharacterSnapshotFallbackPort.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: 최신 `AI_CHARACTER` 스냅샷이 없을 때 fallback service가 AI refresh를 요청하고, refresh 완료 후 저장된 스냅샷을 다시 조회하도록 실패 테스트를 작성한다. 이미 스냅샷이 있으면 refresh를 요청하지 않는 테스트도 추가한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest` - GREEN: fallback service를 추가해 double-check 조회, AI 단일 refresh 호출, refresh 후 재조회 흐름을 구현한다. - REFACTOR: fallback service는 점수 계산/상세 조립을 직접 하지 않고 snapshot 조회와 refresh orchestration만 담당한다. - 기대 결과: 스냅샷 없음 fallback이 scheduler와 같은 저장 로직을 재사용한다. - [x] **Task 5.2: fallback lock, timeout, 백그라운드 완료 정책 구현** - 파일 경로: - 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 대기 최대 300ms, 홈 API refresh 완료 대기 최대 1,500ms, timeout 시 현재 요청 빈 결과 반환, timeout 후 background refresh 계속 진행, lock이 잡혀 있는 동안 후속 요청은 중복 refresh를 시작하지 않는 테스트를 작성한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest` - GREEN: refresh 작업을 worker thread에서 실행하고 해당 thread 안에서 Redisson lock을 획득/해제한다. 홈 요청 thread는 future 완료를 최대 1,500ms만 기다리고, timeout 시 future를 cancel하지 않는다. - REFACTOR: 실제 구현에서 `TaskExecutor`, `CompletableFuture`, 또는 기존 async 인프라 중 하나만 사용한다. 테스트에서는 deterministic executor/fake clock을 사용해 sleep 기반 테스트를 피한다. - 기대 결과: 첫 요청 timeout 후에도 refresh가 완료되면 다음 요청은 저장된 스냅샷을 사용하고, 동시 요청은 중복 refresh를 만들지 않는다. - [x] **Task 5.3: 홈 AI 캐릭터 조회에 fallback 연결** - 파일 경로: - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt` - RED: `findAiCharacterRecommendations`가 최신 스냅샷 없음이면 fallback service를 호출한 뒤 다시 스냅샷을 조회하고 상세를 조립하는 실패 테스트를 작성한다. fallback 후에도 스냅샷이 없으면 빈 리스트를 반환하는 테스트를 추가한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest` - GREEN: AI 캐릭터 조회에서 snapshotPort 1차 조회가 비어 있을 때만 fallback service를 호출하고, 재조회 결과를 기존 상세 조립 로직에 전달한다. 스냅샷이 있는 기존 정상 경로의 상세 조회 순서와 응답 매핑은 유지한다. - REFACTOR: 스냅샷이 이미 있는 정상 경로에서는 fallback service가 호출되지 않도록 분기와 테스트를 명확히 유지한다. - 기대 결과: 홈 통합 조회와 AI 캐릭터 전체보기 모두 기존 응답 스키마를 유지한 채 fallback을 사용할 수 있다. --- ### 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: 홈 통합 조회와 AI 캐릭터 전체보기 응답이 기존 `characterId`, `creatorId`, 이름, 소개, 이미지, 작품명, 전체 채팅 수 필드를 그대로 내려주는 회귀 테스트를 확인/보강한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest` - GREEN: DTO 변경 없이 fallback이 연결된 application service 결과를 기존 facade/controller가 그대로 매핑하게 한다. - REFACTOR: 공개 API URL과 JSON field name 변경이 없음을 테스트 assertion으로 유지한다. - 기대 결과: 클라이언트 공개 스키마는 변경되지 않는다. - [x] **Task 6.2: fallback/refresh 관측 로그 추가** - 파일 경로: - 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` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt` - RED: fallback refresh 실행/성공/실패/timeout, lock 획득 성공/실패, AI 스냅샷 저장 수 로그 이벤트가 남는지 실패 테스트를 작성한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest` - GREEN: 기존 프로젝트 관례대로 신규 metric dependency 없이 구조화 로그를 남긴다. - REFACTOR: 로그 event key는 검색 가능한 snake_case로 고정하고, 예외 로그는 홈 전체 실패를 유발하지 않는 fallback 실패와 실제 refresh 실패를 구분한다. - 기대 결과: 운영에서 fallback 빈 배열 원인을 timeout/lock/실패/데이터 없음으로 구분할 수 있다. --- ### Phase 7: 최종 검증과 문서 갱신 - [x] **Task 6.3: 홈 통합 AI 캐릭터 부족분 랜덤 보충** - 파일 경로: - Modify: `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.md` - Modify: `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.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/HomeRecommendationQueryServiceTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt` - RED: 새 `findRandomAiCharacterRecommendationIds(...)` port 계약을 추가하고, 스냅샷 상세 결과가 limit 미만이면 부족분만큼 랜덤 id를 조회한 뒤 기존 상세 조회를 재사용하는 service 테스트와 제외 id/활성 AI creator member 조건을 검증하는 repository 테스트를 작성한다. - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` - GREEN: 홈 통합 조회에서 `backfillRandom = true`로 호출하고, 보충이 명시적으로 켜져 있으며 스냅샷이 존재하고 상세 결과가 limit 미만인 경우에만 랜덤 id를 먼저 조회한 뒤 기존 상세 조회로 상세와 채팅 수를 재사용한다. - REFACTOR: 빈 스냅샷 refresh 완료 marker와 전체보기 paging 조회는 기존 빈 결과/paging 계약을 유지한다. - 기대 결과: 홈 통합 AI 캐릭터 섹션은 가능한 경우 20개까지 채워지고, 공개 API 응답 필드는 변경되지 않는다. - [x] **Task 7.1: focused regression 실행** - 파일 경로: - Modify: `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md` - RED: 구현 task 완료 후 계획 문서에 기록할 focused command 목록을 확정한다. - 실패 확인: 해당 없음. TDD 예외 사유: 검증 기록 문서화 task다. - GREEN: 아래 명령을 실행하고 결과를 이 문서 하단 검증 기록에 누적한다. - REFACTOR: 실패한 명령이 있으면 원인과 재실행 결과를 같은 task 아래에 기록한다. - 기대 결과: 산식/집계/fallback/API 회귀가 최소 명령으로 검증된다. - 실행 명령: - `./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.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` - [x] **Task 7.2: 전체 회귀와 문서 검증** - 파일 경로: - Modify: `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md` - RED: 구현 완료 후 전체 회귀 명령 실행 전에는 검증 기록이 비어 있어야 한다. - 실패 확인: 해당 없음. TDD 예외 사유: 검증 기록 문서화 task다. - GREEN: `./gradlew ktlintCheck`, `./gradlew test`, `./gradlew tasks --all`, `git diff --check`를 실행하고 결과를 검증 기록에 남긴다. - REFACTOR: 문서와 코드의 산식/timeout 값이 다르면 구현 또는 문서를 수정한 뒤 재검증한다. - 기대 결과: 전체 테스트, 포맷, 문서 명령 유효성, diff 공백 검사가 모두 통과한다. --- ## Coverage Check - Feature A: Task 2.1, Task 3.1에서 AI 채팅 수 45%, 활성 사용자 수 35%, 팔로우 증가 수 20% 산식을 검증한다. - Feature B: Task 2.3, Task 4.1에서 KST 전날 하루를 UTC 조회 범위로 변환하는 정책을 검증한다. - Feature C: Task 2.2, Task 3.2에서 `ChatCharacter.createdAt` 기준 AI 전용 신규 부스트와 null/future 제외를 검증한다. - Feature D: Task 3.3, Task 5.3, Task 6.1, Task 6.3에서 top 20, 동점 생성일 최신순, 홈 통합 부족분 랜덤 보충, 기존 응답 스키마 유지를 검증한다. - Feature E: Task 5.1, Task 5.2, Task 5.3에서 fallback refresh 재사용, double-check, 300ms lock 대기, 1,500ms 홈 API 대기, timeout 후 background 완료, 중복 refresh 방지를 검증한다. - Non-Goals: Task 6.1과 Task 7.2에서 공개 API URL/응답 필드 변경 없음, AI 캐릭터 팔로우 생성/취소 동작 변경 없음, 관리자/ML/A-B 제외를 확인한다. ## 전체 검증 기록 - 2026-07-09: PRD 기반으로 `plan-task.md`를 생성했다. 구현 전 계획 문서 작성 작업이므로 코드 테스트는 아직 실행하지 않았고, 문서 형식/명령 유효성 검증을 진행한다. - 2026-07-09: 사용자 피드백에 따라 계산을 DB에서 하든 Kotlin 단에서 하든 산식 근거를 촘촘히 남기도록 Task 2.1을 AI 캐릭터 점수 계산식 계약 테스트로 보강했다. `0`, 단일 항목 가중치, 세 항목 합산, 신규 부스트 적용, 소수 오차 허용 범위를 구체 테스트 케이스로 문서화했다. - 2026-07-09: 문서 검증으로 `git diff --check`를 실행해 통과했다. `./gradlew tasks --all`은 일반 샌드박스에서 `~/.gradle` wrapper lock 파일 접근 제한으로 실패했고, 권한 상승 재실행 결과 `BUILD SUCCESSFUL`로 통과했다. - 2026-07-10: 구현 검증으로 산식/윈도우 focused 테스트 `RecommendationScorePolicyTest`, `RecommendationSnapshotWindowPolicyTest`를 실행해 `BUILD SUCCESSFUL`을 확인했다. - 2026-07-10: AI 캐릭터 스냅샷 집계 focused 테스트 `DefaultHomeRecommendationQueryRepositoryTest`를 실행해 전날 UTC window, 팔로우 증가량, 후보 제외, top 20/동점 정렬이 `BUILD SUCCESSFUL`임을 확인했다. - 2026-07-10: refresh/fallback/query focused 테스트 `RecommendationSnapshotRefreshServiceTest`, `RecommendationSnapshotFallbackServiceTest`, `HomeRecommendationQueryServiceTest`를 실행해 section lock, 300ms lock wait, 1,500ms home wait, timeout 후 background 완료, double-check, fallback 연결이 `BUILD SUCCESSFUL`임을 확인했다. - 2026-07-22: 사용자 피드백에 따라 홈 통합 AI 캐릭터 섹션의 스냅샷 상세 결과가 20개 미만이면 랜덤 활성 AI 캐릭터로 부족분을 보충하도록 PRD와 plan-task를 보강했다. RED에서 신규 port 계약 미구현으로 `DefaultHomeRecommendationQueryRepository` 컴파일이 실패했고, GREEN에서 홈 통합 조회에만 `backfillRandom = true`를 전달해 랜덤 id 조회와 기존 상세 조회 재사용 조건을 추가했다. focused 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`를 실행해 `BUILD SUCCESSFUL`을 확인했다. - 2026-07-22: 리뷰 지적에 따라 랜덤 보충 쿼리가 전체 AI 캐릭터의 채팅 메시지를 집계한 뒤 `RAND()`/`LIMIT`하지 않도록 2단계 조회로 보정했다. 랜덤 쿼리는 활성 AI 캐릭터 id만 limit만큼 선택하고, 상세/전체 채팅 수는 기존 `findAiCharacterRecommendationDetails(...)` 경로를 재사용한다. RED에서 기존 production 구현의 `findRandomAiCharacterRecommendationDetails(...)` 참조로 `compileKotlin`이 실패했고, GREEN 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`와 `./gradlew ktlintCheck`가 `BUILD SUCCESSFUL`로 통과했다. - 2026-07-22: 리뷰 게이트에서 전체보기 첫 페이지가 `offset == 0` 조건으로 랜덤 보충되는 blocker를 확인해 `backfillRandom` 명시 플래그로 홈 통합 조회와 전체보기 조회를 분리했다. 재검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `git diff --check`, `./gradlew test`를 실행해 모두 `BUILD SUCCESSFUL` 또는 무출력 통과를 확인했고, 재리뷰에서 blocker 없이 승인받았다. - 2026-07-10: API 회귀 focused 테스트 `HomeRecommendationControllerTest`, `HomeRecommendationResponseTest`를 실행해 공개 응답 스키마 유지가 `BUILD SUCCESSFUL`임을 확인했다. - 2026-07-10: 전체 검증으로 `./gradlew ktlintCheck`, `./gradlew test`, `./gradlew tasks --all`, `git diff --check`를 실행했다. `./gradlew test`는 120초 제한에서 1회 timeout되어 600초 제한으로 재실행했고, 모든 명령이 최종 `BUILD SUCCESSFUL` 또는 무출력 통과했다. - 2026-07-10: 리뷰 게이트에서 발견된 fallback 조건/단일 실행/section lock 해제 시점 이슈를 수정했다. 최신 스냅샷 존재 여부는 요청 page가 아니라 `offset=0, limit=1` 존재 확인으로 분리했고, fallback refresh는 단일 in-flight future로 제한했으며, scheduler AI section lock은 트랜잭션 완료 후 해제되도록 보강했다. - 2026-07-10: 수정 후 `HomeRecommendationQueryServiceTest`, `RecommendationSnapshotFallbackServiceTest`, `RecommendationSnapshotRefreshServiceTest` focused 테스트를 실행해 `BUILD SUCCESSFUL`을 확인했다. 이어서 domain/repository/API focused 테스트, `./gradlew ktlintCheck`, `git diff --check`, `./gradlew test`, `./gradlew tasks --all`을 재실행했고 모두 최종 통과했다. - 2026-07-10: 후속 리뷰 응답으로 AI 캐릭터 팔로우 증가량 집계에서 비활성 follower 계정을 제외하도록 `creator_following.member_id`의 `member.is_active = true` 조건을 추가했다. `DefaultHomeRecommendationQueryRepositoryTest`에 활성 follow row이지만 follower 계정이 비활성인 fixture를 추가했고, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`, `./gradlew ktlintCheck`, `git diff --check`가 모두 통과했다. - 2026-07-10: 후속 리뷰 응답으로 `HomeRecommendationQueryService.findAiCharacterRecommendations`의 `Propagation.NOT_SUPPORTED`를 제거해 홈 API 트랜잭션 경계를 유지하도록 되돌렸다. 또한 AI 캐릭터 스냅샷 window를 `endExclusiveUtc` 기반 half-open 범위로 정리해 `cm.created_at < :windowEndExclusive`, `cf.created_at < :windowEndExclusive`, `cc.created_at < :windowEndExclusive`로 집계되도록 수정했다. - 2026-07-10: half-open 경계 회귀 테스트 `shouldFindAiCharacterSnapshotsWithExclusiveWindowEnd`를 추가했다. 첫 focused 재검증은 `executeSnapshotQuery`가 AI SQL에 없는 `:snapshotAt` 파라미터를 항상 바인딩해 실패했고, SQL에 존재하는 경우에만 `snapshotAt`을 바인딩하도록 수정했다. 이후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest --tests 'kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.shouldReturnTwentyAiCharactersOnHomeRecommendations'`를 재실행해 `BUILD SUCCESSFUL`을 확인했다. - 2026-07-10: 전체 회귀 재검증 중 `HomeRecommendationControllerTest.shouldExposeCreatorIdOnAiCharacterRecommendations`가 전체 실행에서 최신 AI 스냅샷 충돌로 1회 실패했다. 테스트 fixture의 AI 스냅샷 시각을 fallback window보다 최신인 `2026-12-31T23:59:59`로 고정해 테스트 독립성을 보강했고, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.shouldExposeCreatorIdOnAiCharacterRecommendations' --tests 'kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.shouldReturnTwentyAiCharactersOnHomeRecommendations'`가 `BUILD SUCCESSFUL`임을 확인했다. - 2026-07-10: 최종 검증으로 `./gradlew test`, `./gradlew ktlintCheck`, `git diff --check`를 재실행했다. 전체 테스트 1125건 포함 모든 명령이 최종 통과했다. - 2026-07-10: 리뷰 게이트에서 PRD inclusive window 문구와 fallback post-refresh 재조회 트랜잭션 가시성 이슈가 차단으로 지적됐다. PRD/plan-task의 기간 표현을 `windowStartUtc <= t < windowEndExclusiveUtc` half-open 기준으로 정렬했고, active 공통 `RecommendationSnapshotFallbackService`의 snapshot read를 `PROPAGATION_REQUIRES_NEW` read-only `TransactionTemplate`으로 분리해 caller read transaction과 독립된 DB read가 되도록 보강했다. - 2026-07-10: 후속 정리로 삭제된 `AiCharacterSnapshotFallbackServiceTest` 참조를 active `RecommendationSnapshotFallbackServiceTest`로 갱신했고, 비활성 legacy `AiCharacterSnapshotFallbackService` 구현을 제거한 뒤 `AiCharacterSnapshotFallbackPort`만 별도 파일로 유지했다.