test #433
273
docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md
Normal file
273
docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
# 메인 홈 추천 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 정렬은 변경하지 않는다.
|
||||||
|
- 변경: `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/AiCharacterSnapshotFallbackService.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/AiCharacterSnapshotFallbackServiceTest.kt`
|
||||||
|
- RED: 최신 `AI_CHARACTER` 스냅샷이 없을 때 fallback service가 AI refresh를 요청하고, refresh 완료 후 저장된 스냅샷을 다시 조회하도록 실패 테스트를 작성한다. 이미 스냅샷이 있으면 refresh를 요청하지 않는 테스트도 추가한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.AiCharacterSnapshotFallbackServiceTest`
|
||||||
|
- 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/AiCharacterSnapshotFallbackService.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/AiCharacterSnapshotFallbackServiceTest.kt`
|
||||||
|
- RED: lock 대기 최대 300ms, 홈 API refresh 완료 대기 최대 1,500ms, timeout 시 현재 요청 빈 결과 반환, timeout 후 background refresh 계속 진행, lock이 잡혀 있는 동안 후속 요청은 중복 refresh를 시작하지 않는 테스트를 작성한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.AiCharacterSnapshotFallbackServiceTest`
|
||||||
|
- 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/AiCharacterSnapshotFallbackService.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/AiCharacterSnapshotFallbackServiceTest.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.AiCharacterSnapshotFallbackServiceTest --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 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.AiCharacterSnapshotFallbackServiceTest --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에서 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`, `AiCharacterSnapshotFallbackServiceTest`, `HomeRecommendationQueryServiceTest`를 실행해 section lock, 300ms lock wait, 1,500ms home wait, timeout 후 background 완료, double-check, fallback 연결이 `BUILD SUCCESSFUL`임을 확인했다.
|
||||||
|
- 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`, `AiCharacterSnapshotFallbackServiceTest`, `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 기준으로 정렬했고, `AiCharacterSnapshotFallbackService`의 snapshot read를 `PROPAGATION_REQUIRES_NEW` read-only `TransactionTemplate`으로 분리해 caller read transaction과 독립된 DB read가 되도록 보강했다.
|
||||||
172
docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.md
Normal file
172
docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.md
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
# PRD: 메인 홈 추천 AI 캐릭터 스냅샷 산식 변경
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
메인 홈 추천 탭의 AI 캐릭터 스냅샷 생성과 조회가 전날 데이터 기반 추천 점수를 사용하도록 수정한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problem
|
||||||
|
- 기존 메인 홈 추천 AI 캐릭터 스냅샷은 최근 7일 채팅 데이터와 기존 신규 부스트를 기준으로 계산되어, 전날 성과를 빠르게 반영하기 어렵다.
|
||||||
|
- 기존 PRD에서는 AI 캐릭터 팔로우 증가량을 산식에서 제외했으나, 이번 요구사항에서는 전날 신규 팔로우 수를 점수에 포함해야 한다.
|
||||||
|
- AI 캐릭터에는 별도 데뷔 날짜가 없으므로 신규 부스트 기준일을 캐릭터 생성 날짜/시간으로 명확히 고정해야 한다.
|
||||||
|
- 산식 변경이 추천 노출 순서에 직접 영향을 주므로 계산 기간, 집계 대상, 부스트 구간, 조회 정렬 기준을 문서로 고정해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
- AI 캐릭터 추천 점수를 전날 데이터 기반으로 산출한다.
|
||||||
|
- AI 캐릭터 점수 산식을 `(AI 채팅 수 * 45% + 최근 활성 사용자 수 * 35% + 팔로우 증가 수 * 20%) * 신규 부스트`로 변경한다.
|
||||||
|
- AI 채팅 수는 전날 AI 캐릭터가 답변한 메시지 수로 계산한다.
|
||||||
|
- 최근 활성 사용자 수는 전날 해당 캐릭터와 1회 이상 채팅한 중복 없는 사용자 수로 계산한다.
|
||||||
|
- 팔로우 증가 수는 전날 발생한 AI 캐릭터 신규 팔로우 수로 계산한다.
|
||||||
|
- 신규 부스트는 `ChatCharacter.createdAt` 기준으로 10일 이내 1.15, 20일 이내 1.10, 30일 이내 1.05, 그 외 1.0을 적용한다.
|
||||||
|
- 홈 통합 조회와 AI 캐릭터 전체보기 조회는 최신 AI 캐릭터 스냅샷의 점수순 결과를 사용한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
- 메인 홈 추천 API URL, 응답 필드, 응답 JSON 스키마는 변경하지 않는다.
|
||||||
|
- AI 캐릭터 상세, AI 채팅방, 크리에이터 채널 API의 공개 스키마는 변경하지 않는다.
|
||||||
|
- 추천 결과 수동 편집, 관리자 화면, A/B 테스트, 머신러닝 개인화는 이번 범위에 포함하지 않는다.
|
||||||
|
- AI 캐릭터 팔로우 생성/취소 동작 자체를 변경하지 않는다.
|
||||||
|
- 기존 AI 캐릭터 `characterId`와 `creatorId`의 의미를 변경하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Target Users
|
||||||
|
- 회원: 메인 홈 추천 탭에서 전날 반응이 좋은 AI 캐릭터를 발견하고 채팅 또는 크리에이터 채널로 이동하는 사용자
|
||||||
|
- 앱 클라이언트: 기존 메인 홈 추천 응답 계약을 유지한 채 AI 캐릭터 추천 순서만 최신 스냅샷 기준으로 사용하는 클라이언트
|
||||||
|
- 운영자: 전날 채팅/사용자/팔로우 반응이 추천 순서에 반영되는지 확인해야 하는 운영 담당자
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. User Stories
|
||||||
|
- 사용자는 메인 홈 추천 탭에서 전날 채팅 반응이 많았던 AI 캐릭터를 우선 보고 싶다.
|
||||||
|
- 사용자는 여러 사용자가 실제로 대화한 AI 캐릭터를 추천받고 싶다.
|
||||||
|
- 사용자는 최근 팔로우가 늘어난 AI 캐릭터를 추천 목록에서 발견하고 싶다.
|
||||||
|
- 사용자는 신규 AI 캐릭터가 일정 기간 동안 적절한 노출 기회를 받기를 기대한다.
|
||||||
|
- 앱 클라이언트는 기존 API 응답 구조를 바꾸지 않고 추천 순서만 변경된 결과를 받고 싶다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Core Features
|
||||||
|
|
||||||
|
### Feature A. AI 캐릭터 스냅샷 점수 산식 변경
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- AI 캐릭터 점수는 아래 산식으로 계산한다.
|
||||||
|
- `score = ((aiChatCount * 0.45) + (activeUserCount * 0.35) + (followIncreaseCount * 0.20)) * newBoost`
|
||||||
|
- `aiChatCount`는 집계 기간 안에 AI 캐릭터가 답변한 활성 메시지 수다.
|
||||||
|
- AI가 답변한 메시지는 기존 AI 캐릭터 추천 집계와 동일하게 `chat_participant.participant_type = 'CHARACTER'`인 활성 participant가 작성한 활성 `chat_message`로 판정한다.
|
||||||
|
- `activeUserCount`는 집계 기간 안에 해당 캐릭터와 1회 이상 채팅한 중복 없는 활성 사용자 수다.
|
||||||
|
- `followIncreaseCount`는 집계 기간 안에 해당 AI 캐릭터에 대응하는 creator member를 신규 팔로우한 수다.
|
||||||
|
- AI 캐릭터에 대응하는 creator member는 기존 홈 추천 응답의 `creatorId`와 동일한 `ChatCharacter.creatorMember.id`다.
|
||||||
|
- 팔로우 증가는 `creator_following.creator_id = ChatCharacter.creatorMember.id`인 활성 팔로우 이력 중 집계 기간에 생성된 row 기준으로 계산한다.
|
||||||
|
- 재팔로우가 기존 비활성 row를 다시 활성화하는 방식이면 신규 팔로우 수에 포함하지 않는다.
|
||||||
|
- 점수 산식 상수는 코드와 테스트에서 재사용 가능한 형태로 관리한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- `aiChatCount`, `activeUserCount`, `followIncreaseCount`가 모두 0인 캐릭터는 스냅샷 후보에서 제외한다.
|
||||||
|
- AI 답변은 있지만 사용자 메시지가 없는 경우 `activeUserCount = 0`으로 계산한다.
|
||||||
|
- 사용자 메시지는 있지만 AI 답변이 없는 경우에도 `activeUserCount`와 `followIncreaseCount`가 있으면 산식에 따라 점수를 계산할 수 있다.
|
||||||
|
- 비활성 메시지, 비활성 participant, 비활성 사용자, 비활성 캐릭터는 집계에서 제외한다.
|
||||||
|
- `creatorMember`가 없거나 연결된 member가 비활성/비 `CREATOR`/비 `AI_CHARACTER`이면 스냅샷 후보와 조회 결과에서 제외한다.
|
||||||
|
|
||||||
|
### Feature B. 전날 데이터 기반 집계 기간
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 스냅샷 점수 입력값은 전날 데이터만 사용한다.
|
||||||
|
- 전날 기준은 KST 기준 전일 00:00:00 이상, 다음날 00:00:00 미만의 half-open 범위로 정의한다.
|
||||||
|
- 운영 DB 시간은 UTC이므로, 실제 조회와 계산에서는 KST 기준 전일 00:00:00 이상, 다음날 00:00:00 미만 범위를 UTC 기준 `windowStartUtc <= createdAt < windowEndExclusiveUtc`로 변환해 사용한다.
|
||||||
|
- 스케줄러 실행 시각이 KST 06:00이면, 실행일 전날 KST 일자를 집계 대상으로 삼는다.
|
||||||
|
- 스냅샷 생성 시 `snapshotAt`은 기존 `RecommendationSnapshot` 저장/조회 계약을 유지하되, 점수 입력 집계 기간은 전날 하루로 제한한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 스케줄러 재실행으로 같은 `sectionType`, `snapshotAt` 스냅샷을 다시 만들 때 기존 최신 스냅샷 대체 정책을 유지한다.
|
||||||
|
- 집계 기간에 해당하는 데이터가 없으면 AI 캐릭터 스냅샷은 빈 결과가 될 수 있으며, 홈 조회 API는 기존처럼 빈 배열을 반환한다.
|
||||||
|
|
||||||
|
### Feature C. 신규 부스트 변경
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- AI 캐릭터 신규 부스트는 데뷔 날짜가 아니라 `ChatCharacter.createdAt` 기준으로 계산한다.
|
||||||
|
- 신규 부스트 구간은 다음과 같다.
|
||||||
|
- 캐릭터 생성 후 10일 이내: `1.15`
|
||||||
|
- 캐릭터 생성 후 20일 이내: `1.10`
|
||||||
|
- 캐릭터 생성 후 30일 이내: `1.05`
|
||||||
|
- 그 외: `1.0`
|
||||||
|
- 경계일 계산은 기존 추천 점수 정책의 `ChronoUnit.DAYS.between(baseAt.toLocalDate(), 기준일.toLocalDate())` 방식과 일관되게 한다.
|
||||||
|
- 부스트 기준일은 스냅샷 생성 기준 시각인 `snapshotAt`으로 한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- `ChatCharacter.createdAt`이 null인 캐릭터는 스냅샷 산식에 포함하지 않는다.
|
||||||
|
- 생성일이 기준 시각보다 미래인 데이터는 데이터 오류로 보고 스냅샷 후보에서 제외한다.
|
||||||
|
|
||||||
|
### Feature D. AI 캐릭터 스냅샷 조회
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 홈 통합 조회 `GET /api/v2/home/recommendations`의 AI 캐릭터 섹션은 최신 `AI_CHARACTER` 스냅샷 순서를 사용한다.
|
||||||
|
- AI 캐릭터 전체보기 조회도 최신 `AI_CHARACTER` 스냅샷 순서를 사용한다.
|
||||||
|
- AI 캐릭터 스냅샷은 최종 점수 기준 20위까지 저장한다.
|
||||||
|
- 기존 노출 정보는 유지한다.
|
||||||
|
- `characterId`
|
||||||
|
- `creatorId`
|
||||||
|
- 캐릭터 이름
|
||||||
|
- 캐릭터 소개
|
||||||
|
- 프로필 이미지
|
||||||
|
- 작품명
|
||||||
|
- 사용자들이 친 전체 채팅 수
|
||||||
|
- 스냅샷 정렬은 점수 내림차순, 동일 점수면 더 늦게 생성된 캐릭터를 우선한다.
|
||||||
|
- 조회 시점에도 비활성 또는 노출 제한 캐릭터는 제외한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 스냅샷에는 존재하지만 조회 시점에 캐릭터 또는 `creatorMember`가 비활성화된 경우 응답에서 제외한다.
|
||||||
|
- fallback refresh 후에도 최신 스냅샷이 없으면 기존 홈 추천 API 동작과 동일하게 빈 배열을 반환한다.
|
||||||
|
- 상세 조회 결과가 스냅샷 저장 개수보다 적어도 홈 조회 전체는 성공 처리한다.
|
||||||
|
|
||||||
|
### Feature E. 스냅샷 없음 fallback refresh
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 최신 `AI_CHARACTER` 스냅샷이 없으면 fallback으로 스케줄러와 동일한 AI 캐릭터 스냅샷 refresh 로직을 재사용한다.
|
||||||
|
- fallback refresh는 홈 조회 API가 직접 점수를 계산해 응답하지 않고, 스케줄러와 동일한 저장 로직으로 스냅샷을 만든 뒤 저장된 값을 다시 조회해 반환한다.
|
||||||
|
- fallback refresh는 AI 캐릭터 섹션 단위 lock 또는 single-flight 처리를 적용해 동시에 여러 요청이 같은 refresh를 중복 실행하지 않도록 한다.
|
||||||
|
- lock 획득 후에는 다른 요청이 이미 스냅샷을 생성했을 수 있으므로 최신 스냅샷을 다시 조회하고, 여전히 없을 때만 refresh를 실행한다.
|
||||||
|
- fallback refresh가 성공하면 최신 스냅샷을 다시 조회해 Feature D의 조회 로직과 동일한 방식으로 응답한다.
|
||||||
|
- fallback refresh timeout은 refresh 작업의 강제 중단 시간이 아니라 홈 API 요청이 refresh 완료를 기다리는 최대 대기 시간이다.
|
||||||
|
- fallback refresh의 lock 대기 시간은 최대 300ms로 제한한다.
|
||||||
|
- fallback refresh를 시작한 홈 API 요청은 refresh 완료를 최대 1,500ms까지 기다린다.
|
||||||
|
- fallback refresh가 홈 API 대기 시간을 초과하면 현재 홈 요청은 AI 캐릭터 섹션을 빈 배열로 반환한다.
|
||||||
|
- 홈 API 대기 시간이 초과되어도 이미 시작된 refresh 작업은 가능한 경우 취소하지 않고 백그라운드에서 완료되도록 한다.
|
||||||
|
- 백그라운드 refresh가 완료되면 이후 홈 조회는 저장된 최신 스냅샷을 조회해 반환한다.
|
||||||
|
- fallback refresh가 진행 중인 동안 lock 또는 single-flight 상태를 유지해 후속 요청이 중복 refresh를 시작하지 않도록 한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- lock을 획득하지 못했지만 다른 요청이 refresh 중이면 짧게 대기한 뒤 최신 스냅샷을 다시 조회한다.
|
||||||
|
- 대기 후에도 refresh가 완료되지 않아 최신 스냅샷이 없으면 해당 요청도 AI 캐릭터 섹션을 빈 배열로 반환하고 중복 refresh는 시작하지 않는다.
|
||||||
|
- fallback refresh가 실패하면 실패 로그를 남기고 홈 조회 전체를 실패시키지 않으며 AI 캐릭터 섹션은 빈 배열로 반환한다.
|
||||||
|
- fallback refresh가 성공했지만 집계 대상 데이터가 없어 스냅샷이 생성되지 않으면 AI 캐릭터 섹션은 빈 배열로 반환한다.
|
||||||
|
- fallback refresh 중 예외가 발생해도 다른 추천 섹션 조회 결과는 가능한 범위에서 유지한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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` 기반 저장/조회 구조를 재사용한다.
|
||||||
|
- 기존 공개 API 응답 DTO는 필드 추가 없이 유지한다.
|
||||||
|
- AI 캐릭터 스냅샷 생성 쿼리는 candidate pre-limit 없이 산식 계산과 정렬을 수행한 뒤 최종 20개 저장 limit을 적용한다.
|
||||||
|
- 스케줄러 refresh와 fallback refresh는 산식, 기간, 저장 limit, 정렬 기준이 갈라지지 않도록 같은 application service 경로를 사용한다.
|
||||||
|
- 홈 API 대기 timeout과 refresh 작업/DB query timeout은 분리한다. 홈 API 대기 timeout이 발생해도 refresh 작업이 반드시 같은 timeout으로 중단되면 안 된다.
|
||||||
|
- fallback refresh의 lock은 단일 서버 환경에서는 JVM local lock을 사용할 수 있지만, 다중 인스턴스 운영 환경에서는 DB lock 또는 분산 lock 등 인스턴스 간 중복 실행을 막을 수 있는 방식을 구현 계획에서 확정한다.
|
||||||
|
- 신규 DDL이 필요한 경우 운영 DB 반영용 SQL 문서는 MySQL 기준으로 별도 작성한다.
|
||||||
|
- 점수 산식과 신규 부스트 값은 테스트에서 경계값을 검증할 수 있도록 상수화한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Metrics
|
||||||
|
- AI 캐릭터 스냅샷 생성 성공/실패 로그
|
||||||
|
- 스냅샷 최종 저장 수
|
||||||
|
- fallback refresh 실행/성공/실패/timeout 로그
|
||||||
|
- fallback refresh lock 획득 성공/실패 로그
|
||||||
|
- `aiChatCount`, `activeUserCount`, `followIncreaseCount` 입력값 분포
|
||||||
|
- AI 캐릭터 홈 섹션 응답 성공/실패 로그
|
||||||
|
- AI 캐릭터 홈 섹션 노출 수
|
||||||
Reference in New Issue
Block a user