OriginalAudioDrama 리스트 조회 쿼리 최적화

OriginalAudioDrama 리스트 조회 시 엔티티 대신 DTO를 직접 조회하도록 개선
콘텐츠 개수, 신규 콘텐츠 여부, 번역 제목을 서브쿼리와 조인을 통해 한 번에 가져오도록 하여 기존의 N+1 문제와 다수의 추가 쿼리 발생을 해결
This commit is contained in:
2026-02-13 12:10:13 +09:00
parent 341f24c643
commit ac0def6187
4 changed files with 104 additions and 26 deletions

View File

@@ -131,15 +131,11 @@ class HomeService(
isAdult = isAdult
)
// 오직 보이스온에서만
val originalAudioDramaList = seriesService.getOriginalAudioDramaList(
isAdult = isAdult,
contentType = contentType,
orderByRandom = true
contentType = contentType
)
val translatedOriginalAudioDramaList = getTranslatedSeriesList(seriesList = originalAudioDramaList)
val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult)
// 요일별 시리즈
@@ -214,7 +210,7 @@ class HomeService(
latestContentList = latestContentList,
bannerList = bannerList,
eventBannerList = eventBannerList,
originalAudioDramaList = translatedOriginalAudioDramaList,
originalAudioDramaList = originalAudioDramaList,
auditionList = auditionList,
dayOfWeekSeriesList = translatedDayOfWeekSeriesList,
popularCharacters = translatedPopularCharacters,

View File

@@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCuration.audi
import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCurationItem.audioContentCurationItem
import kr.co.vividnext.sodalive.content.series.content.GetSeriesContentMinMaxPriceResponse
import kr.co.vividnext.sodalive.content.series.content.QGetSeriesContentMinMaxPriceResponse
import kr.co.vividnext.sodalive.content.series.translation.QSeriesTranslation.seriesTranslation
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.creator.admin.content.series.Series
@@ -20,6 +21,7 @@ import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember.member
import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember
import org.springframework.data.jpa.repository.JpaRepository
import java.time.LocalDateTime
interface ContentSeriesRepository : JpaRepository<Series, Long>, ContentSeriesQueryRepository
@@ -64,12 +66,13 @@ interface ContentSeriesQueryRepository {
fun getSeriesContentMinMaxPrice(seriesId: Long): GetSeriesContentMinMaxPriceResponse
fun getRecommendSeriesList(isAuth: Boolean, contentType: ContentType, limit: Long): List<Series>
fun getOriginalAudioDramaList(
imageHost: String,
isAdult: Boolean,
contentType: ContentType,
orderByRandom: Boolean = false,
locale: String,
offset: Long = 0,
limit: Long = 20
): List<Series>
): List<GetSeriesListResponse.SeriesListItem>
fun getOriginalAudioDramaTotalCount(isAdult: Boolean, contentType: ContentType): Int
fun getGenreList(isAdult: Boolean, memberId: Long, contentType: ContentType): List<GetSeriesGenreListResponse>
@@ -365,12 +368,13 @@ class ContentSeriesQueryRepositoryImpl(
}
override fun getOriginalAudioDramaList(
imageHost: String,
isAdult: Boolean,
contentType: ContentType,
orderByRandom: Boolean,
locale: String,
offset: Long,
limit: Long
): List<Series> {
): List<GetSeriesListResponse.SeriesListItem> {
var where = series.isOriginal.isTrue
.and(series.isActive.isTrue)
@@ -392,20 +396,85 @@ class ContentSeriesQueryRepositoryImpl(
}
}
return queryFactory
.selectFrom(series)
.innerJoin(series.member, member)
.where(where)
.orderBy(
if (orderByRandom) {
Expressions.numberTemplate(Double::class.java, "function('rand')").asc()
} else {
series.id.desc()
}
val now = LocalDateTime.now()
val sevenDaysAgo = now.minusDays(7)
val contentCountSubquery = queryFactory
.select(seriesContent.id.count())
.from(seriesContent)
.innerJoin(seriesContent.content, audioContent)
.where(
seriesContent.series.id.eq(series.id),
audioContent.isActive.isTrue,
if (!isAdult) audioContent.isAdult.isFalse else null
)
val isNewSubquery = queryFactory
.select(seriesContent.id)
.from(seriesContent)
.innerJoin(seriesContent.content, audioContent)
.where(
seriesContent.series.id.eq(series.id),
audioContent.isActive.isTrue,
if (!isAdult) audioContent.isAdult.isFalse else null,
audioContent.releaseDate.between(sevenDaysAgo, now)
)
.limit(1)
val results = queryFactory
.select(
series.id,
series.title,
seriesTranslation.renderedPayload,
series.coverImage,
series.state,
member.id,
member.nickname,
member.profileImage,
contentCountSubquery,
isNewSubquery.exists(),
series
)
.from(series)
.innerJoin(series.member, member)
.leftJoin(seriesTranslation).on(series.id.eq(seriesTranslation.seriesId), seriesTranslation.locale.eq(locale))
.where(where)
.having(contentCountSubquery.gt(0))
.orderBy(series.id.desc())
.offset(offset)
.limit(limit)
.fetch()
return results.map { row ->
val seriesId = row.get(series.id)!!
val originTitle = row.get(series.title)!!
val payload = row.get(seriesTranslation.renderedPayload)
val translatedTitle = payload?.title
val coverImage = row.get(series.coverImage)
val state = row.get(series.state)
val creatorId = row.get(member.id)!!
val nickname = row.get(member.nickname)!!
val profileImage = row.get(member.profileImage)
val numberOfContent = row.get(8, Long::class.java) ?: 0L
val isNew = row.get(9, Boolean::class.java) ?: false
val seriesEntity = row.get(series)!!
GetSeriesListResponse.SeriesListItem(
seriesId = seriesId,
title = if (translatedTitle.isNullOrBlank()) originTitle else translatedTitle,
coverImage = "$imageHost/$coverImage",
publishedDaysOfWeek = "", // Service layer will fill this
isComplete = state == SeriesState.COMPLETE,
creator = GetSeriesListResponse.SeriesListItemCreator(
creatorId = creatorId,
nickname = nickname,
profileImage = "$imageHost/$profileImage"
),
numberOfContent = numberOfContent.toInt(),
isNew = isNew,
rawPublishedDaysOfWeek = seriesEntity.publishedDaysOfWeek
)
}
}
override fun getOriginalAudioDramaTotalCount(isAdult: Boolean, contentType: ContentType): Int {

View File

@@ -55,12 +55,21 @@ class ContentSeriesService(
fun getOriginalAudioDramaList(
isAdult: Boolean,
contentType: ContentType,
orderByRandom: Boolean = false,
offset: Long = 0,
limit: Long = 20
): List<GetSeriesListResponse.SeriesListItem> {
val originalAudioDramaList = repository.getOriginalAudioDramaList(isAdult, contentType, orderByRandom, offset, limit)
return getTranslatedSeriesList(seriesToSeriesListItem(originalAudioDramaList, isAdult, contentType))
val originalAudioDramaList = repository.getOriginalAudioDramaList(
imageHost = coverImageHost,
isAdult = isAdult,
contentType = contentType,
locale = langContext.lang.code,
offset = offset,
limit = limit
)
return originalAudioDramaList.map { item ->
item.copy(publishedDaysOfWeek = publishedDaysOfWeekText(item.rawPublishedDaysOfWeek))
}
}
fun getGenreList(memberId: Long, isAdult: Boolean, contentType: ContentType): List<GetSeriesGenreListResponse> {

View File

@@ -1,10 +1,13 @@
package kr.co.vividnext.sodalive.content.series
import com.querydsl.core.annotations.QueryProjection
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
data class GetSeriesListResponse(
val totalCount: Int,
val items: List<SeriesListItem>
) {
data class SeriesListItem(
data class SeriesListItem @QueryProjection constructor(
val seriesId: Long,
val title: String,
val coverImage: String,
@@ -13,10 +16,11 @@ data class GetSeriesListResponse(
val creator: SeriesListItemCreator,
var numberOfContent: Int = 0,
var isNew: Boolean = false,
var isPopular: Boolean = false
var isPopular: Boolean = false,
val rawPublishedDaysOfWeek: Set<SeriesPublishedDaysOfWeek> = emptySet()
)
data class SeriesListItemCreator(
data class SeriesListItemCreator @QueryProjection constructor(
val creatorId: Long,
val nickname: String,
val profileImage: String