docs(content-ranking): 랭킹 탭 API 요구사항과 계획을 기록한다
This commit is contained in:
481
docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md
Normal file
481
docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md
Normal file
@@ -0,0 +1,481 @@
|
||||
# 메인 콘텐츠 랭킹 탭 API Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
||||
|
||||
**Goal:** `GET /api/v2/audio/rankings`로 메인 콘텐츠 랭킹 탭의 6개 랭킹 타입을 스냅샷 기반으로 조회하고, 순위/순위 변화/신규 진입 여부를 안정적으로 제공한다.
|
||||
|
||||
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.content.ranking` 조립 계층에 둔다. 콘텐츠 랭킹 계산, 스냅샷 조회/생성, fallback, scheduler, legacy adapter는 `kr.co.vividnext.sodalive.v2.content.ranking` 하위에 두고 `v2.api.*`에 의존하지 않는다. 스냅샷은 `rankingType + aggregation period + visibleFromAt`을 기준으로 저장하고, 조회 API는 `visibleFromAt <= now`인 생성 완료 스냅샷만 공개 응답에 사용한다.
|
||||
|
||||
**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/rankings`
|
||||
- 요청 query parameter: `type`, 기본값 `WEEKLY_POPULAR`
|
||||
- 랭킹 타입:
|
||||
- `WEEKLY_POPULAR`: 주간 인기
|
||||
- `RISING`: 지금 뜨는 중
|
||||
- `REVENUE`: 매출
|
||||
- `SALES_COUNT`: 판매량
|
||||
- `COMMENT_COUNT`: 댓글 수
|
||||
- `LIKE_COUNT`: 좋아요
|
||||
- 모든 랭킹 타입은 완료된 지난 주 데이터를 기준으로 한다.
|
||||
- 집계 기준 시각: 매주 월요일 `00:00:00 KST`
|
||||
- 스냅샷 생성 시간대: 매주 월요일 `01:00:00 ~ 07:30:00 KST` 사이 랭킹 타입별 분산 실행
|
||||
- 새 스냅샷 노출 전환 시각: 매주 월요일 `09:00:00 KST`
|
||||
- 조회 API는 `visibleFromAt <= now`인 최신 완료 스냅샷만 응답한다.
|
||||
- 09:00 전에는 새 스냅샷이 생성되어도 직전 공개 스냅샷을 응답한다.
|
||||
- 특정 랭킹 타입의 새 스냅샷 생성이 실패하면 해당 타입은 직전 공개 스냅샷을 유지한다.
|
||||
- fallback은 요청한 랭킹 타입과 동일 집계 기간 기준 최대 3회까지만 실행한다.
|
||||
- 이번 범위는 콘텐츠 랭킹만 수정한다.
|
||||
- 크리에이터 랭킹의 생성 시간/표시 시간 분리와 다중 랭킹 타입 대응은 다음 범위에서 별도 PRD 문서 수정부터 시작한다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 파일 구조 계획
|
||||
|
||||
### 신규 API 조립 계층
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingController.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/application/AudioRankingFacade.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/dto/AudioRankingResponse.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/application/AudioRankingFacadeTest.kt`
|
||||
|
||||
### 신규 콘텐츠 랭킹 도메인 계층
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingType.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRanking.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingPeriodPolicy.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingScorePolicy.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingSchedulePolicy.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingPeriodPolicyTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingScorePolicyTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingSchedulePolicyTest.kt`
|
||||
|
||||
### 신규 콘텐츠 랭킹 application/port
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshService.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotJobService.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotPort.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotJobPort.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingAggregationPort.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingBlockPort.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotJobServiceTest.kt`
|
||||
|
||||
### 신규 persistence/scheduler/legacy adapter
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshot.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotJob.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotRepository.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotJobRepository.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepository.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingBlockRepository.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/legacy/LegacyAudioRankingAdapter.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotScheduler.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepositoryTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/legacy/LegacyAudioRankingAdapterTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotSchedulerTest.kt`
|
||||
|
||||
### 문서/DDL
|
||||
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/create-content-ranking-tables.sql`
|
||||
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/prd.md`
|
||||
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`
|
||||
- Verify: `docs/20260608_크리에이터_랭킹/prd.md`
|
||||
- Verify: `docs/20260608_크리에이터_랭킹/plan-task.md`
|
||||
- Verify: `docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`
|
||||
|
||||
---
|
||||
|
||||
## 2. Response data class 초안
|
||||
|
||||
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/dto/AudioRankingResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
|
||||
|
||||
```kotlin
|
||||
package kr.co.vividnext.sodalive.v2.api.content.ranking.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRanking
|
||||
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingItem
|
||||
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
|
||||
|
||||
data class AudioRankingResponse(
|
||||
val showRankChange: Boolean,
|
||||
val type: AudioRankingType,
|
||||
val items: List<AudioRankingItemResponse>
|
||||
) {
|
||||
companion object {
|
||||
fun from(ranking: AudioRanking): AudioRankingResponse {
|
||||
return AudioRankingResponse(
|
||||
showRankChange = ranking.showRankChange,
|
||||
type = ranking.type,
|
||||
items = ranking.items.map(AudioRankingItemResponse::from)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class AudioRankingItemResponse(
|
||||
val contentId: Long,
|
||||
val title: String,
|
||||
val creatorNickname: String,
|
||||
val rank: Int,
|
||||
val rankChange: Int?,
|
||||
@JsonProperty("isNew")
|
||||
val isNew: Boolean,
|
||||
val coverImageUrl: String?
|
||||
) {
|
||||
companion object {
|
||||
fun from(item: AudioRankingItem): AudioRankingItemResponse {
|
||||
return AudioRankingItemResponse(
|
||||
contentId = item.contentId,
|
||||
title = item.title,
|
||||
creatorNickname = item.creatorNickname,
|
||||
rank = item.rank,
|
||||
rankChange = item.rankChange,
|
||||
isNew = item.isNew,
|
||||
coverImageUrl = item.coverImageUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Domain / Port 초안
|
||||
|
||||
```kotlin
|
||||
package kr.co.vividnext.sodalive.v2.content.ranking.domain
|
||||
|
||||
enum class AudioRankingType {
|
||||
WEEKLY_POPULAR,
|
||||
RISING,
|
||||
REVENUE,
|
||||
SALES_COUNT,
|
||||
COMMENT_COUNT,
|
||||
LIKE_COUNT
|
||||
}
|
||||
|
||||
data class AudioRanking(
|
||||
val showRankChange: Boolean,
|
||||
val type: AudioRankingType,
|
||||
val items: List<AudioRankingItem>
|
||||
)
|
||||
|
||||
data class AudioRankingItem(
|
||||
val contentId: Long,
|
||||
val title: String,
|
||||
val creatorNickname: String,
|
||||
val rank: Int,
|
||||
val rankChange: Int?,
|
||||
val isNew: Boolean,
|
||||
val coverImageUrl: String?
|
||||
)
|
||||
```
|
||||
|
||||
```kotlin
|
||||
package kr.co.vividnext.sodalive.v2.content.ranking.port.out
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface AudioRankingSnapshotPort {
|
||||
fun findLatestVisibleSnapshots(
|
||||
rankingType: AudioRankingType,
|
||||
nowUtc: LocalDateTime
|
||||
): List<AudioRankingSnapshotRecord>
|
||||
|
||||
fun findPreviousVisibleSnapshots(
|
||||
rankingType: AudioRankingType,
|
||||
currentAggregationStartAtUtc: LocalDateTime,
|
||||
nowUtc: LocalDateTime
|
||||
): List<AudioRankingSnapshotRecord>
|
||||
|
||||
fun replaceSnapshots(
|
||||
rankingType: AudioRankingType,
|
||||
aggregationStartAtUtc: LocalDateTime,
|
||||
aggregationEndAtUtc: LocalDateTime,
|
||||
visibleFromAtUtc: LocalDateTime,
|
||||
newSnapshots: List<AudioRankingSnapshotRecord>
|
||||
)
|
||||
}
|
||||
|
||||
data class AudioRankingSnapshotRecord(
|
||||
val rankingType: AudioRankingType,
|
||||
val aggregationStartAtUtc: LocalDateTime,
|
||||
val aggregationEndAtUtc: LocalDateTime,
|
||||
val visibleFromAtUtc: LocalDateTime,
|
||||
val contentId: Long,
|
||||
val title: String,
|
||||
val creatorMemberId: Long,
|
||||
val creatorNickname: String,
|
||||
val coverImageUrl: String?,
|
||||
val releaseDate: LocalDateTime,
|
||||
val rank: Int,
|
||||
val finalScore: Double
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: API 계약과 DTO
|
||||
|
||||
- [ ] **Task 1.1: `AudioRankingType`과 응답 DTO 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingType.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRanking.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/dto/AudioRankingResponse.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/dto/AudioRankingResponseTest.kt`
|
||||
- RED: `AudioRankingResponse.from(...)`이 `showRankChange`, `type`, `contentId`, `title`, `creatorNickname`, `rank`, `rankChange`, `isNew`, `coverImageUrl`을 변환하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.dto.AudioRankingResponseTest`
|
||||
- GREEN: DTO와 domain model을 최소 구현한다.
|
||||
- REFACTOR: 공개 DTO가 persistence/entity를 import하지 않도록 확인한다.
|
||||
- 기대 결과: PRD의 Response data class 계약이 테스트로 고정된다.
|
||||
|
||||
- [ ] **Task 1.2: facade 변환 계층 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/application/AudioRankingFacade.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/application/AudioRankingFacadeTest.kt`
|
||||
- RED: facade가 `AudioRankingQueryService.getRankings(type, member)` 결과를 `AudioRankingResponse`로 변환하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.application.AudioRankingFacadeTest`
|
||||
- GREEN: facade는 query service 호출과 DTO 변환만 담당한다.
|
||||
- REFACTOR: facade에 점수 계산, 스냅샷 조회, fallback 로직을 두지 않는다.
|
||||
- 기대 결과: API 조립 계층과 도메인 조회 계층 의존 방향이 고정된다.
|
||||
|
||||
- [ ] **Task 1.3: 비회원 허용 controller 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingController.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt`
|
||||
- RED: `GET /api/v2/audio/rankings`가 비회원과 인증 회원 모두 `200 OK`를 반환하고, `type` 미지정 시 `WEEKLY_POPULAR`로 facade를 호출하는 MockMvc 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.adapter.in.web.AudioRankingControllerTest`
|
||||
- GREEN: `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴과 `@RequestParam` 기본값을 적용한다.
|
||||
- REFACTOR: controller에는 인증/요청/응답 경계만 남긴다.
|
||||
- 기대 결과: endpoint 경로, 기본 type, wrapper 응답 계약이 controller 테스트로 고정된다.
|
||||
|
||||
### Phase 2: 기간/노출/점수 정책
|
||||
|
||||
- [ ] **Task 2.1: KST 주간 집계 기간과 UTC 변환 정책 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingPeriodPolicy.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingPeriodPolicyTest.kt`
|
||||
- RED: 임의의 KST 수요일 기준으로 지난 주 월요일 00:00 KST 이상, 이번 주 월요일 00:00 KST 미만 기간을 산출하고 UTC `LocalDateTime`으로 변환하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingPeriodPolicyTest`
|
||||
- GREEN: `resolveLastCompletedWeek(now)`와 `toUtcRange(period)`를 구현한다.
|
||||
- REFACTOR: 서버 기본 timezone에 의존하지 않고 `ZoneId.of("Asia/Seoul")`을 명시한다.
|
||||
- 기대 결과: 모든 랭킹 타입의 집계 기준 기간이 동일하게 계산된다.
|
||||
|
||||
- [ ] **Task 2.2: 09:00 노출 전환 정책 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingSchedulePolicy.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingSchedulePolicyTest.kt`
|
||||
- RED: 집계 종료일 월요일 기준 `visibleFromAt`이 같은 날 09:00 KST의 UTC 시각으로 계산되고, 09:00 전에는 새 스냅샷이 공개되지 않는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingSchedulePolicyTest`
|
||||
- GREEN: `resolveVisibleFromAt(aggregationEndAtKst)`와 `isVisible(visibleFromAtUtc, nowUtc)`를 구현한다.
|
||||
- REFACTOR: scheduler 실행 시각과 공개 노출 시각을 별도 함수로 분리한다.
|
||||
- 기대 결과: 계산 완료와 공개 노출 전환이 분리된다.
|
||||
|
||||
- [ ] **Task 2.3: 주간 인기/지금 뜨는 중 점수 정책 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingScorePolicy.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingScorePolicyTest.kt`
|
||||
- RED: 유료/무료 주간 인기 원점수, 0~100 정규화, 지금 뜨는 중 증가율, 최소 반영 기준, 신규 콘텐츠 부스트를 검증하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingScorePolicyTest`
|
||||
- GREEN: `calculateWeeklyPopularScore`, `normalizeScore`, `calculateRisingScore`, `applyMinimumThreshold`, `releaseBoost`를 구현한다.
|
||||
- REFACTOR: 가중치와 최소 기준은 `companion object` 상수로 모은다.
|
||||
- 기대 결과: PRD 산식과 “기준 미달 지표만 0점 처리” 정책이 순수 단위 테스트로 고정된다.
|
||||
|
||||
### Phase 3: 스냅샷 Entity/Port/DDL
|
||||
|
||||
- [ ] **Task 3.1: 스냅샷 Entity와 port 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshot.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotPort.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt`
|
||||
- RED: `visibleFromAtUtc <= nowUtc`인 최신 스냅샷만 조회하고, 09:00 전에는 이전 visible 스냅샷을 반환하는 persistence adapter 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingSnapshotPersistenceAdapterTest`
|
||||
- GREEN: `AudioRankingSnapshot`, `AudioRankingSnapshotRepository`, `DefaultAudioRankingSnapshotPersistenceAdapter`를 구현한다.
|
||||
- REFACTOR: `rankingType`, `aggregationStartAtUtc`, `aggregationEndAtUtc`, `visibleFromAtUtc` 필드명을 DDL과 맞춘다.
|
||||
- 기대 결과: 공개 조회 기준이 `latest generated`가 아니라 `latest visible`로 고정된다.
|
||||
|
||||
- [ ] **Task 3.2: 스냅샷 job Entity와 port 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotJob.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotJobPort.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotJobServiceTest.kt`
|
||||
- RED: `SCHEDULED`, `MANUAL`, `FALLBACK` trigger와 `PENDING`, `PROCESSING`, `DONE`, `FAILED` 상태를 저장/변경할 수 있는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobServiceTest`
|
||||
- GREEN: job entity, repository, port adapter를 구현한다.
|
||||
- REFACTOR: fallback 3회 제한 조회에 필요한 `rankingType + aggregation period + triggerType` 조건을 port에 둔다.
|
||||
- 기대 결과: 스케줄 실행과 fallback 실행이 모두 job 이력으로 추적된다.
|
||||
|
||||
- [ ] **Task 3.3: DDL 문서와 Entity 필드 정합성 확인**
|
||||
- Files:
|
||||
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/create-content-ranking-tables.sql`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshot.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotJob.kt`
|
||||
- TDD 예외 사유: DDL 문서와 JPA Entity 필드 정합성 확인 task이므로 신규 실패 테스트 작성 대상이 아니다.
|
||||
- 대체 검증 방법:
|
||||
- Run: `rg -n "visible_from_at|content_ranking_snapshot|content_ranking_snapshot_job" docs/20260623_메인_콘텐츠_랭킹_탭_API src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking`
|
||||
- Run: `./gradlew tasks --all`
|
||||
- 기대 결과: 신규 Entity에 대응하는 운영 DB DDL이 같은 작업 디렉터리에 기록되어 있다.
|
||||
|
||||
### Phase 4: 랭킹 후보 집계와 legacy 재사용
|
||||
|
||||
- [ ] **Task 4.1: legacy 정렬 랭킹 adapter 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/legacy/LegacyAudioRankingAdapter.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/legacy/LegacyAudioRankingAdapterTest.kt`
|
||||
- RED: `REVENUE`, `SALES_COUNT`, `COMMENT_COUNT`, `LIKE_COUNT`가 각각 `ContentRankingSortType.REVENUE`, `SALES_COUNT`, `COMMENT_COUNT`, `LIKE_COUNT`로 매핑되는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.legacy.LegacyAudioRankingAdapterTest`
|
||||
- GREEN: 기존 `RankingService.getContentRanking(...)`을 port 경계 뒤에서 호출한다.
|
||||
- REFACTOR: v2 application service가 legacy service를 직접 import하지 않도록 한다.
|
||||
- 기대 결과: 기존 산식 4종을 재정의하지 않고 재사용한다.
|
||||
|
||||
- [ ] **Task 4.2: 주간 인기/지금 뜨는 중 후보 집계 repository 작성**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingAggregationPort.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepositoryTest.kt`
|
||||
- RED: 상세 조회수, 매출, 판매량, 좋아요, 댓글 수를 집계하고 비활성/공개 전/비활성 크리에이터 콘텐츠를 제외하는 repository 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingAggregationRepositoryTest`
|
||||
- GREEN: QueryDSL 또는 native SQL로 주간 인기와 지금 뜨는 중 후보 원천 지표를 조회한다.
|
||||
- REFACTOR: 공개 오디오 조건, 성인 콘텐츠 조건, 차단 관계 조건을 private 조건 함수로 분리한다.
|
||||
- 기대 결과: 신규 산식 2종의 원천 지표가 application service에 전달된다.
|
||||
|
||||
- [ ] **Task 4.3: 동점 정렬과 Top 20 스냅샷 후보 생성**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshServiceTest.kt`
|
||||
- RED: 최종 점수 동점이면 `releaseDate desc`, `contentId desc` 순으로 최대 20개를 저장하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotRefreshServiceTest`
|
||||
- GREEN: 후보별 점수 계산, 정규화, 정렬, rank 부여, snapshot record 변환을 구현한다.
|
||||
- REFACTOR: 점수 계산은 `AudioRankingScorePolicy`, 기간/노출 시각 계산은 policy에 위임한다.
|
||||
- 기대 결과: 스냅샷 생성 결과가 조회 시 재정렬되지 않아도 안정적인 순위를 가진다.
|
||||
|
||||
### Phase 5: 스냅샷 생성 job과 분산 scheduler
|
||||
|
||||
- [ ] **Task 5.1: 랭킹 타입별 refresh service 구현**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshServiceTest.kt`
|
||||
- RED: 각 `AudioRankingType`에 대해 집계 기간, `visibleFromAt`, 후보 목록을 계산해 기존 스냅샷을 replace하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotRefreshServiceTest`
|
||||
- GREEN: `refreshLastCompletedWeek(type, now)`를 구현하고 `AudioRankingSnapshotPort.replaceSnapshots(...)`를 호출한다.
|
||||
- REFACTOR: 특정 타입 실패가 다른 타입 refresh를 막지 않도록 job service에서 타입 단위 실행을 분리한다.
|
||||
- 기대 결과: 랭킹 타입별 독립 스냅샷 생성이 가능하다.
|
||||
|
||||
- [ ] **Task 5.2: job service와 fallback 3회 제한 구현**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotJobService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotJobServiceTest.kt`
|
||||
- RED: scheduled job이 `PENDING -> PROCESSING -> DONE/FAILED`로 상태 변경되고, 같은 타입/기간 fallback이 3회 이상이면 refresh를 호출하지 않는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobServiceTest`
|
||||
- GREEN: job 생성, 상태 변경, fallback 제한, 기간 기반 Redisson lock 경계를 구현한다.
|
||||
- REFACTOR: job 이력 저장은 `REQUIRES_NEW` 트랜잭션 패턴을 검토해 크리에이터 랭킹 job service와 맞춘다.
|
||||
- 기대 결과: fallback과 scheduled refresh가 같은 job 이력 구조를 사용한다.
|
||||
|
||||
- [ ] **Task 5.3: 01:00~07:30 분산 scheduler 구현**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotScheduler.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotSchedulerTest.kt`
|
||||
- RED: 랭킹 타입별 scheduler method가 `Asia/Seoul` zone과 서로 다른 cron을 가지고, lock 획득 성공 시에만 job service를 호출하는 reflection/Mockito 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.scheduler.AudioRankingSnapshotSchedulerTest`
|
||||
- GREEN: 예시 배치로 `WEEKLY_POPULAR 02:00`, `RISING 03:00`, `REVENUE 04:00`, `SALES_COUNT 05:00`, `COMMENT_COUNT 06:00`, `LIKE_COUNT 07:00` KST scheduler를 구현한다.
|
||||
- REFACTOR: lock key는 `lock:content-ranking-snapshot-refresh:{rankingType}` 형태로 목적과 타입이 드러나게 한다.
|
||||
- 기대 결과: 콘텐츠 랭킹 스냅샷 생성이 01:00~07:30 범위 안에서 타입별로 분산된다.
|
||||
|
||||
### Phase 6: 조회 서비스와 순위 변화 계산
|
||||
|
||||
- [ ] **Task 6.1: 최신/직전 visible 스냅샷 조회와 rankChange 계산**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt`
|
||||
- RED: 직전 공개 스냅샷이 있으면 `rankChange = previousRank - currentRank`, 신규 진입은 `isNew=true`, 직전 스냅샷이 없으면 `showRankChange=false`가 되는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest`
|
||||
- GREEN: `getRankings(type, member)`에서 최신 visible 스냅샷과 직전 visible 스냅샷을 조회해 `AudioRanking`을 조립한다.
|
||||
- REFACTOR: 순위 변화 계산은 별도 private 함수로 분리한다.
|
||||
- 기대 결과: 크리에이터 랭킹과 같은 의미의 `rank`, `rankChange`, `isNew`가 콘텐츠 랭킹에도 적용된다.
|
||||
|
||||
- [ ] **Task 6.2: 차단/성인 콘텐츠 정책 반영**
|
||||
- Files:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingBlockPort.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingBlockRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt`
|
||||
- RED: 비회원은 성인 콘텐츠를 제외하고, 회원 차단 관계가 있는 콘텐츠는 기존 콘텐츠 랭킹/추천 정책에 맞게 제외 또는 마스킹되는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest`
|
||||
- GREEN: 조회 조건 또는 응답 조립 단계에서 성인 콘텐츠/차단 관계 정책을 적용한다.
|
||||
- REFACTOR: 기존 콘텐츠 추천 탭의 성인 콘텐츠 조회 가능 여부 계산 경로를 재사용한다.
|
||||
- 기대 결과: 공개 조회 정책이 기존 v2 콘텐츠 추천/랭킹 정책과 어긋나지 않는다.
|
||||
|
||||
- [ ] **Task 6.3: 스냅샷 없음 fallback 조회 보강**
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt`
|
||||
- RED: 요청 타입의 최신 visible 스냅샷이 없으면 fallback job을 최대 3회까지 실행하고, 생성 후에도 `visibleFromAt > now`이면 직전 공개 스냅샷 또는 빈 배열을 응답하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest`
|
||||
- GREEN: query service가 snapshot job service에 fallback을 위임하고 공개 응답 스키마를 유지한다.
|
||||
- REFACTOR: fallback 실패는 구조화 로그/job 이력으로 추적하고 공개 응답에 fallback 여부를 추가하지 않는다.
|
||||
- 기대 결과: 테스트 환경 초기 스냅샷 공백을 보강하되, 09:00 노출 정책은 깨지 않는다.
|
||||
|
||||
### Phase 7: 통합 검증과 문서 정리
|
||||
|
||||
- [ ] **Task 7.1: controller/facade/query 통합 테스트**
|
||||
- Files:
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt`
|
||||
- RED: `GET /api/v2/audio/rankings?type=RISING`이 `showRankChange`, `type`, `items[].contentId`, `rank`, `rankChange`, `isNew`를 반환하는 MockMvc 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.adapter.in.web.AudioRankingControllerTest`
|
||||
- GREEN: controller, facade, query service wiring을 완성한다.
|
||||
- REFACTOR: 공개 response에 점수, 집계 기간, fallback 여부가 노출되지 않는지 확인한다.
|
||||
- 기대 결과: 공개 API 계약이 end-to-end로 검증된다.
|
||||
|
||||
- [ ] **Task 7.2: 문서와 DDL 최종 정합성 확인**
|
||||
- Files:
|
||||
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/prd.md`
|
||||
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`
|
||||
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/create-content-ranking-tables.sql`
|
||||
- TDD 예외 사유: 구현 완료 후 문서/DDL 정합성 확인 task이므로 신규 실패 테스트 작성 대상이 아니다.
|
||||
- 대체 검증 방법:
|
||||
- Run: `rg -n "visibleFromAt|visible_from_at|09:00:00|01:00:00|07:30:00|AudioRankingType" docs/20260623_메인_콘텐츠_랭킹_탭_API src/main/kotlin src/test/kotlin`
|
||||
- Run: `./gradlew tasks --all`
|
||||
- 기대 결과: PRD, 계획 문서, DDL, 코드가 같은 정책을 설명한다.
|
||||
|
||||
- [ ] **Task 7.3: 전체 회귀 검증**
|
||||
- Files:
|
||||
- Verify: `build.gradle.kts`
|
||||
- Verify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`
|
||||
- TDD 예외 사유: 전체 회귀 검증과 검증 기록 누적 task이므로 신규 실패 테스트 작성 대상이 아니다.
|
||||
- 대체 검증 방법:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.*`
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.*`
|
||||
- Run: `./gradlew ktlintCheck`
|
||||
- 기대 결과: 콘텐츠 랭킹 신규 테스트와 ktlint가 통과하고, 검증 결과가 이 문서 하단에 누적된다.
|
||||
|
||||
### Phase 8: 다음 범위 크리에이터 랭킹 시간 정책 문서 시작점
|
||||
|
||||
- [ ] **Task 8.1: 크리에이터 랭킹 시간 정책 변경을 별도 PRD 문서 수정으로 시작하도록 기록**
|
||||
- Files:
|
||||
- Verify: `docs/20260608_크리에이터_랭킹/prd.md`
|
||||
- Verify: `docs/20260608_크리에이터_랭킹/plan-task.md`
|
||||
- Verify: `docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`
|
||||
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`
|
||||
- TDD 예외 사유: 이번 구현 범위 밖의 후속 작업 진입점을 문서화하는 task이므로 신규 실패 테스트 작성 대상이 아니다.
|
||||
- 대체 검증 방법:
|
||||
- Run: `rg -n "07:30|visibleFromAt|visible_from_at|ranking_type|크리에이터 랭킹" docs/20260608_크리에이터_랭킹 docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`
|
||||
- Run: `./gradlew tasks --all`
|
||||
- 후속 작업 시작 지침:
|
||||
- 다음 범위는 크리에이터 랭킹 PRD 문서 수정부터 시작한다.
|
||||
- 현재 크리에이터 랭킹 스냅샷 생성 시간은 `@Scheduled(cron = "0 30 7 * * MON", zone = "Asia/Seoul")` 기준 매주 월요일 KST 07:30이다.
|
||||
- 다음 범위에서는 크리에이터 랭킹도 집계 기준 시각 `월요일 00:00:00 KST`, 생성 시간 `월요일 01:00:00 KST` 후보, 노출 전환 시각 `월요일 09:00:00 KST`로 분리하는 정책을 PRD에 먼저 반영한다.
|
||||
- 크리에이터 랭킹도 향후 다중 랭킹 타입 3개가 추가될 예정이므로 `creator_ranking_snapshot`과 `creator_ranking_snapshot_job`에 `ranking_type`, `visible_from_at` 추가가 필요한지 DDL 영향부터 검토한다.
|
||||
- 크리에이터 랭킹 코드 변경은 별도 PRD와 별도 plan-task 문서가 준비된 뒤 진행한다.
|
||||
- 기대 결과: 이번 콘텐츠 랭킹 구현 범위를 넘지 않으면서, 다음 범위의 첫 작업이 문서 수정부터 시작되도록 명확한 기록이 남는다.
|
||||
|
||||
---
|
||||
|
||||
## 검증 기록
|
||||
|
||||
- 작성 시점: PRD 기반 구현 계획 문서를 신규 생성했다. 아직 구현 전이므로 task별 검증 기록은 없다.
|
||||
355
docs/20260623_메인_콘텐츠_랭킹_탭_API/prd.md
Normal file
355
docs/20260623_메인_콘텐츠_랭킹_탭_API/prd.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# PRD: 메인 콘텐츠 랭킹 탭 API
|
||||
|
||||
## 1. Overview
|
||||
메인 콘텐츠 탭의 내부 랭킹 탭에서 사용할 콘텐츠 랭킹을 조회하는 v2 API를 제공한다.
|
||||
|
||||
랭킹 구분은 `주간 인기`, `지금 뜨는 중`, `매출`, `판매량`, `댓글 수`, `좋아요`이며, 각 랭킹은 최대 20위까지 표시한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. Problem
|
||||
- 기존 콘텐츠 랭킹 조회는 `RankingService.getContentRanking` 기반의 정렬 조회를 제공하지만, 신규 랭킹 탭은 `rank`, `rankChange`, `isNew`를 크리에이터 랭킹과 같은 의미로 내려줘야 한다.
|
||||
- `주간 인기`와 `지금 뜨는 중`은 신규 점수 산식, 유료/무료 콘텐츠별 정규화, 주간 스냅샷 갱신, fallback 실행 기록이 필요하다.
|
||||
- `매출`, `판매량`, `댓글 수`, `좋아요`는 기존 랭킹 산식을 재사용할 수 있지만, 순위 변화와 신규 진입 여부를 안정적으로 계산하려면 최신 완료 주차와 직전 완료 주차의 결과가 필요하다.
|
||||
- 조회 시마다 모든 랭킹 타입의 원천 데이터를 집계하면 응답 지연과 계산 중복이 커지고, 운영 서버와 테스트 환경에서 같은 기준의 결과를 재현하기 어렵다.
|
||||
- 기존 v2 패키지에 크리에이터 랭킹 스냅샷/작업 이력/fallback 패턴과 콘텐츠 추천 탭의 API 조립 계층/도메인 조회 계층 분리 패턴이 있으므로 이를 우선 재사용해야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals
|
||||
- 메인 콘텐츠 랭킹 탭 조회 API를 `kr.co.vividnext.sodalive.v2` 하위 신규 코드로 제공한다.
|
||||
- 기존 패턴과 동일하게 API 조립 계층과 도메인 조회 계층을 분리한다.
|
||||
- 모든 랭킹 타입은 최대 20개 콘텐츠를 응답한다.
|
||||
- 모든 랭킹 타입의 동점자는 `releaseDate desc`, `contentId desc` 순으로 2차, 3차 정렬한다.
|
||||
- `rank`, `rankChange`, `isNew`의 의미는 크리에이터 랭킹과 동일하게 정의한다.
|
||||
- `주간 인기`와 `지금 뜨는 중`은 매주 월요일 00:00 KST 기준으로 지난 주 데이터를 계산한다.
|
||||
- `매출`, `판매량`, `댓글 수`, `좋아요`도 완료된 지난 주 데이터를 기준으로 계산한다.
|
||||
- 스냅샷 생성은 부하 분산을 위해 매주 월요일 01:00:00 KST부터 07:30:00 KST 사이에 랭킹 타입별로 분산 실행한다.
|
||||
- 새로 생성된 스냅샷은 매주 월요일 09:00:00 KST부터 조회 API에 노출한다.
|
||||
- 스냅샷이 없어 조회할 수 없는 경우 스케줄러로 예약된 랭킹 계산 로직을 fallback으로 직접 실행한다.
|
||||
- fallback 실행은 스케줄 실행 기록처럼 저장하며, 동일 랭킹 타입과 동일 집계 기간 기준 최대 3회까지만 시도한다.
|
||||
- PRD에 API endpoint와 Response data class 초안을 포함한다.
|
||||
- 신규 Entity가 생성되는 경우 같은 작업 디렉터리에 대응 DB table 생성/수정 DDL을 기록한다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Non-Goals
|
||||
- 기존 공개 API 스키마를 임의 변경하지 않는다.
|
||||
- 기존 `RankingService.getContentRanking`의 정렬 산식을 이번 작업에서 재정의하지 않는다.
|
||||
- 관리자 화면, 수동 보정 기능, 랭킹 결과 고정/제외 기능은 포함하지 않는다.
|
||||
- 개인화 랭킹, A/B 테스트, 머신러닝 기반 점수 산정은 포함하지 않는다.
|
||||
- 20위 이후 전체보기/페이징 API는 이번 요구사항에 포함하지 않는다.
|
||||
- 실시간 랭킹은 포함하지 않는다. 모든 랭킹 타입은 완료된 지난 주 데이터를 기준으로 한다.
|
||||
- 기존 크리에이터 랭킹의 다중 랭킹 타입 전환, 스냅샷 테이블 구조 변경, 계산 스케줄 분산 처리는 이번 PRD에 포함하지 않고 별도 PRD에서 다룬다.
|
||||
|
||||
---
|
||||
|
||||
## 5. Target Users
|
||||
- 회원: 콘텐츠 메인 탭에서 인기 콘텐츠와 상승 중인 콘텐츠를 탐색하는 사용자
|
||||
- 비회원: 인증 없이 조회 가능한 랭킹 콘텐츠를 탐색하는 사용자
|
||||
- 앱 클라이언트: 내부 랭킹 탭의 랭킹 타입별 목록과 순위 변화 UI를 구성하는 클라이언트
|
||||
- 운영자: 주간 콘텐츠 랭킹 계산 결과와 fallback 실행 이력을 확인하는 내부 사용자
|
||||
|
||||
---
|
||||
|
||||
## 6. User Stories
|
||||
- 사용자는 주간 인기 콘텐츠 상위 20개를 보고 싶다.
|
||||
- 사용자는 지난 주 대비 지금 뜨는 중인 콘텐츠를 보고 싶다.
|
||||
- 사용자는 매출, 판매량, 댓글 수, 좋아요 기준의 콘텐츠 랭킹을 보고 싶다.
|
||||
- 사용자는 각 콘텐츠의 현재 순위, 순위 변화, 신규 진입 여부를 보고 싶다.
|
||||
- 앱 클라이언트는 하나의 API endpoint에서 랭킹 타입만 바꿔 동일한 응답 구조로 화면을 구성하고 싶다.
|
||||
- 테스트 환경에서는 스냅샷이 비어 있어도 조회 API 호출만으로 fallback 랭킹 계산이 실행되기를 원한다.
|
||||
|
||||
---
|
||||
|
||||
## 7. Core Features
|
||||
|
||||
### Feature A. 메인 콘텐츠 랭킹 탭 조회 API
|
||||
|
||||
#### Requirements
|
||||
- 신규 API endpoint는 `GET /api/v2/audio/rankings`로 정의한다.
|
||||
- 요청 query parameter는 `type`을 사용한다.
|
||||
- `type` 값은 아래 enum으로 정의한다.
|
||||
- `WEEKLY_POPULAR`: 주간 인기
|
||||
- `RISING`: 지금 뜨는 중
|
||||
- `REVENUE`: 매출
|
||||
- `SALES_COUNT`: 판매량
|
||||
- `COMMENT_COUNT`: 댓글 수
|
||||
- `LIKE_COUNT`: 좋아요
|
||||
- `type`이 없으면 `WEEKLY_POPULAR`를 기본값으로 사용한다.
|
||||
- 응답 wrapper는 기존 패턴과 동일하게 `ApiResponse.ok(...)`를 사용한다.
|
||||
- 조회 API는 `visibleFromAt <= now`이고 생성이 완료된 최신 스냅샷만 응답한다.
|
||||
- 월요일 09:00:00 KST 전에는 새 주차 스냅샷이 이미 생성되어 있어도 직전 공개 스냅샷을 응답한다.
|
||||
- 인증 회원이면 기존 콘텐츠 랭킹/추천 조회와 같은 방식으로 회원의 19금 노출 가능 여부와 차단 관계를 반영한다.
|
||||
- 비회원이면 19금 콘텐츠를 노출하지 않는다.
|
||||
- 비활성 콘텐츠, 공개 전 콘텐츠, 비활성 크리에이터의 콘텐츠는 노출하지 않는다.
|
||||
- 각 랭킹 타입은 최대 20개를 응답한다.
|
||||
- 정렬은 랭킹 점수 또는 정렬 지표 내림차순, `releaseDate desc`, `contentId desc` 순으로 적용한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 랭킹 결과가 없으면 빈 배열로 성공 응답한다.
|
||||
- 후보가 20개 미만이면 가능한 개수만 내려준다.
|
||||
- 특정 랭킹 타입의 새 스냅샷 생성이 실패하면 해당 타입은 직전 공개 스냅샷을 유지한다.
|
||||
- 콘텐츠 제목, 크리에이터 닉네임, 커버 이미지가 기존 정책상 마스킹되어야 하는 경우 기존 콘텐츠 랭킹/추천 조회 정책을 따른다.
|
||||
|
||||
### Feature B. rank, rankChange, isNew 의미
|
||||
|
||||
#### Requirements
|
||||
- `rank`는 최신 완료 주차 스냅샷에서 해당 랭킹 타입의 정렬 결과 순위다.
|
||||
- `rank`는 1부터 시작한다.
|
||||
- `rankChange`는 `직전 완료 주차 rank - 최신 완료 주차 rank`로 계산한다.
|
||||
- 순위가 올라갔으면 양수, 순위가 내려갔으면 음수, 동일하면 `0`을 내려준다.
|
||||
- 예를 들어 직전 완료 주차 10위, 최신 완료 주차 5위이면 `rankChange`는 `5`다.
|
||||
- 예를 들어 직전 완료 주차 1위, 최신 완료 주차 10위이면 `rankChange`는 `-9`다.
|
||||
- 직전 완료 주차에는 없고 최신 완료 주차에 진입한 콘텐츠는 `isNew == true`로 내려준다.
|
||||
- 신규 진입 콘텐츠의 `rankChange`는 비교 가능한 이전 순위가 없으므로 `null`로 내려준다.
|
||||
- 직전 완료 주차 스냅샷이 없으면 `showRankChange == false`로 내려주고, 각 item의 `rankChange`는 `null`, `isNew`는 `false`로 내려준다.
|
||||
- 직전 완료 주차 스냅샷이 있으면 `showRankChange == true`로 내려준다.
|
||||
|
||||
#### Edge Cases
|
||||
- fallback으로 최신 주차 스냅샷을 생성했지만 직전 완료 주차 스냅샷이 없으면 `showRankChange == false`를 유지한다.
|
||||
- 동점자는 `releaseDate desc`, `contentId desc`로 결정되므로 같은 스냅샷을 조회할 때 순위가 랜덤하게 바뀌지 않는다.
|
||||
|
||||
### Feature C. 주간 인기 랭킹
|
||||
|
||||
#### Requirements
|
||||
- 갱신 기준은 매주 월요일 00:00 KST다.
|
||||
- 집계 대상 기간은 지난 주 월요일 00:00:00 KST 이상, 이번 주 월요일 00:00:00 KST 미만이다.
|
||||
- DB 조회 조건은 KST 집계 기간을 UTC로 변환해 사용한다.
|
||||
- 유료 콘텐츠와 무료 콘텐츠는 서로 다른 원천 지표와 가중치로 1차 점수를 산출한다.
|
||||
- 유료 콘텐츠 점수는 `매출 45% + 판매량 35% + 좋아요 수 10% + 댓글 수 10%`로 계산한다.
|
||||
- 무료 콘텐츠 점수는 `조회수 50% + 좋아요 수 25% + 댓글 수 25%`로 계산한다.
|
||||
- 조회수는 상세 페이지 조회 이력인 `creator_content_view_history` 기준으로 집계한다.
|
||||
- 유료 콘텐츠와 무료 콘텐츠는 각 그룹의 최고 점수를 기준으로 0~100 정규화한 뒤 비교한다.
|
||||
- 유료 정규화 점수는 `(현재 유료 콘텐츠 점수 / 유료 콘텐츠 최고 점수) * 100`으로 계산한다.
|
||||
- 무료 정규화 점수는 `(현재 무료 콘텐츠 점수 / 무료 콘텐츠 최고 점수) * 100`으로 계산한다.
|
||||
- 각 그룹의 최고 점수가 0 이하이면 해당 그룹의 정규화 점수는 0으로 처리한다.
|
||||
- 최종 정렬은 정규화 점수 내림차순, `releaseDate desc`, `contentId desc` 순으로 적용한다.
|
||||
|
||||
#### 정규화 판단
|
||||
- `(최고 점수 + 현재 콘텐츠 점수) * 100`은 최고점 대비 상대 위치를 0~100 범위로 맞추지 못하므로 정규화 산식으로 부적절하다.
|
||||
- 유료/무료 콘텐츠를 별도 산식으로 계산한 뒤 한 목록에서 비교하려면 `(현재 점수 / 그룹 최고 점수) * 100` 방식이 더 적절하다.
|
||||
- 이 방식은 각 그룹의 1위 콘텐츠를 100점으로 맞추고 나머지 콘텐츠를 상대 점수로 비교한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 유료 콘텐츠 후보가 없으면 유료 정규화는 수행하지 않고 무료 콘텐츠만 비교한다.
|
||||
- 무료 콘텐츠 후보가 없으면 무료 정규화는 수행하지 않고 유료 콘텐츠만 비교한다.
|
||||
- 원천 지표가 없으면 0으로 계산한다.
|
||||
|
||||
### Feature D. 지금 뜨는 중 랭킹
|
||||
|
||||
#### Requirements
|
||||
- 갱신 기준은 매주 월요일 00:00 KST다.
|
||||
- 집계 대상 기간은 최근 7일과 직전 7일을 비교한다.
|
||||
- 기준 시점은 완료된 지난 주의 종료 시점으로 한다.
|
||||
- 최근 7일: 지난 주 월요일 00:00:00 KST 이상, 이번 주 월요일 00:00:00 KST 미만
|
||||
- 직전 7일: 2주 전 월요일 00:00:00 KST 이상, 지난 주 월요일 00:00:00 KST 미만
|
||||
- 콘텐츠 지금 뜨는 중 점수는 `((0.5 * 콘텐츠 성장 점수) + (0.25 * 좋아요 증가율) + (0.25 * 댓글 증가율)) * 신규 콘텐츠 부스트`로 계산한다.
|
||||
- 유료 콘텐츠 성장 점수는 `(0.6 * 판매 증가율) + (0.4 * 조회수 증가율)`로 계산한다.
|
||||
- 무료 콘텐츠 성장 점수는 `(0.5 * 조회수 증가율) + (0.25 * 좋아요 증가율) + (0.25 * 댓글 증가율)`로 계산한다.
|
||||
- 판매 증가율은 `(최근 7일 판매량 - 직전 7일 판매량) / max(직전 7일 판매량, 1)`로 계산한다.
|
||||
- 조회수 증가율은 `(최근 7일 조회수 - 직전 7일 조회수) / max(직전 7일 조회수, 1)`로 계산한다.
|
||||
- 좋아요 증가율은 `(최근 7일 좋아요 수 - 직전 7일 좋아요 수) / max(직전 7일 좋아요 수, 1)`로 계산한다.
|
||||
- 댓글 증가율은 `(최근 7일 댓글 수 - 직전 7일 댓글 수) / max(직전 7일 댓글 수, 1)`로 계산한다.
|
||||
- 최근 7일 조회수 10회 미만이면 조회수 증가율 반영값은 0으로 처리한다.
|
||||
- 최근 7일 좋아요 수 3개 미만이면 좋아요 증가율 반영값은 0으로 처리한다.
|
||||
- 최근 7일 댓글 수 3개 미만이면 댓글 증가율 반영값은 0으로 처리한다.
|
||||
- 최근 7일 판매량 3건 미만이면 판매 증가율 반영값은 0으로 처리한다.
|
||||
- 유료 콘텐츠와 무료 콘텐츠는 각 그룹의 최고 점수를 기준으로 0~100 정규화한 뒤 비교한다.
|
||||
- 유료 정규화 점수는 `(현재 유료 콘텐츠 점수 / 유료 콘텐츠 최고 점수) * 100`으로 계산한다.
|
||||
- 무료 정규화 점수는 `(현재 무료 콘텐츠 점수 / 무료 콘텐츠 최고 점수) * 100`으로 계산한다.
|
||||
- 각 그룹의 최고 점수가 0 이하이면 해당 그룹의 정규화 점수는 0으로 처리한다.
|
||||
- 신규 콘텐츠 부스트는 집계 종료일 기준 `releaseDate` 경과 일수로 적용한다.
|
||||
- Release 3일 이내: `1.5`
|
||||
- Release 7일 이내: `1.3`
|
||||
- Release 14일 이내: `1.15`
|
||||
- Release 14일 초과: `1.0`
|
||||
- 최종 정렬은 정규화 점수 내림차순, `releaseDate desc`, `contentId desc` 순으로 적용한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 모든 증가율 반영값이 0이면 지금 뜨는 중 원점수는 0으로 계산한다.
|
||||
- 증가율은 음수가 될 수 있으며, 음수 원점수는 정규화 전 후보 점수에 그대로 반영한다.
|
||||
- 그룹 최고 점수가 0 이하인 경우 해당 그룹 콘텐츠의 정규화 점수는 0으로 처리해 음수 최고점으로 인한 역전 현상을 피한다.
|
||||
|
||||
### Feature E. 매출, 판매량, 댓글 수, 좋아요 랭킹
|
||||
|
||||
#### Requirements
|
||||
- `REVENUE`, `SALES_COUNT`, `COMMENT_COUNT`, `LIKE_COUNT`는 기존 `RankingService.getContentRanking` 및 `ContentRankingSortType`의 정렬 기준을 재사용한다.
|
||||
- 신규 v2 도메인 조회 계층에서는 기존 서비스를 직접 노출하지 않고 adapter 또는 port 경계를 통해 필요한 결과만 가져온다.
|
||||
- 각 랭킹 타입도 스냅샷으로 저장한다.
|
||||
- 스냅샷 생성 시 기존 정렬 결과를 기반으로 최대 20개 콘텐츠의 순위와 표시 정보를 저장한다.
|
||||
- 동점자는 `releaseDate desc`, `contentId desc` 순으로 정렬되도록 기존 쿼리 또는 v2 adapter에서 보강한다.
|
||||
- 최종 응답의 `rank`, `rankChange`, `isNew` 계산은 `주간 인기`, `지금 뜨는 중`과 동일한 공통 로직을 사용한다.
|
||||
|
||||
#### 스냅샷 저장 판단
|
||||
- 이번 PRD의 기준안은 모든 랭킹 타입을 스냅샷으로 저장하는 것이다.
|
||||
- 이유는 `rankChange`와 `isNew`가 모든 응답 item에 필요하고, 이 값은 최신 완료 주차와 직전 완료 주차의 같은 랭킹 타입 결과를 비교해야 안정적으로 계산할 수 있기 때문이다.
|
||||
- `주간 인기`와 `지금 뜨는 중`만 스냅샷으로 저장하면 `매출`, `판매량`, `댓글 수`, `좋아요`는 조회 때마다 최신/직전 주차를 동적으로 재계산해야 하며, 계산 비용과 late update에 따른 순위 흔들림이 생긴다.
|
||||
- 기존 산식을 재사용하는 4개 랭킹도 스냅샷 생성 시점에는 기존 `RankingService.getContentRanking`을 활용할 수 있으므로 구현 부담은 낮고, 조회 API는 모든 랭킹 타입에 동일한 경로를 사용할 수 있다.
|
||||
|
||||
#### Edge Cases
|
||||
- 기존 랭킹 조회 결과가 20개 미만이면 해당 개수만 스냅샷으로 저장한다.
|
||||
- 기존 랭킹 조회에서 최소 개수 확보를 위해 과거 기간으로 조회 기간을 확장하는 로직이 있다면, 스냅샷 생성 기준에서는 이번 랭킹 탭의 완료 주차 기준과 충돌하지 않는지 구현 계획 단계에서 확인한다.
|
||||
|
||||
### Feature F. 랭킹 스냅샷 및 작업 이력
|
||||
|
||||
#### Requirements
|
||||
- 콘텐츠 랭킹 스냅샷은 랭킹 타입, 집계 시작/종료 시각, 콘텐츠 id, 순위, 점수 또는 정렬 지표, 표시용 콘텐츠 정보를 저장한다.
|
||||
- 신규 스냅샷 Entity와 작업 이력 Entity의 DB table DDL은 `docs/20260623_메인_콘텐츠_랭킹_탭_API/create-content-ranking-tables.sql`에 기록한다.
|
||||
- 스냅샷에는 `visibleFromAt`을 저장하며, 공개 조회는 이 시각이 지난 스냅샷만 대상으로 한다.
|
||||
- 스냅샷은 최신 완료 주차와 직전 완료 주차를 조회할 수 있어야 한다.
|
||||
- 같은 랭킹 타입과 같은 집계 기간의 스냅샷은 중복 저장하지 않는다.
|
||||
- 스냅샷 생성은 기존 크리에이터 랭킹과 동일하게 job service, refresh service, scheduler 책임으로 분리한다.
|
||||
- 집계 기준 시각은 매주 월요일 00:00:00 KST다.
|
||||
- 스냅샷 생성은 원천 데이터 적재 지연과 운영 부하를 고려해 매주 월요일 01:00:00 KST부터 07:30:00 KST 사이에 랭킹 타입별로 분산 실행한다.
|
||||
- 새 스냅샷의 기본 노출 전환 시각은 매주 월요일 09:00:00 KST다.
|
||||
- 스케줄러는 `Asia/Seoul` zone을 명시한다.
|
||||
- 다중 서버 인스턴스에서 같은 스케줄이 동시에 실행되더라도 Redisson lock으로 동일 기간/랭킹 타입은 한 번만 계산한다.
|
||||
- 작업 이력에는 trigger, status, 집계 시작/종료 시각, 랭킹 타입, 오류 메시지, 처리 시작/종료 시각을 저장한다.
|
||||
- trigger 값은 최소 `SCHEDULED`, `MANUAL`, `FALLBACK`을 지원한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 특정 랭킹 타입 스냅샷 생성이 실패해도 다른 랭킹 타입 생성이 가능한 구조로 분리한다.
|
||||
- 일부 랭킹 타입만 스냅샷이 있으면 요청한 `type` 기준으로만 fallback 여부를 판단한다.
|
||||
- 월요일 09:00:00 KST 전에 새 스냅샷이 일부만 생성되어도 공개 조회에는 반영하지 않는다.
|
||||
- 월요일 09:00:00 KST 이후 특정 랭킹 타입의 새 스냅샷이 없거나 생성 실패 상태이면 해당 타입은 직전 공개 스냅샷을 응답한다.
|
||||
|
||||
### Feature G. 크리에이터 랭킹과의 범위 경계
|
||||
|
||||
#### Requirements
|
||||
- 현재 크리에이터 랭킹 스냅샷 생성 스케줄은 매주 월요일 KST 07:30이다.
|
||||
- 크리에이터 랭킹도 향후 다중 랭킹 타입이 추가될 예정이므로, 콘텐츠 랭킹의 스냅샷/작업 이력 구조는 향후 크리에이터 랭킹에도 같은 운영 모델을 적용할 수 있도록 `rankingType`, 집계 기간, `visibleFromAt`, job trigger/status 축을 기준으로 설계한다.
|
||||
- 이번 PRD는 콘텐츠 랭킹 API와 콘텐츠 랭킹 스냅샷 생성만 구현한다.
|
||||
- 기존 크리에이터 랭킹의 스냅샷 테이블에 `rankingType`을 추가하거나, 크리에이터 랭킹 계산 스케줄을 01:00~07:30 분산 방식으로 변경하는 작업은 이번 PRD에 포함하지 않는다.
|
||||
- 크리에이터 랭킹 다중 타입 전환과 스케줄 분산 처리는 별도 PRD에서 기존 크리에이터 랭킹 PRD/DDL/구현 계획을 갱신해 다룬다.
|
||||
|
||||
### Feature H. fallback 랭킹 계산
|
||||
|
||||
#### Requirements
|
||||
- 조회 시 요청한 랭킹 타입의 최신 완료 주차 스냅샷이 없으면 fallback 실행 가능 여부를 확인한다.
|
||||
- fallback은 스케줄러가 호출하는 랭킹 계산 로직과 동일한 refresh service를 직접 실행한다.
|
||||
- fallback 실행 전 `FALLBACK` trigger의 작업 이력을 `PENDING` 또는 `PROCESSING` 상태로 기록한다.
|
||||
- fallback 성공 시 `DONE`, 실패 시 `FAILED`로 작업 이력을 기록한다.
|
||||
- 동일 랭킹 타입과 동일 집계 기간의 fallback 실행 이력이 3회 이상이면 추가 fallback을 실행하지 않는다.
|
||||
- fallback으로 스냅샷이 생성되면 해당 스냅샷을 다시 조회해 응답한다.
|
||||
- fallback으로 생성된 스냅샷도 `visibleFromAt <= now` 조건을 만족해야 공개 조회에 노출한다.
|
||||
- fallback 실행 후에도 스냅샷이 없으면 빈 배열로 성공 응답한다.
|
||||
- fallback 여부는 공개 API response schema에 포함하지 않는다.
|
||||
|
||||
#### Edge Cases
|
||||
- 다른 요청이 같은 랭킹 타입/기간 fallback을 처리 중이면 lock 획득 실패를 정상 skip으로 간주하고, 현재 요청은 재조회 후 없으면 빈 배열로 응답한다.
|
||||
- fallback 계산 중 예외가 발생해도 공개 API는 내부 오류를 그대로 노출하지 않고 기존 예외/응답 정책을 따른다.
|
||||
- fallback 작업 이력 저장 실패와 랭킹 계산 실패의 트랜잭션 경계는 구현 계획 단계에서 크리에이터 랭킹 작업 이력 패턴을 따른다.
|
||||
|
||||
### Feature I. v2 재사용 후보
|
||||
|
||||
#### Requirements
|
||||
- API 조립 계층은 `v2/api/content/recommendation`의 `AudioRecommendationController`, `AudioRecommendationFacade`, DTO 변환 패턴을 참고한다.
|
||||
- 도메인 조회 계층은 `v2/content/recommendation/application/AudioRecommendationQueryService`처럼 응답 조립에 필요한 도메인 모델을 반환한다.
|
||||
- `rankChange`, `isNew`, `showRankChange`, fallback 로그/작업 이력 패턴은 `v2/ranking/application/CreatorRankingQueryService`와 `CreatorRankingSnapshotJobService`를 참고한다.
|
||||
- 주간 기간 계산, UTC 변환, Redisson lock은 `v2/ranking/domain/CreatorRankingPeriodPolicy`와 크리에이터 랭킹 스냅샷 job 구조를 재사용하거나 콘텐츠 랭킹용으로 동일 패턴을 만든다.
|
||||
- 상세 페이지 조회수는 `v2/recommendation/adapter/out/persistence/CreatorContentViewHistory`와 관련 port/repository를 재사용 후보로 검토한다.
|
||||
- CDN URL 조립은 `v2/common/domain/CdnUrlExtensions.kt`의 `toCdnUrl` 패턴을 우선 사용한다.
|
||||
- `REVENUE`, `SALES_COUNT`, `COMMENT_COUNT`, `LIKE_COUNT`는 기존 `RankingService.getContentRanking`을 legacy adapter로 감싸 재사용하는 방향을 우선 검토한다.
|
||||
|
||||
---
|
||||
|
||||
## 8. API Endpoint
|
||||
|
||||
```http
|
||||
GET /api/v2/audio/rankings?type=WEEKLY_POPULAR
|
||||
Authorization: Bearer {accessToken} (optional)
|
||||
```
|
||||
|
||||
- 비회원 조회를 허용한다.
|
||||
- 회원 조회 시 기존 v2 controller 패턴과 동일하게 anonymous user를 `null` member로 처리한다.
|
||||
- `type`이 없으면 `WEEKLY_POPULAR`를 기본값으로 사용한다.
|
||||
- 잘못된 `type` 값에 대한 오류 응답은 기존 enum request parameter 오류 처리 정책을 따른다.
|
||||
|
||||
---
|
||||
|
||||
## 9. Response Data Class
|
||||
|
||||
```kotlin
|
||||
data class AudioRankingResponse(
|
||||
val showRankChange: Boolean,
|
||||
val type: AudioRankingType,
|
||||
val items: List<AudioRankingItemResponse>
|
||||
)
|
||||
|
||||
enum class AudioRankingType {
|
||||
WEEKLY_POPULAR,
|
||||
RISING,
|
||||
REVENUE,
|
||||
SALES_COUNT,
|
||||
COMMENT_COUNT,
|
||||
LIKE_COUNT
|
||||
}
|
||||
|
||||
data class AudioRankingItemResponse(
|
||||
val contentId: Long,
|
||||
val title: String,
|
||||
val creatorNickname: String,
|
||||
val rank: Int,
|
||||
val rankChange: Int?,
|
||||
@JsonProperty("isNew")
|
||||
val isNew: Boolean,
|
||||
val coverImageUrl: String?
|
||||
)
|
||||
```
|
||||
|
||||
응답 예시는 다음과 같다.
|
||||
|
||||
```json
|
||||
{
|
||||
"showRankChange": true,
|
||||
"type": "WEEKLY_POPULAR",
|
||||
"items": [
|
||||
{
|
||||
"contentId": 123,
|
||||
"title": "Audio title",
|
||||
"creatorNickname": "creator",
|
||||
"rank": 1,
|
||||
"rankChange": 5,
|
||||
"isNew": false,
|
||||
"coverImageUrl": "https://cdn.example.com/audio-cover.png"
|
||||
},
|
||||
{
|
||||
"contentId": 456,
|
||||
"title": "New audio",
|
||||
"creatorNickname": "new creator",
|
||||
"rank": 2,
|
||||
"rankChange": null,
|
||||
"isNew": true,
|
||||
"coverImageUrl": "https://cdn.example.com/audio-cover-new.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Technical Constraints
|
||||
- Kotlin + Spring Boot 2.7.14 기준으로 작성한다.
|
||||
- Java 17 런타임을 기준으로 한다.
|
||||
- 신규 코드는 `kr.co.vividnext.sodalive.v2` 하위에 배치한다.
|
||||
- 공개 API 조립 계층과 도메인 조회 계층을 분리한다.
|
||||
- 스냅샷과 작업 이력 저장은 MySQL 기준 DDL을 별도 문서 또는 구현 계획에서 작성한다.
|
||||
- 이번 PRD에서 예상하는 신규 Entity는 `content_ranking_snapshot`, `content_ranking_snapshot_job` 테이블에 대응하며, 초안 DDL은 `docs/20260623_메인_콘텐츠_랭킹_탭_API/create-content-ranking-tables.sql`에 둔다.
|
||||
- 시간 기준은 `Asia/Seoul`을 명시하고 DB 조회는 UTC 변환 범위를 사용한다.
|
||||
- 테스트는 순위 변화 계산, 정규화 산식, 동점 정렬, fallback 최대 3회 제한, 작업 이력 기록을 포함해야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 11. Metrics
|
||||
- 랭킹 조회 API 응답 시간
|
||||
- 랭킹 타입별 스냅샷 생성 성공/실패 횟수
|
||||
- fallback 실행 횟수와 성공/실패 횟수
|
||||
- fallback 최대 3회 초과로 빈 응답한 횟수
|
||||
- 랭킹 타입별 응답 item 수
|
||||
|
||||
---
|
||||
|
||||
## 12. Open Questions
|
||||
- fallback 최대 3회 제한은 동일 랭킹 타입과 동일 집계 기간 기준으로 가정한다.
|
||||
- 지금 뜨는 중의 최소 반영 기준은 콘텐츠 전체 후보 제외가 아니라 지표별 점수 반영 제외로 해석한다. 예를 들어 최근 7일 조회수는 10회 미만이지만 좋아요 3개 이상, 댓글 3개 이상이면 조회수 증가율만 0으로 계산하고 좋아요/댓글 증가율은 점수에 반영한다. 콘텐츠 전체 후보 제외가 의도라면 구현 전에 수정해야 한다.
|
||||
Reference in New Issue
Block a user