diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshService.kt index 4c93ddfe..78a0af51 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshService.kt @@ -4,8 +4,11 @@ import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeRecommendationQueryPort import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotPort import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotRecord +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionSynchronization +import org.springframework.transaction.support.TransactionSynchronizationManager import java.time.LocalDateTime import java.time.ZoneId @@ -14,6 +17,8 @@ class RecommendationSnapshotRefreshService( private val snapshotPort: RecommendationSnapshotPort, private val queryPort: HomeRecommendationQueryPort ) { + private val log = LoggerFactory.getLogger(javaClass) + @Transactional(readOnly = true) fun getLatestSnapshots(sectionType: RecommendedSectionType): List { return snapshotPort.findLatestSnapshots(sectionType) @@ -26,6 +31,7 @@ class RecommendationSnapshotRefreshService( @Transactional fun refreshDailySnapshots(now: LocalDateTime) { + val startedAt = System.currentTimeMillis() val snapshotAt = now .atZone(UTC_ZONE) .withZoneSameInstant(KST_ZONE) @@ -34,24 +40,69 @@ class RecommendationSnapshotRefreshService( .atTime(23, 59, 59) val windowStart = snapshotAt.toLocalDate().minusDays(6).atStartOfDay() - replaceAiCharacterSnapshots(windowStart, snapshotAt) - replaceCheerCreatorSnapshots(windowStart, snapshotAt) - replacePopularCommunitySnapshots(windowStart, snapshotAt) + runCatching { + val aiCharacterCount = replaceAiCharacterSnapshots(windowStart, snapshotAt) + val cheerCreatorCount = replaceCheerCreatorSnapshots(windowStart, snapshotAt) + val popularCommunityCount = replacePopularCommunitySnapshots(windowStart, snapshotAt) + RefreshCounts(aiCharacterCount, cheerCreatorCount, popularCommunityCount) + }.onSuccess { counts -> + afterCommit { + log.info( + "event=recommendation_snapshot_refresh_success " + + "snapshotAt={} aiCharacterCount={} cheerCreatorCount={} popularCommunityCount={} elapsedMs={}", + snapshotAt, + counts.aiCharacterCount, + counts.cheerCreatorCount, + counts.popularCommunityCount, + System.currentTimeMillis() - startedAt + ) + } + }.onFailure { ex -> + log.warn( + "event=recommendation_snapshot_refresh_failure snapshotAt={} elapsedMs={} error={}", + snapshotAt, + System.currentTimeMillis() - startedAt, + ex.message, + ex + ) + throw ex + } } - private fun replaceAiCharacterSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime) { + private fun replaceAiCharacterSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime): Int { val snapshots = queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, AI_CHARACTER_SNAPSHOT_LIMIT) snapshotPort.replaceSnapshots(RecommendedSectionType.AI_CHARACTER, snapshotAt, snapshots) + return snapshots.size } - private fun replaceCheerCreatorSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime) { + private fun replaceCheerCreatorSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime): Int { val snapshots = queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, CHEER_CREATOR_SNAPSHOT_LIMIT) snapshotPort.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, snapshotAt, snapshots) + return snapshots.size } - private fun replacePopularCommunitySnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime) { + private fun replacePopularCommunitySnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime): Int { val snapshots = queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, POPULAR_COMMUNITY_SNAPSHOT_LIMIT) snapshotPort.replaceSnapshots(RecommendedSectionType.POPULAR_COMMUNITY, snapshotAt, snapshots) + return snapshots.size + } + + private data class RefreshCounts( + val aiCharacterCount: Int, + val cheerCreatorCount: Int, + val popularCommunityCount: Int + ) + + private fun afterCommit(action: () -> Unit) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + action() + return + } + TransactionSynchronizationManager.registerSynchronization( + object : TransactionSynchronization { + override fun afterCommit() = action() + } + ) } companion object { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshServiceTest.kt index 91e2baee..81427178 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshServiceTest.kt @@ -8,10 +8,15 @@ import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotReco import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mockito +import org.springframework.boot.test.system.CapturedOutput +import org.springframework.boot.test.system.OutputCaptureExtension import org.springframework.scheduling.annotation.Scheduled +import org.springframework.transaction.support.TransactionSynchronizationManager import java.time.LocalDateTime +@ExtendWith(OutputCaptureExtension::class) class RecommendationSnapshotRefreshServiceTest { @Test @DisplayName("섹션의 최신 기준 시각 스냅샷만 반환하고 없으면 빈 배열을 반환한다") @@ -40,7 +45,7 @@ class RecommendationSnapshotRefreshServiceTest { @Test @DisplayName("일 스냅샷 갱신은 전날 23시 59분 59초 기준으로 DB 점수 계산 스냅샷을 저장한다") - fun shouldRefreshDailySnapshotsWithPreviousDayEndSnapshotAt() { + fun shouldRefreshDailySnapshotsWithPreviousDayEndSnapshotAt(output: CapturedOutput) { val snapshotPort = FakeRecommendationSnapshotPort() val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) val service = service(snapshotPort = snapshotPort, queryPort = queryPort) @@ -102,6 +107,33 @@ class RecommendationSnapshotRefreshServiceTest { Mockito.verify(queryPort).findAiCharacterSnapshots(windowStart, snapshotAt, 20) Mockito.verify(queryPort).findCheerCreatorSnapshots(windowStart, snapshotAt, 16) Mockito.verify(queryPort).findPopularCommunitySnapshots(windowStart, snapshotAt, 20) + assertEquals(true, output.out.contains("event=recommendation_snapshot_refresh_success")) + assertEquals(true, output.out.contains("aiCharacterCount=1")) + } + + @Test + @DisplayName("일 스냅샷 갱신 성공 로그는 트랜잭션 커밋 후 기록한다") + fun shouldLogSnapshotRefreshSuccessAfterTransactionCommit(output: CapturedOutput) { + val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) + val service = service(queryPort = queryPort) + val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0) + Mockito.`when`(queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList()) + Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(emptyList()) + Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList()) + + TransactionSynchronizationManager.initSynchronization() + try { + service.refreshDailySnapshots(now) + + assertEquals(false, output.out.contains("event=recommendation_snapshot_refresh_success")) + TransactionSynchronizationManager.getSynchronizations().forEach { it.afterCommit() } + } finally { + TransactionSynchronizationManager.clearSynchronization() + } + + assertEquals(true, output.out.contains("event=recommendation_snapshot_refresh_success")) } @Test