From 09c7ea7f346f2bac9bf17316c72de2adf38705ad Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 10 Jul 2026 03:21:41 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20AI=20=EC=BA=90=EB=A6=AD=ED=84=B0?= =?UTF-8?q?=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EA=B0=B1=EC=8B=A0=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EB=A5=BC=20=EB=B6=84=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecommendationSnapshotRefreshService.kt | 69 +++++++++- ...ecommendationSnapshotRefreshServiceTest.kt | 127 ++++++++++++++++-- 2 files changed, 179 insertions(+), 17 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt index 61c79a77..d42ca09d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt @@ -1,9 +1,11 @@ package kr.co.vividnext.sodalive.v2.recommendation.application +import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord +import org.redisson.api.RedissonClient import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -11,13 +13,17 @@ import org.springframework.transaction.support.TransactionSynchronization import org.springframework.transaction.support.TransactionSynchronizationManager import java.time.LocalDateTime import java.time.ZoneId +import java.time.ZoneOffset +import java.util.concurrent.TimeUnit @Service -class RecommendationSnapshotRefreshService( +open class RecommendationSnapshotRefreshService( private val snapshotPort: RecommendationSnapshotPort, - private val queryPort: HomeRecommendationQueryPort + private val queryPort: HomeRecommendationQueryPort, + private val redissonClient: RedissonClient? = null ) { private val log = LoggerFactory.getLogger(javaClass) + private val windowPolicy = RecommendationSnapshotWindowPolicy() @Transactional(readOnly = true) fun getLatestSnapshots(sectionType: RecommendedSectionType): List { @@ -26,7 +32,7 @@ class RecommendationSnapshotRefreshService( @Transactional fun refreshDailySnapshots() { - refreshDailySnapshots(LocalDateTime.now()) + refreshDailySnapshots(LocalDateTime.now(ZoneOffset.UTC)) } @Transactional @@ -41,7 +47,7 @@ class RecommendationSnapshotRefreshService( val windowStart = snapshotAt.toLocalDate().minusDays(6).atStartOfDay() runCatching { - val aiCharacterCount = replaceAiCharacterSnapshots(windowStart, snapshotAt) + val aiCharacterCount = refreshAiCharacterSnapshotsWithSectionLock(now) val cheerCreatorCount = replaceCheerCreatorSnapshots(windowStart, snapshotAt) val popularCommunityCount = replacePopularCommunitySnapshots(windowStart, snapshotAt) RefreshCounts(aiCharacterCount, cheerCreatorCount, popularCommunityCount) @@ -69,12 +75,61 @@ class RecommendationSnapshotRefreshService( } } - 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) + @Transactional + open fun refreshAiCharacterSnapshots(nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)): Int { + val startedAt = System.currentTimeMillis() + val window = windowPolicy.previousKstDayUtcWindow(nowUtc) + val snapshots = queryPort.findAiCharacterSnapshots(window.startUtc, window.endExclusiveUtc, AI_CHARACTER_SNAPSHOT_LIMIT) + snapshotPort.replaceSnapshots(RecommendedSectionType.AI_CHARACTER, window.snapshotAt, snapshots) + afterCommit { + log.info( + "event=ai_character_recommendation_snapshot_refresh_success " + + "snapshotAt={} windowStartUtc={} windowEndExclusiveUtc={} savedCount={} elapsedMs={}", + window.snapshotAt, + window.startUtc, + window.endExclusiveUtc, + snapshots.size, + System.currentTimeMillis() - startedAt + ) + } return snapshots.size } + private fun refreshAiCharacterSnapshotsWithSectionLock(nowUtc: LocalDateTime): Int { + val client = redissonClient ?: return refreshAiCharacterSnapshots(nowUtc) + val lock = client.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY) + + if (!lock.tryLock(0, -1, TimeUnit.MILLISECONDS)) { + log.info( + "event=ai_character_recommendation_snapshot_refresh_lock_missed lockKey={}", + AiCharacterSnapshotFallbackService.LOCK_KEY + ) + return 0 + } + + unlockAfterTransaction(lock) + return refreshAiCharacterSnapshots(nowUtc) + } + + private fun unlockAfterTransaction(lock: org.redisson.api.RLock) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + if (lock.isHeldByCurrentThread) { + lock.unlock() + } + return + } + + TransactionSynchronizationManager.registerSynchronization( + object : TransactionSynchronization { + override fun afterCompletion(status: Int) { + if (lock.isHeldByCurrentThread) { + lock.unlock() + } + } + } + ) + } + 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) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt index b90c26e3..b34e3506 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt @@ -55,14 +55,17 @@ class RecommendationSnapshotRefreshServiceTest { 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) + val aiWindowStart = LocalDateTime.of(2026, 5, 28, 15, 0, 0) + val aiSnapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59) + val aiWindowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0) - Mockito.`when`(queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, 20)).thenReturn( + Mockito.`when`(queryPort.findAiCharacterSnapshots(aiWindowStart, aiWindowEndExclusive, 20)).thenReturn( listOf( RecommendationSnapshotRecord( sectionType = RecommendedSectionType.AI_CHARACTER, targetId = 11L, score = 78.0, - snapshotAt = snapshotAt, + snapshotAt = aiSnapshotAt, randomTieBreaker = 0.1 ) ) @@ -96,7 +99,7 @@ class RecommendationSnapshotRefreshServiceTest { val cheerSnapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR) val communitySnapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY) - assertEquals(snapshotAt, aiSnapshots.single().snapshotAt) + assertEquals(aiSnapshotAt, aiSnapshots.single().snapshotAt) assertEquals(11L, aiSnapshots.single().targetId) assertEquals(78.0, aiSnapshots.single().score, 0.0001) assertEquals(0.1, aiSnapshots.single().randomTieBreaker, 0.0001) @@ -107,7 +110,7 @@ class RecommendationSnapshotRefreshServiceTest { assertEquals(33L, communitySnapshots.single().targetId) assertEquals(40.0, communitySnapshots.single().score, 0.0001) - Mockito.verify(queryPort).findAiCharacterSnapshots(windowStart, snapshotAt, 20) + Mockito.verify(queryPort).findAiCharacterSnapshots(aiWindowStart, aiWindowEndExclusive, 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")) @@ -122,7 +125,13 @@ class RecommendationSnapshotRefreshServiceTest { 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.findAiCharacterSnapshots( + LocalDateTime.of(2026, 5, 28, 15, 0, 0), + LocalDateTime.of(2026, 5, 29, 15, 0, 0), + 20 + ) + ).thenReturn(emptyList()) Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(emptyList()) Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList()) @@ -148,9 +157,17 @@ class RecommendationSnapshotRefreshServiceTest { 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) + val aiSnapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59) + val aiWindowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0) - Mockito.`when`(queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, 20)).thenReturn( - listOf(snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 25L, score = 25.0, snapshotAt = snapshotAt)) + Mockito.`when`( + queryPort.findAiCharacterSnapshots( + LocalDateTime.of(2026, 5, 28, 15, 0, 0), + aiWindowEndExclusive, + 20 + ) + ).thenReturn( + listOf(snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 25L, score = 25.0, snapshotAt = aiSnapshotAt)) ) Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn( listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 120L, score = 120.0, snapshotAt = snapshotAt)) @@ -166,6 +183,76 @@ class RecommendationSnapshotRefreshServiceTest { assertEquals(listOf(225L), snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY).map { it.targetId }) } + @Test + @DisplayName("일 스냅샷 갱신은 AI 캐릭터 섹션 lock을 획득한 경우에만 AI refresh를 실행한다") + fun shouldRefreshAiCharacterSectionOnlyWhenSectionLockAcquired() { + val snapshotPort = FakeRecommendationSnapshotPort() + val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + val service = service(snapshotPort = snapshotPort, queryPort = queryPort, redissonClient = redissonClient) + 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`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock) + Mockito.`when`(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) + Mockito.`when`( + queryPort.findAiCharacterSnapshots( + LocalDateTime.of(2026, 5, 28, 15, 0, 0), + LocalDateTime.of(2026, 5, 29, 15, 0, 0), + 20 + ) + ).thenReturn(emptyList()) + Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(emptyList()) + Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList()) + + service.refreshDailySnapshots(now) + + Mockito.verify(redissonClient).getLock(AiCharacterSnapshotFallbackService.LOCK_KEY) + Mockito.verify(lock).tryLock(0, -1, TimeUnit.MILLISECONDS) + Mockito.verify(queryPort).findAiCharacterSnapshots( + LocalDateTime.of(2026, 5, 28, 15, 0, 0), + LocalDateTime.of(2026, 5, 29, 15, 0, 0), + 20 + ) + Mockito.verify(lock).unlock() + } + + @Test + @DisplayName("일 스냅샷 갱신은 AI 캐릭터 섹션 lock 획득 실패 시 AI refresh만 건너뛰고 다른 섹션은 유지한다") + fun shouldSkipOnlyAiCharacterRefreshWhenSectionLockNotAcquired() { + val snapshotPort = FakeRecommendationSnapshotPort() + val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + val service = service(snapshotPort = snapshotPort, queryPort = queryPort, redissonClient = redissonClient) + 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`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock) + Mockito.`when`(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(false) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false) + Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn( + listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 1L, score = 1.0, snapshotAt = snapshotAt)) + ) + Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList()) + + service.refreshDailySnapshots(now) + + Mockito.verify(queryPort, Mockito.never()).findAiCharacterSnapshots( + LocalDateTime.of(2026, 5, 28, 15, 0, 0), + LocalDateTime.of(2026, 5, 29, 15, 0, 0), + 20 + ) + assertEquals( + emptyList(), + snapshotPort.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER) + ) + assertEquals(listOf(1L), snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId }) + Mockito.verify(lock, Mockito.never()).unlock() + } + @Test @DisplayName("추천 스냅샷 스케줄러는 매일 06:00 KST cron으로 갱신 서비스를 호출한다") fun shouldScheduleDailySnapshotRefreshAtKstSix() { @@ -227,11 +314,13 @@ class RecommendationSnapshotRefreshServiceTest { private fun service( snapshotPort: RecommendationSnapshotPort = FakeRecommendationSnapshotPort(), - queryPort: HomeRecommendationQueryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) + queryPort: HomeRecommendationQueryPort = Mockito.mock(HomeRecommendationQueryPort::class.java), + redissonClient: RedissonClient? = null ): RecommendationSnapshotRefreshService { return RecommendationSnapshotRefreshService( snapshotPort = snapshotPort, - queryPort = queryPort + queryPort = queryPort, + redissonClient = redissonClient ) } @@ -264,18 +353,36 @@ private class FakeRecommendationSnapshotPort : RecommendationSnapshotPort { .maxOfOrNull { it.snapshotAt } val all = snapshots - .filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt } + .filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt && it.targetId != 0L } .sortedWith(compareByDescending { it.score }.thenBy { it.randomTieBreaker }) if (offset == 0L && limit == Int.MAX_VALUE) return all return all.drop(offset.toInt()).take(limit) } + override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean { + return snapshots.any { it.sectionType == sectionType } + } + override fun replaceSnapshots( sectionType: RecommendedSectionType, snapshotAt: LocalDateTime, newSnapshots: List ) { + if (newSnapshots.isEmpty() && sectionType == RecommendedSectionType.AI_CHARACTER) { + snapshots.removeIf { it.sectionType == sectionType } + snapshots.add( + RecommendationSnapshotRecord( + sectionType = sectionType, + targetId = 0L, + score = 0.0, + snapshotAt = snapshotAt, + randomTieBreaker = 0.0 + ) + ) + return + } + snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt } snapshots.addAll(newSnapshots) }