feat(home): 인기 커뮤니티 fallback을 홈 조회에 연결한다

This commit is contained in:
2026-07-10 08:58:16 +09:00
parent 94b03a8c11
commit b4555a4460
2 changed files with 117 additions and 8 deletions

View File

@@ -27,6 +27,7 @@ class HomeRecommendationQueryService(
private val snapshotPort: RecommendationSnapshotPort, private val snapshotPort: RecommendationSnapshotPort,
private val aiCharacterSnapshotFallbackService: AiCharacterSnapshotFallbackPort? = null, private val aiCharacterSnapshotFallbackService: AiCharacterSnapshotFallbackPort? = null,
private val cheerCreatorSnapshotFallbackService: CheerCreatorSnapshotFallbackPort? = null, private val cheerCreatorSnapshotFallbackService: CheerCreatorSnapshotFallbackPort? = null,
private val popularCommunitySnapshotFallbackService: PopularCommunitySnapshotFallbackPort? = null,
private val clock: Clock = Clock.systemUTC() private val clock: Clock = Clock.systemUTC()
) { ) {
private val snapshotWindowPolicy = RecommendationSnapshotWindowPolicy() private val snapshotWindowPolicy = RecommendationSnapshotWindowPolicy()
@@ -129,8 +130,7 @@ class HomeRecommendationQueryService(
memberId: Long? = null, memberId: Long? = null,
includeAdultCommunities: Boolean = false includeAdultCommunities: Boolean = false
): List<HomePopularCommunityRecommendationRecord> { ): List<HomePopularCommunityRecommendationRecord> {
val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY) val snapshots = findPopularCommunitySnapshotsWithFallback()
.take(POPULAR_COMMUNITY_CANDIDATE_LIMIT)
val detailsById = queryPort.findPopularCommunityRecommendationDetails( val detailsById = queryPort.findPopularCommunityRecommendationDetails(
snapshots.map { it.targetId }, snapshots.map { it.targetId },
memberId, memberId,
@@ -144,6 +144,21 @@ class HomeRecommendationQueryService(
}.take(limit) }.take(limit)
} }
private fun findPopularCommunitySnapshotsWithFallback(): List<RecommendationSnapshotRecord> {
val nowUtc = LocalDateTime.now(clock)
val snapshotAt = snapshotWindowPolicy.previousKstDayUtcWindow(nowUtc).snapshotAt
val snapshots = snapshotPort.findSnapshots(
RecommendedSectionType.POPULAR_COMMUNITY,
snapshotAt,
limit = POPULAR_COMMUNITY_CANDIDATE_LIMIT
)
if (snapshots.isNotEmpty()) return snapshots
if (snapshotPort.existsSnapshot(RecommendedSectionType.POPULAR_COMMUNITY, snapshotAt)) return emptyList()
return popularCommunitySnapshotFallbackService
?.refreshPopularCommunityIfMissing(offset = 0, limit = POPULAR_COMMUNITY_CANDIDATE_LIMIT, nowUtc = nowUtc)
.orEmpty()
}
fun findGenreCreatorRecommendations( fun findGenreCreatorRecommendations(
memberId: Long?, memberId: Long?,
includeAdultGenres: Boolean, includeAdultGenres: Boolean,

View File

@@ -412,9 +412,10 @@ class HomeRecommendationQueryServiceTest {
} }
@Test @Test
@DisplayName("인기 커뮤니티 추천은 최신 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다") @DisplayName("인기 커뮤니티 추천은 대상일 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다")
fun shouldFindPopularCommunitiesFromLatestSnapshotsWithLimitDetailsAndCreatorUniqueness() { fun shouldFindPopularCommunitiesFromTargetDaySnapshotsWithLimitDetailsAndCreatorUniqueness() {
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val service = HomeRecommendationQueryService(port, snapshotPort, clock = fixedClock())
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
snapshotPort.replaceSnapshots( snapshotPort.replaceSnapshots(
RecommendedSectionType.POPULAR_COMMUNITY, RecommendedSectionType.POPULAR_COMMUNITY,
snapshotAt, snapshotAt,
@@ -487,7 +488,8 @@ class HomeRecommendationQueryServiceTest {
@Test @Test
@DisplayName("인기 커뮤니티 추천은 상위 스냅샷 중복 제거 후 뒤 후보로 기본 10개를 채운다") @DisplayName("인기 커뮤니티 추천은 상위 스냅샷 중복 제거 후 뒤 후보로 기본 10개를 채운다")
fun shouldBackfillPopularCommunitiesAfterRemovingDuplicateCreators() { fun shouldBackfillPopularCommunitiesAfterRemovingDuplicateCreators() {
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val service = HomeRecommendationQueryService(port, snapshotPort, clock = fixedClock())
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
snapshotPort.replaceSnapshots( snapshotPort.replaceSnapshots(
RecommendedSectionType.POPULAR_COMMUNITY, RecommendedSectionType.POPULAR_COMMUNITY,
snapshotAt, snapshotAt,
@@ -521,6 +523,77 @@ class HomeRecommendationQueryServiceTest {
assertEquals(communities.size, communities.map { it.creatorId }.toSet().size) assertEquals(communities.size, communities.map { it.creatorId }.toSet().size)
} }
@Test
@DisplayName("인기 커뮤니티 추천은 대상일 스냅샷이 없고 오래된 스냅샷만 있으면 fallback refresh를 호출한다")
fun shouldFallbackWhenOnlyStalePopularCommunitySnapshotsExist() {
val fallback = FakePopularCommunitySnapshotFallbackPort(
listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, 10L, 10.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
)
val service = HomeRecommendationQueryService(
port,
snapshotPort,
popularCommunitySnapshotFallbackService = fallback,
clock = fixedClock()
)
val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59)
snapshotPort.replaceSnapshots(
RecommendedSectionType.POPULAR_COMMUNITY,
staleSnapshotAt,
listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, 99L, 99.0, staleSnapshotAt))
)
port.popularCommunityDetails = listOf(
HomePopularCommunityRecommendationRecord(
communityId = 10L,
creatorId = 110L,
creatorNickname = "fallback-community-creator",
creatorProfileImage = null,
imagePath = null,
audioPath = null,
content = "content",
price = 0,
createdAt = LocalDateTime.of(2026, 7, 9, 1, 0),
likeCount = 0L,
commentCount = 0L,
existOrdered = false,
isLiked = false
)
)
val communities = service.findPopularCommunityRecommendations(memberId = 105L)
assertEquals(0L, fallback.offset)
assertEquals(20, fallback.limit)
assertEquals(LocalDateTime.of(2026, 7, 9, 21, 0), fallback.nowUtc)
assertEquals(listOf(10L), port.popularCommunityDetailIds)
assertEquals(listOf(10L), communities.map { it.communityId })
}
@Test
@DisplayName("인기 커뮤니티 추천은 빈 스냅샷 refresh 완료 marker가 있으면 fallback refresh를 반복하지 않는다")
fun shouldNotFallbackWhenEmptyPopularCommunitySnapshotCycleExists() {
val fallback = FakePopularCommunitySnapshotFallbackPort(
listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
)
val service = HomeRecommendationQueryService(
port,
snapshotPort,
popularCommunitySnapshotFallbackService = fallback,
clock = fixedClock()
)
snapshotPort.replaceSnapshots(
RecommendedSectionType.POPULAR_COMMUNITY,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
emptyList()
)
val communities = service.findPopularCommunityRecommendations(memberId = 105L)
assertEquals(null, fallback.offset)
assertEquals(null, fallback.limit)
assertEquals(emptyList<Long>(), port.popularCommunityDetailIds)
assertEquals(emptyList<HomePopularCommunityRecommendationRecord>(), communities)
}
@Test @Test
@DisplayName("최신 스냅샷이 없으면 AI 캐릭터/최근 응원/인기 커뮤니티 추천은 빈 배열을 반환한다") @DisplayName("최신 스냅샷이 없으면 AI 캐릭터/최근 응원/인기 커뮤니티 추천은 빈 배열을 반환한다")
fun shouldReturnEmptyListWhenLatestSnapshotsDoNotExist() { fun shouldReturnEmptyListWhenLatestSnapshotsDoNotExist() {
@@ -969,7 +1042,7 @@ class HomeRecommendationQueryServiceTest {
override fun findPopularCommunitySnapshots( override fun findPopularCommunitySnapshots(
windowStart: LocalDateTime, windowStart: LocalDateTime,
snapshotAt: LocalDateTime, windowEndExclusive: LocalDateTime,
limit: Int limit: Int
): List<RecommendationSnapshotRecord> = emptyList() ): List<RecommendationSnapshotRecord> = emptyList()
@@ -1071,7 +1144,9 @@ private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort {
} }
private fun supportsEmptySnapshotMarker(sectionType: RecommendedSectionType): Boolean { 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
} }
} }
@@ -1098,6 +1173,25 @@ private class FakeCheerCreatorSnapshotFallbackPort(
} }
} }
private class FakePopularCommunitySnapshotFallbackPort(
private val snapshots: List<RecommendationSnapshotRecord>
) : PopularCommunitySnapshotFallbackPort {
var offset: Long? = null
var limit: Int? = null
var nowUtc: LocalDateTime? = null
override fun refreshPopularCommunityIfMissing(
offset: Long,
limit: Int,
nowUtc: LocalDateTime
): List<RecommendationSnapshotRecord> {
this.offset = offset
this.limit = limit
this.nowUtc = nowUtc
return snapshots
}
}
private class FakeAiCharacterSnapshotFallbackPort( private class FakeAiCharacterSnapshotFallbackPort(
private val snapshots: List<RecommendationSnapshotRecord> private val snapshots: List<RecommendationSnapshotRecord>
) : AiCharacterSnapshotFallbackPort { ) : AiCharacterSnapshotFallbackPort {