test #426
504
docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md
Normal file
504
docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md
Normal file
@@ -0,0 +1,504 @@
|
||||
# 메인 콘텐츠 추천 탭 API Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
||||
|
||||
**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 확장 방식으로 재사용한다.
|
||||
|
||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, native SQL, Redisson, JUnit 5, MockMvc, Gradle Wrapper
|
||||
|
||||
---
|
||||
|
||||
## 0. 구현 전 확정 사항
|
||||
|
||||
- API endpoint: `GET /api/v2/audio/recommendations`
|
||||
- 인증 정책: 비회원 조회 가능. 인증 회원이면 회원의 콘텐츠 조회 설정과 19금 노출 가능 여부를 반영한다.
|
||||
- 응답 wrapper: `ApiResponse.ok(...)`
|
||||
- 기본 노출 수:
|
||||
- `banners`: 메인 홈 추천 배너와 동일
|
||||
- `originalSeries`: 최신순 12개
|
||||
- `latestAudios`: 최신순 12개
|
||||
- `newAndHotAudios`: 최대 12개
|
||||
- `freeAudios`: 최대 10개 랜덤
|
||||
- `pointAudios`: 최대 10개 랜덤
|
||||
- `mostCommentedAudios`: 최대 5개
|
||||
- `recommendedAudios`: 최대 10개
|
||||
- 공개 오디오 공통 조건: `AudioContent.isActive == true`, `AudioContent.duration != null`, `AudioContent.releaseDate != null`, `AudioContent.releaseDate <= now`, 크리에이터 회원 활성.
|
||||
- 비회원과 19금 노출 불가 회원은 성인 콘텐츠를 제외하고 `SAFE` 스냅샷을 조회한다.
|
||||
- 19금 노출 가능 회원은 성인/비성인 콘텐츠를 모두 포함하는 `ALL` 스냅샷을 조회한다.
|
||||
- 스냅샷 기준: KST 매일 00:00 실행, 전날 23:59:59 KST까지의 데이터 반영.
|
||||
- 스냅샷 저장 방식: 기존 `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%.
|
||||
- 최근 댓글 많은 오디오 점수: 댓글 수 80%, 댓글 최신성 20%.
|
||||
- 조회수/좋아요/댓글 수는 후보 내 정규화 없이 원본 count를 그대로 사용한다.
|
||||
- 무료/포인트/추천 오디오 섹션 사이에는 같은 콘텐츠가 중복 노출될 수 있다.
|
||||
- `isOriginalSeries`는 시리즈 미소속 오디오이면 `false`로 내려준다.
|
||||
- 전체보기/페이징 API, 관리자 화면, 수동 편집 기능은 이번 범위에 포함하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 파일 구조 계획
|
||||
|
||||
### API 공통 DTO
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/RecommendationBanner.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/common/dto/RecommendationBannerResponse.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||
- 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: `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`
|
||||
- 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`
|
||||
|
||||
### 기존 재사용 파일 확인
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshot.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/RecommendationSnapshotPort.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/CreatorContentViewHistory.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`
|
||||
|
||||
---
|
||||
|
||||
## 2. Response data class 초안
|
||||
|
||||
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/dto/AudioRecommendationsResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
|
||||
|
||||
```kotlin
|
||||
package kr.co.vividnext.sodalive.v2.api.audio.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
|
||||
|
||||
data class AudioRecommendationsResponse(
|
||||
val banners: List<RecommendationBannerResponse>,
|
||||
val originalSeries: List<OriginalSeriesResponse>,
|
||||
val latestAudios: List<AudioCardResponse>,
|
||||
val newAndHotAudios: List<AudioCardResponse>,
|
||||
val freeAudios: List<AudioCardResponse>,
|
||||
val pointAudios: List<AudioCardResponse>,
|
||||
val mostCommentedAudios: List<CommentedAudioResponse>,
|
||||
val recommendedAudios: List<AudioCardResponse>
|
||||
) {
|
||||
companion object {
|
||||
fun from(recommendations: AudioRecommendations): AudioRecommendationsResponse {
|
||||
return AudioRecommendationsResponse(
|
||||
banners = recommendations.banners.map(RecommendationBannerResponse::from),
|
||||
originalSeries = recommendations.originalSeries.map(OriginalSeriesResponse::from),
|
||||
latestAudios = recommendations.latestAudios.map(AudioCardResponse::from),
|
||||
newAndHotAudios = recommendations.newAndHotAudios.map(AudioCardResponse::from),
|
||||
freeAudios = recommendations.freeAudios.map(AudioCardResponse::from),
|
||||
pointAudios = recommendations.pointAudios.map(AudioCardResponse::from),
|
||||
mostCommentedAudios = recommendations.mostCommentedAudios.map(CommentedAudioResponse::from),
|
||||
recommendedAudios = recommendations.recommendedAudios.map(AudioCardResponse::from)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class OriginalSeriesResponse(
|
||||
val seriesId: Long,
|
||||
val coverImageUrl: String?
|
||||
) {
|
||||
companion object {
|
||||
fun from(series: OriginalSeries): OriginalSeriesResponse {
|
||||
return OriginalSeriesResponse(series.seriesId, series.coverImageUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class AudioCardResponse(
|
||||
val audioContentId: Long,
|
||||
val title: String,
|
||||
val duration: String?,
|
||||
val imageUrl: String?,
|
||||
val price: Int,
|
||||
@JsonProperty("isAdult")
|
||||
val isAdult: Boolean,
|
||||
@JsonProperty("isPointAvailable")
|
||||
val isPointAvailable: Boolean,
|
||||
@JsonProperty("isFirstContent")
|
||||
val isFirstContent: Boolean,
|
||||
@JsonProperty("isOriginalSeries")
|
||||
val isOriginalSeries: Boolean,
|
||||
val creatorNickname: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(audio: AudioCard): AudioCardResponse {
|
||||
return AudioCardResponse(
|
||||
audioContentId = audio.audioContentId,
|
||||
title = audio.title,
|
||||
duration = audio.duration,
|
||||
imageUrl = audio.imageUrl,
|
||||
price = audio.price,
|
||||
isAdult = audio.isAdult,
|
||||
isPointAvailable = audio.isPointAvailable,
|
||||
isFirstContent = audio.isFirstContent,
|
||||
isOriginalSeries = audio.isOriginalSeries,
|
||||
creatorNickname = audio.creatorNickname
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class CommentedAudioResponse(
|
||||
val audioContentId: Long,
|
||||
val title: String,
|
||||
val imageUrl: String?,
|
||||
val latestComment: String,
|
||||
val latestCommentWriterProfileImageUrl: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(audio: CommentedAudio): CommentedAudioResponse {
|
||||
return CommentedAudioResponse(
|
||||
audioContentId = audio.audioContentId,
|
||||
title = audio.title,
|
||||
imageUrl = audio.imageUrl,
|
||||
latestComment = audio.latestComment,
|
||||
latestCommentWriterProfileImageUrl = audio.latestCommentWriterProfileImageUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Domain / Port 초안
|
||||
|
||||
```kotlin
|
||||
package kr.co.vividnext.sodalive.v2.audio.recommendation.domain
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
|
||||
|
||||
data class AudioRecommendations(
|
||||
val banners: List<RecommendationBanner>,
|
||||
val originalSeries: List<OriginalSeries>,
|
||||
val latestAudios: List<AudioCard>,
|
||||
val newAndHotAudios: List<AudioCard>,
|
||||
val freeAudios: List<AudioCard>,
|
||||
val pointAudios: List<AudioCard>,
|
||||
val mostCommentedAudios: List<CommentedAudio>,
|
||||
val recommendedAudios: List<AudioCard>
|
||||
)
|
||||
|
||||
data class OriginalSeries(
|
||||
val seriesId: Long,
|
||||
val coverImageUrl: String?
|
||||
)
|
||||
|
||||
data class AudioCard(
|
||||
val audioContentId: Long,
|
||||
val title: String,
|
||||
val duration: String?,
|
||||
val imageUrl: String?,
|
||||
val price: Int,
|
||||
val isAdult: Boolean,
|
||||
val isPointAvailable: Boolean,
|
||||
val isFirstContent: Boolean,
|
||||
val isOriginalSeries: Boolean,
|
||||
val creatorNickname: String
|
||||
)
|
||||
|
||||
data class CommentedAudio(
|
||||
val audioContentId: Long,
|
||||
val title: String,
|
||||
val imageUrl: String?,
|
||||
val latestComment: String,
|
||||
val latestCommentWriterProfileImageUrl: String
|
||||
)
|
||||
|
||||
enum class AudioRecommendationVisibility {
|
||||
SAFE,
|
||||
ALL
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
package kr.co.vividnext.sodalive.v2.audio.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.common.domain.RecommendationBanner
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface AudioRecommendationQueryPort {
|
||||
fun findBanners(limit: Int, memberId: Long?, canViewAdultContent: Boolean): List<RecommendationBanner>
|
||||
fun findOriginalSeries(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<OriginalSeries>
|
||||
fun findLatestAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
|
||||
fun findFreeAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
|
||||
fun findPointAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
|
||||
fun findAudioCardsByIds(contentIds: List<Long>, memberId: Long?, canViewAdultContent: Boolean): List<AudioCard>
|
||||
fun findCommentedAudiosByIds(contentIds: List<Long>, memberId: Long?, canViewAdultContent: Boolean): List<CommentedAudio>
|
||||
fun findNewAndHotSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime, visibility: AudioRecommendationVisibility, limit: Int): List<RecommendationSnapshotRecord>
|
||||
fun findMostCommentedSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime, visibility: AudioRecommendationVisibility, limit: Int): List<RecommendationSnapshotRecord>
|
||||
fun findRecommendedAudioSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime, visibility: AudioRecommendationVisibility, limit: Int): List<RecommendationSnapshotRecord>
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: 공통 DTO와 API 계약
|
||||
|
||||
- [x] **Task 1.1: 배너 응답 DTO를 공통 패키지로 분리**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/RecommendationBanner.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/common/dto/RecommendationBannerResponse.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt`
|
||||
- RED: `HomeRecommendationResponse`의 `banners`가 공통 `RecommendationBannerResponse` 타입을 사용하고 기존 JSON 필드 `imageUrl`, `eventItem`, `creatorId`, `seriesId`, `link`를 유지하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`
|
||||
- GREEN: `HomeBannerItem` 필드 구조를 `RecommendationBanner` domain model과 `RecommendationBannerResponse` DTO로 분리하고 홈 추천 DTO/facade import를 갱신한다.
|
||||
- REFACTOR: 홈 탭 전용 controller/facade 로직은 이동하지 않고 DTO 타입만 공통화한다.
|
||||
- 기대 결과: 기존 홈 추천 배너 JSON 계약은 유지되고 신규 오디오 추천 API가 같은 DTO를 재사용할 수 있다.
|
||||
|
||||
- [x] **Task 1.2: 오디오 추천 응답 DTO와 facade 변환 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/dto/AudioRecommendationsResponse.kt`
|
||||
- Create: `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`
|
||||
- RED: facade가 도메인 `AudioRecommendations`를 `AudioRecommendationsResponse`로 변환하고 `originalSeries`, `latestAudios`, `newAndHotAudios`, `freeAudios`, `pointAudios`, `mostCommentedAudios`, `recommendedAudios` 필드를 모두 채우는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.application.AudioRecommendationFacadeTest`
|
||||
- GREEN: facade는 `AudioRecommendationQueryService.getRecommendations(member)`만 호출하고 공개 DTO 변환만 담당한다.
|
||||
- REFACTOR: `isOriginalSeries`는 `Boolean`으로 유지하고 nullable 변환을 만들지 않는다.
|
||||
- 기대 결과: API 조립 계층은 도메인 조회 계층에만 의존하고, 도메인 조회 계층은 API DTO에 의존하지 않는다.
|
||||
|
||||
- [x] **Task 1.3: 비회원 허용 controller 작성**
|
||||
- Files:
|
||||
- Create: `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`
|
||||
- RED: `GET /api/v2/audio/recommendations`가 비회원과 인증 회원 모두 `200 OK`를 반환하고 `ApiResponse.ok` wrapper를 사용하는 MockMvc 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationControllerTest`
|
||||
- GREEN: `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴으로 member nullable을 facade에 전달한다.
|
||||
- REFACTOR: request parameter는 추가하지 않고 controller에는 인증/응답 경계만 남긴다.
|
||||
- 기대 결과: 비회원 조회 가능 계약과 endpoint 경로가 controller 테스트로 고정된다.
|
||||
|
||||
### Phase 2: 도메인 모델과 점수 정책
|
||||
|
||||
- [x] **Task 2.1: 도메인 모델과 visibility enum 작성**
|
||||
- Files:
|
||||
- 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/AudioRecommendationVisibility.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryServiceTest.kt`
|
||||
- RED: `AudioRecommendationVisibility.SAFE`는 `NEW_AND_HOT_AUDIO_SAFE`, `ALL`은 `NEW_AND_HOT_AUDIO_ALL`처럼 section type을 선택해야 한다는 service 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest`
|
||||
- GREEN: `AudioRecommendations`, `OriginalSeries`, `AudioCard`, `CommentedAudio`, `AudioRecommendationVisibility`를 추가한다.
|
||||
- REFACTOR: domain model에는 API DTO import를 두지 않는다. `AudioRecommendations.banners`는 `v2.common.domain.RecommendationBanner`만 사용한다.
|
||||
- 기대 결과: SAFE/ALL 선택이 문자열이 아니라 enum으로 고정된다.
|
||||
|
||||
- [x] **Task 2.2: 오디오 추천 점수 정책 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicy.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicyTest.kt`
|
||||
- RED: New & Hot 최신성 배수 3/7/14일/그 외, 추천 오디오 최신성 배수 3/7/30일/그 외, 최근 댓글 최신성 배수 3/7/14일/그 이상을 검증하는 테스트를 작성한다. 원본 count 가중합이 정규화 없이 계산되는 테스트도 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicyTest`
|
||||
- GREEN: `calculateNewAndHotScore`, `calculateRecommendedAudioScore`, `calculateCommentScore`와 각 recency multiplier 함수를 구현한다.
|
||||
- REFACTOR: 가중치와 일수 경계는 `companion object` 상수로 모아 테스트 기대값과 용어를 맞춘다.
|
||||
- 기대 결과: PRD 산식과 최신성 경계가 순수 단위 테스트로 고정된다.
|
||||
|
||||
- [x] **Task 2.3: 스냅샷 section enum 확장**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedSectionType.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryServiceTest.kt`
|
||||
- RED: visibility와 섹션 조합이 `NEW_AND_HOT_AUDIO_SAFE`, `NEW_AND_HOT_AUDIO_ALL`, `MOST_COMMENTED_AUDIO_SAFE`, `MOST_COMMENTED_AUDIO_ALL`, `RECOMMENDED_AUDIO_SAFE`, `RECOMMENDED_AUDIO_ALL`로 매핑되는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest`
|
||||
- GREEN: 기존 `RecommendedSectionType`에 오디오 추천 섹션 enum 값을 추가하고 service 내부 매핑 함수를 구현한다.
|
||||
- REFACTOR: `recommendation_snapshot.section_type` 길이 50 안에 모든 enum 이름이 들어가는지 확인한다.
|
||||
- 기대 결과: 신규 테이블 없이 기존 스냅샷 저장 구조를 재사용한다.
|
||||
|
||||
### Phase 3: 실시간 조회 섹션 repository
|
||||
|
||||
- [x] **Task 3.1: 배너/오리지널 시리즈/최신 오디오 조회 구현**
|
||||
- Files:
|
||||
- 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`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
||||
- RED: 배너는 기존 홈 추천 배너와 동일 필드/활성/차단 정책을 적용하고, 오리지널 시리즈는 `isOriginal = true` 최신순 12개, 최신 오디오는 `releaseDate desc`, `audioContentId desc` 12개를 반환하는 repository 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
|
||||
- GREEN: QueryDSL로 `findBanners`, `findOriginalSeries`, `findLatestAudios`를 구현한다. 이미지 경로는 `toCdnUrl(cloudFrontHost)`를 사용한다.
|
||||
- REFACTOR: 공개 오디오 조건, 성인 콘텐츠 조건, 차단 관계 조건을 private 조건 함수로 분리한다.
|
||||
- 기대 결과: 비회원은 성인 콘텐츠를 제외하고, 인증 회원은 성인 노출 가능 여부에 따라 결과가 달라진다.
|
||||
|
||||
- [x] **Task 3.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`
|
||||
- RED: 무료 오디오는 `price = 0` 공개 오디오 중 최대 10개, 포인트 오디오는 `isPointAvailable = true` 공개 오디오 중 최대 10개를 반환하고 두 섹션 간 중복을 제거하지 않는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
|
||||
- GREEN: `findFreeAudios`, `findPointAudios`를 구현하고 DB 랜덤 정렬은 기존 repository 관례에 맞춰 `Expressions.numberTemplate(Double::class.java, "function('rand')")` 또는 동일 프로젝트에서 쓰는 랜덤 정렬 방식을 사용한다.
|
||||
- REFACTOR: 무료/포인트 조회가 같은 공통 projection 함수를 사용하게 정리한다.
|
||||
- 기대 결과: 랜덤 섹션도 공개/성인/차단 조건을 동일하게 적용한다.
|
||||
|
||||
- [x] **Task 3.3: 공통 오디오 카드 enrichment 구현**
|
||||
- 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`
|
||||
- RED: `AudioCard`가 `audioContentId`, `title`, `duration`, `imageUrl`, `price`, `isAdult`, `isPointAvailable`, `isFirstContent`, `isOriginalSeries`, `creatorNickname`을 채우고, 시리즈 미소속이면 `isOriginalSeries = false`인 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
|
||||
- GREEN: first content 판정은 기존 크리에이터 채널 오디오 조회 repository의 첫 콘텐츠 계산 패턴을 참고해 구현한다. 원본 시리즈 연결이 없으면 `false`, 연결 시리즈가 있으면 `series.isOriginal`을 사용한다.
|
||||
- REFACTOR: latest/free/point/snapshot 상세 조회 모두 같은 `toAudioCard` 변환을 사용한다.
|
||||
- 기대 결과: 섹션별 오디오 카드 필드 의미가 동일하게 유지된다.
|
||||
|
||||
### Phase 4: 스냅샷 산정과 일 배치
|
||||
|
||||
- [ ] **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`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
||||
- RED: 최근 3일 `creator_content_view_history` count, `content_like` active count, `audio_content_comment` active count, 최신성 배수를 원본 count 가중합으로 계산하고 `SAFE`는 비성인만, `ALL`은 성인/비성인을 모두 포함하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
|
||||
- GREEN: native SQL CTE 또는 QueryDSL aggregate로 `findNewAndHotSnapshots(windowStart, snapshotAt, visibility, limit)`를 구현한다. 정렬은 `score desc`, `randomTieBreaker asc`로 한다.
|
||||
- REFACTOR: Kotlin `AudioRecommendationScorePolicy` 기대값과 DB score가 일치하는 parity 테스트 데이터를 유지한다.
|
||||
- 기대 결과: `NEW_AND_HOT_AUDIO_SAFE/ALL`에 저장할 top 12 후보가 정확한 점수순으로 산출된다.
|
||||
|
||||
- [ ] **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`
|
||||
- RED: 최근 7일 댓글 데이터 기반으로 댓글 수 80%, 댓글 최신성 20% 점수를 계산하고 데이터가 없으면 빈 후보를 반환하는 테스트를 작성한다. 가장 최신 댓글 1개의 본문과 작성자 프로필 이미지가 상세 조회에서 내려가는 테스트도 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
|
||||
- GREEN: `findMostCommentedSnapshots(...)`와 `findCommentedAudiosByIds(...)`를 구현한다. 상세 조회 결과에는 가장 최신 댓글 본문과 작성자 프로필 이미지를 포함한다. 비활성 댓글, 삭제된 댓글, 차단 관계의 댓글 작성자는 제외한다.
|
||||
- REFACTOR: 댓글 최신성 배수 계산은 repository SQL과 `AudioRecommendationScorePolicy`가 같은 경계값을 사용하도록 테스트로 고정한다.
|
||||
- 기대 결과: 스냅샷이 없거나 후보가 없으면 `mostCommentedAudios`는 빈 배열이다.
|
||||
|
||||
- [ ] **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`
|
||||
- RED: 상세 조회수 45%, 좋아요 25%, 댓글 수 20%, 최신성 10% 점수를 계산하고 `SAFE/ALL` visibility별 최대 10개 후보를 반환하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
|
||||
- GREEN: `findRecommendedAudioSnapshots(...)`를 구현한다. 상세 조회수는 `creator_content_view_history` count를 사용하고 `AudioContent.playCount`를 사용하지 않는다.
|
||||
- REFACTOR: New & Hot과 공유 가능한 조회수/좋아요/댓글 aggregate CTE를 private SQL fragment 또는 QueryDSL helper로 정리한다.
|
||||
- 기대 결과: `RECOMMENDED_AUDIO_SAFE/ALL`에 저장할 top 10 후보가 정확한 점수순으로 산출된다.
|
||||
|
||||
- [ ] **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 변환 테스트를 유지한다.
|
||||
- 기대 결과: 일 배치와 lazy 보강 모두 같은 산정 함수를 사용한다.
|
||||
|
||||
- [ ] **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`
|
||||
- RED: cron이 `0 0 0 * * *`, zone이 `Asia/Seoul`, lock key가 `lock:audio-recommendation-snapshot-refresh`이고 lock 획득 성공 시에만 refresh service를 호출하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.scheduler.AudioRecommendationSnapshotSchedulerTest`
|
||||
- GREEN: `RedissonClient`를 주입하고 기존 추천 스냅샷 scheduler 패턴처럼 `tryLock` 성공 시 `refreshDailySnapshots()`를 호출한다.
|
||||
- REFACTOR: 스케줄러에는 lock과 service 호출만 남기고 집계 로직을 두지 않는다.
|
||||
- 기대 결과: 다중 서버에서 하루 한 번만 오디오 추천 스냅샷을 갱신한다.
|
||||
|
||||
### Phase 5: 통합 조회 service와 API 연결
|
||||
|
||||
- [ ] **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`
|
||||
- RED: 비회원은 `SAFE` visibility와 19금 제외 조건을 사용하고, 19금 노출 가능 회원은 `ALL` visibility를 사용하며, 각 섹션 limit이 PRD와 일치하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest`
|
||||
- GREEN: query service가 real-time 섹션과 snapshot 섹션을 조립해 `AudioRecommendations`를 반환한다. `MemberContentPreferenceService`는 facade가 아니라 query service 또는 별도 resolver에서 사용해 도메인 조회 조건을 만든다.
|
||||
- REFACTOR: 섹션 limit은 companion object 상수로 고정하고 테스트에서 같은 값을 검증한다.
|
||||
- 기대 결과: 특정 섹션이 빈 배열이어도 전체 응답은 성공 가능한 domain model로 조립된다.
|
||||
|
||||
- [ ] **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`
|
||||
- RED: 회원/비회원별 성인 노출 정책이 query service에 전달되고, CDN URL이 포함된 domain 응답이 공개 DTO로 변환되는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.application.AudioRecommendationFacadeTest`
|
||||
- GREEN: facade는 member를 그대로 query service에 전달하고 `AudioRecommendationsResponse.from(...)`만 수행한다.
|
||||
- REFACTOR: Home 탭 전용 `HomeRecommendationFacade`를 주입하거나 호출하지 않는지 import를 확인한다.
|
||||
- 기대 결과: API 조립 계층은 신규 audio recommendation use case만 호출한다.
|
||||
|
||||
- [ ] **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`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt`
|
||||
- RED: MockMvc controller 테스트와 최소 E2E 테스트를 작성해 JSON path `$.data.originalSeries`, `$.data.latestAudios`, `$.data.recommendedAudios`, `$.data.latestAudios[0].isOriginalSeries`, `$.data.mostCommentedAudios[0].latestComment`, `$.data.mostCommentedAudios[0].latestCommentWriterProfileImageUrl`가 존재하는지 검증한다.
|
||||
- 실패 확인: `./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`
|
||||
- GREEN: controller와 Spring bean wiring을 완성한다.
|
||||
- REFACTOR: 응답 필드명이 PRD와 plan-task의 DTO 초안과 같은지 검색으로 확인한다.
|
||||
- 기대 결과: 비회원과 인증 회원 모두 endpoint 호출이 성공한다.
|
||||
|
||||
### Phase 6: 회귀 검증과 문서 기록
|
||||
|
||||
- [ ] **Task 6.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.api.home.dto.recommendation.HomeRecommendationResponseTest`
|
||||
- Run: `./gradlew ktlintCheck`
|
||||
- 기대 결과: 모든 관련 테스트와 ktlint가 `BUILD SUCCESSFUL`이다.
|
||||
- 검증 기록: 구현 완료 시 실행 명령, 결과, 실패 시 원인과 수정 내용을 이 task 아래에 한국어로 누적 기록한다.
|
||||
|
||||
- [ ] **Task 6.2: 문서/스키마 영향 최종 확인**
|
||||
- Files:
|
||||
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md`
|
||||
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendedSectionType.kt`
|
||||
- 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: `./gradlew tasks --all`
|
||||
- 기대 결과: 공개 API endpoint와 응답 필드명이 문서/코드/테스트에서 일치하고, 신규 DB 테이블 DDL이 필요하지 않음이 확인된다.
|
||||
- 검증 기록: 구현 완료 시 문서와 코드 검색 결과를 이 task 아래에 한국어로 누적 기록한다.
|
||||
|
||||
---
|
||||
|
||||
## 전체 검증 기록
|
||||
|
||||
- 계획 문서 생성 시점에는 구현 코드를 변경하지 않았으므로 테스트 실행 대상은 없다.
|
||||
- 문서 변경 후 명령 유효성 확인은 `./gradlew tasks --all`로 수행한다.
|
||||
|
||||
|
||||
## Phase 1-3 검증 기록
|
||||
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`: `BUILD SUCCESSFUL` (홈 배너 공통 DTO 직렬화 필드 포함 확인).
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.application.AudioRecommendationFacadeTest`: 최초 실행 시 점수 정책 테스트 기대값 산식 오산으로 실패 후 기대값 수정.
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.application.AudioRecommendationFacadeTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`: `BUILD SUCCESSFUL`.
|
||||
- Phase 4 범위로 보일 수 있는 snapshot 후보 조회 stub 제거 후 동일한 6개 타깃 테스트 명령을 재실행했고 `BUILD SUCCESSFUL`.
|
||||
- reviewer 지적 사항 반영: `latestComment` 응답 필드 추가, PRD 기준 최신성 배수 수정, JSON boolean 필드명과 공개 오디오 필터 테스트 보강.
|
||||
- `./gradlew test --rerun-tasks --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.application.AudioRecommendationFacadeTest --tests kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.in.web.AudioRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`: `BUILD SUCCESSFUL`.
|
||||
- `./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`.
|
||||
298
docs/20260623_메인_콘텐츠_추천_탭_API/prd.md
Normal file
298
docs/20260623_메인_콘텐츠_추천_탭_API/prd.md
Normal file
@@ -0,0 +1,298 @@
|
||||
# PRD: 메인 콘텐츠 추천 탭 API
|
||||
|
||||
## 1. Overview
|
||||
메인 콘텐츠 탭의 내부 추천 탭에서 사용할 배너, 오리지널 시리즈, 신규/추천/무료/포인트 오디오, New & Hot, 최근 댓글 많은 오디오 섹션을 한 번에 조회하는 v2 API를 제공한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. Problem
|
||||
- 기존 `content.main.tab.home` API는 콘텐츠 홈 화면 전체 구성을 조립하지만, 신규 내부 추천 탭의 섹션 구성과 응답 필드가 다르다.
|
||||
- 신규 추천 탭은 실시간 최신순/랜덤 조회와 일 단위 스냅샷 기반 점수 섹션이 섞여 있어, API 조립 계층과 도메인 조회 계층의 책임을 분리해야 한다.
|
||||
- 기존 v2 패키지에 홈 추천 API, 스냅샷, 배너 조회, 오디오 응답 DTO와 유사한 코드가 있으므로 구현 전 재사용 범위를 명확히 해야 한다.
|
||||
- New & Hot, 최근 댓글 많은 오디오처럼 매일 갱신되는 섹션은 데이터가 없을 때 표시/스케줄 보강 정책이 필요하다.
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals
|
||||
- 메인 콘텐츠 추천 탭 조회 API를 `kr.co.vividnext.sodalive.v2` 하위 신규 코드로 제공한다.
|
||||
- 기존 패턴과 동일하게 공개 API 조립 계층과 도메인 조회 계층을 분리한다.
|
||||
- 메인 배너는 메인 홈 추천 배너와 동일한 데이터를 응답한다.
|
||||
- 오리지널 시리즈, 최신 오디오, 무료 오디오, 포인트 오디오는 요청 시점 기준으로 조회한다.
|
||||
- New & Hot, 최근 댓글 많은 오디오, 추천 오디오는 KST 매일 00:00에 전날 23:59:59 KST까지의 데이터를 반영한 스냅샷을 사용한다.
|
||||
- New & Hot 스냅샷 데이터가 없으면 조회 시점에 lazy로 스케줄/집계 보강을 요청할 수 있어야 한다.
|
||||
- 최근 댓글 많은 오디오는 스냅샷 데이터가 없으면 섹션을 빈 배열로 내려주어 앱에서 표시하지 않게 한다.
|
||||
- PRD에 API endpoint와 Response data class 초안을 포함한다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Non-Goals
|
||||
- 기존 `content.main.tab.home` 공개 API 스키마를 변경하지 않는다.
|
||||
- 기존 메인 홈 추천 API의 공개 스키마를 변경하지 않는다.
|
||||
- 관리자 화면, 수동 편집 기능, 추천 결과 강제 고정 기능은 포함하지 않는다.
|
||||
- 개인화 추천 모델, A/B 테스트, 머신러닝 기반 추천은 포함하지 않는다.
|
||||
- 전체보기/페이징 API는 이번 요구사항에 포함하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 5. Target Users
|
||||
- 회원: 콘텐츠 메인 탭에서 추천 오디오와 오리지널 시리즈를 탐색하는 사용자
|
||||
- 비회원: 인증 없이 조회 가능한 추천 콘텐츠를 탐색하는 사용자
|
||||
- 앱 클라이언트: 추천 탭 첫 화면 섹션을 한 API 응답으로 구성하는 클라이언트
|
||||
|
||||
---
|
||||
|
||||
## 6. User Stories
|
||||
- 사용자는 추천 탭 진입 시 메인 홈 추천 배너와 동일한 배너를 보고 싶다.
|
||||
- 사용자는 오직 보이스 온에서만 볼 수 있는 오리지널 시리즈를 최신순으로 보고 싶다.
|
||||
- 사용자는 새로 올라온 오디오를 최신순으로 확인하고 싶다.
|
||||
- 사용자는 최근 반응이 좋은 New & Hot 오디오를 보고 싶다.
|
||||
- 사용자는 무료 오디오와 포인트 사용 가능 오디오를 빠르게 탐색하고 싶다.
|
||||
- 사용자는 최근 댓글이 많은 오디오와 해당 오디오의 최신 댓글, 최신 댓글 작성자 프로필 이미지를 보고 싶다.
|
||||
- 사용자는 서버 추천 점수 기반의 추천 오디오를 보고 싶다.
|
||||
|
||||
---
|
||||
|
||||
## 7. Core Features
|
||||
|
||||
### Feature A. 메인 콘텐츠 추천 탭 통합 조회
|
||||
|
||||
#### Requirements
|
||||
- 신규 API endpoint는 `GET /api/v2/audio/recommendations`로 정의한다.
|
||||
- 응답 wrapper는 기존 패턴과 동일하게 `ApiResponse.ok(...)`를 사용한다.
|
||||
- 인증 회원이면 회원의 콘텐츠 조회 설정과 19금 노출 가능 여부를 반영한다.
|
||||
- 비회원이면 19금 콘텐츠를 노출하지 않는다.
|
||||
- 회원이 차단했거나 회원을 차단한 크리에이터의 시리즈/오디오는 노출하지 않는다.
|
||||
- 섹션별 기본 노출 수는 아래와 같다.
|
||||
- `banners`: 메인 홈 추천 배너와 동일
|
||||
- `originalSeries`: 최신순 12개
|
||||
- `latestAudios`: 최신순 12개
|
||||
- `newAndHotAudios`: 최대 12개
|
||||
- `freeAudios`: 최대 10개 랜덤
|
||||
- `pointAudios`: 최대 10개 랜덤
|
||||
- `mostCommentedAudios`: 최대 5개
|
||||
- `recommendedAudios`: 최대 10개
|
||||
- 특정 섹션 데이터가 부족하면 가능한 개수만 내려주고 전체 API는 성공 처리한다.
|
||||
- 무료/포인트/추천 오디오 섹션 사이에는 같은 오디오가 중복 노출될 수 있다.
|
||||
|
||||
#### Edge Cases
|
||||
- 한 섹션 조회 실패가 전체 API 실패로 이어질지는 구현 계획 단계에서 기존 v2 통합 조회 API의 로깅/실패 정책과 비교해 결정한다.
|
||||
- 예약 공개 콘텐츠는 공개 전에는 노출하지 않는다.
|
||||
- 비활성 콘텐츠, duration이 없는 콘텐츠, 비활성 크리에이터의 콘텐츠는 노출하지 않는다.
|
||||
|
||||
### Feature B. 메인 배너
|
||||
|
||||
#### Requirements
|
||||
- 메인 홈 추천 배너와 동일한 데이터를 사용한다.
|
||||
- 기존 v2 홈 추천 API의 배너 응답 구조를 공통 DTO로 분리해 재사용한다.
|
||||
- 배너 응답 필드는 `imageUrl`, `eventItem`, `creatorId`, `seriesId`, `link`를 유지한다.
|
||||
- 배너 대상 엔티티가 비활성 처리되었거나 차단 관계에 있으면 기존 홈 추천 배너 정책과 동일하게 제외한다.
|
||||
|
||||
### Feature C. 오직 보이스 온에서만
|
||||
|
||||
#### Requirements
|
||||
- 오리지널 시리즈를 최신순으로 12개 조회한다.
|
||||
- `series.isOriginal = true`인 시리즈만 대상으로 한다.
|
||||
- 활성 시리즈와 활성 크리에이터만 노출한다.
|
||||
- Response 필드는 `seriesId`, `coverImageUrl`만 포함하고, 최상위 응답 필드명은 `originalSeries`로 한다.
|
||||
|
||||
### Feature D. 새로 올라온 오디오
|
||||
|
||||
#### Requirements
|
||||
- 공개된 오디오 콘텐츠를 최신순으로 12개 조회한다.
|
||||
- 최신순 기준은 `releaseDate desc`, 동률이면 `audioContentId desc`로 한다.
|
||||
- Response는 공통 오디오 카드 응답을 사용한다.
|
||||
|
||||
### Feature E. New & Hot
|
||||
|
||||
#### Requirements
|
||||
- 최대 12개를 표시한다.
|
||||
- KST 매일 00:00에 전날 23:59:59 KST까지의 데이터를 반영해 스냅샷을 갱신한다.
|
||||
- 최근 3일 데이터를 기반으로 최종 점수를 산출한다.
|
||||
- 최종 점수는 `최신성 35% + 조회수 35% + 좋아요 15% + 댓글 수 15%`로 계산한다.
|
||||
- 조회수는 `creator_content_view_history`의 상세 페이지 조회 이력을 기준으로 최근 3일 `content_id`별 count를 사용한다.
|
||||
- 조회수, 좋아요 수, 댓글 수는 후보 내 정규화 없이 원본 count를 그대로 사용한다.
|
||||
- 최신성 배수는 공개 3일 이내 1.3, 7일 이내 1.15, 14일 이내 1.0, 그 외 0.8을 적용한다.
|
||||
- 19금 노출 정책은 스냅샷 variant로 분리한다.
|
||||
- `SAFE`: 19금이 아닌 콘텐츠만 포함한다.
|
||||
- `ALL`: 19금 콘텐츠와 19금이 아닌 콘텐츠를 모두 포함한다.
|
||||
- 19금 노출이 불가능한 사용자와 비회원은 `SAFE` 스냅샷을 조회한다.
|
||||
- 19금 노출이 가능한 회원은 `ALL` 스냅샷을 조회한다.
|
||||
- 산출된 스냅샷 데이터가 없으면 lazy로 스케줄/집계 보강을 추가한다.
|
||||
- Response는 공통 오디오 카드 응답을 사용한다.
|
||||
|
||||
#### Edge Cases
|
||||
- lazy 보강 중에도 즉시 산출 가능한 결과가 없으면 빈 배열로 내려준다.
|
||||
|
||||
### Feature F. 무료 오디오
|
||||
|
||||
#### Requirements
|
||||
- 무료 오디오 중 랜덤으로 최대 10개 조회한다.
|
||||
- 무료 오디오는 `price = 0`인 공개 오디오로 정의한다.
|
||||
- Response는 공통 오디오 카드 응답을 사용한다.
|
||||
|
||||
### Feature G. 포인트 오디오
|
||||
|
||||
#### Requirements
|
||||
- 포인트 사용 가능 오디오 중 랜덤으로 최대 10개 조회한다.
|
||||
- 포인트 오디오는 `isPointAvailable = true`인 공개 오디오로 정의한다.
|
||||
- Response는 공통 오디오 카드 응답을 사용한다.
|
||||
|
||||
### Feature H. 최근 댓글이 많은 오디오
|
||||
|
||||
#### Requirements
|
||||
- 댓글 점수는 `댓글 수 80% + 댓글 최신성 20%`로 계산한다.
|
||||
- 댓글 최신성 점수는 댓글 작성 3일 이내 1.3, 7일 이내 1.15, 14일 이내 1.0, 그 이상 0을 적용한다.
|
||||
- KST 매일 00:00에 전날 23:59:59 KST까지의 데이터를 반영해 스냅샷을 갱신한다.
|
||||
- 최근 7일 댓글 데이터를 기반으로 최종 점수를 산출한다.
|
||||
- 데이터가 없으면 섹션을 표시하지 않도록 빈 배열로 내려준다.
|
||||
- 최대 5개를 표시한다.
|
||||
- 오디오별 가장 최신 댓글 1개의 본문과 글쓴이 프로필 이미지를 함께 내려준다.
|
||||
|
||||
#### Edge Cases
|
||||
- 비활성 댓글, 삭제된 댓글, 차단 관계의 댓글 작성자 프로필은 노출하지 않는다.
|
||||
- 최신 댓글 작성자 프로필 이미지가 없으면 기본 프로필 이미지 URL 정책을 적용한다.
|
||||
|
||||
### Feature I. 추천 오디오
|
||||
|
||||
#### Requirements
|
||||
- 최대 10개를 표시한다.
|
||||
- KST 매일 00:00에 전날 23:59:59 KST까지의 데이터를 반영해 스냅샷을 갱신한다.
|
||||
- 추천 점수는 `조회수 45% + 좋아요 25% + 댓글 수 20% + 최신성 10%`로 계산한다.
|
||||
- 조회수는 `creator_content_view_history`의 상세 페이지 조회 이력을 기준으로 스냅샷 집계 기간 내 `content_id`별 count를 사용한다.
|
||||
- 조회수, 좋아요 수, 댓글 수는 후보 내 정규화 없이 원본 count를 그대로 사용한다.
|
||||
- 최신성 배수는 공개 3일 이내 1.3, 7일 이내 1.15, 30일 이내 1.1, 그 외 1.0을 적용한다.
|
||||
- 19금 노출 정책은 New & Hot과 동일하게 `SAFE`, `ALL` 스냅샷 variant로 분리한다.
|
||||
- Response는 공통 오디오 카드 응답을 사용한다.
|
||||
|
||||
---
|
||||
|
||||
## 8. API Endpoint
|
||||
|
||||
```http
|
||||
GET /api/v2/audio/recommendations
|
||||
Authorization: Bearer {accessToken} (optional)
|
||||
```
|
||||
|
||||
- 비회원 조회를 허용한다.
|
||||
- 회원 조회 시 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴을 사용한다.
|
||||
- 별도 request query parameter는 정의하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 9. Response Data Class
|
||||
|
||||
```kotlin
|
||||
data class AudioRecommendationsResponse(
|
||||
val banners: List<AudioBannerResponse>,
|
||||
val originalSeries: List<OriginalSeriesResponse>,
|
||||
val latestAudios: List<AudioCardResponse>,
|
||||
val newAndHotAudios: List<AudioCardResponse>,
|
||||
val freeAudios: List<AudioCardResponse>,
|
||||
val pointAudios: List<AudioCardResponse>,
|
||||
val mostCommentedAudios: List<CommentedAudioResponse>,
|
||||
val recommendedAudios: List<AudioCardResponse>
|
||||
)
|
||||
|
||||
data class AudioBannerResponse(
|
||||
val imageUrl: String,
|
||||
val eventItem: EventItem?,
|
||||
val creatorId: Long?,
|
||||
val seriesId: Long?,
|
||||
val link: String?
|
||||
)
|
||||
|
||||
data class OriginalSeriesResponse(
|
||||
val seriesId: Long,
|
||||
val coverImageUrl: String?
|
||||
)
|
||||
|
||||
data class AudioCardResponse(
|
||||
val audioContentId: Long,
|
||||
val title: String,
|
||||
val duration: String?,
|
||||
val imageUrl: String?,
|
||||
val price: Int,
|
||||
@JsonProperty("isAdult")
|
||||
val isAdult: Boolean,
|
||||
@JsonProperty("isPointAvailable")
|
||||
val isPointAvailable: Boolean,
|
||||
@JsonProperty("isFirstContent")
|
||||
val isFirstContent: Boolean,
|
||||
@JsonProperty("isOriginalSeries")
|
||||
val isOriginalSeries: Boolean,
|
||||
val creatorNickname: String
|
||||
)
|
||||
|
||||
data class CommentedAudioResponse(
|
||||
val audioContentId: Long,
|
||||
val title: String,
|
||||
val imageUrl: String?,
|
||||
val latestComment: String,
|
||||
val latestCommentWriterProfileImageUrl: String
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Technical Constraints
|
||||
|
||||
### 패키지 구조
|
||||
- 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.audio.recommendation` 하위에 둔다.
|
||||
- Controller: `...adapter.in.web`
|
||||
- Facade: `...application`
|
||||
- Response DTO: `...dto`
|
||||
- 도메인 조회 계층은 `kr.co.vividnext.sodalive.v2.audio.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`만 허용한다.
|
||||
|
||||
### V2 공통화/재사용 대상
|
||||
- `HomeBannerItem`은 메인 홈 전용 DTO가 아니라 여러 추천 화면에서 사용할 배너 응답 구조이므로 `v2.api.common.dto` 계열 공통 DTO로 분리한다.
|
||||
- `v2.recommendation.adapter.out.persistence.RecommendationSnapshot`: 일 단위 추천 스냅샷 저장 구조
|
||||
- `v2.recommendation.adapter.out.scheduler.RecommendationSnapshotScheduler`: Redisson 분산 lock이 적용된 스케줄러 패턴
|
||||
- `v2.recommendation.adapter.out.persistence.CreatorContentViewHistory`: 오디오 상세 페이지 조회 이력 저장 구조
|
||||
- `v2.recommendation.application.CreatorContentViewHistoryService`: `AudioContentService.getDetail(...)`에서 상세 조회 이력을 기록하는 서비스
|
||||
- `v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponse`: 오디오 카드 응답 필드와 `JsonProperty` 네이밍 패턴
|
||||
- `v2.common.domain.CdnUrlExtensions`: 이미지 URL 변환 공통 함수
|
||||
|
||||
### 참고할 기존 패턴
|
||||
- `v2.api.home.adapter.in.web.HomeRecommendationController`와 `v2.api.home.application.HomeRecommendationFacade`는 메인 페이지 Home 탭 전용이므로 직접 재사용하지 않는다.
|
||||
- 신규 API도 controller가 인증/요청 경계를 담당하고 facade가 도메인 조회 결과를 공개 응답 DTO로 변환하는 계층 분리 방식만 참고한다.
|
||||
|
||||
### 스냅샷/스케줄
|
||||
- New & Hot, 최근 댓글 많은 오디오, 추천 오디오는 스냅샷 기반 조회를 우선한다.
|
||||
- 스케줄러는 `@Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul")` 기준으로 설계한다.
|
||||
- 다중 서버 환경에서 중복 실행을 막기 위해 기존 Redisson lock 패턴을 따른다.
|
||||
- 스냅샷 기준 시각은 KST 전날 `23:59:59`로 저장한다.
|
||||
- 19금 노출 영향을 받는 스냅샷 섹션은 visibility variant를 저장한다.
|
||||
- `SAFE`: 19금이 아닌 콘텐츠만 포함한다.
|
||||
- `ALL`: 19금 콘텐츠와 19금이 아닌 콘텐츠를 모두 포함한다.
|
||||
- `SAFE`와 `ALL`을 분리하는 이유는 스냅샷 조회 후 19금 콘텐츠를 필터링할 경우 비회원/19금 노출 불가 회원에게 최대 노출 개수를 안정적으로 채우기 어렵기 때문이다.
|
||||
- 기존 `recommendation_snapshot`을 확장 재사용할지, 콘텐츠 추천 전용 스냅샷 테이블을 만들지는 구현 계획에서 DDL 영향과 enum 확장 범위를 비교해 결정한다.
|
||||
|
||||
### 조회 정책
|
||||
- 모든 오디오 섹션은 활성 콘텐츠, 활성 크리에이터, `duration is not null`, `releaseDate <= now` 조건을 기본으로 한다.
|
||||
- 성인 콘텐츠는 회원의 `MemberContentPreference`와 본인인증 정책을 반영한다.
|
||||
- 비회원은 성인 콘텐츠를 제외한다.
|
||||
- 차단 관계 필터는 기존 v2 홈/크리에이터 채널 조회 패턴을 따른다.
|
||||
- 조회수 점수의 조회수는 `AudioContent.playCount`가 아니라 `creator_content_view_history`의 상세 페이지 조회 이력 count를 사용한다.
|
||||
- 공통 오디오 카드 응답의 `isOriginalSeries`는 시리즈 미소속 오디오이면 클라이언트 편의를 위해 `false`로 내려준다.
|
||||
- 무료/포인트/추천 오디오처럼 서로 다른 추천 섹션에 같은 콘텐츠가 동시에 포함되어도 서버에서 중복 제거하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 11. Metrics
|
||||
- API 성공/실패 로그
|
||||
- 섹션별 응답 개수
|
||||
- 스냅샷 갱신 성공/실패 로그
|
||||
- 스냅샷 갱신 대상 개수
|
||||
- lazy 보강 발생 횟수
|
||||
- 빈 섹션 목록
|
||||
|
||||
---
|
||||
|
||||
## 12. Open Questions
|
||||
- 없음
|
||||
Reference in New Issue
Block a user