From 320adb037c576290b0750f683c5c6613b8f355e2 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 9 Nov 2026 15:40:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(i18n):=20=EA=B0=90=EC=A7=80=C2=B7=EB=B2=88?= =?UTF-8?q?=EC=97=AD=20=EA=B3=B5=EA=B8=89=EC=9E=90=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=EA=B3=BC=20=EB=8F=85=EB=A6=BD=20=EC=88=9C=EC=84=9C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../i18n/translation/DetectedLanguage.kt | 3 + .../translation/LanguageDetectionProvider.kt | 7 + .../translation/LanguageProviderProperties.kt | 24 +++ .../PapagoLanguageDetectionProvider.kt | 38 ++++ .../translation/ProviderTranslationResult.kt | 7 + .../translation/TranslationCoordinator.kt | 50 ++++++ .../i18n/translation/TranslationLanguages.kt | 12 ++ src/main/resources/application.yml | 5 + .../LanguageProviderPropertiesTest.kt | 41 +++++ .../PapagoLanguageDetectionProviderTest.kt | 71 ++++++++ .../translation/TranslationCoordinatorTest.kt | 166 ++++++++++++++++++ 11 files changed, 424 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/DetectedLanguage.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageDetectionProvider.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderProperties.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoLanguageDetectionProvider.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/ProviderTranslationResult.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationCoordinator.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationLanguages.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderPropertiesTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoLanguageDetectionProviderTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationCoordinatorTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/DetectedLanguage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/DetectedLanguage.kt new file mode 100644 index 00000000..d7653ceb --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/DetectedLanguage.kt @@ -0,0 +1,3 @@ +package kr.co.vividnext.sodalive.i18n.translation + +data class DetectedLanguage(val detectedLanguage: String, val providerName: String) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageDetectionProvider.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageDetectionProvider.kt new file mode 100644 index 00000000..9ee23e74 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageDetectionProvider.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.i18n.translation + +interface LanguageDetectionProvider { + val providerName: String + + fun detect(query: String): String? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderProperties.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderProperties.kt new file mode 100644 index 00000000..00157910 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderProperties.kt @@ -0,0 +1,24 @@ +package kr.co.vividnext.sodalive.i18n.translation + +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component + +@Component +class LanguageProviderProperties( + @Value("\${sodalive.language-providers.detection-order:papago}") + detectionOrder: String = "papago", + @Value("\${sodalive.language-providers.translation-order:papago}") + translationOrder: String = "papago" +) { + val detectionOrder = detectionOrder.split(',').map { it.trim() } + val translationOrder = translationOrder.split(',').map { it.trim() } + + fun orderedProviders(order: List, providers: List, name: (T) -> String): List { + require(order.isNotEmpty() && order.none { it.isBlank() }) { "language provider order must not be empty" } + require(order.distinct().size == order.size) { "duplicate language provider order" } + val byName = providers.associateBy(name) + require(byName.size == providers.size) { "duplicate registered language provider name" } + require(order.all { it in byName }) { "unknown language provider in order" } + return order.map { byName.getValue(it) } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoLanguageDetectionProvider.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoLanguageDetectionProvider.kt new file mode 100644 index 00000000..f0af2808 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoLanguageDetectionProvider.kt @@ -0,0 +1,38 @@ +package kr.co.vividnext.sodalive.i18n.translation + +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.client.RestTemplate + +@Component +class PapagoLanguageDetectionProvider( + @Value("\${cloud.naver.papago-client-id}") + private val papagoClientId: String, + @Value("\${cloud.naver.papago-client-secret}") + private val papagoClientSecret: String +) : LanguageDetectionProvider { + private val restTemplate = RestTemplate() + override val providerName = "papago" + + override fun detect(query: String): String? { + 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 { add("query", query) } + val response = restTemplate.postForEntity( + "https://papago.apigw.ntruss.com/langs/v1/dect", + HttpEntity(body, headers), + PapagoLanguageDetectResponse::class.java + ) + if (!response.statusCode.is2xxSuccessful) return null + return response.body?.langCode?.takeIf { it.isNotBlank() } + } +} + +data class PapagoLanguageDetectResponse(val langCode: String?) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/ProviderTranslationResult.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/ProviderTranslationResult.kt new file mode 100644 index 00000000..0e022faa --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/ProviderTranslationResult.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.i18n.translation + +data class ProviderTranslationResult( + val result: TranslateResult, + val providerName: String, + val providerVersion: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationCoordinator.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationCoordinator.kt new file mode 100644 index 00000000..4aeec3fc --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationCoordinator.kt @@ -0,0 +1,50 @@ +package kr.co.vividnext.sodalive.i18n.translation + +import org.springframework.stereotype.Service + +@Service +class TranslationCoordinator( + providers: List, + properties: LanguageProviderProperties, + private val translationMemoryRepository: TranslationMemoryRepository +) { + private val providers = properties.orderedProviders(properties.translationOrder, providers) { it.providerName } + + fun findCached(sourceHash: String, sourceLanguage: String, targetLanguage: String): TranslationMemory? { + providers.forEach { provider -> + val memory = translationMemoryRepository + .findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion( + sourceHash, + sourceLanguage, + targetLanguage, + provider.providerName, + SourceTextNormalizer.NORMALIZATION_VERSION + ) + if (memory != null && memory.translatedText.isNotBlank()) return memory + } + return null + } + + fun translate(request: TranslateRequest): ProviderTranslationResult { + require( + request.texts.isNotEmpty() && request.texts.none { it.isBlank() } && + request.sourceLanguage.lowercase() != request.targetLanguage.lowercase() && + TranslationLanguages.isSupported(request.sourceLanguage) && + TranslationLanguages.isSupported(request.targetLanguage) + ) { "invalid translation request" } + providers.forEach { provider -> + val result = try { + provider.translate(request) + } catch (_: Exception) { + null + } + if ( + result != null && result.translatedText.size == request.texts.size && + result.translatedText.none { it.isBlank() } + ) { + return ProviderTranslationResult(result, provider.providerName, provider.providerVersion) + } + } + throw IllegalStateException("all translation providers failed") + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationLanguages.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationLanguages.kt new file mode 100644 index 00000000..a7dcdcd7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationLanguages.kt @@ -0,0 +1,12 @@ +package kr.co.vividnext.sodalive.i18n.translation + +object TranslationLanguages { + private val supportedLanguageCodes = setOf("ko", "en", "ja") + + fun isSupported(language: String): Boolean = language.lowercase() in supportedLanguageCodes + + fun getTranslatableLanguageCodes(excludedLanguageCode: String?): Set { + val normalized = excludedLanguageCode?.lowercase() + return supportedLanguageCodes.filterNot { it == normalized }.toSet() + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f9ba76d7..3cbb4da4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -19,6 +19,11 @@ management: show-details: never show-components: never +sodalive: + language-providers: + detection-order: ${LANGUAGE_PROVIDERS_DETECTION_ORDER:papago} + translation-order: ${LANGUAGE_PROVIDERS_TRANSLATION_ORDER:papago} + deployment: artifact-version: ${DEPLOY_ARTIFACT_VERSION:local} config-commit: ${DEPLOY_CONFIG_COMMIT:0000000000000000000000000000000000000000} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderPropertiesTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderPropertiesTest.kt new file mode 100644 index 00000000..5551576c --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderPropertiesTest.kt @@ -0,0 +1,41 @@ +package kr.co.vividnext.sodalive.i18n.translation + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +class LanguageProviderPropertiesTest { + @Test + @DisplayName("기능별 목록을 서로 독립된 설정 순서로 정렬한다") + fun shouldOrderCapabilitiesIndependently() { + val properties = LanguageProviderProperties("second,first", "first,second") + assertEquals( + listOf("second", "first"), + properties.orderedProviders(properties.detectionOrder, listOf("first", "second")) { it } + ) + assertEquals( + listOf("first", "second"), + properties.orderedProviders(properties.translationOrder, listOf("second", "first")) { it } + ) + } + + @Test + @DisplayName("빈 순서와 빈 이름과 중복 순서와 미등록 공급자는 초기화에서 거부한다") + fun shouldRejectInvalidOrders() { + val properties = LanguageProviderProperties() + listOf(emptyList(), listOf(""), listOf("first", "first"), listOf("unknown")).forEach { order -> + assertThrows(IllegalArgumentException::class.java) { + properties.orderedProviders(order, listOf("first", "second")) { it } + } + } + } + + @Test + @DisplayName("같은 기능 안에서 중복 등록된 공급자 이름은 초기화에서 거부한다") + fun shouldRejectDuplicateRegisteredNamesEvenWhenNotSelected() { + assertThrows(IllegalArgumentException::class.java) { + LanguageProviderProperties().orderedProviders(listOf("papago"), listOf("papago", "other", "other")) { it } + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoLanguageDetectionProviderTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoLanguageDetectionProviderTest.kt new file mode 100644 index 00000000..d5ad9eea --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoLanguageDetectionProviderTest.kt @@ -0,0 +1,71 @@ +package kr.co.vividnext.sodalive.i18n.translation + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.test.web.client.MockRestServiceServer +import org.springframework.test.web.client.match.MockRestRequestMatchers.content +import org.springframework.test.web.client.match.MockRestRequestMatchers.header +import org.springframework.test.web.client.match.MockRestRequestMatchers.method +import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo +import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus +import org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.client.RestTemplate + +class PapagoLanguageDetectionProviderTest { + @Test + @DisplayName("파파고 감지는 인증 헤더와 form query 원문을 보존한다") + fun shouldPreservePapagoFormContract() { + val provider = PapagoLanguageDetectionProvider("test-id", "test-secret") + val template = RestTemplate() + val server = MockRestServiceServer.bindTo(template).build() + ReflectionTestUtils.setField(provider, "restTemplate", template) + val form = LinkedMultiValueMap().apply { add("query", "Hello world & 안녕") } + server.expect(requestTo("https://papago.apigw.ntruss.com/langs/v1/dect")) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("X-NCP-APIGW-API-KEY-ID", "test-id")) + .andExpect(header("X-NCP-APIGW-API-KEY", "test-secret")) + .andExpect(content().contentType(MediaType("application", "x-www-form-urlencoded", Charsets.UTF_8))) + .andExpect(content().formData(form)) + .andRespond(withSuccess("""{"langCode":"en"}""", MediaType.APPLICATION_JSON)) + + assertEquals("en", provider.detect("Hello world & 안녕")) + server.verify() + } + + @Test + @DisplayName("빈 응답과 공백 감지와 HTTP 실패는 유효 감지값을 반환하지 않는다") + fun shouldReturnNullForInvalidResponses() { + listOf("", "{}", """{"langCode":" "}""").forEach { body -> + val provider = PapagoLanguageDetectionProvider("test-id", "test-secret") + val template = RestTemplate() + val server = MockRestServiceServer.bindTo(template).build() + ReflectionTestUtils.setField(provider, "restTemplate", template) + server.expect(requestTo("https://papago.apigw.ntruss.com/langs/v1/dect")) + .andRespond(withSuccess(body, MediaType.APPLICATION_JSON)) + assertNull(provider.detect("source")) + server.verify() + } + } + + @Test + @DisplayName("HTTP 실패는 coordinator에서 처리하도록 전파한다") + fun shouldExposeHttpFailureToCoordinator() { + val provider = PapagoLanguageDetectionProvider("test-id", "test-secret") + val template = RestTemplate() + val server = MockRestServiceServer.bindTo(template).build() + ReflectionTestUtils.setField(provider, "restTemplate", template) + server.expect(requestTo("https://papago.apigw.ntruss.com/langs/v1/dect")) + .andRespond(withStatus(HttpStatus.INTERNAL_SERVER_ERROR)) + org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.client.RestClientException::class.java) { + provider.detect("source") + } + server.verify() + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationCoordinatorTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationCoordinatorTest.kt new file mode 100644 index 00000000..8dbab3a3 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationCoordinatorTest.kt @@ -0,0 +1,166 @@ +package kr.co.vividnext.sodalive.i18n.translation + +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 TranslationCoordinatorTest { + private val repository = Mockito.mock(TranslationMemoryRepository::class.java) + private val request = TranslateRequest(listOf("first source", "second source"), "ko", "en") + + @Test + @DisplayName("설정 순서의 첫 성공 공급자와 버전을 반환하고 후속 호출을 멈춘다") + fun shouldUseConfiguredOrderAndStopAfterSuccess() { + val first = RecordingTranslationProvider("first") + val second = RecordingTranslationProvider("second") + val coordinator = coordinator(listOf(first, second), listOf("second", "first")) + + val result = coordinator.translate(request) + + assertEquals("second", result.providerName) + assertEquals("second-v1", result.providerVersion) + assertEquals(listOf("second:0", "second:1"), result.result.translatedText) + assertEquals(listOf(request), second.requests) + assertEquals(emptyList(), first.requests) + } + + @Test + @DisplayName("예외와 부분 결과와 공백 결과 뒤에는 전체 요청을 다음 공급자로 넘긴다") + fun shouldFallbackWholeRequestAfterExceptionsOrInvalidResults() { + val failures: List<(TranslateRequest) -> TranslateResult> = listOf( + { throw IllegalStateException("sensitive response body") }, + { TranslateResult(listOf("partial")) }, + { TranslateResult(listOf("translated", " ")) } + ) + failures.forEach { failure -> + val first = RecordingTranslationProvider("first", failure) + val second = RecordingTranslationProvider("second") + val coordinator = coordinator(listOf(first, second)) + + assertEquals("second", coordinator.translate(request).providerName) + assertEquals(listOf(request), first.requests) + assertEquals(listOf(request), second.requests) + } + } + + @Test + @DisplayName("전체 공급자 실패는 원문이나 원시 예외 원인 없이 고정 메시지를 반환한다") + fun shouldSanitizeAllProviderFailures() { + val first = RecordingTranslationProvider("first") { throw IllegalStateException("sensitive response body") } + val second = RecordingTranslationProvider("second") { TranslateResult(emptyList()) } + + val error = assertThrows(IllegalStateException::class.java) { + coordinator(listOf(first, second)).translate(request) + } + + assertEquals("all translation providers failed", error.message) + assertNull(error.cause) + assertEquals(1, first.requests.size) + assertEquals(1, second.requests.size) + } + + @Test + @DisplayName("빈 목록과 공백과 동일 언어와 미지원 언어는 공급자 호출 전에 거부한다") + fun shouldRejectInvalidRequestsBeforeCallingProviders() { + val provider = RecordingTranslationProvider("first") + val coordinator = coordinator(listOf(provider)) + listOf( + request.copy(texts = emptyList()), + request.copy(texts = listOf("source", " \t")), + request.copy(targetLanguage = "KO"), + request.copy(sourceLanguage = "fr"), + request.copy(targetLanguage = "fr") + ).forEach { invalid -> + val error = assertThrows(IllegalArgumentException::class.java) { coordinator.translate(invalid) } + assertEquals("invalid translation request", error.message) + assertNull(error.cause) + } + assertEquals(emptyList(), provider.requests) + } + + @Test + @DisplayName("모든 설정 공급자 캐시를 호출보다 먼저 조회하고 순서상 첫 유효 캐시를 선택한다") + fun shouldFindSecondaryCacheAndPreferConfiguredFirstValidCache() { + val first = RecordingTranslationProvider("first") + val second = RecordingTranslationProvider("second") + val coordinator = coordinator(listOf(first, second)) + val secondary = memory("second", "cached secondary") + givenMemory("second", secondary) + + assertSame(secondary, coordinator.findCached("hash", "ko", "en")) + givenMemory("first", memory("first", " ")) + assertSame(secondary, coordinator.findCached("hash", "ko", "en")) + val primary = memory("first", "cached primary") + givenMemory("first", primary) + assertSame(primary, coordinator.findCached("hash", "ko", "en")) + assertEquals(emptyList(), first.requests + second.requests) + } + + @Test + @DisplayName("캐시 저장소 오류는 공급자 실패로 숨기지 않는다") + fun shouldPropagateCacheRepositoryFailure() { + val provider = RecordingTranslationProvider("first") + val failure = IllegalStateException("database unavailable") + Mockito.`when`( + repository.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion( + "hash", + "ko", + "en", + "first", + "v1" + ) + ).thenThrow(failure) + + assertSame( + failure, + assertThrows(IllegalStateException::class.java) { coordinator(listOf(provider)).findCached("hash", "ko", "en") } + ) + assertEquals(emptyList(), provider.requests) + } + + private fun coordinator( + providers: List, + order: List = providers.map { it.providerName } + ) = TranslationCoordinator(providers, LanguageProviderProperties(translationOrder = order.joinToString(",")), repository) + + private fun memory(provider: String, text: String) = TranslationMemory( + "hash", + "source", + "ko", + "en", + text, + provider, + "$provider-v1" + ) + + private fun givenMemory(provider: String, memory: TranslationMemory) { + Mockito.`when`( + repository.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion( + "hash", + "ko", + "en", + provider, + "v1" + ) + ).thenReturn(memory) + } +} + +class RecordingTranslationProvider( + override val providerName: String, + private val response: (TranslateRequest) -> TranslateResult = { request -> + TranslateResult(request.texts.indices.map { "$providerName:$it" }) + } +) : TranslationProvider { + override val providerVersion = "$providerName-v1" + val requests = mutableListOf() + + override fun translate(request: TranslateRequest): TranslateResult { + requests.add(request) + return response(request) + } +}