diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt index 25b054cd..e7a41960 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt @@ -25,6 +25,7 @@ import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeLiveRecommendationReco import kr.co.vividnext.sodalive.v2.recommend.port.out.HomePopularCommunityRecommendationRecord import kr.co.vividnext.sodalive.v2.recommend.port.out.RecentDebutCreatorRecord import kr.co.vividnext.sodalive.v2.recommend.port.out.RecentlyActiveCreatorRecord +import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component import java.time.LocalDateTime @@ -36,78 +37,176 @@ class HomeRecommendationFacade( @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) { - fun getHomeRecommendations(member: Member?): HomeRecommendationResponse { - val now = LocalDateTime.now() - val includeAdult = resolveAdultVisibility(member) + private val log = LoggerFactory.getLogger(javaClass) - return HomeRecommendationResponse( - lives = queryService.findLiveRecommendations( - limit = HOME_LIVE_LIMIT, - includeAdultLives = includeAdult - ).map { it.toItem() }, - banners = queryService.findHomeBanners(HOME_BANNER_LIMIT).map { it.toItem() }, - recentlyActiveCreators = queryService.findRecentlyActiveCreators(HOME_ACTIVE_CREATOR_LIMIT, includeAdult) - .map { it.toItem() }, - recentDebutCreators = queryService.findRecentDebutCreators( - now, - limit = HOME_RECENT_DEBUT_CREATOR_LIMIT, - includeAdultContents = includeAdult + fun getHomeRecommendations(member: Member?): HomeRecommendationResponse { + val startedAt = System.currentTimeMillis() + return runCatching { + val now = LocalDateTime.now() + val includeAdult = resolveAdultVisibility(member) + + HomeRecommendationResponse( + lives = queryService.findLiveRecommendations( + limit = HOME_LIVE_LIMIT, + memberId = member?.id, + includeAdultLives = includeAdult + ).map { it.toItem() }, + banners = queryService.findHomeBanners(HOME_BANNER_LIMIT, member?.id).map { it.toItem() }, + recentlyActiveCreators = queryService.findRecentlyActiveCreators( + HOME_ACTIVE_CREATOR_LIMIT, + member?.id, + includeAdult + ) + .map { it.toItem() }, + recentDebutCreators = queryService.findRecentDebutCreators( + now, + limit = HOME_RECENT_DEBUT_CREATOR_LIMIT, + memberId = member?.id, + includeAdultContents = includeAdult + ) + .map { it.toItem() }, + firstAudioContents = queryService.findFirstAudioContents( + now, + limit = HOME_FIRST_AUDIO_CONTENT_LIMIT, + memberId = member?.id, + includeAdultContents = includeAdult + ) + .map { it.toItem() }, + aiCharacters = queryService.findAiCharacterRecommendations(limit = HOME_AI_CHARACTER_LIMIT).map { it.toItem() }, + genreCreators = queryService.findGenreCreatorRecommendations( + memberId = member?.id, + includeAdultGenres = includeAdult, + genreLimit = HOME_GENRE_CREATOR_GENRE_LIMIT, + creatorLimit = HOME_GENRE_CREATOR_CREATOR_LIMIT + ).map { it.toItem() }, + cheerCreators = queryService.findCheerCreatorRecommendations(HOME_CHEER_CREATOR_LIMIT, member?.id) + .map { it.toCreatorItem() }, + popularCommunities = queryService.findPopularCommunityRecommendations( + limit = HOME_POPULAR_COMMUNITY_LIMIT, + memberId = member?.id, + includeAdultCommunities = includeAdult + ).map { it.toItem() } ) - .map { it.toItem() }, - firstAudioContents = queryService.findFirstAudioContents( - now, - limit = HOME_FIRST_AUDIO_CONTENT_LIMIT, - includeAdultContents = includeAdult + }.onSuccess { response -> + log.info( + "event=home_recommendations_query_success memberId={} elapsedMs={} emptySections={}", + member?.id, + System.currentTimeMillis() - startedAt, + response.emptySections() ) - .map { it.toItem() }, - aiCharacters = queryService.findAiCharacterRecommendations(limit = HOME_AI_CHARACTER_LIMIT).map { it.toItem() }, - genreCreators = queryService.findGenreCreatorRecommendations( - memberId = member?.id, - includeAdultGenres = includeAdult, - genreLimit = HOME_GENRE_CREATOR_GENRE_LIMIT, - creatorLimit = HOME_GENRE_CREATOR_CREATOR_LIMIT - ).map { it.toItem() }, - cheerCreators = queryService.findCheerCreatorRecommendations(HOME_CHEER_CREATOR_LIMIT) - .map { it.toCreatorItem() }, - popularCommunities = queryService.findPopularCommunityRecommendations( - limit = HOME_POPULAR_COMMUNITY_LIMIT, - includeAdultCommunities = includeAdult - ).map { it.toItem() } - ) + }.onFailure { ex -> + log.warn( + "event=home_recommendations_query_failure memberId={} elapsedMs={} error={}", + member?.id, + System.currentTimeMillis() - startedAt, + ex.message, + ex + ) + }.getOrThrow() } fun getLives(member: Member, page: Int, size: Int): HomeRecommendationPageResponse { - val fetched = queryService.findLiveRecommendations( - offset = page.toOffset(size), - limit = size + 1, - includeAdultLives = resolveAdultVisibility(member) - ) - return fetched.toPage(page, size) { it.toItem() } + val startedAt = System.currentTimeMillis() + return runCatching { + val fetched = queryService.findLiveRecommendations( + offset = page.toOffset(size), + limit = size + 1, + memberId = member.id, + includeAdultLives = resolveAdultVisibility(member) + ) + fetched.toPage(page, size) { it.toItem() } + }.onSuccess { + logPageSuccess("LIVE", member, page, size, it.items.size, System.currentTimeMillis() - startedAt) + }.onFailure { ex -> + logPageFailure("LIVE", member, page, size, startedAt, ex) + }.getOrThrow() } fun getRecentDebutCreators(member: Member, page: Int, size: Int): HomeRecommendationPageResponse { - val fetched = queryService.findRecentDebutCreators( - now = LocalDateTime.now(), - offset = page.toOffset(size), - limit = size + 1, - includeAdultContents = resolveAdultVisibility(member) - ) - return fetched.toPage(page, size) { it.toItem() } + val startedAt = System.currentTimeMillis() + return runCatching { + val fetched = queryService.findRecentDebutCreators( + now = LocalDateTime.now(), + offset = page.toOffset(size), + limit = size + 1, + memberId = member.id, + includeAdultContents = resolveAdultVisibility(member) + ) + fetched.toPage(page, size) { it.toItem() } + }.onSuccess { + logPageSuccess("DEBUT_CREATOR", member, page, size, it.items.size, System.currentTimeMillis() - startedAt) + }.onFailure { ex -> + logPageFailure("DEBUT_CREATOR", member, page, size, startedAt, ex) + }.getOrThrow() } fun getFirstAudioContents(member: Member, page: Int, size: Int): HomeRecommendationPageResponse { - val fetched = queryService.findFirstAudioContents( - now = LocalDateTime.now(), - offset = page.toOffset(size), - limit = size + 1, - includeAdultContents = resolveAdultVisibility(member) - ) - return fetched.toPage(page, size) { it.toItem() } + val startedAt = System.currentTimeMillis() + return runCatching { + val fetched = queryService.findFirstAudioContents( + now = LocalDateTime.now(), + offset = page.toOffset(size), + limit = size + 1, + memberId = member.id, + includeAdultContents = resolveAdultVisibility(member) + ) + fetched.toPage(page, size) { it.toItem() } + }.onSuccess { + logPageSuccess("FIRST_AUDIO_CONTENT", member, page, size, it.items.size, System.currentTimeMillis() - startedAt) + }.onFailure { ex -> + logPageFailure("FIRST_AUDIO_CONTENT", member, page, size, startedAt, ex) + }.getOrThrow() } fun getAiCharacters(member: Member, page: Int, size: Int): HomeRecommendationPageResponse { - val fetched = queryService.findAiCharacterRecommendations(offset = page.toOffset(size), limit = size + 1) - return fetched.toPage(page, size) { it.toItem() } + val startedAt = System.currentTimeMillis() + return runCatching { + val fetched = queryService.findAiCharacterRecommendations(offset = page.toOffset(size), limit = size + 1) + fetched.toPage(page, size) { it.toItem() } + }.onSuccess { + logPageSuccess("AI_CHARACTER", member, page, size, it.items.size, System.currentTimeMillis() - startedAt) + }.onFailure { ex -> + logPageFailure("AI_CHARACTER", member, page, size, startedAt, ex) + }.getOrThrow() + } + + private fun logPageSuccess(section: String, member: Member, page: Int, size: Int, itemCount: Int, elapsedMs: Long) { + log.info( + "event=home_recommendations_page_query_success section={} memberId={} page={} size={} itemCount={} elapsedMs={}", + section, + member.id, + page, + size, + itemCount, + elapsedMs + ) + } + + private fun logPageFailure(section: String, member: Member, page: Int, size: Int, startedAt: Long, ex: Throwable) { + log.warn( + "event=home_recommendations_page_query_failure section={} memberId={} page={} size={} elapsedMs={} error={}", + section, + member.id, + page, + size, + System.currentTimeMillis() - startedAt, + ex.message, + ex + ) + } + + private fun HomeRecommendationResponse.emptySections(): List { + return buildList { + if (lives.isEmpty()) add("lives") + if (banners.isEmpty()) add("banners") + if (recentlyActiveCreators.isEmpty()) add("recentlyActiveCreators") + if (recentDebutCreators.isEmpty()) add("recentDebutCreators") + if (firstAudioContents.isEmpty()) add("firstAudioContents") + if (aiCharacters.isEmpty()) add("aiCharacters") + if (genreCreators.isEmpty()) add("genreCreators") + if (cheerCreators.isEmpty()) add("cheerCreators") + if (popularCommunities.isEmpty()) add("popularCommunities") + } } private fun resolveAdultVisibility(member: Member?): Boolean { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt index 23706608..47d4328e 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt @@ -5,17 +5,26 @@ import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.member.following.CreatorFollowing import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacade +import kr.co.vividnext.sodalive.v2.recommend.application.HomeRecommendationQueryService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mockito import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.system.CapturedOutput +import org.springframework.boot.test.system.OutputCaptureExtension import org.springframework.http.MediaType import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user import org.springframework.test.context.ContextConfiguration @@ -32,6 +41,7 @@ import javax.persistence.EntityManager @AutoConfigureMockMvc @Transactional @ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@ExtendWith(OutputCaptureExtension::class) class HomeRecommendationControllerTest @Autowired constructor( private val mockMvc: MockMvc, private val memberRepository: MemberRepository, @@ -197,7 +207,7 @@ class HomeRecommendationControllerTest @Autowired constructor( @Test @DisplayName("메인 홈 통합 조회는 비회원도 호출 가능하고 섹션별 빈 배열을 포함해 성공 응답한다") - fun shouldReturnHomeRecommendationsForAnonymous() { + fun shouldReturnHomeRecommendationsForAnonymous(output: CapturedOutput) { mockMvc.perform(get("/api/v2/home/recommendations")) .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) @@ -210,6 +220,9 @@ class HomeRecommendationControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.genreCreators").isArray) .andExpect(jsonPath("$.data.cheerCreators").isArray) .andExpect(jsonPath("$.data.popularCommunities").isArray) + + assertTrue(output.out.contains("event=home_recommendations_query_success")) + assertTrue(output.out.contains("emptySections=")) } @Test @@ -227,9 +240,27 @@ class HomeRecommendationControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.lives").isArray) } + @Test + @DisplayName("메인 홈 통합 조회 실패는 응답 시간과 함께 로그로 관측된다") + fun shouldLogHomeRecommendationFailure(output: CapturedOutput) { + val failingQueryService = Mockito.mock(HomeRecommendationQueryService::class.java) + val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) + val facade = HomeRecommendationFacade(failingQueryService, preferenceService, "https://cdn.test") + Mockito.`when`(failingQueryService.findLiveRecommendations(limit = 20, memberId = null, includeAdultLives = false)) + .thenThrow(IllegalStateException("home query failed")) + + val exception = assertThrows(IllegalStateException::class.java) { + facade.getHomeRecommendations(member = null) + } + + assertEquals("home query failed", exception.message) + assertTrue(output.out.contains("event=home_recommendations_query_failure")) + assertTrue(output.out.contains("memberId=null")) + } + @Test @DisplayName("라이브 전체보기는 page/size를 포함한 페이징 응답 형식으로 반환한다") - fun shouldReturnPagedLives() { + fun shouldReturnPagedLives(output: CapturedOutput) { val member = saveMember("paged-live-viewer", MemberRole.USER) entityManager.flush() entityManager.clear() @@ -241,6 +272,74 @@ class HomeRecommendationControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.page").value(0)) .andExpect(jsonPath("$.data.size").value(20)) .andExpect(jsonPath("$.data.hasNext").value(false)) + + assertTrue(output.out.contains("event=home_recommendations_page_query_success")) + assertTrue(output.out.contains("section=LIVE")) + } + + @Test + @DisplayName("세부 전체보기 조회 실패는 섹션과 응답 시간과 함께 로그로 관측된다") + fun shouldLogHomeRecommendationPageFailure(output: CapturedOutput) { + val member = saveMember("page-failure-viewer", MemberRole.USER) + val failingQueryService = Mockito.mock(HomeRecommendationQueryService::class.java) + val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) + val facade = HomeRecommendationFacade(failingQueryService, preferenceService, "https://cdn.test") + Mockito.`when`(preferenceService.initializeDefaultPreference(member)).thenReturn(MemberContentPreference()) + Mockito.`when`( + failingQueryService.findLiveRecommendations( + offset = 0, + limit = 21, + memberId = member.id, + includeAdultLives = false + ) + ) + .thenThrow(IllegalStateException("page query failed")) + + val exception = assertThrows(IllegalStateException::class.java) { + facade.getLives(member, page = 0, size = 20) + } + + assertEquals("page query failed", exception.message) + assertTrue(output.out.contains("event=home_recommendations_page_query_failure")) + assertTrue(output.out.contains("section=LIVE")) + } + + @Test + @DisplayName("나머지 세부 전체보기 조회 실패도 섹션과 응답 시간과 함께 로그로 관측된다") + fun shouldLogOtherHomeRecommendationPageFailures(output: CapturedOutput) { + val member = saveMember("other-page-failure-viewer", MemberRole.USER) + val failingQueryService = Mockito.mock(HomeRecommendationQueryService::class.java) + val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) + val facade = HomeRecommendationFacade(failingQueryService, preferenceService, "https://cdn.test") + Mockito.`when`(preferenceService.initializeDefaultPreference(member)).thenReturn(MemberContentPreference()) + Mockito.`when`( + failingQueryService.findRecentDebutCreators( + now = Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.MIN, + offset = Mockito.eq(0), + limit = Mockito.eq(21), + memberId = Mockito.eq(member.id), + includeAdultContents = Mockito.eq(false) + ) + ).thenThrow(IllegalStateException("debut page failed")) + Mockito.`when`( + failingQueryService.findFirstAudioContents( + now = Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.MIN, + offset = Mockito.eq(0), + limit = Mockito.eq(21), + memberId = Mockito.eq(member.id), + includeAdultContents = Mockito.eq(false) + ) + ).thenThrow(IllegalStateException("first audio page failed")) + Mockito.`when`(failingQueryService.findAiCharacterRecommendations(offset = 0, limit = 21)) + .thenThrow(IllegalStateException("ai page failed")) + + assertThrows(IllegalStateException::class.java) { facade.getRecentDebutCreators(member, page = 0, size = 20) } + assertThrows(IllegalStateException::class.java) { facade.getFirstAudioContents(member, page = 0, size = 20) } + assertThrows(IllegalStateException::class.java) { facade.getAiCharacters(member, page = 0, size = 20) } + + assertTrue(output.out.contains("section=DEBUT_CREATOR")) + assertTrue(output.out.contains("section=FIRST_AUDIO_CONTENT")) + assertTrue(output.out.contains("section=AI_CHARACTER")) } @Test