feat(home): 인기 커뮤니티 스냅샷 갱신 경로를 분리한다

This commit is contained in:
2026-07-10 08:57:12 +09:00
parent 6cda73270f
commit 94b03a8c11
4 changed files with 171 additions and 41 deletions
@@ -97,6 +97,57 @@ class RecommendationSnapshotFallbackServiceTest {
Mockito.verify(lock).unlock()
}
@Test
@DisplayName("인기 커뮤니티 fallback은 section lock을 잡고 공통 refresh 후 대상일 스냅샷을 다시 조회한다")
fun shouldRefreshMissingPopularCommunitySnapshotsWithSectionLock() {
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.POPULAR_COMMUNITY_LOCK_KEY)).thenReturn(lock)
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
Mockito.doAnswer {
snapshotPort.replaceSnapshots(
RecommendedSectionType.POPULAR_COMMUNITY,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, 7L))
)
1
}.`when`(refreshService).refreshPopularCommunitySnapshots(nowUtc)
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshPopularCommunityIfMissing(offset = 0, limit = 20, nowUtc = nowUtc)
assertEquals(listOf(7L), snapshots.map { it.targetId })
Mockito.verify(redissonClient).getLock(RecommendationSnapshotFallbackService.POPULAR_COMMUNITY_LOCK_KEY)
Mockito.verify(lock).tryLock(300, -1, TimeUnit.MILLISECONDS)
Mockito.verify(refreshService).refreshPopularCommunitySnapshots(nowUtc)
Mockito.verify(lock).unlock()
}
@Test
@DisplayName("인기 커뮤니티 fallback은 빈 스냅샷 marker가 있으면 refresh를 반복하지 않는다")
fun shouldSkipPopularCommunityRefreshWhenEmptySnapshotMarkerExists() {
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
snapshotPort.replaceSnapshots(
RecommendedSectionType.POPULAR_COMMUNITY,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
emptyList()
)
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshPopularCommunityIfMissing(offset = 0, limit = 20, nowUtc = nowUtc)
assertEquals(emptyList<RecommendationSnapshotRecord>(), snapshots)
Mockito.verifyNoInteractions(redissonClient)
Mockito.verifyNoInteractions(refreshService)
}
@Test
@DisplayName("응원 크리에이터 fallback은 오래된 스냅샷만 있으면 refresh를 실행한다")
fun shouldRefreshCheerCreatorWhenOnlyStaleSnapshotsExist() {
@@ -371,7 +422,9 @@ private class FakeRecommendationFallbackSnapshotPort : RecommendationSnapshotPor
}
private fun supportsEmptySnapshotMarker(sectionType: RecommendedSectionType): Boolean {
return sectionType == RecommendedSectionType.AI_CHARACTER || sectionType == RecommendedSectionType.CHEER_CREATOR
return sectionType == RecommendedSectionType.AI_CHARACTER ||
sectionType == RecommendedSectionType.CHEER_CREATOR ||
sectionType == RecommendedSectionType.POPULAR_COMMUNITY
}
}
@@ -53,8 +53,9 @@ class RecommendationSnapshotRefreshServiceTest {
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
val service = service(snapshotPort = snapshotPort, 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)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59)
val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0)
val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 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)
@@ -81,7 +82,7 @@ class RecommendationSnapshotRefreshServiceTest {
)
)
)
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(
listOf(
RecommendationSnapshotRecord(
sectionType = RecommendedSectionType.POPULAR_COMMUNITY,
@@ -112,7 +113,7 @@ class RecommendationSnapshotRefreshServiceTest {
Mockito.verify(queryPort).findAiCharacterSnapshots(aiWindowStart, aiWindowEndExclusive, 20)
Mockito.verify(queryPort).findCheerCreatorSnapshots(aiWindowStart, aiWindowEndExclusive, 16)
Mockito.verify(queryPort).findPopularCommunitySnapshots(windowStart, snapshotAt, 20)
Mockito.verify(queryPort).findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)
assertEquals(true, output.out.contains("event=recommendation_snapshot_refresh_success"))
assertEquals(true, output.out.contains("aiCharacterCount=1"))
}
@@ -123,8 +124,8 @@ class RecommendationSnapshotRefreshServiceTest {
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)
val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0)
val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
Mockito.`when`(
queryPort.findAiCharacterSnapshots(
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
@@ -139,7 +140,7 @@ class RecommendationSnapshotRefreshServiceTest {
16
)
).thenReturn(emptyList())
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList())
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(emptyList())
TransactionSynchronizationManager.initSynchronization()
try {
@@ -161,8 +162,9 @@ class RecommendationSnapshotRefreshServiceTest {
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
val service = service(snapshotPort = snapshotPort, 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)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59)
val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0)
val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val aiSnapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59)
val aiWindowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
@@ -184,7 +186,7 @@ class RecommendationSnapshotRefreshServiceTest {
).thenReturn(
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 120L, score = 120.0, snapshotAt = snapshotAt))
)
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(
listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, targetId = 225L, score = 225.0, snapshotAt = snapshotAt))
)
@@ -203,16 +205,21 @@ class RecommendationSnapshotRefreshServiceTest {
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
val cheerLock = Mockito.mock(RLock::class.java)
val popularLock = 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)
val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0)
val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(lock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(cheerLock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.POPULAR_COMMUNITY_LOCK_KEY))
.thenReturn(popularLock)
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(popularLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(popularLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(
queryPort.findAiCharacterSnapshots(
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
@@ -227,7 +234,7 @@ class RecommendationSnapshotRefreshServiceTest {
16
)
).thenReturn(emptyList())
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList())
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(emptyList())
service.refreshDailySnapshots(now)
@@ -240,6 +247,7 @@ class RecommendationSnapshotRefreshServiceTest {
)
Mockito.verify(lock).unlock()
Mockito.verify(cheerLock).unlock()
Mockito.verify(popularLock).unlock()
}
@Test
@@ -250,22 +258,28 @@ class RecommendationSnapshotRefreshServiceTest {
val redissonClient = Mockito.mock(RedissonClient::class.java)
val aiLock = Mockito.mock(RLock::class.java)
val cheerLock = Mockito.mock(RLock::class.java)
val popularLock = 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 windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0)
val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59)
val cheerWindowStart = LocalDateTime.of(2026, 5, 28, 15, 0, 0)
val cheerWindowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(aiLock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(cheerLock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.POPULAR_COMMUNITY_LOCK_KEY))
.thenReturn(popularLock)
Mockito.`when`(aiLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(false)
Mockito.`when`(aiLock.isHeldByCurrentThread).thenReturn(false)
Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(popularLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(popularLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(queryPort.findCheerCreatorSnapshots(cheerWindowStart, cheerWindowEndExclusive, 16)).thenReturn(
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 10L, score = 10.0, snapshotAt = snapshotAt))
)
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList())
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(emptyList())
service.refreshDailySnapshots(now)
@@ -274,6 +288,7 @@ class RecommendationSnapshotRefreshServiceTest {
Mockito.verify(queryPort).findCheerCreatorSnapshots(cheerWindowStart, cheerWindowEndExclusive, 16)
assertEquals(listOf(10L), snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId })
Mockito.verify(cheerLock).unlock()
Mockito.verify(popularLock).unlock()
}
@Test
@@ -284,17 +299,23 @@ class RecommendationSnapshotRefreshServiceTest {
val redissonClient = Mockito.mock(RedissonClient::class.java)
val aiLock = Mockito.mock(RLock::class.java)
val cheerLock = Mockito.mock(RLock::class.java)
val popularLock = 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)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59)
val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0)
val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(aiLock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(cheerLock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.POPULAR_COMMUNITY_LOCK_KEY))
.thenReturn(popularLock)
Mockito.`when`(aiLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(false)
Mockito.`when`(aiLock.isHeldByCurrentThread).thenReturn(false)
Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(false)
Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(false)
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(
Mockito.`when`(popularLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(popularLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(
listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, targetId = 20L, score = 20.0, snapshotAt = snapshotAt))
)
@@ -311,6 +332,7 @@ class RecommendationSnapshotRefreshServiceTest {
)
assertEquals(listOf(20L), snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY).map { it.targetId })
Mockito.verify(cheerLock, Mockito.never()).unlock()
Mockito.verify(popularLock).unlock()
}
@Test
@@ -321,16 +343,22 @@ class RecommendationSnapshotRefreshServiceTest {
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
val cheerLock = Mockito.mock(RLock::class.java)
val popularLock = 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)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59)
val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0)
val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(lock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(cheerLock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.POPULAR_COMMUNITY_LOCK_KEY))
.thenReturn(popularLock)
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(false)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false)
Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(popularLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(popularLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(
queryPort.findCheerCreatorSnapshots(
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
@@ -340,7 +368,7 @@ class RecommendationSnapshotRefreshServiceTest {
).thenReturn(
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 1L, score = 1.0, snapshotAt = snapshotAt))
)
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList())
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(emptyList())
service.refreshDailySnapshots(now)
@@ -356,6 +384,7 @@ class RecommendationSnapshotRefreshServiceTest {
assertEquals(listOf(1L), snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId })
Mockito.verify(lock, Mockito.never()).unlock()
Mockito.verify(cheerLock).unlock()
Mockito.verify(popularLock).unlock()
}
@Test