feat(ranking): 조회 cold-start fallback을 추가한다

This commit is contained in:
2026-06-09 12:32:06 +09:00
parent 017ba309f0
commit 32460e550c
2 changed files with 285 additions and 1 deletions

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.ranking.application
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingItem
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationPort
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingBlockPort
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotPort
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord
@@ -16,6 +17,8 @@ import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.boot.test.system.CapturedOutput
import org.springframework.boot.test.system.OutputCaptureExtension
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
@ExtendWith(OutputCaptureExtension::class)
class CreatorRankingQueryServiceTest {
@@ -71,6 +74,90 @@ class CreatorRankingQueryServiceTest {
assertTrue(result.items.isEmpty())
}
@Test
@DisplayName("최신 스냅샷이 있으면 cold-start fallback 집계를 호출하지 않는다")
fun shouldNotUseColdStartFallbackWhenLatestSnapshotsExist() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
snapshotPort.latestSnapshots = listOf(snapshot(creatorId = 1L, finalScore = 100.0))
snapshotPort.snapshotTableEmpty = true
aggregationPort.candidates = listOf(candidate(creatorId = 2L))
val service = service(snapshotPort = snapshotPort, aggregationPort = aggregationPort)
val result = service.getCreatorRankings(viewerMemberId = null)
assertEquals(listOf(1L), result.items.map { it.creatorId })
assertEquals(0, aggregationPort.aggregateCallCount)
}
@Test
@DisplayName("최신 스냅샷이 없고 스냅샷 테이블이 완전히 비어 있으면 cold-start fallback을 반환한다")
fun shouldUseColdStartFallbackOnlyWhenSnapshotTableIsEmpty() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
snapshotPort.snapshotTableEmpty = true
aggregationPort.candidates = listOf(
candidate(creatorId = 1L, liveCanAmount = 100),
candidate(creatorId = 2L, liveCanAmount = 200)
)
val service = service(snapshotPort = snapshotPort, aggregationPort = aggregationPort)
val result = service.getCreatorRankings(viewerMemberId = null)
assertFalse(result.showRankChange)
assertEquals(listOf(2L, 1L), result.items.map { it.creatorId })
assertEquals(listOf(1, 2), result.items.map { it.rank })
assertTrue(result.items.all { it.rankChange == null })
assertTrue(result.items.none { it.isNew })
assertEquals(1, aggregationPort.aggregateCallCount)
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), aggregationPort.startInclusiveUtc)
assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0), aggregationPort.endExclusiveUtc)
}
@Test
@DisplayName("최신 스냅샷이 없어도 과거 스냅샷 row가 있으면 cold-start fallback을 호출하지 않는다")
fun shouldNotUseColdStartFallbackWhenAnyHistoricalSnapshotExists() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
snapshotPort.snapshotTableEmpty = false
aggregationPort.candidates = listOf(candidate(creatorId = 1L))
val service = service(snapshotPort = snapshotPort, aggregationPort = aggregationPort)
val result = service.getCreatorRankings(viewerMemberId = null)
assertFalse(result.showRankChange)
assertTrue(result.items.isEmpty())
assertEquals(0, aggregationPort.aggregateCallCount)
}
@Test
@DisplayName("cold-start fallback도 차단 관계가 있으면 크리에이터 식별 정보만 마스킹한다")
fun shouldMaskBlockedCreatorIdentityInColdStartFallback() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
val blockPort = FakeCreatorRankingBlockPort()
snapshotPort.snapshotTableEmpty = true
aggregationPort.candidates = listOf(
candidate(creatorId = 1L, liveCanAmount = 200),
candidate(creatorId = 2L, liveCanAmount = 100)
)
blockPort.blockedCreatorIds = setOf(1L)
val service = service(
snapshotPort = snapshotPort,
blockPort = blockPort,
aggregationPort = aggregationPort
)
val result = service.getCreatorRankings(viewerMemberId = 99L)
assertEquals(99L, blockPort.memberId)
assertEquals(setOf(1L, 2L), blockPort.creatorIds)
assertEquals(0L, result.items.first().creatorId)
assertEquals("", result.items.first().nickname)
assertEquals("https://cdn.test/profile/default-profile.png", result.items.first().profileImageUrl)
assertEquals(2L, result.items[1].creatorId)
}
@Test
@DisplayName("직전 완료 주차 스냅샷이 없으면 순위 변화 없이 최신 스냅샷 상위 20명을 반환한다")
fun shouldReturnLatestTopTwentyWithoutRankChangeWhenPreviousSnapshotsDoNotExist() {
@@ -213,17 +300,86 @@ class CreatorRankingQueryServiceTest {
assertTrue(output.out.contains("error=latest snapshots failed"))
}
@Test
@DisplayName("cold-start fallback 성공은 기간과 반환 수를 로그로 남긴다")
fun shouldLogColdStartFallbackSuccessWithPeriodAndCount(output: CapturedOutput) {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
snapshotPort.snapshotTableEmpty = true
aggregationPort.candidates = listOf(candidate(creatorId = 1L))
val service = service(snapshotPort = snapshotPort, aggregationPort = aggregationPort)
service.getCreatorRankings(viewerMemberId = null)
assertTrue(output.out.contains("event=creator_ranking_query_cold_start_fallback_attempt"))
assertTrue(output.out.contains("event=creator_ranking_query_cold_start_fallback_success"))
assertTrue(output.out.contains("aggregationStartAtUtc=2026-05-31T15:00"))
assertTrue(output.out.contains("aggregationEndAtUtc=2026-06-07T15:00"))
assertTrue(output.out.contains("itemCount=1"))
}
@Test
@DisplayName("cold-start fallback 실패는 기간과 에러를 로그로 남기고 예외를 전파한다")
fun shouldLogColdStartFallbackFailureWithError(output: CapturedOutput) {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
snapshotPort.snapshotTableEmpty = true
aggregationPort.failure = IllegalStateException("fallback failed")
val service = service(snapshotPort = snapshotPort, aggregationPort = aggregationPort)
val exception = assertThrows(IllegalStateException::class.java) {
service.getCreatorRankings(viewerMemberId = null)
}
assertEquals("fallback failed", exception.message)
assertTrue(output.out.contains("event=creator_ranking_query_cold_start_fallback_attempt"))
assertTrue(output.out.contains("event=creator_ranking_query_cold_start_fallback_failure"))
assertTrue(output.out.contains("aggregationStartAtUtc=2026-05-31T15:00"))
assertTrue(output.out.contains("aggregationEndAtUtc=2026-06-07T15:00"))
assertTrue(output.out.contains("error=fallback failed"))
}
private fun service(
snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingQuerySnapshotPort(),
blockPort: CreatorRankingBlockPort = FakeCreatorRankingBlockPort()
blockPort: CreatorRankingBlockPort = FakeCreatorRankingBlockPort(),
aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingQueryAggregationPort()
): CreatorRankingQueryService {
return CreatorRankingQueryService(
snapshotPort = snapshotPort,
blockPort = blockPort,
aggregationPort = aggregationPort,
nowProvider = {
ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul"))
},
cloudFrontHost = "https://cdn.test"
)
}
private fun candidate(
creatorId: Long,
liveCanAmount: Long = 100
): CreatorRankingSnapshotCandidate {
return CreatorRankingSnapshotCandidate(
creatorId = creatorId,
nickname = "creator-$creatorId",
profileImageUrl = "profile-$creatorId.png",
finalScore = 0.0,
contentLiveScore = 0.0,
engagementScore = 0.0,
supportScore = 0.0,
fanLoyaltyScore = 0.0,
liveCanAmount = liveCanAmount,
contentPurchaseCanAmount = 0,
contentLikeCount = 0,
contentCommentCount = 0,
channelDonationCanAmount = 0,
channelDonationCount = 0,
fanTalkCount = 0,
finalFollowerCount = 0,
followIncrease = 0
)
}
private fun snapshot(
creatorId: Long,
finalScore: Double
@@ -256,6 +412,7 @@ private class FakeCreatorRankingQuerySnapshotPort : CreatorRankingSnapshotPort {
var latestSnapshots: List<CreatorRankingSnapshotRecord> = emptyList()
var previousSnapshots: List<CreatorRankingSnapshotRecord> = emptyList()
var latestFailure: RuntimeException? = null
var snapshotTableEmpty: Boolean = true
override fun findSnapshotsByAggregationPeriod(
aggregationStartAtUtc: LocalDateTime,
@@ -269,6 +426,8 @@ private class FakeCreatorRankingQuerySnapshotPort : CreatorRankingSnapshotPort {
override fun findPreviousCompletedSnapshots(): List<CreatorRankingSnapshotRecord> = previousSnapshots
override fun isSnapshotTableEmpty(): Boolean = snapshotTableEmpty
override fun replaceSnapshots(
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime,
@@ -276,6 +435,25 @@ private class FakeCreatorRankingQuerySnapshotPort : CreatorRankingSnapshotPort {
) = Unit
}
private class FakeCreatorRankingQueryAggregationPort : CreatorRankingAggregationPort {
var candidates: List<CreatorRankingSnapshotCandidate> = emptyList()
var failure: RuntimeException? = null
var aggregateCallCount = 0
var startInclusiveUtc: LocalDateTime? = null
var endExclusiveUtc: LocalDateTime? = null
override fun aggregateCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<CreatorRankingSnapshotCandidate> {
aggregateCallCount++
this.startInclusiveUtc = startInclusiveUtc
this.endExclusiveUtc = endExclusiveUtc
failure?.let { throw it }
return candidates
}
}
private class FakeCreatorRankingBlockPort : CreatorRankingBlockPort {
var blockedCreatorIds: Set<Long> = emptySet()
var memberId: Long? = null