From f7fadf7ac71818d1ea936e264f7d7a826c75cd8f Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 9 Nov 2026 15:41:13 +0900 Subject: [PATCH] =?UTF-8?q?refactor(i18n):=20=EA=B3=B5=EA=B8=89=EC=9E=90?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=EA=B3=BC=20=EC=BA=90=EC=8B=9C=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=EC=9D=84=20=EA=B3=B5=ED=86=B5=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EB=A1=9C=20=EC=97=B0=EA=B2=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/content/LanguageDetectEvent.kt | 145 ++++---------- .../content/LanguageDetectionCacheService.kt | 68 ++++--- .../translation/PapagoTranslationService.kt | 24 +-- .../ResourceTranslationJobScheduler.kt | 2 +- .../translation/TranslationJobScheduler.kt | 6 +- .../i18n/translation/TranslationJobWorker.kt | 22 +-- .../TranslationReadModelMaterializer.kt | 19 +- .../CreatorCommunityTranslationService.kt | 2 +- .../CreatorCommunityLanguageDetectTest.kt | 27 ++- ...tyLanguageDetectionCacheConcurrencyTest.kt | 14 +- .../LanguageDetectionCacheServiceTest.kt | 28 ++- .../LanguageDetectionProviderTest.kt | 108 +++++++++++ .../LanguageProviderCacheEndToEndTest.kt | 178 ++++++++++++++++++ ...LanguageProviderSpringConfigurationTest.kt | 100 ++++++++++ .../translation/TranslationJobWorkerTest.kt | 14 +- .../CreatorCommunityTranslationServiceTest.kt | 9 +- 16 files changed, 545 insertions(+), 221 deletions(-) create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageDetectionProviderTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderCacheEndToEndTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderSpringConfigurationTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt index 669b8141..2ea6e6f0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt @@ -10,15 +10,11 @@ import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType -import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes +import kr.co.vividnext.sodalive.i18n.translation.TranslationLanguages.getTranslatableLanguageCodes import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Value import org.springframework.context.ApplicationEventPublisher import org.springframework.data.repository.findByIdOrNull -import org.springframework.http.HttpEntity -import org.springframework.http.HttpHeaders -import org.springframework.http.MediaType import org.springframework.scheduling.annotation.Async import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Propagation @@ -27,13 +23,11 @@ import org.springframework.transaction.event.TransactionPhase import org.springframework.transaction.event.TransactionalEventListener import org.springframework.transaction.support.TransactionSynchronization import org.springframework.transaction.support.TransactionSynchronizationManager -import org.springframework.util.LinkedMultiValueMap -import org.springframework.web.client.RestTemplate import javax.persistence.EntityManager import javax.persistence.LockModeType /** - * 텍스트 기반 데이터(콘텐츠, 댓글 등)에 대해 파파고 언어 감지를 요청하기 위한 이벤트. + * 텍스트 기반 데이터(콘텐츠, 댓글 등)에 대해 언어 감지를 요청하기 위한 이벤트. */ enum class LanguageDetectTargetType { CONTENT, @@ -56,10 +50,6 @@ class LanguageDetectEvent( val targetLanguage: String? = null ) -data class PapagoLanguageDetectResponse( - val langCode: String? -) - @Component class LanguageDetectListener( private val audioContentRepository: AudioContentRepository, @@ -75,27 +65,17 @@ class LanguageDetectListener( private val creatorCommunityTranslationService: CreatorCommunityTranslationService, private val entityManager: EntityManager, - private val applicationEventPublisher: ApplicationEventPublisher, - - @Value("\${cloud.naver.papago-client-id}") - private val papagoClientId: String, - - @Value("\${cloud.naver.papago-client-secret}") - private val papagoClientSecret: String + private val applicationEventPublisher: ApplicationEventPublisher ) { private val log = LoggerFactory.getLogger(LanguageDetectListener::class.java) - private val restTemplate: RestTemplate = RestTemplate() - - private val papagoDetectUrl = "https://papago.apigw.ntruss.com/langs/v1/dect" - @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Transactional(propagation = Propagation.REQUIRES_NEW) fun detectLanguage(event: LanguageDetectEvent) { if (event.query.isBlank()) { - log.debug("[PapagoLanguageDetect] query is blank. Skip language detection. event={}", event) + log.debug("[LanguageDetect] query is blank. Skip language detection. targetId={}", event.id) return } @@ -117,21 +97,21 @@ class LanguageDetectListener( val character = chatCharacterRepository.findById(characterId).orElse(null) if (character == null) { - log.warn("[PapagoLanguageDetect] ChatCharacter not found. characterId={}", characterId) + log.warn("[LanguageDetect] ChatCharacter not found. characterId={}", characterId) return } // 이미 언어 코드가 설정된 경우 호출하지 않음 if (!character.languageCode.isNullOrBlank()) { log.debug( - "[PapagoLanguageDetect] languageCode already set. Skip language detection. characterId={}, languageCode={}", + "[LanguageDetect] languageCode already set. Skip language detection. characterId={}, languageCode={}", characterId, character.languageCode ) return } - val langCode = detectLanguageCode(event, characterId) ?: return + val langCode = detectLanguageCode(event) ?: return character.languageCode = langCode chatCharacterRepository.save(character) @@ -144,7 +124,7 @@ class LanguageDetectListener( ) log.info( - "[PapagoLanguageDetect] languageCode updated from Papago. characterId={}, langCode={}", + "[LanguageDetect] languageCode updated. characterId={}, langCode={}", characterId, langCode ) @@ -155,21 +135,21 @@ class LanguageDetectListener( val audioContent = audioContentRepository.findById(contentId).orElse(null) if (audioContent == null) { - log.warn("[PapagoLanguageDetect] AudioContent not found. contentId={}", contentId) + log.warn("[LanguageDetect] AudioContent not found. contentId={}", contentId) return } // 이미 언어 코드가 설정된 경우 호출하지 않음 if (!audioContent.languageCode.isNullOrBlank()) { log.debug( - "[PapagoLanguageDetect] languageCode already set. Skip language detection. contentId={}, languageCode={}", + "[LanguageDetect] languageCode already set. Skip language detection. contentId={}, languageCode={}", contentId, audioContent.languageCode ) return } - val langCode = detectLanguageCode(event, contentId) ?: return + val langCode = detectLanguageCode(event) ?: return audioContent.languageCode = langCode @@ -184,7 +164,7 @@ class LanguageDetectListener( ) log.info( - "[PapagoLanguageDetect] languageCode updated from Papago. contentId={}, langCode={}", + "[LanguageDetect] languageCode updated. contentId={}, langCode={}", contentId, langCode ) @@ -195,27 +175,27 @@ class LanguageDetectListener( val comment = audioContentCommentRepository.findById(commentId).orElse(null) if (comment == null) { - log.warn("[PapagoLanguageDetect] AudioContentComment not found. commentId={}", commentId) + log.warn("[LanguageDetect] AudioContentComment not found. commentId={}", commentId) return } // 이미 언어 코드가 설정된 경우 호출하지 않음 if (!comment.languageCode.isNullOrBlank()) { log.debug( - "[PapagoLanguageDetect] languageCode already set. Skip language detection. commentId={}, languageCode={}", + "[LanguageDetect] languageCode already set. Skip language detection. commentId={}, languageCode={}", commentId, comment.languageCode ) return } - val langCode = detectLanguageCode(event, commentId) ?: return + val langCode = detectLanguageCode(event) ?: return comment.languageCode = langCode audioContentCommentRepository.save(comment) log.info( - "[PapagoLanguageDetect] languageCode updated from Papago. commentId={}, langCode={}", + "[LanguageDetect] languageCode updated. commentId={}, langCode={}", commentId, langCode ) @@ -226,14 +206,14 @@ class LanguageDetectListener( val comment = characterCommentRepository.findById(commentId).orElse(null) if (comment == null) { - log.warn("[PapagoLanguageDetect] CharacterComment not found. commentId={}", commentId) + log.warn("[LanguageDetect] CharacterComment not found. commentId={}", commentId) return } // 이미 언어 코드가 설정된 경우 호출하지 않음 if (!comment.languageCode.isNullOrBlank()) { log.debug( - "[PapagoLanguageDetect] languageCode already set. Skip language detection. " + + "[LanguageDetect] languageCode already set. Skip language detection. " + "characterCommentId={}, languageCode={}", commentId, comment.languageCode @@ -241,13 +221,13 @@ class LanguageDetectListener( return } - val langCode = detectLanguageCode(event, commentId) ?: return + val langCode = detectLanguageCode(event) ?: return comment.languageCode = langCode characterCommentRepository.save(comment) log.info( - "[PapagoLanguageDetect] languageCode updated from Papago. characterCommentId={}, langCode={}", + "[LanguageDetect] languageCode updated. characterCommentId={}, langCode={}", commentId, langCode ) @@ -258,27 +238,27 @@ class LanguageDetectListener( val cheers = creatorCheersRepository.findById(cheersId).orElse(null) if (cheers == null) { - log.warn("[PapagoLanguageDetect] CreatorCheers not found. cheersId={}", cheersId) + log.warn("[LanguageDetect] CreatorCheers not found. cheersId={}", cheersId) return } // 이미 언어 코드가 설정된 경우 호출하지 않음 if (!cheers.languageCode.isNullOrBlank()) { log.debug( - "[PapagoLanguageDetect] languageCode already set. Skip language detection. cheersId={}, languageCode={}", + "[LanguageDetect] languageCode already set. Skip language detection. cheersId={}, languageCode={}", cheersId, cheers.languageCode ) return } - val langCode = detectLanguageCode(event, cheersId) ?: return + val langCode = detectLanguageCode(event) ?: return cheers.languageCode = langCode creatorCheersRepository.save(cheers) log.info( - "[PapagoLanguageDetect] languageCode updated from Papago. cheersId={}, langCode={}", + "[LanguageDetect] languageCode updated. cheersId={}, langCode={}", cheersId, langCode ) @@ -289,21 +269,21 @@ class LanguageDetectListener( val series = seriesRepository.findByIdOrNull(seriesId) if (series == null) { - log.warn("[PapagoLanguageDetect] Series not found. seriesId={}", seriesId) + log.warn("[LanguageDetect] Series not found. seriesId={}", seriesId) return } // 이미 언어 코드가 설정된 경우 호출하지 않음 if (!series.languageCode.isNullOrBlank()) { log.debug( - "[PapagoLanguageDetect] languageCode already set. Skip language detection. seriesId={}, languageCode={}", + "[LanguageDetect] languageCode already set. Skip language detection. seriesId={}, languageCode={}", seriesId, series.languageCode ) return } - val langCode = detectLanguageCode(event, seriesId) ?: return + val langCode = detectLanguageCode(event) ?: return series.languageCode = langCode seriesRepository.save(series) @@ -316,7 +296,7 @@ class LanguageDetectListener( ) log.info( - "[PapagoLanguageDetect] languageCode updated from Papago. seriesId={}, langCode={}", + "[LanguageDetect] languageCode updated. seriesId={}, langCode={}", seriesId, langCode ) @@ -327,21 +307,21 @@ class LanguageDetectListener( val originalWork = originalWorkRepository.findByIdOrNull(originalWorkId) if (originalWork == null) { - log.warn("[PapagoLanguageDetect] OriginalWork not found. originalWorkId={}", originalWorkId) + log.warn("[LanguageDetect] OriginalWork not found. originalWorkId={}", originalWorkId) return } // 이미 언어 코드가 설정된 경우 호출하지 않음 if (!originalWork.languageCode.isNullOrBlank()) { log.debug( - "[PapagoLanguageDetect] languageCode already set. Skip language detection. originalWorkId={}, languageCode={}", + "[LanguageDetect] languageCode already set. Skip language detection. originalWorkId={}, languageCode={}", originalWorkId, originalWork.languageCode ) return } - val langCode = detectLanguageCode(event, originalWorkId) ?: return + val langCode = detectLanguageCode(event) ?: return originalWork.languageCode = langCode originalWorkRepository.save(originalWork) @@ -355,7 +335,7 @@ class LanguageDetectListener( ) log.info( - "[PapagoLanguageDetect] languageCode updated from Papago. originalWorkId={}, langCode={}", + "[LanguageDetect] languageCode updated. originalWorkId={}, langCode={}", originalWorkId, langCode ) @@ -367,7 +347,7 @@ class LanguageDetectListener( val category = categoryRepository.findByIdOrNull(categoryId) ?: return if (!category.languageCode.isNullOrBlank()) return - val langCode = detectLanguageCode(event, categoryId) ?: return + val langCode = detectLanguageCode(event) ?: return category.languageCode = langCode categoryRepository.save(category) @@ -395,7 +375,7 @@ class LanguageDetectListener( return } - val languageCode = detectLanguageCode(event, event.id) ?: return + val languageCode = detectLanguageCode(event) ?: return if (languageCode !in getTranslatableLanguageCodes(null)) return val currentCreatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(event.id) ?: return @@ -427,60 +407,7 @@ class LanguageDetectListener( ) } - private fun detectLanguageCode(event: LanguageDetectEvent, targetIdForLog: Long): String? { - return languageDetectionCacheService.detectWithCache(event.query) { - requestPapagoLanguageCode(event.query, targetIdForLog) - } - } - - private fun requestPapagoLanguageCode(query: String, targetIdForLog: Long): String? { - return try { - val headers = HttpHeaders().apply { - contentType = MediaType.APPLICATION_FORM_URLENCODED - set("X-NCP-APIGW-API-KEY-ID", papagoClientId) - set("X-NCP-APIGW-API-KEY", papagoClientSecret) - } - - val body = LinkedMultiValueMap().apply { - // 파파고 스펙에 따라 query 파라미터에 텍스트를 공백으로 구분하여 전달 - add("query", query) - } - - val requestEntity = HttpEntity(body, headers) - - val response = restTemplate.postForEntity( - papagoDetectUrl, - requestEntity, - PapagoLanguageDetectResponse::class.java - ) - - if (!response.statusCode.is2xxSuccessful) { - log.warn( - "[PapagoLanguageDetect] Non-success status from Papago. status={}, targetId={}", - response.statusCode, - targetIdForLog - ) - return null - } - - val langCode = response.body?.langCode?.takeIf { it.isNotBlank() } - if (langCode == null) { - log.warn( - "[PapagoLanguageDetect] langCode is null or blank in Papago response. targetId={}", - targetIdForLog - ) - return null - } - - langCode - } catch (ex: Exception) { - // 언어 감지는 부가 기능이므로, 실패 시 예외를 전파하지 않고 로그만 남긴다. - log.error( - "[PapagoLanguageDetect] Failed to detect language via Papago. targetId={}", - targetIdForLog, - ex - ) - null - } + private fun detectLanguageCode(event: LanguageDetectEvent): String? { + return languageDetectionCacheService.detectWithCache(event.query)?.detectedLanguage } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt index d1048470..57527013 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt @@ -1,50 +1,64 @@ package kr.co.vividnext.sodalive.content +import kr.co.vividnext.sodalive.i18n.translation.DetectedLanguage +import kr.co.vividnext.sodalive.i18n.translation.LanguageDetectionProvider +import kr.co.vividnext.sodalive.i18n.translation.LanguageProviderProperties import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @Service class LanguageDetectionCacheService( - private val languageDetectionResultRepository: LanguageDetectionResultRepository + private val languageDetectionResultRepository: LanguageDetectionResultRepository, + providers: List, + properties: LanguageProviderProperties ) { + private val providers = properties.orderedProviders(properties.detectionOrder, providers) { it.providerName } + @Transactional - fun detectWithCache( - query: String, - provider: String = DEFAULT_PROVIDER, - detector: () -> String? - ): String? { + fun detectWithCache(query: String): DetectedLanguage? { val normalizedQuery = SourceTextNormalizer.normalize(query) if (normalizedQuery.isBlank()) return null val sourceHash = SourceTextNormalizer.hash(normalizedQuery) - val cached = languageDetectionResultRepository.findBySourceHashAndProviderAndNormalizationVersion( - sourceHash = sourceHash, - provider = provider, - normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION - ) - if (cached != null) return cached.detectedLanguage - - val detectedLanguage = detector()?.takeIf { it.isNotBlank() }?.lowercase() ?: return null - languageDetectionResultRepository.insertIfAbsent( - sourceHash = sourceHash, - sourceTextSample = normalizedQuery.take(MAX_SAMPLE_LENGTH), - detectedLanguage = detectedLanguage, - provider = provider, - confidence = null, - normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION - ) - return requireNotNull( - languageDetectionResultRepository.findDetectedLanguageForUpdate( + providers.forEach { provider -> + val cached = languageDetectionResultRepository.findBySourceHashAndProviderAndNormalizationVersion( sourceHash = sourceHash, - provider = provider, + provider = provider.providerName, normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION ) - ) + if (cached != null && cached.detectedLanguage.isNotBlank()) { + return DetectedLanguage(cached.detectedLanguage, provider.providerName) + } + } + + providers.forEach { provider -> + val detectedLanguage = try { + provider.detect(query) + } catch (_: Exception) { + null + }?.takeIf { it.isNotBlank() }?.lowercase() ?: return@forEach + languageDetectionResultRepository.insertIfAbsent( + sourceHash = sourceHash, + sourceTextSample = normalizedQuery.take(MAX_SAMPLE_LENGTH), + detectedLanguage = detectedLanguage, + provider = provider.providerName, + confidence = null, + normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION + ) + val winner = requireNotNull( + languageDetectionResultRepository.findDetectedLanguageForUpdate( + sourceHash = sourceHash, + provider = provider.providerName, + normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION + ) + ) + return DetectedLanguage(winner, provider.providerName) + } + return null } companion object { - const val DEFAULT_PROVIDER = "papago" private const val MAX_SAMPLE_LENGTH = 500 } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt index 7a7ffe43..75b2a903 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt @@ -92,32 +92,10 @@ class PapagoTranslationService( } private fun validateLanguages(sourceLanguage: String, targetLanguage: String): Boolean { - return requireSupportedLanguage(sourceLanguage) && requireSupportedLanguage(targetLanguage) - } - - private fun requireSupportedLanguage(language: String): Boolean { - val normalized = language.lowercase() - return SUPPORTED_LANGUAGE_CODES.contains(normalized) + return TranslationLanguages.isSupported(sourceLanguage) && TranslationLanguages.isSupported(targetLanguage) } companion object { private const val MAX_REQUEST_TEXT_LENGTH = 5000 - - private val SUPPORTED_LANGUAGE_CODES = setOf( - "ko", - "en", - "ja" - ) - - /** - * 번역 대상 언어 코드 집합을 반환한다. - * - * @param excludedLanguageCode 번역 대상에서 제외할 언어 코드(대소문자 무시) - * @return 지원되는 언어 코드 중 [excludedLanguageCode]를 제외한 집합 - */ - fun getTranslatableLanguageCodes(excludedLanguageCode: String?): Set { - val normalized = excludedLanguageCode?.lowercase() - return SUPPORTED_LANGUAGE_CODES.filterNot { it == normalized }.toSet() - } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/ResourceTranslationJobScheduler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/ResourceTranslationJobScheduler.kt index 035b08ce..5c5013b4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/ResourceTranslationJobScheduler.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/ResourceTranslationJobScheduler.kt @@ -1,6 +1,6 @@ package kr.co.vividnext.sodalive.i18n.translation -import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes +import kr.co.vividnext.sodalive.i18n.translation.TranslationLanguages.getTranslatableLanguageCodes import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobScheduler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobScheduler.kt index bac92ec5..5f8f849a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobScheduler.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobScheduler.kt @@ -21,7 +21,7 @@ class TranslationJobScheduler( if (normalizedText.isBlank()) return val normalizedSourceLanguage = sourceLanguage.lowercase() - if (!SUPPORTED_SOURCE_LANGUAGE_CODES.contains(normalizedSourceLanguage)) return + if (!TranslationLanguages.isSupported(normalizedSourceLanguage)) return val normalizedTargetLanguage = targetLanguage.lowercase() if (normalizedSourceLanguage == normalizedTargetLanguage) return @@ -49,8 +49,4 @@ class TranslationJobScheduler( ) ) } - - companion object { - private val SUPPORTED_SOURCE_LANGUAGE_CODES = setOf("ko", "en", "ja") - } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorker.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorker.kt index 9513c383..46f3960c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorker.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorker.kt @@ -11,7 +11,7 @@ import java.time.LocalDateTime class TranslationJobWorker( private val translationJobRepository: TranslationJobRepository, private val translationMemoryRepository: TranslationMemoryRepository, - private val translationProvider: TranslationProvider, + private val translationCoordinator: TranslationCoordinator, private val materializer: TranslationReadModelMaterializer, transactionManager: PlatformTransactionManager ) { @@ -69,25 +69,17 @@ class TranslationJobWorker( } private fun ensureMemory(job: TranslationJob) { - val existing = translationMemoryRepository - .findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion( - sourceHash = job.sourceHash, - sourceLanguage = job.sourceLanguage, - targetLanguage = job.targetLanguage, - provider = translationProvider.providerName, - normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION - ) + val existing = translationCoordinator.findCached(job.sourceHash, job.sourceLanguage, job.targetLanguage) if (existing != null) return - val response = translationProvider.translate( + val response = translationCoordinator.translate( TranslateRequest( texts = listOf(job.sourceText), sourceLanguage = job.sourceLanguage, targetLanguage = job.targetLanguage ) ) - val translated = response.translatedText.firstOrNull()?.takeIf { it.isNotBlank() } - ?: throw IllegalStateException("empty translation result") + val translated = response.result.translatedText.single() transactionTemplate.executeWithoutResult { val memory = translationMemoryRepository @@ -95,7 +87,7 @@ class TranslationJobWorker( sourceHash = job.sourceHash, sourceLanguage = job.sourceLanguage, targetLanguage = job.targetLanguage, - provider = translationProvider.providerName, + provider = response.providerName, normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION ) if (memory == null) { @@ -106,8 +98,8 @@ class TranslationJobWorker( sourceLanguage = job.sourceLanguage, targetLanguage = job.targetLanguage, translatedText = translated, - provider = translationProvider.providerName, - providerVersion = translationProvider.providerVersion + provider = response.providerName, + providerVersion = response.providerVersion ) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationReadModelMaterializer.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationReadModelMaterializer.kt index 7adcc50b..c9fb5c87 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationReadModelMaterializer.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationReadModelMaterializer.kt @@ -29,7 +29,7 @@ import javax.persistence.LockModeType @Service class TranslationReadModelMaterializer( private val sourceExtractor: TranslationSourceExtractor, - private val translationMemoryRepository: TranslationMemoryRepository, + private val translationCoordinator: TranslationCoordinator, private val contentTranslationRepository: ContentTranslationRepository, private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, private val contentThemeTranslationRepository: ContentThemeTranslationRepository, @@ -71,14 +71,11 @@ class TranslationReadModelMaterializer( return@forEach } - val memory = translationMemoryRepository - .findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion( - sourceHash = SourceTextNormalizer.hash(normalizedText), - sourceLanguage = source.sourceLanguage.lowercase(), - targetLanguage = targetLanguage, - provider = DEFAULT_PROVIDER, - normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION - ) ?: return null + val memory = translationCoordinator.findCached( + sourceHash = SourceTextNormalizer.hash(normalizedText), + sourceLanguage = source.sourceLanguage.lowercase(), + targetLanguage = targetLanguage + ) ?: return null result[field.fieldKey] = memory.translatedText } @@ -231,8 +228,4 @@ class TranslationReadModelMaterializer( } return true } - - companion object { - const val DEFAULT_PROVIDER = "papago" - } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt index e51a61e0..7c1b4972 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt @@ -5,9 +5,9 @@ import kr.co.vividnext.sodalive.content.LanguageDetectTargetType import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType -import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer +import kr.co.vividnext.sodalive.i18n.translation.TranslationLanguages.getTranslatableLanguageCodes import kr.co.vividnext.sodalive.i18n.translation.TranslationReadModelMaterializer import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceExtractor import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslation diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt index 2ccd3785..fcb2c6d5 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt @@ -15,9 +15,14 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.Creato import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLikeRepository import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.i18n.translation.DetectedLanguage +import kr.co.vividnext.sodalive.i18n.translation.LanguageDetectionProvider +import kr.co.vividnext.sodalive.i18n.translation.LanguageProviderProperties import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType +import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer +import kr.co.vividnext.sodalive.i18n.translation.TranslationCoordinator import kr.co.vividnext.sodalive.i18n.translation.TranslationJobRepository import kr.co.vividnext.sodalive.i18n.translation.TranslationJobScheduler import kr.co.vividnext.sodalive.i18n.translation.TranslationMemory @@ -83,6 +88,9 @@ import javax.persistence.EntityManager CreatorCommunityTranslationService::class, TranslationSourceExtractor::class, TranslationReadModelMaterializer::class, + TranslationCoordinator::class, + LanguageProviderProperties::class, + PapagoTranslationService::class, ResourceTranslationJobScheduler::class, TranslationJobScheduler::class, CreatorCommunityLanguageDetectTest.AsyncConfiguration::class @@ -255,7 +263,7 @@ class CreatorCommunityLanguageDetectTest @Autowired constructor( sourceLanguage = "en", targetLanguage = "ko", translatedText = "translated detail content", - provider = TranslationReadModelMaterializer.DEFAULT_PROVIDER, + provider = "papago", providerVersion = "nmt-v1" ) ) @@ -719,7 +727,14 @@ class CreatorCommunityLanguageDetectTest @Autowired constructor( class ControlledLanguageDetectionCacheService( languageDetectionResultRepository: LanguageDetectionResultRepository -) : LanguageDetectionCacheService(languageDetectionResultRepository) { +) : LanguageDetectionCacheService( + languageDetectionResultRepository, + listOf(object : LanguageDetectionProvider { + override val providerName = "papago" + override fun detect(query: String): String? = null + }), + LanguageProviderProperties() +) { val detections = ConcurrentHashMap() val queries = ConcurrentLinkedQueue() private val blockingDetections = ConcurrentHashMap() @@ -740,19 +755,19 @@ class ControlledLanguageDetectionCacheService( return ConcurrentDetection(detectedLanguage).also { concurrentDetections[query] = it } } - override fun detectWithCache(query: String, provider: String, detector: () -> String?): String? { + override fun detectWithCache(query: String): DetectedLanguage? { queries.add(query) val concurrentDetection = concurrentDetections[query] if (concurrentDetection != null) { - return concurrentDetection.detect() + return DetectedLanguage(concurrentDetection.detect(), "papago") } val blockingDetection = blockingDetections[query] if (blockingDetection != null) { blockingDetection.started.countDown() check(blockingDetection.release.await(5, TimeUnit.SECONDS)) - return blockingDetection.detectedLanguage + return DetectedLanguage(blockingDetection.detectedLanguage, "papago") } - return detections[query] + return detections[query]?.let { DetectedLanguage(it, "papago") } } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectionCacheConcurrencyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectionCacheConcurrencyTest.kt index a5688946..8382c191 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectionCacheConcurrencyTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectionCacheConcurrencyTest.kt @@ -4,9 +4,13 @@ import kr.co.vividnext.sodalive.configs.QueryDslConfig import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository +import kr.co.vividnext.sodalive.i18n.translation.LanguageProviderProperties import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType +import kr.co.vividnext.sodalive.i18n.translation.PapagoLanguageDetectionProvider +import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer +import kr.co.vividnext.sodalive.i18n.translation.TranslationCoordinator import kr.co.vividnext.sodalive.i18n.translation.TranslationJobRepository import kr.co.vividnext.sodalive.i18n.translation.TranslationJobScheduler import kr.co.vividnext.sodalive.i18n.translation.TranslationJobStatus @@ -67,6 +71,10 @@ import java.util.concurrent.atomic.AtomicInteger QueryDslConfig::class, AudioContentThemeQueryRepository::class, LanguageDetectionCacheService::class, + LanguageProviderProperties::class, + PapagoLanguageDetectionProvider::class, + PapagoTranslationService::class, + TranslationCoordinator::class, LanguageDetectListener::class, CreatorCommunityTranslationService::class, TranslationSourceExtractor::class, @@ -82,6 +90,7 @@ class CreatorCommunityLanguageDetectionCacheConcurrencyTest @Autowired construct private val translationJobRepository: TranslationJobRepository, private val languageDetectionCacheService: LanguageDetectionCacheService, private val languageDetectListener: LanguageDetectListener, + private val papagoLanguageDetectionProvider: PapagoLanguageDetectionProvider, transactionManager: PlatformTransactionManager ) { private val requiresNewTransactionTemplate = TransactionTemplate(transactionManager).apply { @@ -92,8 +101,7 @@ class CreatorCommunityLanguageDetectionCacheConcurrencyTest @Autowired construct @BeforeEach fun setUp() { papagoRequests = ControlledPapagoRequestFactory() - val listener: LanguageDetectListener = AopTestUtils.getTargetObject(languageDetectListener) - ReflectionTestUtils.setField(listener, "restTemplate", RestTemplate(papagoRequests)) + ReflectionTestUtils.setField(papagoLanguageDetectionProvider, "restTemplate", RestTemplate(papagoRequests)) } @Test @@ -232,7 +240,7 @@ class CreatorCommunityLanguageDetectionCacheConcurrencyTest @Autowired construct val rows = requiresNewTransactionTemplate.execute { languageDetectionResultRepository.findAll().filter { it.sourceHash == SourceTextNormalizer.hash(content) && - it.provider == LanguageDetectionCacheService.DEFAULT_PROVIDER && + it.provider == "papago" && it.normalizationVersion == SourceTextNormalizer.NORMALIZATION_VERSION } }.orEmpty() diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheServiceTest.kt index 9125bb91..25314e53 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheServiceTest.kt @@ -1,5 +1,8 @@ package kr.co.vividnext.sodalive.content +import kr.co.vividnext.sodalive.i18n.translation.DetectedLanguage +import kr.co.vividnext.sodalive.i18n.translation.LanguageDetectionProvider +import kr.co.vividnext.sodalive.i18n.translation.LanguageProviderProperties import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName @@ -10,7 +13,8 @@ class LanguageDetectionCacheServiceTest { @Test fun shouldReuseCachedLanguageDetectionForSameNormalizedText() { val repository = Mockito.mock(LanguageDetectionResultRepository::class.java) - val service = LanguageDetectionCacheService(repository) + var providerCalls = 0 + val service = service(repository) { providerCalls++; "ko" } val sourceHash = SourceTextNormalizer.hash("Hello world") Mockito.`when`( @@ -30,13 +34,9 @@ class LanguageDetectionCacheServiceTest { ) ) - var providerCalls = 0 - val detected = service.detectWithCache("Hello world") { - providerCalls++ - "ko" - } + val detected = service.detectWithCache("Hello world") - assertEquals("en", detected) + assertEquals(DetectedLanguage("en", "papago"), detected) assertEquals(0, providerCalls) Mockito.verify(repository).findBySourceHashAndProviderAndNormalizationVersion( sourceHash = sourceHash, @@ -50,7 +50,7 @@ class LanguageDetectionCacheServiceTest { @DisplayName("최초 캐시 저장 경합에서는 저장된 승자의 감지 언어를 반환한다") fun shouldReturnStoredWinnerAfterInitialCacheInsert() { val repository = Mockito.mock(LanguageDetectionResultRepository::class.java) - val service = LanguageDetectionCacheService(repository) + val service = service(repository) { "en" } val sourceHash = SourceTextNormalizer.hash("Hello world") Mockito.`when`( @@ -78,9 +78,9 @@ class LanguageDetectionCacheServiceTest { ) ).thenReturn("ko") - val detected = service.detectWithCache("Hello world") { "en" } + val detected = service.detectWithCache("Hello world") - assertEquals("ko", detected) + assertEquals(DetectedLanguage("ko", "papago"), detected) Mockito.verify(repository).findBySourceHashAndProviderAndNormalizationVersion( sourceHash = sourceHash, provider = "papago", @@ -101,4 +101,12 @@ class LanguageDetectionCacheServiceTest { ) Mockito.verifyNoMoreInteractions(repository) } + + private fun service(repository: LanguageDetectionResultRepository, detect: () -> String?): LanguageDetectionCacheService { + val provider = object : LanguageDetectionProvider { + override val providerName = "papago" + override fun detect(query: String) = detect() + } + return LanguageDetectionCacheService(repository, listOf(provider), LanguageProviderProperties()) + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageDetectionProviderTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageDetectionProviderTest.kt new file mode 100644 index 00000000..9f05a9df --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageDetectionProviderTest.kt @@ -0,0 +1,108 @@ +package kr.co.vividnext.sodalive.i18n.translation + +import kr.co.vividnext.sodalive.content.LanguageDetectionCacheService +import kr.co.vividnext.sodalive.content.LanguageDetectionResult +import kr.co.vividnext.sodalive.content.LanguageDetectionResultRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito + +class LanguageDetectionProviderTest { + private val repository = Mockito.mock(LanguageDetectionResultRepository::class.java) + private val query = "original source" + private val hash = SourceTextNormalizer.hash(query) + + @Test + @DisplayName("감지 예외와 빈 결과는 다음 공급자로 넘기고 실제 공급자의 저장 승자를 반환한다") + fun shouldFallbackAndReturnCanonicalWinnerWithActualProvider() { + val failures: List<() -> String?> = listOf({ throw IllegalStateException("sensitive body") }, { null }, { " " }) + failures.forEach { failure -> + val first = RecordingDetectionProvider("first", failure) + val second = RecordingDetectionProvider("second") { "EN" } + Mockito.`when`(repository.findDetectedLanguageForUpdate(hash, "second", "v1")).thenReturn("ko") + + assertEquals(DetectedLanguage("ko", "second"), service(listOf(first, second)).detectWithCache(query)) + assertEquals(listOf(query), first.queries) + assertEquals(listOf(query), second.queries) + } + Mockito.verify(repository, Mockito.times(3)).insertIfAbsent(hash, "original source", "en", "second", null, "v1") + } + + @Test + @DisplayName("보조 공급자의 유효 캐시가 있으면 첫 공급자도 호출하지 않는다") + fun shouldCheckAllConfiguredCachesBeforeCallingAnyProvider() { + val first = RecordingDetectionProvider("first") { "ko" } + val second = RecordingDetectionProvider("second") { "en" } + Mockito.`when`(repository.findBySourceHashAndProviderAndNormalizationVersion(hash, "second", "v1")) + .thenReturn(LanguageDetectionResult(hash, "sample", "ja", "second", null, "v1")) + + assertEquals(DetectedLanguage("ja", "second"), service(listOf(first, second)).detectWithCache(query)) + assertEquals(emptyList(), first.queries + second.queries) + } + + @Test + @DisplayName("정상 미지원 언어 감지는 성공이며 후속 공급자를 호출하지 않는다") + fun shouldKeepUnsupportedDetectionAsSuccessAndHonorOrder() { + val first = RecordingDetectionProvider("first") { "ko" } + val second = RecordingDetectionProvider("second") { "FR" } + Mockito.`when`(repository.findDetectedLanguageForUpdate(hash, "second", "v1")).thenReturn("fr") + + assertEquals( + DetectedLanguage("fr", "second"), + service(listOf(first, second), listOf("second", "first")).detectWithCache(query) + ) + assertEquals(emptyList(), first.queries) + assertEquals(listOf(query), second.queries) + } + + @Test + @DisplayName("감지 전체 실패와 공백 입력은 null이고 공백은 저장소와 공급자를 호출하지 않는다") + fun shouldReturnNullAfterAllFailuresAndSkipBlankInput() { + val first = RecordingDetectionProvider("first") { null } + val second = RecordingDetectionProvider("second") { null } + val service = service(listOf(first, second)) + assertNull(service.detectWithCache(" \n")) + Mockito.verifyNoInteractions(repository) + assertNull(service.detectWithCache(query)) + assertEquals(listOf(query), first.queries) + assertEquals(listOf(query), second.queries) + } + + @Test + @DisplayName("감지 저장소 조회와 저장 오류는 다음 공급자로 전환하지 않고 전파한다") + fun shouldPropagateRepositoryFailuresWithoutFallback() { + val first = RecordingDetectionProvider("first") { "ko" } + val second = RecordingDetectionProvider("second") { "en" } + val service = service(listOf(first, second)) + val failure = IllegalStateException("database unavailable") + Mockito.`when`(repository.findBySourceHashAndProviderAndNormalizationVersion(hash, "first", "v1")) + .thenThrow(failure) + assertSame(failure, assertThrows(IllegalStateException::class.java) { service.detectWithCache(query) }) + assertEquals(emptyList(), first.queries + second.queries) + + Mockito.reset(repository) + Mockito.`when`(repository.insertIfAbsent(hash, "original source", "ko", "first", null, "v1")).thenThrow(failure) + assertSame(failure, assertThrows(IllegalStateException::class.java) { service.detectWithCache(query) }) + assertEquals(listOf(query), first.queries) + assertEquals(emptyList(), second.queries) + } + + private fun service(providers: List, order: List = providers.map { it.providerName }) = + LanguageDetectionCacheService(repository, providers, LanguageProviderProperties(detectionOrder = order.joinToString(","))) +} + +class RecordingDetectionProvider( + override val providerName: String, + private val response: () -> String? +) : LanguageDetectionProvider { + val queries = mutableListOf() + + override fun detect(query: String): String? { + queries.add(query) + return response() + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderCacheEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderCacheEndToEndTest.kt new file mode 100644 index 00000000..5cf02a4b --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderCacheEndToEndTest.kt @@ -0,0 +1,178 @@ +package kr.co.vividnext.sodalive.i18n.translation + +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.content.LanguageDetectionCacheService +import kr.co.vividnext.sodalive.content.LanguageDetectionResultRepository +import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslationRepository +import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.boot.test.mock.mockito.SpyBean +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionTemplate +import java.time.LocalDateTime +import java.util.UUID + +@DataJpaTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:language-provider-cache;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE", + "sodalive.language-providers.detection-order=papago,secondary", + "sodalive.language-providers.translation-order=papago,secondary" + ] +) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import( + QueryDslConfig::class, + AudioContentThemeQueryRepository::class, + LanguageProviderProperties::class, + LanguageDetectionCacheService::class, + TranslationCoordinator::class, + TranslationJobWorker::class, + TranslationReadModelMaterializer::class, + TranslationSourceExtractor::class, + TranslationJobScheduler::class, + ResourceTranslationJobScheduler::class, + CreatorCommunityTranslationService::class, + LanguageProviderCacheEndToEndTest.Providers::class +) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class LanguageProviderCacheEndToEndTest @Autowired constructor( + private val detector: LanguageDetectionCacheService, + private val detectionRepository: LanguageDetectionResultRepository, + private val memoryRepository: TranslationMemoryRepository, + private val scheduler: TranslationJobScheduler, + private val worker: TranslationJobWorker, + private val materializer: TranslationReadModelMaterializer, + private val displayService: CreatorCommunityTranslationService, + private val posts: CreatorCommunityRepository, + private val members: MemberRepository, + private val translations: CreatorCommunityTranslationRepository, + private val papagoDetection: RecordingDetectionProvider, + private val secondaryDetection: RecordingDetectionProvider, + private val papagoTranslation: RecordingTranslationProvider, + private val secondaryTranslation: RecordingTranslationProvider, + transactionManager: PlatformTransactionManager +) { + private val transaction = TransactionTemplate(transactionManager) + + @SpyBean + private lateinit var jobRepository: TranslationJobRepository + + @BeforeEach + fun useH2CompatibleClaimQuery() { + Mockito.doAnswer { + jobRepository.findAll().filter { it.status == TranslationJobStatus.PENDING }.mapNotNull { it.id } + }.`when`(jobRepository).findNextPendingGroupJobIdsForUpdate(Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()) + } + + @Test + @DisplayName("보조 공급자의 감지를 H2에 귀속 저장하고 외부 호출 없이 캐시를 재사용한다") + fun shouldPersistSecondaryDetectionAndReuseCache() { + val source = "provider source ${UUID.randomUUID()}" + assertEquals(DetectedLanguage("ko", "secondary"), detector.detectWithCache(source)) + assertEquals(DetectedLanguage("ko", "secondary"), detector.detectWithCache(source)) + val detection = transaction.execute { + detectionRepository.findBySourceHashAndProviderAndNormalizationVersion( + SourceTextNormalizer.hash(source), + "secondary", + "v1" + ) + }!! + assertEquals("secondary", detection.provider) + assertEquals("ko", detection.detectedLanguage) + assertEquals(1, papagoDetection.queries.size) + assertEquals(1, secondaryDetection.queries.size) + } + + @Test + @DisplayName("보조 공급자의 번역을 H2에 귀속 저장하고 무호출 캐시와 실제 표시를 재사용한다") + fun shouldPersistSecondaryTranslationAndReuseCacheThroughWorkerAndMaterializer() { + val source = "translation source ${UUID.randomUUID()}" + val postId = persistPost(source) + schedule(postId, source) + assertTrue(worker.processNextGroup()) + val memory = transaction.execute { + memoryRepository.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion( + SourceTextNormalizer.hash(source), + "ko", + "en", + "secondary", + "v1" + ) + } + assertNotNull(memory) + assertEquals("secondary", memory!!.provider) + assertEquals("secondary-v1", memory.providerVersion) + assertEquals("secondary:0", memory.translatedText) + assertEquals( + TranslationJobStatus.COMPLETED, + transaction.execute { jobRepository.findAll().single { it.resourceId == postId }.status } + ) + assertEquals("secondary:0", displayService.findDisplayContents(listOf(postId), "en")[postId]?.content) + + transaction.executeWithoutResult { + translations.deleteAll() + jobRepository.deleteAll() + } + assertTrue(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, "en")) + schedule(postId, source) + assertTrue(worker.processNextGroup()) + assertEquals(1, papagoTranslation.requests.size) + assertEquals(1, secondaryTranslation.requests.size) + assertEquals(listOf(source), secondaryTranslation.requests.single().texts) + assertEquals("secondary:0", displayService.findDisplayContents(listOf(postId), "en")[postId]?.content) + } + + private fun schedule(postId: Long, source: String) { + scheduler.scheduleMissingTranslation( + LanguageTranslationTargetType.CREATOR_COMMUNITY, + postId, + "content", + source, + "ko", + "en" + ) + } + + private fun persistPost(source: String): Long = transaction.execute { + val member = members.save( + Member(password = "password", nickname = "provider-${UUID.randomUUID()}", role = MemberRole.CREATOR) + ) + posts.save(CreatorCommunity(source, 0, true, false, languageCode = "ko").apply { this.member = member }).id!! + }!! + + @TestConfiguration + class Providers { + @Bean + fun papagoDetection() = RecordingDetectionProvider("papago") { throw IllegalStateException("sensitive provider body") } + + @Bean + fun secondaryDetection() = RecordingDetectionProvider("secondary") { "ko" } + + @Bean + fun papagoTranslation() = RecordingTranslationProvider("papago") { TranslateResult(emptyList()) } + + @Bean + fun secondaryTranslation() = RecordingTranslationProvider("secondary") + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderSpringConfigurationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderSpringConfigurationTest.kt new file mode 100644 index 00000000..50af9bf0 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderSpringConfigurationTest.kt @@ -0,0 +1,100 @@ +package kr.co.vividnext.sodalive.i18n.translation + +import kr.co.vividnext.sodalive.content.LanguageDetectionCacheService +import kr.co.vividnext.sodalive.content.LanguageDetectionResultRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.boot.test.context.runner.ApplicationContextRunner +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import + +class LanguageProviderSpringConfigurationTest { + private val runner = ApplicationContextRunner().withUserConfiguration(Providers::class.java) + + @Test + @DisplayName("실제 Spring 설정 바인딩으로 감지만 순서를 바꾸어도 번역 순서는 유지한다") + fun shouldBindIndependentCapabilityOrders() { + listOf("papago,secondary" to "papago", "secondary,papago" to "secondary").forEach { (order, expected) -> + runner.withPropertyValues( + "sodalive.language-providers.detection-order=$order", + "sodalive.language-providers.translation-order=papago,secondary" + ).run { context -> + assertNull(context.startupFailure) + val detector = context.getBean(LanguageDetectionCacheService::class.java) + assertEquals(expected, detector.detectWithCache("source")?.providerName) + assertEquals( + "papago", + context.getBean(TranslationCoordinator::class.java) + .translate(TranslateRequest(listOf("source"), "ko", "en")).providerName + ) + val secondary = context.getBean("secondaryDetection", RecordingDetectionProvider::class.java) + assertEquals(if (expected == "secondary") 1 else 0, secondary.queries.size) + assertEquals(0, context.getBean("secondaryTranslation", RecordingTranslationProvider::class.java).requests.size) + } + } + runner.withPropertyValues("sodalive.language-providers.translation-order=secondary,papago").run { context -> + assertNull(context.startupFailure) + assertEquals( + "papago", + context.getBean(LanguageDetectionCacheService::class.java).detectWithCache("source")?.providerName + ) + assertEquals( + "secondary", + context.getBean(TranslationCoordinator::class.java) + .translate(TranslateRequest(listOf("source"), "ko", "en")).providerName + ) + } + } + + @Test + @DisplayName("Spring 기본값은 두 기능 모두 파파고이며 잘못된 설정은 시작 시 실패한다") + fun shouldDefaultToPapagoAndRejectInvalidConfigurationAtStartup() { + runner.run { context -> + assertNull(context.startupFailure) + val properties = context.getBean(LanguageProviderProperties::class.java) + assertEquals(listOf("papago"), properties.detectionOrder) + assertEquals(listOf("papago"), properties.translationOrder) + } + listOf("detection-order", "translation-order").forEach { key -> + listOf("", "papago,papago", "unknown").forEach { value -> + runner.withPropertyValues("sodalive.language-providers.$key=$value").run { context -> + assertNotNull(context.startupFailure) + } + } + } + } + + @TestConfiguration + @Import(LanguageProviderProperties::class, TranslationCoordinator::class, LanguageDetectionCacheService::class) + class Providers { + @Bean + fun memoryRepository(): TranslationMemoryRepository = Mockito.mock(TranslationMemoryRepository::class.java) + + @Bean + fun detectionRepository(): LanguageDetectionResultRepository { + val repository = Mockito.mock(LanguageDetectionResultRepository::class.java) + listOf("papago", "secondary").forEach { provider -> + Mockito.`when`(repository.findDetectedLanguageForUpdate(SourceTextNormalizer.hash("source"), provider, "v1")) + .thenReturn("ko") + } + return repository + } + + @Bean + fun papagoDetection() = RecordingDetectionProvider("papago") { "ko" } + + @Bean + fun secondaryDetection() = RecordingDetectionProvider("secondary") { "ko" } + + @Bean + fun papagoTranslation() = RecordingTranslationProvider("papago") + + @Bean + fun secondaryTranslation() = RecordingTranslationProvider("secondary") + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorkerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorkerTest.kt index 1bf48ef7..b20baa7b 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorkerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorkerTest.kt @@ -30,7 +30,7 @@ class TranslationJobWorkerTest { val worker = TranslationJobWorker( translationJobRepository = jobRepository, translationMemoryRepository = memoryRepository, - translationProvider = provider, + translationCoordinator = TranslationCoordinator(listOf(provider), LanguageProviderProperties(), memoryRepository), materializer = materializer, transactionManager = TestTransactionManager() ) @@ -54,7 +54,7 @@ class TranslationJobWorkerTest { val worker = TranslationJobWorker( translationJobRepository = jobRepository, translationMemoryRepository = memoryRepository, - translationProvider = provider, + translationCoordinator = TranslationCoordinator(listOf(provider), LanguageProviderProperties(), memoryRepository), materializer = materializer, transactionManager = TestTransactionManager() ) @@ -84,7 +84,7 @@ class TranslationJobWorkerTest { val worker = TranslationJobWorker( translationJobRepository = jobRepository, translationMemoryRepository = memoryRepository, - translationProvider = provider, + translationCoordinator = TranslationCoordinator(listOf(provider), LanguageProviderProperties(), memoryRepository), materializer = materializer, transactionManager = TestTransactionManager() ) @@ -129,7 +129,7 @@ class TranslationJobWorkerTest { val worker = TranslationJobWorker( translationJobRepository = jobRepository, translationMemoryRepository = memoryRepository, - translationProvider = provider, + translationCoordinator = TranslationCoordinator(listOf(provider), LanguageProviderProperties(), memoryRepository), materializer = materializer, transactionManager = TestTransactionManager() ) @@ -165,7 +165,7 @@ class TranslationJobWorkerTest { val worker = TranslationJobWorker( translationJobRepository = jobRepository, translationMemoryRepository = memoryRepository, - translationProvider = provider, + translationCoordinator = TranslationCoordinator(listOf(provider), LanguageProviderProperties(), memoryRepository), materializer = materializer, transactionManager = TestTransactionManager() ) @@ -198,7 +198,7 @@ class TranslationJobWorkerTest { val worker = TranslationJobWorker( translationJobRepository = jobRepository, translationMemoryRepository = memoryRepository, - translationProvider = provider, + translationCoordinator = TranslationCoordinator(listOf(provider), LanguageProviderProperties(), memoryRepository), materializer = materializer, transactionManager = TestTransactionManager() ) @@ -213,7 +213,7 @@ class TranslationJobWorkerTest { assertEquals(TranslationJobStatus.PENDING, job.status) assertEquals(1, job.retryCount) - assertEquals("provider down", job.lastErrorMessage) + assertEquals("all translation providers failed", job.lastErrorMessage) assertTrue(job.nextRetryAt.isAfter(beforeRetryAt)) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/CreatorCommunityTranslationServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/CreatorCommunityTranslationServiceTest.kt index caf3cb0b..33d29e76 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/CreatorCommunityTranslationServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/CreatorCommunityTranslationServiceTest.kt @@ -19,9 +19,12 @@ import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslatio import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository +import kr.co.vividnext.sodalive.i18n.translation.LanguageProviderProperties import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType +import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer +import kr.co.vividnext.sodalive.i18n.translation.TranslationCoordinator import kr.co.vividnext.sodalive.i18n.translation.TranslationJob import kr.co.vividnext.sodalive.i18n.translation.TranslationJobRepository import kr.co.vividnext.sodalive.i18n.translation.TranslationJobScheduler @@ -668,7 +671,11 @@ class CreatorCommunityTranslationServiceTest @Autowired constructor( ): TranslationReadModelMaterializer { return TranslationReadModelMaterializer( sourceExtractor = sourceExtractor, - translationMemoryRepository = memoryRepository, + translationCoordinator = TranslationCoordinator( + listOf(PapagoTranslationService("test-id", "test-secret")), + LanguageProviderProperties(), + memoryRepository + ), contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java), aiCharacterTranslationRepository = Mockito.mock(AiCharacterTranslationRepository::class.java), contentThemeTranslationRepository = Mockito.mock(ContentThemeTranslationRepository::class.java),