docs(audio-recommendation): 추천 탭 snapshot 계획을 갱신한다

This commit is contained in:
2026-06-23 21:05:05 +09:00
parent 7212067101
commit b7052f03f6
2 changed files with 193 additions and 52 deletions

View File

@@ -4,7 +4,7 @@
**Goal:** `GET /api/v2/audio/recommendations`로 메인 콘텐츠 추천 탭의 배너, 오리지널 시리즈, 최신/무료/포인트/추천 오디오, New & Hot, 최근 댓글 많은 오디오 섹션을 한 번에 조회할 수 있게 한다.
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.audio.recommendation` 조립 계층에 둔다. 추천 조회 service, 점수 정책, 조회 domain model, port, QueryDSL/native SQL repository, scheduler는 `kr.co.vividnext.sodalive.v2.audio.recommendation` 하위에 두고 `v2.api.*`에 의존하지 않는다. 배너 값 모델은 `v2.common.domain`, 배너 응답 DTO는 `v2.api.common.dto`로 분리하고, 기존 `recommendation_snapshot`은 section enum 확장 방식으로 재사용한다.
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.content.recommendation` 조립 계층에 둔다. 추천 조회 service, 점수 정책, 조회 domain model, port, QueryDSL/native SQL repository, scheduler는 `kr.co.vividnext.sodalive.v2.content.recommendation` 하위에 두고 `v2.api.*`에 의존하지 않는다. `content` 패키지는 오디오 콘텐츠뿐 아니라 오리지널 시리즈 등 추천 탭에 포함될 수 있는 콘텐츠 범주를 포괄하기 위한 명칭이다. 배너 값 모델은 `v2.common.domain`, 배너 응답 DTO는 `v2.api.common.dto`로 분리하고, 기존 `recommendation_snapshot`은 section enum 확장 방식으로 재사용한다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, native SQL, Redisson, JUnit 5, MockMvc, Gradle Wrapper
@@ -13,6 +13,8 @@
## 0. 구현 전 확정 사항
- API endpoint: `GET /api/v2/audio/recommendations`
- 최종 패키지 구조: 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.content.recommendation`, 도메인 조회 계층은 `kr.co.vividnext.sodalive.v2.content.recommendation`을 사용한다.
- 기존 Phase 1-5 구현 산출물이 `audio.recommendation` 패키지에 있으면 Phase 6에서 `content.recommendation` 패키지로 이동한다.
- 인증 정책: 비회원 조회 가능. 인증 회원이면 회원의 콘텐츠 조회 설정과 19금 노출 가능 여부를 반영한다.
- 응답 wrapper: `ApiResponse.ok(...)`
- 기본 노출 수:
@@ -27,7 +29,7 @@
- 공개 오디오 공통 조건: `AudioContent.isActive == true`, `AudioContent.duration != null`, `AudioContent.releaseDate != null`, `AudioContent.releaseDate <= now`, 크리에이터 회원 활성.
- 비회원과 19금 노출 불가 회원은 성인 콘텐츠를 제외하고 `SAFE` 스냅샷을 조회한다.
- 19금 노출 가능 회원은 성인/비성인 콘텐츠를 모두 포함하는 `ALL` 스냅샷을 조회한다.
- 스냅샷 기준: KST 매일 00:00 실행, 전날 23:59:59 KST까지의 데이터 반영.
- 스냅샷 기준: KST 매일 00:00 실행, 전날 23:59:59 KST까지의 데이터를 UTC 변환 없이 KST-local `LocalDateTime`으로 반영.
- 스냅샷 저장 방식: 기존 `recommendation_snapshot` 테이블을 재사용하고 `RecommendedSectionType` enum에 `NEW_AND_HOT_AUDIO_SAFE`, `NEW_AND_HOT_AUDIO_ALL`, `MOST_COMMENTED_AUDIO_SAFE`, `MOST_COMMENTED_AUDIO_ALL`, `RECOMMENDED_AUDIO_SAFE`, `RECOMMENDED_AUDIO_ALL`을 추가한다. 신규 테이블 DDL은 작성하지 않는다.
- New & Hot 점수: 최신성 35%, 상세 조회수 35%, 좋아요 15%, 댓글 수 15%. 상세 조회수는 `creator_content_view_history``content_id`별 count를 사용한다.
- 추천 오디오 점수: 상세 조회수 45%, 좋아요 25%, 댓글 수 20%, 최신성 10%.
@@ -49,28 +51,28 @@
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt`
### 신규 API 조립 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacade.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/dto/AudioRecommendationsResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacadeTest.kt`
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationController.kt`
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt`
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt`
### 신규 도메인 조회 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationSnapshotRefreshService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendation.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicy.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationVisibility.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/port/out/AudioRecommendationQueryPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/AudioRecommendationQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt`
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt`
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt`
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendation.kt`
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendationScorePolicy.kt`
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendationVisibility.kt`
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt`
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/AudioRecommendationQueryRepository.kt`
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
- Create/Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedSectionType.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicyTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendationScorePolicyTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt`
### 기존 재사용 파일 확인
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshot.kt`
@@ -84,17 +86,17 @@
## 2. Response data class 초안
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/dto/AudioRecommendationsResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
```kotlin
package kr.co.vividnext.sodalive.v2.api.audio.recommendation.dto
package kr.co.vividnext.sodalive.v2.api.content.recommendation.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendations
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.CommentedAudio
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.OriginalSeries
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendations
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.OriginalSeries
data class AudioRecommendationsResponse(
val banners: List<RecommendationBannerResponse>,
@@ -193,7 +195,7 @@ data class CommentedAudioResponse(
## 3. Domain / Port 초안
```kotlin
package kr.co.vividnext.sodalive.v2.audio.recommendation.domain
package kr.co.vividnext.sodalive.v2.content.recommendation.domain
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
@@ -241,12 +243,12 @@ enum class AudioRecommendationVisibility {
```
```kotlin
package kr.co.vividnext.sodalive.v2.audio.recommendation.port.out
package kr.co.vividnext.sodalive.v2.content.recommendation.port.out
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationVisibility
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.CommentedAudio
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.OriginalSeries
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.OriginalSeries
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
import java.time.LocalDateTime
@@ -372,7 +374,7 @@ interface AudioRecommendationQueryPort {
### Phase 4: 스냅샷 산정과 일 배치
- [ ] **Task 4.1: New & Hot 스냅샷 후보 산정 구현**
- [x] **Task 4.1: New & Hot 스냅샷 후보 산정 구현**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/port/out/AudioRecommendationQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
@@ -383,7 +385,7 @@ interface AudioRecommendationQueryPort {
- REFACTOR: Kotlin `AudioRecommendationScorePolicy` 기대값과 DB score가 일치하는 parity 테스트 데이터를 유지한다.
- 기대 결과: `NEW_AND_HOT_AUDIO_SAFE/ALL`에 저장할 top 12 후보가 정확한 점수순으로 산출된다.
- [ ] **Task 4.2: 최근 댓글 많은 오디오 스냅샷 후보 산정 구현**
- [x] **Task 4.2: 최근 댓글 많은 오디오 스냅샷 후보 산정 구현**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
@@ -393,7 +395,7 @@ interface AudioRecommendationQueryPort {
- REFACTOR: 댓글 최신성 배수 계산은 repository SQL과 `AudioRecommendationScorePolicy`가 같은 경계값을 사용하도록 테스트로 고정한다.
- 기대 결과: 스냅샷이 없거나 후보가 없으면 `mostCommentedAudios`는 빈 배열이다.
- [ ] **Task 4.3: 추천 오디오 스냅샷 후보 산정 구현**
- [x] **Task 4.3: 추천 오디오 스냅샷 후보 산정 구현**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
@@ -403,17 +405,17 @@ interface AudioRecommendationQueryPort {
- REFACTOR: New & Hot과 공유 가능한 조회수/좋아요/댓글 aggregate CTE를 private SQL fragment 또는 QueryDSL helper로 정리한다.
- 기대 결과: `RECOMMENDED_AUDIO_SAFE/ALL`에 저장할 top 10 후보가 정확한 점수순으로 산출된다.
- [ ] **Task 4.4: 스냅샷 refresh service와 lazy 보강 구현**
- [x] **Task 4.4: 스냅샷 refresh service와 lazy 보강 구현**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationSnapshotRefreshService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt`
- RED: `refreshDailySnapshots(now)`가 KST 전날 23:59:59 기준으로 여섯 section type(`NEW_AND_HOT_AUDIO_SAFE/ALL`, `MOST_COMMENTED_AUDIO_SAFE/ALL`, `RECOMMENDED_AUDIO_SAFE/ALL`)을 replace하고, New & Hot 조회 시 최신 스냅샷이 없으면 lazy refresh를 1회 호출하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest`
- GREEN: 기존 `RecommendationSnapshotPort.replaceSnapshots(...)``findLatestSnapshots(...)`를 재사용한다. lazy 보강은 New & Hot에만 적용하고, 최근 댓글 많은 오디오는 스냅샷이 없으면 빈 배열로 유지한다.
- REFACTOR: 기준 시각 계산은 private 함수로 분리하고 UTC/KST 변환 테스트를 유지한다.
- REFACTOR: 기준 시각 계산은 private 함수로 분리하고 KST-local `LocalDateTime` 경계 테스트를 유지한다. 보강 후에도 New & Hot 후보가 0개이면 Redis marker 기준 같은 KST 날짜에는 lazy refresh를 반복하지 않는다.
- 기대 결과: 일 배치와 lazy 보강 모두 같은 산정 함수를 사용한다.
- [ ] **Task 4.5: 00:00 KST 스케줄러와 Redisson lock 작성**
- [x] **Task 4.5: 00:00 KST 스케줄러와 Redisson lock 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt`
@@ -425,7 +427,7 @@ interface AudioRecommendationQueryPort {
### Phase 5: 통합 조회 service와 API 연결
- [ ] **Task 5.1: AudioRecommendationQueryService 통합 조립**
- [x] **Task 5.1: AudioRecommendationQueryService 통합 조립**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryServiceTest.kt`
@@ -435,7 +437,7 @@ interface AudioRecommendationQueryPort {
- REFACTOR: 섹션 limit은 companion object 상수로 고정하고 테스트에서 같은 값을 검증한다.
- 기대 결과: 특정 섹션이 빈 배열이어도 전체 응답은 성공 가능한 domain model로 조립된다.
- [ ] **Task 5.2: Facade 성인 정책/이미지 URL 변환 연결**
- [x] **Task 5.2: Facade 성인 정책/이미지 URL 변환 연결**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacade.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacadeTest.kt`
@@ -445,7 +447,7 @@ interface AudioRecommendationQueryPort {
- REFACTOR: Home 탭 전용 `HomeRecommendationFacade`를 주입하거나 호출하지 않는지 import를 확인한다.
- 기대 결과: API 조립 계층은 신규 audio recommendation use case만 호출한다.
- [ ] **Task 5.3: Controller/E2E 통합 검증**
- [x] **Task 5.3: Controller/E2E 통합 검증**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationController.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt`
@@ -456,22 +458,133 @@ interface AudioRecommendationQueryPort {
- REFACTOR: 응답 필드명이 PRD와 plan-task의 DTO 초안과 같은지 검색으로 확인한다.
- 기대 결과: 비회원과 인증 회원 모두 endpoint 호출이 성공한다.
### Phase 6: 회귀 검증과 문서 기록
### Phase 6: 패키지 구조 content.recommendation 이동
- [ ] **Task 6.1: 전체 관련 테스트와 ktlint 실행**
- [ ] **Task 6.1: 공개 API 조립 계층 패키지 이동**
- Files:
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationController.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationController.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacade.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/dto/AudioRecommendationsResponse.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt`
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationControllerTest.kt`
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/application/AudioRecommendationFacadeTest.kt` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt`
- TDD 예외 사유: 동작 변경 없이 패키지/디렉터리만 이동하는 구조 정리 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.*`
- Run: `rg -n "v2\\.api\\.audio\\.recommendation" src/main/kotlin src/test/kotlin`
- GREEN: `package` 선언과 import를 `kr.co.vividnext.sodalive.v2.api.content.recommendation` 기준으로 갱신한다.
- REFACTOR: endpoint `GET /api/v2/audio/recommendations`, response DTO class/field 이름은 공개 API 계약이므로 변경하지 않는다.
- 기대 결과: 공개 API 조립 계층은 `v2.api.content.recommendation` 아래에만 존재한다.
- [ ] **Task 6.2: 도메인 조회 계층 패키지 이동**
- Files:
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryService.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationSnapshotRefreshService.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendation.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendation.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicy.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendationScorePolicy.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationVisibility.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/domain/AudioRecommendationVisibility.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/port/out/AudioRecommendationQueryPort.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/AudioRecommendationQueryRepository.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/AudioRecommendationQueryRepository.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
- Move: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt` -> `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt`
- Move: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/**` -> `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/**`
- TDD 예외 사유: 동작 변경 없이 패키지/디렉터리만 이동하는 구조 정리 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.*`
- Run: `rg -n "v2\\.audio\\.recommendation" src/main/kotlin src/test/kotlin`
- GREEN: 도메인 조회 계층의 `package` 선언과 import를 `kr.co.vividnext.sodalive.v2.content.recommendation` 기준으로 갱신한다.
- REFACTOR: class 이름(`AudioRecommendation*`)은 현재 API/섹션 의미가 오디오 중심이므로 유지하고, 패키지명만 콘텐츠 범주로 확장한다.
- 기대 결과: 도메인 조회 계층은 `v2.content.recommendation` 아래에만 존재하고 `v2.api.*`에 의존하지 않는다.
- [ ] **Task 6.3: 패키지 잔여 참조와 문서 동기화 확인**
- Files:
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md`
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`
- Verify: `src/main/kotlin`
- Verify: `src/test/kotlin`
- TDD 예외 사유: 문서와 package/import 잔여 참조 확인 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run: `rg -n "api\\.audio\\.recommendation|v2\\.audio\\.recommendation|api/audio/recommendation|v2/audio/recommendation" docs/20260623_메인_콘텐츠_추천_탭_API src/main/kotlin src/test/kotlin`
- Run: `rg -n "api\\.content\\.recommendation|v2\\.content\\.recommendation|api/content/recommendation|v2/content/recommendation" docs/20260623_메인_콘텐츠_추천_탭_API src/main/kotlin src/test/kotlin`
- 기대 결과: 잔여 `audio.recommendation` 패키지 참조는 과거 검증 기록을 제외하고 남지 않고, PRD/plan-task의 최종 구조가 `content.recommendation` 기준으로 일치한다.
- 검증 기록: 구현 완료 시 실행 명령, 결과, 잔여 참조가 남은 경우 사유를 이 task 아래에 한국어로 누적 기록한다.
### Phase 7: 성인 콘텐츠 조회 정책 계산 경로 통일
- [ ] **Task 7.1: MemberContentPreferenceService에 성인 콘텐츠 조회 가능 여부 메서드 추가**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt`
- RED: `canViewAdultContent(member)`가 저장된 `isAdultContentVisible` 설정, 국가 정책, 성인 인증 여부를 반영해 `ViewerContentPreference.isAdult`와 같은 값을 반환하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest`
- GREEN: `MemberContentPreferenceService.canViewAdultContent(member: Member): Boolean`을 추가하고 내부 구현은 `getStoredPreference(member).isAdult`를 반환한다.
- REFACTOR: 성인 콘텐츠 조회 가능 여부를 계산하는 신규 호출부는 `isAdultVisibleByPolicy(...)`를 직접 호출하지 않고 service 메서드를 사용한다.
- 기대 결과: 사용자 설정(`isAdultContentVisible`), 국가 정책, 성인 인증 여부가 하나의 공개 service 메서드로 일관되게 계산된다.
- 검증 기록: 구현 완료 시 실행 명령, 결과, 실패 시 원인과 수정 내용을 이 task 아래에 한국어로 누적 기록한다.
- [ ] **Task 7.2: 추천 탭과 v2 조회 계층의 성인 정책 호출부를 service 메서드로 교체**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt`
- Test: 기존 v2 조회 service 테스트 중 성인 콘텐츠 노출 정책을 검증하는 테스트 파일
- RED: `AudioRecommendationQueryServiceTest`에서 `memberContentPreferenceService.canViewAdultContent(member)`가 호출되고 `getStoredPreference(...)` 또는 `isAdultVisibleByPolicy(...)` 직접 조합을 사용하지 않는 테스트를 작성한다. 기존 v2 조회 service 테스트에는 성인 콘텐츠 노출 가능/불가 회원별 조회 조건이 유지되는 회귀 테스트를 추가한다.
- 실패 확인:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest`
- Run: 변경한 기존 v2 조회 service 테스트
- GREEN: 각 호출부의 `getStoredPreference(...)` + `isAdultVisibleByPolicy(...)` 조합 또는 `getStoredPreference(...).isAdult` 직접 사용을 `memberContentPreferenceService.canViewAdultContent(member)`로 교체한다.
- REFACTOR: 더 이상 필요 없는 `isAdultVisibleByPolicy` import와 중간 `preference` 지역 변수를 제거한다.
- 기대 결과: v2 조회 계층과 추천 탭 API의 성인 콘텐츠 조회 정책 계산 경로가 `MemberContentPreferenceService.canViewAdultContent(...)`로 통일된다.
- 검증 기록: 구현 완료 시 실행 명령, 결과, 실패 시 원인과 수정 내용을 이 task 아래에 한국어로 누적 기록한다.
- [ ] **Task 7.3: 성인 정책 직접 호출 잔여 참조 확인**
- Files:
- Verify: `src/main/kotlin`
- Verify: `src/test/kotlin`
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`
- TDD 예외 사유: 검색 기반 잔여 참조 확인 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run: `rg -n "isAdultVisibleByPolicy|getStoredPreference\\([^\\n]*\\)\\.isAdult" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2`
- Run: `rg -n "canViewAdultContent\\(" src/main/kotlin/kr/co/vividnext/sodalive src/test/kotlin/kr/co/vividnext/sodalive`
- 기대 결과: v2 조회 계층에는 성인 콘텐츠 조회 가능 여부 계산을 위한 `isAdultVisibleByPolicy(...)` 직접 호출이나 `getStoredPreference(...).isAdult` 직접 사용이 남지 않고, `canViewAdultContent(...)` 호출로 통일된다.
- 검증 기록: 구현 완료 시 실행 명령, 결과, 잔여 참조가 남은 경우 사유를 이 task 아래에 한국어로 누적 기록한다.
- [ ] **Task 7.4: 중복 성인 정책 함수 정리**
- Files:
- Modify/Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicy.kt`
- Modify/Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`
- Verify: `src/main/kotlin`
- Verify: `src/test/kotlin`
- TDD 예외 사유: 정책 계산 로직의 공개 진입점 정리와 잔여 사용처 확인 task이며, Task 7.1/7.2의 회귀 테스트가 동작 동일성을 검증한다.
- 대체 검증 방법:
- Run: `rg -n "isAdultVisibleByPolicy|resolveCountryCodeByPolicy" src/main/kotlin src/test/kotlin`
- Run: `rg -n "calculateIsAdultForQuery|canViewAdultContent\\(" src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference`
- GREEN: `isAdultVisibleByPolicy(...)``resolveCountryCodeByPolicy(...)`의 production 사용처가 모두 없어졌으면 제거한다. 아직 v2 외부 사용처가 남아 있으면 즉시 제거하지 않고 `@Deprecated("Use MemberContentPreferenceService.canViewAdultContent(member)")`로 표시한 뒤 별도 후속 task를 남긴다.
- REFACTOR: 성인 콘텐츠 조회 가능 여부 정책의 canonical 진입점은 `MemberContentPreferenceService.canViewAdultContent(member)`로 문서화하고, 내부 계산은 기존 `calculateIsAdultForQuery(...)`를 재사용한다.
- 기대 결과: 동일한 정책을 중복 구현한 `isAdultVisibleByPolicy(...)` 경로가 제거되거나 명확히 deprecated 처리되어, 신규 호출부가 다시 분산되지 않는다.
- 검증 기록: 구현 완료 시 실행 명령, 결과, 제거하지 못한 사용처가 있으면 사유와 후속 task를 이 task 아래에 한국어로 누적 기록한다.
### Phase 8: 회귀 검증과 문서 기록
- [ ] **Task 8.1: 전체 관련 테스트와 ktlint 실행**
- Files:
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md`
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`
- TDD 예외 사유: 구현 완료 후 회귀 검증과 문서 기록 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.*`
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.*`
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.*`
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.*`
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest`
- Run: `./gradlew ktlintCheck`
- 기대 결과: 모든 관련 테스트와 ktlint가 `BUILD SUCCESSFUL`이다.
- 검증 기록: 구현 완료 시 실행 명령, 결과, 실패 시 원인과 수정 내용을 이 task 아래에 한국어로 누적 기록한다.
- [ ] **Task 6.2: 문서/스키마 영향 최종 확인**
- [ ] **Task 8.2: 문서/스키마 영향 최종 확인**
- Files:
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md`
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`
@@ -479,8 +592,11 @@ interface AudioRecommendationQueryPort {
- TDD 예외 사유: 문서와 enum 확장 범위 확인 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run: `rg -n "GET /api/v2/audio/recommendations|AudioRecommendationsResponse|NEW_AND_HOT_AUDIO_SAFE|RECOMMENDED_AUDIO_ALL" docs src/main/kotlin src/test/kotlin`
- Run: `rg -n "api\\.audio\\.recommendation|v2\\.audio\\.recommendation|api/audio/recommendation|v2/audio/recommendation" src/main/kotlin src/test/kotlin`
- Run: `rg -n "isAdultVisibleByPolicy|getStoredPreference\\([^\\n]*\\)\\.isAdult" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2`
- Run: `rg -n "isAdultVisibleByPolicy|resolveCountryCodeByPolicy" src/main/kotlin src/test/kotlin`
- Run: `./gradlew tasks --all`
- 기대 결과: 공개 API endpoint와 응답 필드명이 문서/코드/테스트에서 일치하고, 신규 DB 테이블 DDL이 필요하지 않음이 확인된다.
- 기대 결과: 공개 API endpoint와 응답 필드명이 문서/코드/테스트에서 일치하고, 신규 DB 테이블 DDL이 필요하지 않으며, 코드의 최종 패키지 구조가 `content.recommendation` 기준이고, v2 성인 콘텐츠 조회 정책 계산 경로가 service 메서드로 통일됐음이 확인된다.
- 검증 기록: 구현 완료 시 문서와 코드 검색 결과를 이 task 아래에 한국어로 누적 기록한다.
---
@@ -489,6 +605,7 @@ interface AudioRecommendationQueryPort {
- 계획 문서 생성 시점에는 구현 코드를 변경하지 않았으므로 테스트 실행 대상은 없다.
- 문서 변경 후 명령 유효성 확인은 `./gradlew tasks --all`로 수행한다.
- 패키지 구조 변경 계획 문서 수정 후 `./gradlew tasks --all`을 실행했다. 최초 sandbox 실행은 Gradle wrapper lock 파일의 `~/.gradle` 접근 권한 문제로 실패했고, 승인 후 재실행해 `BUILD SUCCESSFUL`을 확인했다.
## Phase 1-3 검증 기록
@@ -502,3 +619,23 @@ interface AudioRecommendationQueryPort {
- `./gradlew ktlintCheck`: `BUILD SUCCESSFUL`.
- 추가 code review 지적 사항 반영: production `SecurityConfig``GET /api/v2/audio/recommendations` 비회원 허용 추가, controller 테스트의 테스트 전용 permitAll 보안 체인 제거, 오디오 추천 배너의 성인 배너 필터 제거로 홈 추천 배너와 동일 정책 유지, 오리지널 시리즈 최신순/12개 limit 및 무료/포인트 10개 limit 테스트 보강.
- 동일 targeted test 명령과 `./gradlew ktlintCheck`를 재실행했고 모두 `BUILD SUCCESSFUL`.
## Phase 4-5 검증 기록
- RED: `DefaultAudioRecommendationQueryRepositoryTest`, `AudioRecommendationSnapshotRefreshServiceTest`, `AudioRecommendationSnapshotSchedulerTest`, `AudioRecommendationQueryServiceTest`에 Phase 4/5 실패 테스트를 먼저 추가했다. 초기 실행에서 query service Mockito matcher 오류와 동시 Gradle 실행으로 인한 XML 결과 파일 쓰기 충돌, ktlint formatting 실패를 확인했다.
- GREEN: snapshot 후보 native SQL, 최신 댓글 상세 조회, KST 기준 refresh service, 00:00 KST Redisson lock scheduler, query service snapshot 조립과 New & Hot lazy refresh를 구현하고 실패 원인을 수정했다.
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`: `BUILD SUCCESSFUL` (New & Hot/최근 댓글/추천 후보 산정, 댓글 상세, 기존 실시간 섹션 회귀 포함).
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest`: `BUILD SUCCESSFUL` (KST 전날 23:59:59 기준과 여섯 section replace 확인).
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.scheduler.AudioRecommendationSnapshotSchedulerTest`: `BUILD SUCCESSFUL` (cron/zone, lock 획득/skip/unlock 확인).
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest`: `BUILD SUCCESSFUL` (SAFE snapshot 조회, New & Hot lazy refresh, 빈 mostCommented/recommended 허용 확인).
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.audio.recommendation.*'`: `BUILD SUCCESSFUL` (facade DTO 변환과 controller permitAll 응답 계약 확인).
- `./gradlew ktlintCheck`: `BUILD SUCCESSFUL`.
- 추가 점검: 댓글 상세 조회에서 차단 작성자 제외 후 최신 active 댓글을 선택하도록 보강하고 `DefaultAudioRecommendationQueryRepositoryTest`, `./gradlew ktlintCheck`를 재실행해 모두 `BUILD SUCCESSFUL`을 확인했다.
- 추가 code review 지적 사항 반영: `findCommentedAudiosByIds`의 최신 댓글 상세 조회가 스냅샷 산정 SQL과 동일하게 크리에이터-댓글 작성자 간 차단 댓글을 제외하도록 보강하고, 해당 작성자의 더 최신 댓글이 이전 정상 댓글 선택을 막지 않도록 `newer` 후보에도 같은 차단 조건을 적용했다.
- `DefaultAudioRecommendationQueryRepositoryTest`에 viewer와 무관한 크리에이터-댓글 작성자 차단 회귀 테스트를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`: `BUILD SUCCESSFUL`.
- Task 5.3 보강: `AudioRecommendationEndToEndTest`를 추가해 `@SpringBootTest` + `@AutoConfigureMockMvc`로 production SecurityConfig, controller, facade, query service, repository, snapshot 조회 조합을 통과하는 최소 E2E를 검증했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationEndToEndTest`: `BUILD SUCCESSFUL`.
- 2026-06-23 리뷰 보정: 스냅샷 기준/윈도우를 UTC 변환 `LocalDateTime`이 아니라 KST-local `LocalDateTime`으로 저장/조회하도록 보정하고, 최신성 일수는 24시간 경과 기준으로 Kotlin 정책을 맞췄다. New & Hot lazy refresh는 보강 후에도 row가 없으면 Redis marker 기준 같은 KST 날짜에 반복 실행하지 않도록 보강했다.
- 2026-06-23 리뷰 보정 후 추가 보정: post-implementation review에서 `getRecommendations()`의 read-only transaction 안에서 lazy refresh 후 재조회하면 MySQL `REPEATABLE_READ` read view 때문에 새 스냅샷이 같은 요청에서 보이지 않을 수 있다고 지적해, query service의 외부 read-only transaction을 제거했다. 또한 인메모리 guard는 프로세스 재시작/다중 서버에서 KST 날짜별 1회를 보장하지 못하므로 `RedissonClient` Redis marker(`audio-recommendation:new-and-hot:lazy-refresh-attempted:{yyyy-MM-dd}`, TTL 2일)로 변경했다.
- 2026-06-23 리뷰 보정 검증: `./gradlew --stop && ./gradlew clean test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicyTest`: `BUILD SUCCESSFUL`.
- 2026-06-23 리뷰 보정 검증: repository focused test는 병렬 Gradle 실행 중 `kaptGenerateStubsTestKotlin` 출력 디렉터리 충돌로 1회 실패해 단독 재실행했다. H2 `MODE=MySQL``TIMESTAMPDIFF` 경계 동작이 운영 MySQL 공식 기준과 달라 신규 repository 경계 테스트는 제거하고 Kotlin 정책 테스트로 24시간 경계를 고정했다. 최종 `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`: `BUILD SUCCESSFUL`.
- 2026-06-23 리뷰 보정 검증: `./gradlew ktlintCheck`: `BUILD SUCCESSFUL`, `git diff --check`: 출력 없음.

View File

@@ -237,17 +237,18 @@ data class CommentedAudioResponse(
## 10. Technical Constraints
### 패키지 구조
- 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.audio.recommendation` 하위에 둔다.
- 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.content.recommendation` 하위에 둔다.
- Controller: `...adapter.in.web`
- Facade: `...application`
- Response DTO: `...dto`
- 도메인 조회 계층은 `kr.co.vividnext.sodalive.v2.audio.recommendation` 하위에 둔다.
- 도메인 조회 계층은 `kr.co.vividnext.sodalive.v2.content.recommendation` 하위에 둔다.
- Query service: `...application`
- 점수 정책/domain model: `...domain`
- 조회 port: `...port.out`
- QueryDSL/JPA 구현: `...adapter.out.persistence`
- scheduler: `...adapter.out.scheduler`
- 의존 방향은 `v2.api.audio.recommendation -> v2.audio.recommendation`만 허용한다.
- `content` 패키지는 오디오 콘텐츠뿐 아니라 오리지널 시리즈 등 추천 탭에 포함될 수 있는 콘텐츠 범주를 포괄하기 위한 명칭이다.
- 의존 방향은 `v2.api.content.recommendation -> v2.content.recommendation`만 허용한다.
### V2 공통화/재사용 대상
- `HomeBannerItem`은 메인 홈 전용 DTO가 아니라 여러 추천 화면에서 사용할 배너 응답 구조이므로 `v2.api.common.dto` 계열 공통 DTO로 분리한다.
@@ -266,7 +267,8 @@ data class CommentedAudioResponse(
- New & Hot, 최근 댓글 많은 오디오, 추천 오디오는 스냅샷 기반 조회를 우선한다.
- 스케줄러는 `@Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul")` 기준으로 설계한다.
- 다중 서버 환경에서 중복 실행을 막기 위해 기존 Redisson lock 패턴을 따른다.
- 스냅샷 기준 시각은 KST 전날 `23:59:59`로 저장한다.
- 스냅샷 기준 시각은 KST 전날 `23:59:59`를 UTC 변환 없이 KST-local `LocalDateTime`로 저장한다.
- 스냅샷 집계 window도 KST-local `00:00:00`부터 KST-local `23:59:59`까지를 기준으로 계산한다.
- 19금 노출 영향을 받는 스냅샷 섹션은 visibility variant를 저장한다.
- `SAFE`: 19금이 아닌 콘텐츠만 포함한다.
- `ALL`: 19금 콘텐츠와 19금이 아닌 콘텐츠를 모두 포함한다.
@@ -279,6 +281,8 @@ data class CommentedAudioResponse(
- 비회원은 성인 콘텐츠를 제외한다.
- 차단 관계 필터는 기존 v2 홈/크리에이터 채널 조회 패턴을 따른다.
- 조회수 점수의 조회수는 `AudioContent.playCount`가 아니라 `creator_content_view_history`의 상세 페이지 조회 이력 count를 사용한다.
- 최신성 점수의 일수는 날짜 경계가 아니라 시간까지 포함한 24시간 경과 일수 기준으로 계산한다.
- New & Hot lazy 보강은 스냅샷 row가 없을 때 Redis marker 기준 KST 날짜별 1회만 시도하고, 보강 후 후보가 0개인 정상 상황에서는 같은 날짜의 다음 조회가 전체 refresh를 반복하지 않는다.
- 공통 오디오 카드 응답의 `isOriginalSeries`는 시리즈 미소속 오디오이면 클라이언트 편의를 위해 `false`로 내려준다.
- 무료/포인트/추천 오디오처럼 서로 다른 추천 섹션에 같은 콘텐츠가 동시에 포함되어도 서버에서 중복 제거하지 않는다.