From f384ee0dd5bdc53efd1d0e32af591bb87de78772 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 8 Jun 2026 20:19:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(ranking):=20=EC=8A=A4=EB=83=85=EC=83=B7=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20lock=EC=9D=84=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorRankingSnapshotScheduler.kt | 20 +++++-- ...reatorRankingSnapshotRefreshServiceTest.kt | 54 +++++++++++++++++-- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/scheduler/CreatorRankingSnapshotScheduler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/scheduler/CreatorRankingSnapshotScheduler.kt index e6220f08..cfcdcf38 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/scheduler/CreatorRankingSnapshotScheduler.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/scheduler/CreatorRankingSnapshotScheduler.kt @@ -1,15 +1,29 @@ package kr.co.vividnext.sodalive.v2.ranking.adapter.out.scheduler import kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshService +import org.redisson.api.RedissonClient import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component +import java.util.concurrent.TimeUnit @Component class CreatorRankingSnapshotScheduler( - private val refreshService: CreatorRankingSnapshotRefreshService + private val refreshService: CreatorRankingSnapshotRefreshService, + private val redissonClient: RedissonClient ) { - @Scheduled(cron = "0 0 6 * * MON", zone = "Asia/Seoul") + @Scheduled(cron = "0 30 7 * * MON", zone = "Asia/Seoul") fun refreshLastCompletedWeek() { - refreshService.refreshLastCompletedWeek() + val lockName = "lock:creator-ranking-snapshot-refresh" + val lock = redissonClient.getLock(lockName) + + try { + if (lock.tryLock(0, -1, TimeUnit.SECONDS)) { + refreshService.refreshLastCompletedWeek() + } + } finally { + if (lock.isHeldByCurrentThread) { + lock.unlock() + } + } } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt index 3caf3960..a123e24a 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt @@ -9,10 +9,13 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.Mockito +import org.redisson.api.RLock +import org.redisson.api.RedissonClient import org.springframework.scheduling.annotation.Scheduled import java.time.LocalDateTime import java.time.ZoneId import java.time.ZonedDateTime +import java.util.concurrent.TimeUnit class CreatorRankingSnapshotRefreshServiceTest { @Test @@ -86,21 +89,64 @@ class CreatorRankingSnapshotRefreshServiceTest { } @Test - @DisplayName("주간 스냅샷 스케줄러는 매주 월요일 06:00 KST cron으로 갱신 서비스를 호출한다") - fun shouldScheduleWeeklySnapshotRefreshAtKstMondaySix() { + @DisplayName("주간 스냅샷 스케줄러는 매주 월요일 07:30 KST cron으로 갱신 서비스를 호출한다") + fun shouldScheduleWeeklySnapshotRefreshAtKstMondaySevenThirty() { val scheduled = CreatorRankingSnapshotScheduler::class.java .getDeclaredMethod("refreshLastCompletedWeek") .getAnnotation(Scheduled::class.java) val service = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java) - val scheduler = CreatorRankingSnapshotScheduler(service) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + Mockito.`when`(redissonClient.getLock("lock:creator-ranking-snapshot-refresh")).thenReturn(lock) + Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(true) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) + val scheduler = CreatorRankingSnapshotScheduler(service, redissonClient) scheduler.refreshLastCompletedWeek() - assertEquals("0 0 6 * * MON", scheduled.cron) + assertEquals("0 30 7 * * MON", scheduled.cron) assertEquals("Asia/Seoul", scheduled.zone) Mockito.verify(service).refreshLastCompletedWeek() } + @Test + @DisplayName("주간 스냅샷 스케줄러는 Redisson lock을 획득한 인스턴스만 갱신을 실행한다") + fun shouldRefreshLastCompletedWeekOnlyWhenRedissonLockAcquired() { + val service = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + Mockito.`when`(redissonClient.getLock("lock:creator-ranking-snapshot-refresh")).thenReturn(lock) + Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(true) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) + val scheduler = CreatorRankingSnapshotScheduler(service, redissonClient) + + scheduler.refreshLastCompletedWeek() + + Mockito.verify(redissonClient).getLock("lock:creator-ranking-snapshot-refresh") + Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS) + Mockito.verify(service).refreshLastCompletedWeek() + Mockito.verify(lock).unlock() + } + + @Test + @DisplayName("주간 스냅샷 스케줄러는 Redisson lock 획득 실패 시 갱신을 건너뛴다") + fun shouldSkipLastCompletedWeekRefreshWhenRedissonLockNotAcquired() { + val service = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + Mockito.`when`(redissonClient.getLock("lock:creator-ranking-snapshot-refresh")).thenReturn(lock) + Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(false) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false) + val scheduler = CreatorRankingSnapshotScheduler(service, redissonClient) + + scheduler.refreshLastCompletedWeek() + + Mockito.verify(redissonClient).getLock("lock:creator-ranking-snapshot-refresh") + Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS) + Mockito.verify(service, Mockito.never()).refreshLastCompletedWeek() + Mockito.verify(lock, Mockito.never()).unlock() + } + private fun service( aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingAggregationPort(), snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingSnapshotPort()