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

View File

@@ -32,7 +32,7 @@ class RecommendationSnapshotFallbackService(
executor: Executor? = null,
private val homeWaitMillis: Long = HOME_WAIT_MILLIS,
transactionManager: PlatformTransactionManager? = null
) : AiCharacterSnapshotFallbackPort, CheerCreatorSnapshotFallbackPort {
) : AiCharacterSnapshotFallbackPort, CheerCreatorSnapshotFallbackPort, PopularCommunitySnapshotFallbackPort {
private val log = LoggerFactory.getLogger(javaClass)
private val ownedExecutor: ExecutorService? = if (executor == null) {
Executors.newFixedThreadPool(DEFAULT_WORKER_THREADS)
@@ -65,6 +65,19 @@ class RecommendationSnapshotFallbackService(
return refreshIfMissing(target(RecommendedSectionType.CHEER_CREATOR, CHEER_CREATOR_LOCK_KEY), offset, limit, nowUtc)
}
override fun refreshPopularCommunityIfMissing(
offset: Long,
limit: Int,
nowUtc: LocalDateTime
): List<RecommendationSnapshotRecord> {
return refreshIfMissing(
target(RecommendedSectionType.POPULAR_COMMUNITY, POPULAR_COMMUNITY_LOCK_KEY),
offset,
limit,
nowUtc
)
}
private fun refreshIfMissing(
target: FallbackTarget,
offset: Long,
@@ -186,6 +199,7 @@ class RecommendationSnapshotFallbackService(
return when (sectionType) {
RecommendedSectionType.AI_CHARACTER -> refreshService.refreshAiCharacterSnapshots(nowUtc)
RecommendedSectionType.CHEER_CREATOR -> refreshService.refreshCheerCreatorSnapshots(nowUtc)
RecommendedSectionType.POPULAR_COMMUNITY -> refreshService.refreshPopularCommunitySnapshots(nowUtc)
else -> error("Unsupported fallback sectionType: $sectionType")
}
}
@@ -207,6 +221,7 @@ class RecommendationSnapshotFallbackService(
companion object {
const val AI_CHARACTER_LOCK_KEY = "lock:recommendation-snapshot-refresh:AI_CHARACTER"
const val CHEER_CREATOR_LOCK_KEY = "lock:recommendation-snapshot-refresh:CHEER_CREATOR"
const val POPULAR_COMMUNITY_LOCK_KEY = "lock:recommendation-snapshot-refresh:POPULAR_COMMUNITY"
private const val LOCK_WAIT_MILLIS = 300L
private const val HOME_WAIT_MILLIS = 1_500L
private const val DEFAULT_WORKER_THREADS = 2
@@ -220,3 +235,11 @@ interface CheerCreatorSnapshotFallbackPort {
nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
): List<RecommendationSnapshotRecord>
}
interface PopularCommunitySnapshotFallbackPort {
fun refreshPopularCommunityIfMissing(
offset: Long,
limit: Int,
nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
): List<RecommendationSnapshotRecord>
}

View File

@@ -12,7 +12,6 @@ import org.springframework.transaction.annotation.Transactional
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
@@ -38,18 +37,12 @@ open class RecommendationSnapshotRefreshService(
@Transactional
fun refreshDailySnapshots(now: LocalDateTime) {
val startedAt = System.currentTimeMillis()
val snapshotAt = now
.atZone(UTC_ZONE)
.withZoneSameInstant(KST_ZONE)
.toLocalDate()
.minusDays(1)
.atTime(23, 59, 59)
val windowStart = snapshotAt.toLocalDate().minusDays(6).atStartOfDay()
val snapshotAt = windowPolicy.previousKstDayUtcWindow(now).snapshotAt
runCatching {
val aiCharacterCount = refreshAiCharacterSnapshotsWithSectionLock(now)
val cheerCreatorCount = refreshCheerCreatorSnapshotsWithSectionLock(now)
val popularCommunityCount = replacePopularCommunitySnapshots(windowStart, snapshotAt)
val popularCommunityCount = refreshPopularCommunitySnapshotsWithSectionLock(now)
RefreshCounts(aiCharacterCount, cheerCreatorCount, popularCommunityCount)
}.onSuccess { counts ->
afterCommit {
@@ -119,6 +112,30 @@ open class RecommendationSnapshotRefreshService(
return snapshots.size
}
@Transactional
open fun refreshPopularCommunitySnapshots(nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)): Int {
val startedAt = System.currentTimeMillis()
val window = windowPolicy.previousKstSevenDayUtcWindow(nowUtc)
val snapshots = queryPort.findPopularCommunitySnapshots(
window.startUtc,
window.endExclusiveUtc,
POPULAR_COMMUNITY_SNAPSHOT_LIMIT
)
snapshotPort.replaceSnapshots(RecommendedSectionType.POPULAR_COMMUNITY, window.snapshotAt, snapshots)
afterCommit {
log.info(
"event=popular_community_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(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)
@@ -151,6 +168,22 @@ open class RecommendationSnapshotRefreshService(
return refreshCheerCreatorSnapshots(nowUtc)
}
private fun refreshPopularCommunitySnapshotsWithSectionLock(nowUtc: LocalDateTime): Int {
val client = redissonClient ?: return refreshPopularCommunitySnapshots(nowUtc)
val lock = client.getLock(RecommendationSnapshotFallbackService.POPULAR_COMMUNITY_LOCK_KEY)
if (!lock.tryLock(0, -1, TimeUnit.MILLISECONDS)) {
log.info(
"event=popular_community_recommendation_snapshot_refresh_lock_missed lockKey={}",
RecommendationSnapshotFallbackService.POPULAR_COMMUNITY_LOCK_KEY
)
return 0
}
unlockAfterTransaction(lock)
return refreshPopularCommunitySnapshots(nowUtc)
}
private fun unlockAfterTransaction(lock: org.redisson.api.RLock) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
if (lock.isHeldByCurrentThread) {
@@ -170,12 +203,6 @@ open class RecommendationSnapshotRefreshService(
)
}
private fun replacePopularCommunitySnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime): Int {
val snapshots = queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, POPULAR_COMMUNITY_SNAPSHOT_LIMIT)
snapshotPort.replaceSnapshots(RecommendedSectionType.POPULAR_COMMUNITY, snapshotAt, snapshots)
return snapshots.size
}
private data class RefreshCounts(
val aiCharacterCount: Int,
val cheerCreatorCount: Int,
@@ -198,7 +225,5 @@ open class RecommendationSnapshotRefreshService(
private const val AI_CHARACTER_SNAPSHOT_LIMIT = 20
private const val CHEER_CREATOR_SNAPSHOT_LIMIT = 16
private const val POPULAR_COMMUNITY_SNAPSHOT_LIMIT = 20
private val UTC_ZONE: ZoneId = ZoneId.of("UTC")
private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul")
}
}