feat(recommend): 추천 스냅샷 성공 로그를 커밋 후 기록한다

This commit is contained in:
2026-06-01 17:57:16 +09:00
parent bb96f07872
commit 85591c2a8b
2 changed files with 90 additions and 7 deletions

View File

@@ -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.HomeRecommendationQueryPort
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotPort import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotPort
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotRecord import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotRecord
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional 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.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
@@ -14,6 +17,8 @@ class RecommendationSnapshotRefreshService(
private val snapshotPort: RecommendationSnapshotPort, private val snapshotPort: RecommendationSnapshotPort,
private val queryPort: HomeRecommendationQueryPort private val queryPort: HomeRecommendationQueryPort
) { ) {
private val log = LoggerFactory.getLogger(javaClass)
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getLatestSnapshots(sectionType: RecommendedSectionType): List<RecommendationSnapshotRecord> { fun getLatestSnapshots(sectionType: RecommendedSectionType): List<RecommendationSnapshotRecord> {
return snapshotPort.findLatestSnapshots(sectionType) return snapshotPort.findLatestSnapshots(sectionType)
@@ -26,6 +31,7 @@ class RecommendationSnapshotRefreshService(
@Transactional @Transactional
fun refreshDailySnapshots(now: LocalDateTime) { fun refreshDailySnapshots(now: LocalDateTime) {
val startedAt = System.currentTimeMillis()
val snapshotAt = now val snapshotAt = now
.atZone(UTC_ZONE) .atZone(UTC_ZONE)
.withZoneSameInstant(KST_ZONE) .withZoneSameInstant(KST_ZONE)
@@ -34,24 +40,69 @@ class RecommendationSnapshotRefreshService(
.atTime(23, 59, 59) .atTime(23, 59, 59)
val windowStart = snapshotAt.toLocalDate().minusDays(6).atStartOfDay() val windowStart = snapshotAt.toLocalDate().minusDays(6).atStartOfDay()
replaceAiCharacterSnapshots(windowStart, snapshotAt) runCatching {
replaceCheerCreatorSnapshots(windowStart, snapshotAt) val aiCharacterCount = replaceAiCharacterSnapshots(windowStart, snapshotAt)
replacePopularCommunitySnapshots(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) val snapshots = queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, AI_CHARACTER_SNAPSHOT_LIMIT)
snapshotPort.replaceSnapshots(RecommendedSectionType.AI_CHARACTER, snapshotAt, snapshots) 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) val snapshots = queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, CHEER_CREATOR_SNAPSHOT_LIMIT)
snapshotPort.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, snapshotAt, snapshots) 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) val snapshots = queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, POPULAR_COMMUNITY_SNAPSHOT_LIMIT)
snapshotPort.replaceSnapshots(RecommendedSectionType.POPULAR_COMMUNITY, snapshotAt, snapshots) 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 { companion object {

View File

@@ -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.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.Mockito 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.scheduling.annotation.Scheduled
import org.springframework.transaction.support.TransactionSynchronizationManager
import java.time.LocalDateTime import java.time.LocalDateTime
@ExtendWith(OutputCaptureExtension::class)
class RecommendationSnapshotRefreshServiceTest { class RecommendationSnapshotRefreshServiceTest {
@Test @Test
@DisplayName("섹션의 최신 기준 시각 스냅샷만 반환하고 없으면 빈 배열을 반환한다") @DisplayName("섹션의 최신 기준 시각 스냅샷만 반환하고 없으면 빈 배열을 반환한다")
@@ -40,7 +45,7 @@ class RecommendationSnapshotRefreshServiceTest {
@Test @Test
@DisplayName("일 스냅샷 갱신은 전날 23시 59분 59초 기준으로 DB 점수 계산 스냅샷을 저장한다") @DisplayName("일 스냅샷 갱신은 전날 23시 59분 59초 기준으로 DB 점수 계산 스냅샷을 저장한다")
fun shouldRefreshDailySnapshotsWithPreviousDayEndSnapshotAt() { fun shouldRefreshDailySnapshotsWithPreviousDayEndSnapshotAt(output: CapturedOutput) {
val snapshotPort = FakeRecommendationSnapshotPort() val snapshotPort = FakeRecommendationSnapshotPort()
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
val service = service(snapshotPort = snapshotPort, queryPort = queryPort) val service = service(snapshotPort = snapshotPort, queryPort = queryPort)
@@ -102,6 +107,33 @@ class RecommendationSnapshotRefreshServiceTest {
Mockito.verify(queryPort).findAiCharacterSnapshots(windowStart, snapshotAt, 20) Mockito.verify(queryPort).findAiCharacterSnapshots(windowStart, snapshotAt, 20)
Mockito.verify(queryPort).findCheerCreatorSnapshots(windowStart, snapshotAt, 16) Mockito.verify(queryPort).findCheerCreatorSnapshots(windowStart, snapshotAt, 16)
Mockito.verify(queryPort).findPopularCommunitySnapshots(windowStart, snapshotAt, 20) 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 @Test