From 6a6deb33a3c9bac862fdf7668946443829faac28 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 23 Jun 2026 21:05:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(audio-recommendation):=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20snapshot=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AudioRecommendationSnapshotScheduler.kt | 32 +++++++++++ ...udioRecommendationSnapshotSchedulerTest.kt | 55 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt new file mode 100644 index 00000000..36c60784 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotScheduler.kt @@ -0,0 +1,32 @@ +package kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.scheduler + +import kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationSnapshotRefreshService +import org.redisson.api.RedissonClient +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import java.util.concurrent.TimeUnit + +@Component +class AudioRecommendationSnapshotScheduler( + private val refreshService: AudioRecommendationSnapshotRefreshService, + private val redissonClient: RedissonClient +) { + @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") + fun refreshDailySnapshots() { + val lock = redissonClient.getLock(LOCK_KEY) + + try { + if (lock.tryLock(0, -1, TimeUnit.SECONDS)) { + refreshService.refreshDailySnapshots() + } + } finally { + if (lock.isHeldByCurrentThread) { + lock.unlock() + } + } + } + + companion object { + const val LOCK_KEY = "lock:audio-recommendation-snapshot-refresh" + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt new file mode 100644 index 00000000..c22c88aa --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/scheduler/AudioRecommendationSnapshotSchedulerTest.kt @@ -0,0 +1,55 @@ +package kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.scheduler + +import kr.co.vividnext.sodalive.v2.audio.recommendation.application.AudioRecommendationSnapshotRefreshService +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.util.concurrent.TimeUnit + +class AudioRecommendationSnapshotSchedulerTest { + private val refreshService = Mockito.mock(AudioRecommendationSnapshotRefreshService::class.java) + private val redissonClient = Mockito.mock(RedissonClient::class.java) + private val lock = Mockito.mock(RLock::class.java) + private val scheduler = AudioRecommendationSnapshotScheduler(refreshService, redissonClient) + + @Test + @DisplayName("스케줄러는 매일 00:00 KST cron을 사용한다") + fun shouldUseMidnightKstCron() { + val annotation = AudioRecommendationSnapshotScheduler::class.java + .getDeclaredMethod("refreshDailySnapshots") + .getAnnotation(Scheduled::class.java) + + assertEquals("0 0 0 * * *", annotation.cron) + assertEquals("Asia/Seoul", annotation.zone) + } + + @Test + @DisplayName("락 획득 성공 시에만 refresh를 호출하고 보유 중이면 unlock한다") + fun shouldRefreshOnlyWhenLockAcquired() { + Mockito.doReturn(lock).`when`(redissonClient).getLock(AudioRecommendationSnapshotScheduler.LOCK_KEY) + Mockito.doReturn(true).`when`(lock).tryLock(0, -1, TimeUnit.SECONDS) + Mockito.doReturn(true).`when`(lock).isHeldByCurrentThread + + scheduler.refreshDailySnapshots() + + Mockito.verify(refreshService).refreshDailySnapshots() + Mockito.verify(lock).unlock() + } + + @Test + @DisplayName("락 획득 실패 시 refresh와 unlock을 호출하지 않는다") + fun shouldSkipWhenLockNotAcquired() { + Mockito.doReturn(lock).`when`(redissonClient).getLock(AudioRecommendationSnapshotScheduler.LOCK_KEY) + Mockito.doReturn(false).`when`(lock).tryLock(0, -1, TimeUnit.SECONDS) + Mockito.doReturn(false).`when`(lock).isHeldByCurrentThread + + scheduler.refreshDailySnapshots() + + Mockito.verify(refreshService, Mockito.never()).refreshDailySnapshots() + Mockito.verify(lock, Mockito.never()).unlock() + } +}