Files
sodalive-backend-spring-boot/docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md

47 KiB
Raw Blame History

메인 콘텐츠 랭킹 탭 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는 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

  • 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/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/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와 이 문서를 갱신한다.

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 초안

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?
)
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: 랭킹 후보 집계와 스냅샷 후보 생성

  • Task 4.1: 기존 4종 지표의 v2 전용 집계 작성

    • Files:
      • Modify: src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingAggregationPort.kt
      • Modify: 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: REVENUE, SALES_COUNT, COMMENT_COUNT, LIKE_COUNT가 v2 집계 지표를 그대로 finalScore로 전달하는 테스트를 작성한다.
    • 실패 확인: ./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingAggregationRepositoryTest
    • GREEN: legacy RankingService 호출 없이 v2 집계 repository에서 매출, 판매량, 댓글 수, 좋아요 후보를 만든다.
    • REFACTOR: 기존 랭킹 조회 조건과 v2 스냅샷 공개/제외 조건이 섞이지 않도록 snapshot 생성 경로에서 legacy 의존성을 제거한다.
    • 기대 결과: 6개 랭킹 타입 모두 v2 집계/스냅샷 경로로 생성된다.
  • 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: 비회원은 성인 콘텐츠를 제외하고, 회원 차단 관계가 있는 콘텐츠는 기존 콘텐츠 랭킹/추천 정책에 맞게 제외 또는 마스킹되는 테스트를 작성한다. 성인 콘텐츠 제외는 스냅샷의 isAdultglobal top 20 non-adult top 20 후보 보존으로 보충 가능해야 한다.
    • 실패 확인: ./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=RISINGshowRankChange, 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_snapshotcreator_ranking_snapshot_jobranking_type, visible_from_at 추가가 필요한지 DDL 영향부터 검토한다.
      • 크리에이터 랭킹 코드 변경은 별도 PRD와 별도 plan-task 문서가 준비된 뒤 진행한다.
    • 기대 결과: 이번 콘텐츠 랭킹 구현 범위를 넘지 않으면서, 다음 범위의 첫 작업이 문서 수정부터 시작되도록 명확한 기록이 남는다.

검증 기록

  • 작성 시점: PRD 기반 구현 계획 문서를 신규 생성했다. 아직 구현 전이므로 task별 검증 기록은 없다.
  • 2026-06-24 Phase 1, 2 구현: AudioRankingType, 응답 DTO, facade, 비회원 허용 controller, KST 주간 기간 정책, 09:00 KST 노출 전환 정책, 주간 인기/지금 뜨는 중 점수 정책을 추가했다.
  • 2026-06-24 RED/GREEN: 각 task는 대상 테스트를 먼저 추가한 뒤 미구현 참조 또는 컨트롤러 미존재 실패를 확인하고 최소 구현으로 GREEN 전환했다.
  • 2026-06-24 검증: ./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.dto.AudioRankingResponseTest --tests kr.co.vividnext.sodalive.v2.api.content.ranking.application.AudioRankingFacadeTest --tests kr.co.vividnext.sodalive.v2.api.content.ranking.adapter.in.web.AudioRankingControllerTest --tests kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingPeriodPolicyTest --tests kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingSchedulePolicyTest --tests kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingScorePolicyTest 통과.
  • 2026-06-24 검증: ./gradlew ktlintCheck 통과.
  • 2026-06-24 Phase 3, 4 구현: content_ranking_snapshot, content_ranking_snapshot_job Entity/Repository/Port/Adapter, 6개 타입 v2 집계 repository, 스냅샷 refresh service를 추가했다.
  • 2026-06-24 RED/GREEN: DefaultAudioRankingAggregationRepositoryTest에서 H2 native query의 release_dateTimestamp로 반환되어 LocalDateTime cast 실패를 확인했고, Timestamp.toLocalDateTime() 변환을 추가해 GREEN 전환했다.
  • 2026-06-24 검증: ./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingSnapshotPersistenceAdapterTest --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingSnapshotJobRepositoryTest 통과.
  • 2026-06-24 검증: ./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingAggregationRepositoryTest --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotRefreshServiceTest 통과.
  • 2026-06-24 검증: rg -n "visible_from_at|ranking_type|content_ranking_snapshot|content_ranking_snapshot_job" docs/20260623_메인_콘텐츠_랭킹_탭_API src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking으로 DDL/Entity 핵심 컬럼 정합성을 확인했다.
  • 2026-06-24 최종 검증: ./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*', ./gradlew ktlintCheck, ./gradlew tasks --all 통과.
  • 2026-06-24 리뷰 반영: RISING 점수도 유료/무료 그룹별 0~100 정규화를 거치도록 보강했고, REVENUE, SALES_COUNT, COMMENT_COUNT, LIKE_COUNT 스냅샷 후보 산정은 기존 랭킹 조회 재사용이 아닌 v2 전용 집계 repository 책임으로 변경했다.
  • 2026-06-24 리뷰 반영 검증: ./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*', ./gradlew ktlintCheck 통과.
  • 2026-06-24 리뷰 재반영: AudioRankingQueryService가 최신/직전 visible snapshot을 조회해 rankChange, isNew, showRankChange를 계산하도록 구현했다.
  • 2026-06-24 리뷰 재반영: 비회원/비성인 조회자를 위해 스냅샷에 isAdult를 저장하고, snapshot refresh는 전체 상위 20개와 비성인 상위 20개 후보의 합집합을 보존하도록 변경했다.
  • 2026-06-24 Phase 7 구현: AudioRankingControllerTest를 Spring context 기반 MockMvc 통합 테스트로 전환해 Controller -> Facade -> QueryService -> SnapshotRepository 경로로 GET /api/v2/audio/rankings?type=RISING 응답의 showRankChange, type, contentId, rank, rankChange, isNew를 검증했다.
  • 2026-06-24 Phase 7 검증: 공개 response에 finalScore, aggregationStartAtUtc, aggregationEndAtUtc, visibleFromAtUtc, fallback이 노출되지 않음을 통합 테스트로 확인했다.
  • 2026-06-24 Phase 7 문서/DDL 정합성 검증: 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, ./gradlew tasks --all 통과.
  • 2026-06-24 Phase 7 회귀 검증: ./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.adapter.in.web.AudioRankingControllerTest, ./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*', ./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.ranking.*', ./gradlew ktlintCheck 통과.
  • 2026-06-24 Phase 5 구현: AudioRankingSnapshotJobServiceAudioRankingSnapshotScheduler를 추가해 타입별 scheduled/fallback job 이력, fallback 3회 제한, 타입/기간 기반 Redisson lock, 02:00~07:00 KST 분산 스케줄을 구현했다.
  • 2026-06-24 Phase 6 구현: AudioRankingBlockPort, DefaultAudioRankingBlockRepository를 추가하고 AudioRankingQueryService가 회원 차단 관계 콘텐츠를 제외하며 최신 visible snapshot 공백 시 fallback job 실행 후 재조회하도록 보강했다.
  • 2026-06-24 RED/GREEN: AudioRankingSnapshotJobServiceTest는 service 미존재 컴파일 실패를 확인한 뒤 GREEN 전환했고, AudioRankingSnapshotSchedulerTest는 scheduler 미존재 컴파일 실패를 확인한 뒤 GREEN 전환했다. AudioRankingQueryServiceTestAudioRankingBlockPort와 query service 의존성 미구현 컴파일 실패를 확인한 뒤 차단/fallback 구현으로 GREEN 전환했다.
  • 2026-06-24 Phase 5/6 검증: ./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotRefreshServiceTest, ./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobServiceTest, ./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.scheduler.AudioRankingSnapshotSchedulerTest, ./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest 통과.
  • 2026-06-24 Phase 5/6 회귀 검증: ./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*', ./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.ranking.*', ./gradlew ktlintCheck 통과. 병렬 실행 중 kaptTestKotlin에서 StreamCorruptedException: unexpected EOF in middle of data block이 1회 발생했으나, 동일 content ranking 테스트 단독 재실행은 통과했다.
  • 2026-06-24 Phase 5/6 리뷰 반영: snapshot refresh 실패 시 FAILED job 이력이 rollback되지 않도록 job 생성/상태 변경을 각각 REQUIRES_NEW 트랜잭션으로 분리했다. 공개 조회 fallback 실행 중 예외가 발생해도 응답 스키마를 유지하도록 보강했고, 차단 creator가 직전 스냅샷에만 있는 경우도 rankChange 계산에서 제외되도록 latest/previous creator 합집합 기준으로 차단 관계를 조회한다.
  • 2026-06-24 Phase 5/6 리뷰 반영 검증: ./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobServiceTest, ./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest, ./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*', ./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.ranking.*', ./gradlew ktlintCheck 통과.
  • 2026-06-24 Phase 5/6 코드 리뷰 추가 반영: class-level @Transactional(readOnly = true) 경계에서 snapshot replace write가 실행되지 않도록 refreshService.refreshLastCompletedWeek(...) 호출 자체를 REQUIRES_NEW 트랜잭션으로 감쌌다. fallback job 생성 이전 또는 lock/transaction 단계 예외도 추적 가능하도록 AudioRankingQueryServiceevent=audio_ranking_query_fallback_failure warn 로그를 추가했다.
  • 2026-06-24 Phase 5/6 코드 리뷰 추가 검증: ./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobServiceTest, ./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest, ./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*', ./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.ranking.*', ./gradlew ktlintCheck 통과.
  • 2026-06-24 Phase 6 잔여 리스크 반영: DefaultAudioRankingBlockRepositoryTest DB slice 테스트를 추가해 실제 QueryDSL 양방향 활성 차단 조회, 비활성 차단 제외, 입력 목록 외 차단 제외, 빈 입력 반환을 검증하도록 보강했다.
  • 2026-06-24 Phase 6 잔여 리스크 검증: ./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingBlockRepositoryTest, ./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*' 통과.
  • 2026-06-24 Phase 6 트랜잭션 가시성 리뷰 반영: MySQL REPEATABLE READ에서 fallback REQUIRES_NEW 커밋 후 같은 read-only 트랜잭션 재조회가 새 스냅샷을 보지 못할 수 있어, AudioRankingQueryService.getRankings()의 외부 @Transactional(readOnly = true) 경계를 제거했다.
  • 2026-06-24 Phase 6 트랜잭션 가시성 검증: getRankings()@Transactional이 다시 붙지 않도록 회귀 테스트를 추가했다. RED 확인 후 수정했고, ./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest, ./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*', ./gradlew ktlintCheck 통과.
  • 2026-06-24 Phase 8 문서 확인: rg -n "07:30|visibleFromAt|visible_from_at|ranking_type|크리에이터 랭킹" docs/20260608_크리에이터_랭킹 docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md로 크리에이터 랭킹 현재 07:30 스케줄과 다음 범위의 visible_from_at, ranking_type DDL 검토 시작점을 확인했다.
  • 2026-06-24 Phase 8 범위 확정: 크리에이터 랭킹 코드/DDL은 수정하지 않고, 다음 범위가 별도 PRD 문서 수정부터 시작되도록 Task 8.1 완료 상태와 검증 기록만 갱신했다.

Phase 9: coverImageUrl CDN host 누락 버그 수정

  • Task 9.1: AudioRankingQueryService 응답 변환 지점에서 CDN URL 정책 고정
    • Files:
      • Modify: src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt
      • Modify: src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt
      • Modify: docs/20260623_메인_콘텐츠_랭킹_탭_API/prd.md
      • Modify: docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md
    • 버그 내용: 메인 콘텐츠 랭킹 탭 API는 스냅샷의 coverImageUrl 값을 AudioRankingQueryService.toItem(...)에서 그대로 AudioRankingItem.coverImageUrl로 옮기고 있었다. 스냅샷 생성 과정의 원천 값은 audio_content.cover_image 계열의 저장 path이므로, 공개 API 응답도 cover-1.png처럼 host 없는 path만 내려갔다.
    • 영향 범위: GET /api/v2/audio/rankings의 item coverImageUrl만 대상이다. 순위 계산, 최신/직전 visible snapshot 조회, 19금 필터, 차단 필터, fallback job 실행, 스냅샷 저장 구조와 DDL은 변경하지 않는다.
    • 원인: 콘텐츠 랭킹 조회 서비스가 크리에이터 랭킹의 profileImageUrl.toCdnUrl(cloudFrontHost) 패턴이나 v2 콘텐츠/크리에이터 조회 계층의 toCdnUrl 패턴을 적용하지 않았다. DTO 변환 계층은 domain item 값을 그대로 응답으로 내보내므로, domain item 조립 시점에 URL 변환이 누락되면 Response에서도 그대로 노출된다.
    • RED: AudioRankingQueryServiceTest.shouldReturnLatestVisibleSnapshotsWithRankChangesAndNewFlags에 스냅샷 fixture의 coverImageUrl = "cover-N.png"가 응답 item에서는 https://cdn.test/cover-N.png로 변환되어야 한다는 assertion을 추가한다. 기존 구현에서는 path만 반환하므로 이 assertion이 실패해야 한다.
    • GREEN: AudioRankingQueryService 생성자에 @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String을 주입하고, AudioRankingSnapshotRecord.toItem(...)에서 coverImageUrl.toCdnUrl(cloudFrontHost)를 사용한다. 이 방식은 null/blank는 null, 이미 http:// 또는 https://로 시작하는 값은 그대로 유지하는 기존 공통 확장 함수를 재사용한다.
    • REFACTOR: 별도 URL helper를 새로 만들지 않는다. 스냅샷 저장 데이터를 full URL로 마이그레이션하지 않고, 공개 응답 조립 지점에서만 변환해 기존 데이터와 신규 데이터 모두 동일하게 처리한다.
    • 기대 결과: GET /api/v2/audio/rankings 응답의 items[*].coverImageUrl은 path가 아니라 cloud.aws.cloud-front.host가 포함된 이미지 URL로 내려간다.

Phase 9 검증 기록

  • 2026-06-25 문서 갱신: 사용자 후속 요청에 따라 prd.mdcoverImageUrl host 누락 버그, 공개 응답 URL 정책, toCdnUrl 기반 변환 규칙을 추가했다. plan-task.md에는 버그 내용, 영향 범위, 원인, RED/GREEN/REFACTOR 기준을 Phase 9로 누적 기록했다.
  • 2026-06-25 focused 검증: ./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest 실행 결과 BUILD SUCCESSFUL in 30s를 확인했다. 이 테스트는 스냅샷 fixture의 path 값(cover-N.png)이 응답 item에서는 https://cdn.test/cover-N.png로 변환되는지 검증한다.
  • 2026-06-25 문서 명령 검증: ./gradlew tasks --all 실행 결과 BUILD SUCCESSFUL in 8s를 확인했고, rg -n "coverImageUrl|Phase 9|cdn|cloud-front|toCdnUrl|host 없는 path|CDN host" docs/20260623_메인_콘텐츠_랭킹_탭_API/prd.md docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md로 PRD와 plan-task에 버그 내용 및 수정 정책이 반영된 위치를 확인했다.
  • 2026-06-25 포맷 검증: ./gradlew ktlintCheck 실행 결과 BUILD SUCCESSFUL in 32s를 확인했다.
  • 2026-06-25 회귀 검증: ./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.ranking.*' 실행 결과 BUILD SUCCESSFUL in 1m 4s를 확인했다. ./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'를 API 테스트와 병렬 실행했을 때는 build/test-results/test/TEST-*.xml 파일 쓰기 충돌로 실패했으나, 동일 명령을 단독 재실행해 BUILD SUCCESSFUL in 19s를 확인했다.