From 394786e6bc245eb9f342aef8b9adf9c2ffc10e63 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 9 Jun 2026 00:09:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(ranking):=20=EB=9E=AD=ED=82=B9=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B4=80=EC=B8=A1=20=EB=A1=9C=EA=B7=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/CreatorRankingQueryService.kt | 64 ++++++++++++++----- .../CreatorRankingQueryServiceTest.kt | 42 +++++++++++- 2 files changed, 89 insertions(+), 17 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt index f4372d34..d690382a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt @@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingItem 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 +import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -15,28 +16,59 @@ class CreatorRankingQueryService( @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) { + private val log = LoggerFactory.getLogger(javaClass) + @Transactional(readOnly = true) fun getCreatorRankings(viewerMemberId: Long?): CreatorRankingResult { - val latestItems = snapshotPort.findLatestSnapshots().toRankedItems() - if (latestItems.isEmpty()) { - return CreatorRankingResult(showRankChange = false, items = emptyList()) - } + val startedAt = System.currentTimeMillis() + return runCatching { + val latestItems = snapshotPort.findLatestSnapshots().toRankedItems() + if (latestItems.isEmpty()) { + return@runCatching QueryLogResult( + result = CreatorRankingResult(showRankChange = false, items = emptyList()), + blockedCreatorCount = 0 + ) + } - val previousItems = snapshotPort.findPreviousCompletedSnapshots().toRankedItems() - val previousRankByCreatorId = previousItems.associate { it.creatorId to it.rank } - val showRankChange = previousRankByCreatorId.isNotEmpty() - val blockedCreatorIds = findBlockedCreatorIds(viewerMemberId = viewerMemberId, items = latestItems) - val items = latestItems.map { item -> - val previousRank = previousRankByCreatorId[item.creatorId] - item.copy( - rankChange = if (showRankChange && previousRank != null) previousRank - item.rank else null, - isNew = showRankChange && previousRank == null - ).maskIfBlocked(blockedCreatorIds) - } + val previousItems = snapshotPort.findPreviousCompletedSnapshots().toRankedItems() + val previousRankByCreatorId = previousItems.associate { it.creatorId to it.rank } + val showRankChange = previousRankByCreatorId.isNotEmpty() + val blockedCreatorIds = findBlockedCreatorIds(viewerMemberId = viewerMemberId, items = latestItems) + val items = latestItems.map { item -> + val previousRank = previousRankByCreatorId[item.creatorId] + item.copy( + rankChange = if (showRankChange && previousRank != null) previousRank - item.rank else null, + isNew = showRankChange && previousRank == null + ).maskIfBlocked(blockedCreatorIds) + } - return CreatorRankingResult(showRankChange = showRankChange, items = items) + QueryLogResult( + result = CreatorRankingResult(showRankChange = showRankChange, items = items), + blockedCreatorCount = blockedCreatorIds.size + ) + }.onSuccess { logResult -> + log.info( + "event=creator_ranking_query_success showRankChange={} itemCount={} blockedCreatorCount={} elapsedMs={}", + logResult.result.showRankChange, + logResult.result.items.size, + logResult.blockedCreatorCount, + System.currentTimeMillis() - startedAt + ) + }.onFailure { ex -> + log.warn( + "event=creator_ranking_query_failure elapsedMs={} error={}", + System.currentTimeMillis() - startedAt, + ex.message, + ex + ) + }.getOrThrow().result } + private data class QueryLogResult( + val result: CreatorRankingResult, + val blockedCreatorCount: Int + ) + private fun List.toRankedItems(): List { return groupBy { it.finalScore } .toSortedMap(compareByDescending { it }) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt index 8ad9758f..309a067c 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt @@ -8,11 +8,16 @@ import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +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 +@ExtendWith(OutputCaptureExtension::class) class CreatorRankingQueryServiceTest { @Test @DisplayName("스냅샷 후보와 조회 item 내부 모델은 순위 변화와 신규 진입 값을 담을 수 있다") @@ -177,6 +182,37 @@ class CreatorRankingQueryServiceTest { assertEquals("profile-1.png", result.items.single().profileImageUrl) } + @Test + @DisplayName("크리에이터 랭킹 조회 성공은 순위 변화 노출 여부와 반환 수를 로그로 남긴다") + fun shouldLogCreatorRankingQuerySuccessWithResultCounts(output: CapturedOutput) { + val snapshotPort = FakeCreatorRankingQuerySnapshotPort() + snapshotPort.latestSnapshots = listOf(snapshot(creatorId = 1L, finalScore = 100.0)) + val service = service(snapshotPort = snapshotPort) + + service.getCreatorRankings(viewerMemberId = null) + + assertTrue(output.out.contains("event=creator_ranking_query_success")) + assertTrue(output.out.contains("showRankChange=false")) + assertTrue(output.out.contains("itemCount=1")) + assertTrue(output.out.contains("blockedCreatorCount=0")) + } + + @Test + @DisplayName("크리에이터 랭킹 조회 실패는 에러를 로그로 남기고 예외를 전파한다") + fun shouldLogCreatorRankingQueryFailureWithError(output: CapturedOutput) { + val snapshotPort = FakeCreatorRankingQuerySnapshotPort() + snapshotPort.latestFailure = IllegalStateException("latest snapshots failed") + val service = service(snapshotPort = snapshotPort) + + val exception = assertThrows(IllegalStateException::class.java) { + service.getCreatorRankings(viewerMemberId = 99L) + } + + assertEquals("latest snapshots failed", exception.message) + assertTrue(output.out.contains("event=creator_ranking_query_failure")) + assertTrue(output.out.contains("error=latest snapshots failed")) + } + private fun service( snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingQuerySnapshotPort(), blockPort: CreatorRankingBlockPort = FakeCreatorRankingBlockPort() @@ -219,13 +255,17 @@ class CreatorRankingQueryServiceTest { private class FakeCreatorRankingQuerySnapshotPort : CreatorRankingSnapshotPort { var latestSnapshots: List = emptyList() var previousSnapshots: List = emptyList() + var latestFailure: RuntimeException? = null override fun findSnapshotsByAggregationPeriod( aggregationStartAtUtc: LocalDateTime, aggregationEndAtUtc: LocalDateTime ): List = emptyList() - override fun findLatestSnapshots(): List = latestSnapshots + override fun findLatestSnapshots(): List { + latestFailure?.let { throw it } + return latestSnapshots + } override fun findPreviousCompletedSnapshots(): List = previousSnapshots