From 341f24c643782b9f2f4b8f7edb0720ccb05e1070 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 13 Feb 2026 10:37:06 +0900 Subject: [PATCH] =?UTF-8?q?HomeService=20fetchData=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81=20=EB=B0=8F=20DB=20JOIN=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EB=B2=88=EC=97=AD=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchData 함수에서 별도로 수행하던 번역 데이터 조회를 DB JOIN 및 COALESCE를 사용하도록 개선하여 성능을 최적화함. - AudioContentRepository, RankingRepository 등에 locale 파라미터 추가 - DB 레벨에서 번역된 제목을 조회하도록 쿼리 수정 - HomeService에서 불필요한 getTranslatedContentList 호출 제거 --- .../sodalive/api/home/HomeService.kt | 118 ++---------------- .../content/AudioContentRepository.kt | 29 ++++- .../sodalive/content/AudioContentService.kt | 23 +--- .../RecommendChannelQueryRepository.kt | 29 ++++- .../recommend/RecommendChannelQueryService.kt | 9 +- .../sodalive/rank/RankingRepository.kt | 27 +++- .../vividnext/sodalive/rank/RankingService.kt | 5 +- 7 files changed, 102 insertions(+), 138 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt index 152f6f6f..6d44d725 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt @@ -14,7 +14,6 @@ import kr.co.vividnext.sodalive.content.series.ContentSeriesService import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService -import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek import kr.co.vividnext.sodalive.event.GetEventResponse import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository @@ -50,7 +49,6 @@ class HomeService( private val rankingRepository: RankingRepository, private val explorerQueryRepository: ExplorerQueryRepository, - private val contentTranslationRepository: ContentTranslationRepository, private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, private val seriesTranslationRepository: SeriesTranslationRepository, @@ -122,8 +120,6 @@ class HomeService( isAdult = isAdult ) - val translatedLatestContentList = getTranslatedContentList(contentList = latestContentList) - val eventBannerList = GetEventResponse( totalCount = 0, eventList = emptyList() @@ -177,64 +173,12 @@ class HomeService( sort = ContentRankingSortType.REVENUE ) - val contentRankingContentIds = contentRanking.map { it.contentId } - val translatedContentRanking = if (contentRankingContentIds.isNotEmpty()) { - val translations = contentTranslationRepository - .findByContentIdInAndLocale(contentIds = contentRankingContentIds, locale = langContext.lang.code) - .associateBy { it.contentId } - - contentRanking.map { item -> - val translatedTitle = translations[item.contentId]?.renderedPayload?.title - if (translatedTitle.isNullOrBlank()) { - item - } else { - item.copy(title = translatedTitle) - } - } - } else { - contentRanking - } - val recommendChannelList = recommendChannelService.getRecommendChannel( memberId = memberId, isAdult = isAdult, contentType = contentType ) - /** - * recommendChannelList의 콘텐츠 번역 데이터 조회 - * - * languageCode != null - * contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale - * - * 한 번에 조회하고 contentId를 매핑하여 recommendChannelList의 콘텐츠 title을 번역 데이터로 변경한다 - */ - val channelContentIds = recommendChannelList - .flatMap { it.contentList } - .map { it.contentId } - .distinct() - - val translatedRecommendChannelList = if (channelContentIds.isNotEmpty()) { - val translations = contentTranslationRepository - .findByContentIdInAndLocale(contentIds = channelContentIds, locale = langContext.lang.code) - .associateBy { it.contentId } - - recommendChannelList.map { channel -> - val translatedContentList = channel.contentList.map { item -> - val translatedTitle = translations[item.contentId]?.renderedPayload?.title - if (translatedTitle.isNullOrBlank()) { - item - } else { - item.copy(title = translatedTitle) - } - } - - channel.copy(contentList = translatedContentList) - } - } else { - recommendChannelList - } - val freeContentList = getRandomizedContentList( memberId = memberId, isAdult = isAdult, @@ -248,8 +192,6 @@ class HomeService( isPointAvailableOnly = false ) - val translatedFreeContentList = getTranslatedContentList(contentList = freeContentList) - // 포인트 사용가능 콘텐츠 리스트 - 랜덤으로 가져오기 (DB에서 isPointAvailable 조건 적용) val pointAvailableContentList = getRandomizedContentList( memberId = memberId, @@ -260,28 +202,26 @@ class HomeService( isPointAvailableOnly = true ) - val translatedPointAvailableContentList = getTranslatedContentList(contentList = pointAvailableContentList) - val excludeContentIds = ( - translatedLatestContentList.map { it.contentId } + - translatedContentRanking.map { it.contentId } + latestContentList.map { it.contentId } + + contentRanking.map { it.contentId } ).distinct() return GetHomeResponse( liveList = liveList, creatorRanking = creatorRanking, latestContentThemeList = latestContentThemeList, - latestContentList = translatedLatestContentList, + latestContentList = latestContentList, bannerList = bannerList, eventBannerList = eventBannerList, originalAudioDramaList = translatedOriginalAudioDramaList, auditionList = auditionList, dayOfWeekSeriesList = translatedDayOfWeekSeriesList, popularCharacters = translatedPopularCharacters, - contentRanking = translatedContentRanking, - recommendChannelList = translatedRecommendChannelList, - freeContentList = translatedFreeContentList, - pointAvailableContentList = translatedPointAvailableContentList, + contentRanking = contentRanking, + recommendChannelList = recommendChannelList, + freeContentList = freeContentList, + pointAvailableContentList = pointAvailableContentList, recommendContentList = getRecommendContentList( isAdultContentVisible = isAdultContentVisible, contentType = contentType, @@ -311,15 +251,13 @@ class HomeService( listOf(theme) } - val contentList = contentService.getLatestContentByTheme( + return contentService.getLatestContentByTheme( memberId = memberId, theme = themeList, contentType = contentType, isFree = false, isAdult = isAdult ) - - return getTranslatedContentList(contentList = contentList) } fun getDayOfWeekSeriesList( @@ -445,7 +383,7 @@ class HomeService( } } - return getTranslatedContentList(contentList = result.take(RECOMMEND_TARGET_SIZE).shuffled()) + return result.take(RECOMMEND_TARGET_SIZE).shuffled() } private fun pickByTimeDecay( @@ -542,43 +480,7 @@ class HomeService( } } - return getTranslatedContentList(contentList = result.take(targetSize).shuffled()) - } - - /** - * 콘텐츠 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다. - * - * 처리 절차: - * - 입력된 콘텐츠들의 contentId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로 - * contentTranslationRepository에서 번역 데이터를 한 번에 조회한다. - * - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다. - * - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다. - * - * 성능: - * - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다. - * - * @param contentList 번역 대상 AudioContentMainItem 목록 - * @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지 - */ - private fun getTranslatedContentList(contentList: List): List { - val contentIds = contentList.map { it.contentId } - - return if (contentIds.isNotEmpty()) { - val translations = contentTranslationRepository - .findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code) - .associateBy { it.contentId } - - contentList.map { item -> - val translatedTitle = translations[item.contentId]?.renderedPayload?.title - if (translatedTitle.isNullOrBlank()) { - item - } else { - item.copy(title = translatedTitle) - } - } - } else { - contentList - } + return result.take(targetSize).shuffled() } /** diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt index 47732e3d..05bd96ec 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt @@ -25,6 +25,7 @@ import kr.co.vividnext.sodalive.content.pin.QPinContent.pinContent import kr.co.vividnext.sodalive.content.playlist.AudioContentPlaylistContent import kr.co.vividnext.sodalive.content.playlist.QAudioContentPlaylistContent import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme +import kr.co.vividnext.sodalive.content.translation.QContentTranslation.contentTranslation import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent import kr.co.vividnext.sodalive.event.QEvent.event @@ -188,7 +189,8 @@ interface AudioContentQueryRepository { isAdult: Boolean, orderByRandom: Boolean = false, isPointAvailableOnly: Boolean = false, - excludeContentIds: List = emptyList() + excludeContentIds: List = emptyList(), + locale: String? = null ): List fun findContentByCurationId( @@ -1331,7 +1333,8 @@ class AudioContentQueryRepositoryImpl( isAdult: Boolean, orderByRandom: Boolean, isPointAvailableOnly: Boolean, - excludeContentIds: List + excludeContentIds: List, + locale: String? ): List { val blockMemberCondition = if (memberId != null) { blockMember.member.id.eq(member.id) @@ -1382,12 +1385,27 @@ class AudioContentQueryRepositoryImpl( where = where.and(audioContent.id.notIn(excludeContentIds)) } + val titleExpression = if (locale != null) { + val translatedTitle = Expressions.stringTemplate( + "JSON_EXTRACT({0}, '$.title')", + contentTranslation.renderedPayload + ) + val coalesceTitle = Expressions.stringTemplate( + "COALESCE(NULLIF({0}, ''), {1})", + translatedTitle, + audioContent.title + ) + coalesceTitle + } else { + audioContent.title + } + var select = queryFactory .select( QAudioContentMainItem( audioContent.id, member.id, - audioContent.title, + titleExpression, audioContent.coverImage.prepend("/").prepend(imageHost), member.nickname, audioContent.isPointAvailable @@ -1397,6 +1415,11 @@ class AudioContentQueryRepositoryImpl( .innerJoin(audioContent.member, member) .innerJoin(audioContent.theme, audioContentTheme) + if (locale != null) { + select = select.leftJoin(contentTranslation) + .on(contentTranslation.contentId.eq(audioContent.id).and(contentTranslation.locale.eq(locale))) + } + if (memberId != null) { where = where.and(blockMember.id.isNull) select = select.leftJoin(blockMember).on(blockMemberCondition) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt index 00ca2d60..89922a4a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -1222,7 +1222,7 @@ class AudioContentService( isPointAvailableOnly = isPointAvailableOnly ) - val contentList = repository.getLatestContentByTheme( + return repository.getLatestContentByTheme( memberId = memberId, theme = normalizedTheme, contentType = contentType, @@ -1233,26 +1233,9 @@ class AudioContentService( isAdult = isAdult, orderByRandom = orderByRandom, isPointAvailableOnly = isPointAvailableOnly, - excludeContentIds = excludeContentIds + excludeContentIds = excludeContentIds, + locale = langContext.lang.code ) - - val contentIds = contentList.map { it.contentId } - return if (contentIds.isNotEmpty()) { - val translations = contentTranslationRepository - .findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code) - .associateBy { it.contentId } - - contentList.map { item -> - val translatedTitle = translations[item.contentId]?.renderedPayload?.title - if (translatedTitle.isNullOrBlank()) { - item - } else { - item.copy(title = translatedTitle) - } - } - } else { - contentList - } } /** diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryRepository.kt index 95208d96..df513800 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryRepository.kt @@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.content.ContentType import kr.co.vividnext.sodalive.content.QAudioContent.audioContent import kr.co.vividnext.sodalive.content.comment.QAudioContentComment.audioContentComment import kr.co.vividnext.sodalive.content.like.QAudioContentLike.audioContentLike +import kr.co.vividnext.sodalive.content.translation.QContentTranslation.contentTranslation import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.auth.QAuth.auth @@ -83,7 +84,8 @@ class RecommendChannelQueryRepository( fun getContentsByCreatorIdLikeDesc( creatorId: Long, memberId: Long?, - isAdult: Boolean + isAdult: Boolean, + locale: String? = null ): List { val blockMemberCondition = if (memberId != null) { blockMember.member.id.eq(audioContent.member.id) @@ -99,11 +101,26 @@ class RecommendChannelQueryRepository( where = where.and(audioContent.isAdult.isFalse) } + val titleExpression = if (locale != null) { + val translatedTitle = Expressions.stringTemplate( + "JSON_EXTRACT({0}, '$.title')", + contentTranslation.renderedPayload + ) + val coalesceTitle = Expressions.stringTemplate( + "COALESCE(NULLIF({0}, ''), {1})", + translatedTitle, + audioContent.title + ) + coalesceTitle + } else { + audioContent.title + } + var select = queryFactory .select( QRecommendChannelContentItem( audioContent.id, - audioContent.title, + titleExpression, audioContent.coverImage.prepend("/").prepend(imageHost), audioContentLike.id.countDistinct(), audioContentComment.id.countDistinct() @@ -121,6 +138,14 @@ class RecommendChannelQueryRepository( .and(audioContentComment.isActive.isTrue) ) + if (locale != null) { + select = select.leftJoin(contentTranslation) + .on( + contentTranslation.contentId.eq(audioContent.id) + .and(contentTranslation.locale.eq(locale)) + ) + } + if (memberId != null) { where = where.and(blockMember.id.isNull) select = select.leftJoin(blockMember).on(blockMemberCondition) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryService.kt index 4888b1ab..727509ad 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryService.kt @@ -1,13 +1,17 @@ package kr.co.vividnext.sodalive.query.recommend import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.i18n.LangContext import org.springframework.cache.annotation.Cacheable import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @Service @Transactional(readOnly = true) -class RecommendChannelQueryService(private val repository: RecommendChannelQueryRepository) { +class RecommendChannelQueryService( + private val repository: RecommendChannelQueryRepository, + private val langContext: LangContext +) { @Cacheable( cacheNames = ["default"], key = "'recommendChannel:' + (#memberId ?: 'guest') + ':' + #isAdult + ':' + #contentType" @@ -27,7 +31,8 @@ class RecommendChannelQueryService(private val repository: RecommendChannelQuery it.contentList = repository.getContentsByCreatorIdLikeDesc( creatorId = it.channelId, memberId = memberId, - isAdult = isAdult + isAdult = isAdult, + locale = langContext.lang.code ) it diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt index cea4fab0..d16c0f39 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt @@ -69,7 +69,8 @@ class RankingRepository( offset: Long, limit: Long, sortType: String, - theme: String = "" + theme: String = "", + locale: String? = null ): List { val blockMemberCondition = if (memberId != null) { blockMember.member.id.eq(member.id) @@ -79,6 +80,8 @@ class RankingRepository( null } + val contentTranslation = kr.co.vividnext.sodalive.content.translation.QContentTranslation.contentTranslation + var where = audioContent.isActive.isTrue .and(audioContent.member.isActive.isTrue) .and(audioContent.member.isNotNull) @@ -109,11 +112,26 @@ class RankingRepository( where = where.and(audioContentTheme.theme.eq(theme)) } + val titleExpression = if (locale != null) { + val translatedTitle = Expressions.stringTemplate( + "JSON_EXTRACT({0}, '$.title')", + contentTranslation.renderedPayload + ) + val coalesceTitle = Expressions.stringTemplate( + "COALESCE(NULLIF({0}, ''), {1})", + translatedTitle, + audioContent.title + ) + coalesceTitle + } else { + audioContent.title + } + var select = queryFactory .select( QGetAudioContentRankingItem( audioContent.id, - audioContent.title, + titleExpression, audioContent.coverImage.prepend("/").prepend(imageHost), audioContentTheme.theme, audioContent.price, @@ -167,6 +185,11 @@ class RankingRepository( } } + if (locale != null) { + select = select.leftJoin(contentTranslation) + .on(contentTranslation.contentId.eq(audioContent.id).and(contentTranslation.locale.eq(locale))) + } + if (memberId != null) { where = where.and(blockMember.id.isNull) select = select.leftJoin(blockMember).on(blockMemberCondition) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingService.kt index ea36e7bc..49d59bfb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingService.kt @@ -9,6 +9,7 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.Series import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState import kr.co.vividnext.sodalive.explorer.GetExplorerSectionResponse +import kr.co.vividnext.sodalive.i18n.LangContext import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import java.time.LocalDateTime @@ -17,6 +18,7 @@ import java.time.LocalDateTime class RankingService( private val repository: RankingRepository, private val seriesContentRepository: ContentSeriesContentRepository, + private val langContext: LangContext, @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String @@ -59,7 +61,8 @@ class RankingService( offset = offset, limit = limit, sortType = sortType, - theme = theme + theme = theme, + locale = langContext.lang.code ) loopCount++ } while (contentList.size < 5 && loopCount < 5)