feat(home): AI 캐릭터 스냅샷 갱신 경로를 분리한다

This commit is contained in:
2026-07-10 03:21:41 +09:00
parent ca26577279
commit 09c7ea7f34
2 changed files with 179 additions and 17 deletions

View File

@@ -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<RecommendationSnapshotRecord> {
@@ -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)