콘텐츠 테마 번역 N+1 제거
- 온라인 경로에서 콘텐츠 테마 번역을 배치 조회/번역/저장으로 처리. - 기존 번역은 IN 조회, 미번역만 한 번의 번역 요청 후 저장. - 결과 순서 보전, 번역 누락/실패 시 원문으로 폴백. - 공개 API 변경 없음.
This commit is contained in:
@@ -15,6 +15,10 @@ class AudioContentThemeQueryRepository(
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val cloudFrontHost: String
|
||||
) {
|
||||
data class ThemeIdAndName(
|
||||
val id: Long,
|
||||
val theme: String
|
||||
)
|
||||
fun getActiveThemes(): List<GetAudioContentThemeResponse> {
|
||||
return queryFactory
|
||||
.select(
|
||||
@@ -88,6 +92,69 @@ class AudioContentThemeQueryRepository(
|
||||
return query.fetch()
|
||||
}
|
||||
|
||||
fun getActiveThemeWithIdsOfContent(
|
||||
isAdult: Boolean = false,
|
||||
isFree: Boolean = false,
|
||||
isPointAvailableOnly: Boolean = false,
|
||||
contentType: ContentType
|
||||
): List<ThemeIdAndName> {
|
||||
var where = audioContent.isActive.isTrue
|
||||
.and(audioContentTheme.isActive.isTrue)
|
||||
|
||||
if (!isAdult) {
|
||||
where = where.and(audioContent.isAdult.isFalse)
|
||||
} else {
|
||||
if (contentType != ContentType.ALL) {
|
||||
where = where.and(
|
||||
audioContent.member.isNull.or(
|
||||
audioContent.member.auth.gender.eq(
|
||||
if (contentType == ContentType.MALE) {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isFree) {
|
||||
where = where.and(audioContent.price.loe(0))
|
||||
}
|
||||
|
||||
if (isPointAvailableOnly) {
|
||||
where = where.and(audioContent.isPointAvailable.isTrue)
|
||||
}
|
||||
|
||||
val query = queryFactory
|
||||
.select(audioContentTheme.id, audioContentTheme.theme)
|
||||
.from(audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.innerJoin(audioContent.theme, audioContentTheme)
|
||||
.where(where)
|
||||
.groupBy(audioContentTheme.id)
|
||||
|
||||
if (isFree) {
|
||||
query.orderBy(
|
||||
CaseBuilder()
|
||||
.`when`(audioContentTheme.theme.eq("자기소개")).then(0)
|
||||
.otherwise(1)
|
||||
.asc(),
|
||||
audioContentTheme.orders.asc()
|
||||
)
|
||||
} else {
|
||||
query.orderBy(audioContentTheme.orders.asc())
|
||||
}
|
||||
|
||||
return query.fetch().map { tuple ->
|
||||
ThemeIdAndName(
|
||||
id = tuple.get(audioContentTheme.id)!!,
|
||||
theme = tuple.get(audioContentTheme.theme)!!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun findThemeByIdAndActive(id: Long): AudioContentTheme? {
|
||||
return queryFactory
|
||||
.selectFrom(audioContentTheme)
|
||||
|
||||
@@ -5,6 +5,12 @@ import kr.co.vividnext.sodalive.content.AudioContentRepository
|
||||
import kr.co.vividnext.sodalive.content.ContentType
|
||||
import kr.co.vividnext.sodalive.content.SortType
|
||||
import kr.co.vividnext.sodalive.content.theme.content.GetContentByThemeResponse
|
||||
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslation
|
||||
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
|
||||
import kr.co.vividnext.sodalive.i18n.Lang
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
@@ -12,26 +18,94 @@ import org.springframework.transaction.annotation.Transactional
|
||||
@Service
|
||||
class AudioContentThemeService(
|
||||
private val queryRepository: AudioContentThemeQueryRepository,
|
||||
private val contentRepository: AudioContentRepository
|
||||
private val contentRepository: AudioContentRepository,
|
||||
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
|
||||
|
||||
private val papagoTranslationService: PapagoTranslationService,
|
||||
private val langContext: LangContext
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun getThemes(): List<GetAudioContentThemeResponse> {
|
||||
return queryRepository.getActiveThemes()
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@Transactional
|
||||
fun getActiveThemeOfContent(
|
||||
isAdult: Boolean = false,
|
||||
isFree: Boolean = false,
|
||||
isPointAvailableOnly: Boolean = false,
|
||||
contentType: ContentType
|
||||
): List<String> {
|
||||
return queryRepository.getActiveThemeOfContent(
|
||||
val themesWithIds = queryRepository.getActiveThemeWithIdsOfContent(
|
||||
isAdult = isAdult,
|
||||
isFree = isFree,
|
||||
isPointAvailableOnly = isPointAvailableOnly,
|
||||
contentType = contentType
|
||||
)
|
||||
|
||||
/**
|
||||
* langContext.lang == Lang.EN || Lang.JA 일 때 번역된 콘텐츠 테마 반환
|
||||
* 번역이 없으면 번역 API 호출 후 저장하고 반환
|
||||
*/
|
||||
val currentLang = langContext.lang
|
||||
if (currentLang == Lang.EN || currentLang == Lang.JA) {
|
||||
val targetLocale = currentLang.code
|
||||
// 1) 기존 번역을 한 번에 조회
|
||||
val ids = themesWithIds.map { it.id }
|
||||
val existingTranslations = if (ids.isNotEmpty()) {
|
||||
contentThemeTranslationRepository.findByContentThemeIdInAndLocale(ids, targetLocale)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val existingMap = existingTranslations.associateBy { it.contentThemeId }
|
||||
|
||||
// 2) 미번역 항목만 수집하여 한 번에 번역 요청
|
||||
val untranslatedPairs = themesWithIds.filter { existingMap[it.id] == null }
|
||||
|
||||
if (untranslatedPairs.isNotEmpty()) {
|
||||
val texts = untranslatedPairs.map { it.theme }
|
||||
|
||||
val response = papagoTranslationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = "ko",
|
||||
targetLanguage = targetLocale
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
val entitiesToSave = mutableListOf<ContentThemeTranslation>()
|
||||
|
||||
// translatedTexts 크기가 다르면 안전하게 원문으로 대체
|
||||
untranslatedPairs.forEachIndexed { index, pair ->
|
||||
val translated = translatedTexts.getOrNull(index) ?: pair.theme
|
||||
entitiesToSave.add(
|
||||
ContentThemeTranslation(
|
||||
contentThemeId = pair.id,
|
||||
locale = targetLocale,
|
||||
theme = translated
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (entitiesToSave.isNotEmpty()) {
|
||||
contentThemeTranslationRepository.saveAll(entitiesToSave)
|
||||
}
|
||||
|
||||
// 저장 후 맵을 갱신
|
||||
entitiesToSave.forEach { entity ->
|
||||
(existingMap as MutableMap)[entity.contentThemeId] = entity
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 원래 순서대로 결과 조립 (번역 없으면 원문 fallback)
|
||||
return themesWithIds.map { pair ->
|
||||
existingMap[pair.id]?.theme ?: pair.theme
|
||||
}
|
||||
}
|
||||
|
||||
return themesWithIds.map { it.theme }
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
|
||||
@@ -4,4 +4,6 @@ import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface ContentThemeTranslationRepository : JpaRepository<ContentThemeTranslation, Long> {
|
||||
fun findByContentThemeIdAndLocale(contentThemeId: Long, locale: String): ContentThemeTranslation?
|
||||
|
||||
fun findByContentThemeIdInAndLocale(contentThemeIds: Collection<Long>, locale: String): List<ContentThemeTranslation>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user