From 320adb037c576290b0750f683c5c6613b8f355e2 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 9 Nov 2026 15:40:05 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat(i18n):=20=EA=B0=90=EC=A7=80=C2=B7?= =?UTF-8?q?=EB=B2=88=EC=97=AD=20=EA=B3=B5=EA=B8=89=EC=9E=90=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=EA=B3=BC=20=EB=8F=85=EB=A6=BD=20=EC=88=9C=EC=84=9C=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=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) + } +} -- 2.54.0 From f7fadf7ac71818d1ea936e264f7d7a826c75cd8f Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 9 Nov 2026 15:41:13 +0900 Subject: [PATCH 02/12] =?UTF-8?q?refactor(i18n):=20=EA=B3=B5=EA=B8=89?= =?UTF-8?q?=EC=9E=90=20=EC=A0=84=ED=99=98=EA=B3=BC=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EC=9D=84=20=EA=B3=B5=ED=86=B5=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=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), -- 2.54.0 From f3a558f659d66bf88237fb0069d4364f28b5e9a4 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 9 Nov 2026 15:43:23 +0900 Subject: [PATCH 03/12] =?UTF-8?q?docs(i18n):=20=EA=B3=B5=EA=B8=89=EC=9E=90?= =?UTF-8?q?=20=ED=99=95=EC=9E=A5=20=EB=B0=A9=EB=B2=95=EA=B3=BC=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 341 ++++++++++++++++++ docs/20261109_감지번역공급자공통화/prd.md | 81 +++++ .../provider-guide.md | 106 ++++++ .../reviews/phase-1-review.md | 45 +++ 4 files changed, 573 insertions(+) create mode 100644 docs/20261109_감지번역공급자공통화/plan-task.md create mode 100644 docs/20261109_감지번역공급자공통화/prd.md create mode 100644 docs/20261109_감지번역공급자공통화/provider-guide.md create mode 100644 docs/20261109_감지번역공급자공통화/reviews/phase-1-review.md diff --git a/docs/20261109_감지번역공급자공통화/plan-task.md b/docs/20261109_감지번역공급자공통화/plan-task.md new file mode 100644 index 00000000..dcfbb5de --- /dev/null +++ b/docs/20261109_감지번역공급자공통화/plan-task.md @@ -0,0 +1,341 @@ +# 언어 감지·번역 공급자 공통화 구현 계획 + +| 항목 | 내용 | +|---|---| +| 작성일 | 2026-11-09 | +| 상태 | 구현·검증·독립 리뷰·리소스 정리 완료 | +| 요구사항 | [prd.md](prd.md), LP-001~010 및 확정 구현 계약 | +| 활성 Goal | 없음 | + +## 목표와 제약 + +별도 감지·번역 인터페이스와 독립 공급자 순서 설정으로 새 구현체를 도메인 변경 없이 추가할 수 있게 한다. +기존 Kotlin 1.6.21 / Java 17 / Spring Boot 2.7.14 / RestTemplate / JPA를 사용한다. +AWS SDK·실제 adapter·새 의존성·DDL·공개 API·워커 스케줄/재시도·커뮤니티 revision 정책은 변경하지 않는다. +새 공급자 selector를 vendor처럼 등록하거나 실제 성공 결과를 selector 이름으로 저장하지 않는다. +별도 worktree 없이 현재 작업 트리에서 진행한다. 구현 위임 범위에서는 commit/staging/push하지 않는다. +부모 세션은 ULW의 명시적 커밋 지시에 따라 검증 완료 후 원자적 커밋을 만들며 push하지 않는다. + +## 실행 순서 + +공통 계약·설정 → 감지/번역 연결과 fixture 갱신 → 기능별 회귀·HTTP/DB QA → 독립 리뷰 순서다. +생산 코드/fixture 간 공유 의존성이 있어 구현 에이전트 한 명이 순차 진행한다. 부모는 별도 QA·최종 리뷰를 맡는다. + +### Phase 1: 공급자 독립 실행과 캐시 + +- [x] **Task 1.1: 공통 공급자 구조 구현 (P1-T1)** + +**Objective:** 두 기능의 독립 우선순위, 실패 전환, 실제 provider 저장과 공통 cache 선택을 완성한다. +**시작 조건:** PRD 확정, 기존 64건 baseline 통과, Oracle 계약 검토 및 Plan 순서 검토 완료. +**완료 증거:** 새 테스트의 의미 있는 RED/GREEN, 기존 fixture 회귀, 기능 범위 빌드/정적 검사. +**범위 밖:** 실 AWS 연동, 실행 중 설정 변경, 범용 plugin framework, 새로운 DB 경합 해결 정책. + +**Files (src/main/kotlin/kr/co/vividnext/sodalive 기준):** +- Create: `i18n/translation/LanguageProviderProperties.kt`, `LanguageDetectionProvider.kt`, `DetectedLanguage.kt`, + `ProviderTranslationResult.kt`, `TranslationLanguages.kt`, `PapagoLanguageDetectionProvider.kt`, `TranslationCoordinator.kt`. +- Modify: `content/LanguageDetectionCacheService.kt`, `content/LanguageDetectEvent.kt`. +- Modify: `i18n/translation/TranslationJobWorker.kt`, `TranslationReadModelMaterializer.kt`, `PapagoTranslationService.kt`, + `ResourceTranslationJobScheduler.kt`, `TranslationJobScheduler.kt`. +- Modify: `v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt` (언어 helper 참조만). +- Modify: `src/main/resources/application.yml` (독립 순서 설정과 환경변수). + +**Tests (src/test/kotlin/kr/co/vividnext/sodalive 기준):** +- Add focused tests in `i18n/translation/`: 공급자 순서 설정, `TranslationCoordinator`, `PapagoLanguageDetectionProvider`, + 독립 순서 Spring wiring 및 보조 공급자 캐시 저장/materialization 통합 시나리오. +- Update: `content/LanguageDetectionCacheServiceTest.kt`, `CreatorCommunityLanguageDetectTest.kt`, + `CreatorCommunityLanguageDetectionCacheConcurrencyTest.kt`. +- Update: `i18n/translation/TranslationJobWorkerTest.kt` 및 생성자 변경의 직접 영향 fixture. +- Update where required: `v2/creator/channel/community/translation/CreatorCommunityTranslationServiceTest.kt`, + `explorer/profile/creatorCommunity/CreatorCommunityTranslationEndToEndTest.kt`. +- Preserve: `i18n/translation/PapagoTranslationServiceTest.kt`의 17개 HTTP/chunk 회귀 assertion. + +**Interfaces:** PRD의 확정 구현 계약을 그대로 적용한다. 설정 validation은 각 capability의 등록 구현체 목록과 대조한다. +후보 구현체는 설정 순서로 정렬하고, 첫 성공 뒤 중단한다. catch 밖의 repository 오류는 그대로 전파한다. +DB 스키마를 유지하고 cache key의 provider 값에는 실제 성공 adapter 이름을 사용한다. + +- [x] **RED:** 기존 behavior를 보존하는 fixture 갱신 후, 두 가짜 공급자의 순서·실패 전환·저장 귀속 테스트를 작성한다. +- [x] **RED 확인:** 신규 타입의 unresolved symbol 오류는 RED로 계산하지 않는다. 필요한 최소 선언으로 컴파일한 뒤 + 누락된 순서/전환/캐시 동작 때문에 실패하는 assertion을 실제로 확인한다. +- [x] **GREEN:** PRD 계약을 충족하는 최소 두 coordinator 및 설정/adapter 연결을 구현한다. +- [x] **GREEN 확인:** 새 focused 테스트에서 독립 순서·캐시·폴백·모두 실패·설정 오류가 통과한다. +- [x] **REFACTOR:** 이번 변경의 중복만 정리하고 기존 focused·DB 경합·revision·chunk 테스트를 회귀 실행한다. + +**P1-T1 결과:** 새 테스트 21건과 기존 64건을 포함한 85건이 기능별 실행에서 모두 통과했다. +`LanguageDetectionCacheService`의 insertIfAbsent → locking read와 커뮤니티 경합·revision assertion은 유지했다. +번역 worker/materializer는 같은 coordinator cache lookup을 사용하며 저장 provider/version은 실제 성공 adapter 값이다. +기본 production bean은 Papago뿐이며 테스트 secondary는 `@Primary` 없이 list 주입으로 등록된다. +전체 테스트·운영 MySQL·실 API 호출은 수행하지 않았다. 명령/RED·GREEN/한계는 아래 실행 증거에 누적했다. + +**구현상 보존 항목:** +1. 감지 cache 트랜잭션, insertIfAbsent 이후 locking read 승자 반환, cache hit 무호출. +2. listener의 AFTER_COMMIT/REQUIRES_NEW, domain별 languageCode 가드, 커뮤니티 lock/refresh/revision 및 afterCommit 요청. +3. worker는 외부 번역을 DB 저장 트랜잭션 밖에서 실행하고, 성공 vendor 기준 저장 직전 재조회·저장한다. +4. materializer의 조회에는 외부 호출이 없고, 현재 원문 검증·read model 저장 방식은 유지한다. +5. 테스트의 latch·동시 경합·stale revision assertions는 삭제/완화하지 않는다. 새 seam으로 fixture만 옮긴다. + +**검증 명령:** +```bash +./gradlew --no-daemon --offline test --tests 'kr.co.vividnext.sodalive.i18n.translation.*' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' +./gradlew --no-daemon --offline test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectionCacheConcurrencyTest' +./gradlew --no-daemon --offline test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityTranslationEndToEndTest' +./gradlew --no-daemon --offline ktlintCheck bootJar +``` + +- [x] **Task 1.2: 확장 방법 문서화 (P1-T2)** + +**Objective:** 새 공급자 추가에 필요한 구현체·빈·설정·테스트 절차를 개발자가 바로 적용할 수 있게 한다. +**시작 조건:** 실제 구현 계약과 설정 key 확정. +**Files:** `docs/20261109_감지번역공급자공통화/provider-guide.md`. +**완료 증거:** 현재 설정과 일치하는 예시, 감지/번역별 순서 변경, 미등록 vendor 시작 실패, AWS 미구현 명시. +**TDD 예외:** 문서 변경이며 실제 코드/config/테스트와 대조한다. +**범위 밖:** 미구현 AWS 코드 예제를 작동 코드처럼 제공하거나 가짜 SDK 계약 작성. + +**P1-T2 결과:** `provider-guide.md`를 작성하고 실제 interface/생성자/config/test와 대조했다. +구현체+빈+해당 기능 순서 설정만으로 확장하며 기존 business 호출자는 수정하지 않는 경로를 문서화했다. +독립 환경변수, AWS/secondary의 현재 미구현·테스트 전용 경계, cache-first 및 오류·검증 한계를 명시했다. +문서 추가 후 별도 Gradle 실행은 하지 않았고 코드 검증은 P1-T1의 fresh 결과를 사용했다. + +- [x] **Phase Gate: 통합 검증·리뷰 (P1-GATE)** + +**Objective:** 가짜 공급자만 추가해 실패 전환·순서 교체·저장·재사용이 관측됨을 확인한다. +**시작 조건:** P1-T1/P1-T2 완료. +**완료 증거:** 아래 시나리오 통과, 독립 리뷰의 차단 이슈 없음, 임시 리소스 정리 영수증. +**TDD 예외:** 독립 실행 확인·리뷰로, 새 구현은 추가하지 않는다. + +| 시나리오 | 이진 통과 조건 | 표면 | +|---|---|---| +| S1 기존 동작 | 파파고 단독 및 기존 64건 회귀 유지 | 실제 서비스 HTTP mock + 기존 H2 integration | +| S2 독립 순서·전환 | 기능별 설정 교체가 다른 기능에 영향 없음, 첫 성공 이후 호출 0회 | Spring context와 fake adapters, 부모 driver | +| S3 저장·재사용 | 보조 공급자 실제 이름으로 저장, 이후 모든 외부 호출 0회, materializer 결과 일치 | 실제 H2 repository 재조회 | +| S4 실패·설정 | 모두 실패 시 안전한 결과, 빈/중복/unknown 설정 시작 실패, DB 오류를 공급자 실패로 숨기지 않음 | focused tests 및 부모 context driver | +| S5 파파고 감지 분리 | 감지 HTTP 형식과 결과를 유지하고 listener는 vendor를 모름 | 실제 RestTemplate loopback 또는 controlled HTTP 경계 | + +## 검증 기록 + +- 변경 전 baseline: 번역 package, 감지 cache, 커뮤니티 감지/경합 및 번역 service 60건 통과, exit 0, 1m38s. +- 추가 baseline: `CreatorCommunityTranslationEndToEndTest` 4건 통과, exit 0, 2m38s. 합계 64건. +- Oracle는 두 coordinator, cache-first, 실제 provider 귀속, 기존 DB/트랜잭션 보존 설계를 검토했다. +- LSP 도구 미제공. Kotlin 컴파일/ktlint로 보완하며 성공한 LSP 진단으로 표현하지 않는다. +- 전체 테스트는 실행하지 않는다. 사용자 명령: `./gradlew test`. +- RUN_TMP: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/language-providers.06uAbV`. + 모든 실행의 임시 자원은 이 작업 소유 root 아래에 두고 종료 시 삭제한다. 공유 Gradle 캐시나 다른 daemon은 건드리지 않는다. +- 구현 child root: `implementation.F9mSBa` (위 RUN_TMP 하위, parent 존재 확인 후 `mktemp -d` 생성). + 모든 shell 실행은 이 child로 TMPDIR/NODE_COMPILE_CACHE/BUN_RUNTIME_TRANSPILER_CACHE_PATH를 지정한다. + 정리 완료: 검증 프로세스 종료 확인 후 이 child만 삭제했다. 아래 영수증 참조. parent/shared cache는 유지한다. +- 실행 판정: 기존 승인 문서를 재사용하며 새 planning/worktree/리뷰 dispatch는 하지 않는다. P1-GATE는 부모가 판정한다. + +### P1-T1 실행 증거 (2026-11-09) + +모든 Gradle 실행은 `--no-daemon --offline`, tool timeout `600000ms`로 시작했다. 무필터 test/check/build는 실행하지 않았다. +아래 명령 전에 다음 환경변수를 child root로 지정했다. 로그는 child 안에서만 보관하며 정리 시 삭제한다. + +```bash +export TMPDIR=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/language-providers.06uAbV/implementation.F9mSBa +export NODE_COMPILE_CACHE="$TMPDIR" BUN_RUNTIME_TRANSPILER_CACHE_PATH="$TMPDIR" +``` + +**RED-1 (9건, 9실패, exit 1, 7m40s):** 컴파일 가능한 최소 계약과 첫 adapter만 호출하는 coordinator로 실행했다. +설정 순서·잘못된 설정 거부·폴백·안전한 전체 실패·guard·캐시 조회 누락 assertion이 실패했다. +unresolved symbol이나 컴파일 실패는 없었다. + +```bash +./gradlew --no-daemon --offline test --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationCoordinatorTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.LanguageProviderPropertiesTest' +``` + +**RED-2 / 부분 GREEN (19건, 7실패, exit 1, 9m01s):** RED-1의 9건은 GREEN. +감지의 다음 공급자 미호출·예외 전파·보조 cache-first 누락 3건이 행동 RED였다. +raw 감지 stub의 HTTP 미호출 3건과 Spring context의 쉼표 목록 변환 문제 1건도 관측했다. +설정은 `@Value String` → split/trim으로 고쳐 별도 변환 서비스 의존을 제거했다. + +```bash +./gradlew --no-daemon --offline test --tests 'kr.co.vividnext.sodalive.i18n.translation.LanguageDetectionProviderTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.PapagoLanguageDetectionProviderTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.LanguageProviderSpringConfigurationTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationCoordinatorTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.LanguageProviderPropertiesTest' +``` + +**H2 첫 실행 (4건, 2실패, exit 1, 3m27s):** Spring 독립 순서 2건 GREEN, 감지 fallback 누락은 행동 RED. +번역 테스트는 H2의 기존 MySQL `FOR UPDATE SKIP LOCKED` syntax 오류로 worker 결과까지 도달하지 못했으므로 +이 SQL 오류는 행동 RED로 계산하지 않았다. production query는 바꾸지 않고 test spy로 claim만 대체했다. + +```bash +./gradlew --no-daemon --offline test --tests 'kr.co.vividnext.sodalive.i18n.translation.LanguageProviderCacheEndToEndTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.LanguageProviderSpringConfigurationTest' +``` + +**H2 RED / 부분 GREEN (21건, 2실패, exit 1, 7m20s):** 감지 fallback과 H2 secondary 감지 귀속은 GREEN. +기존 worker가 Papago 실패 뒤 secondary를 호출하지 않아 secondary memory 재조회가 null인 행동 RED를 확인했다. +다른 1건은 HTTP form Content-Type에 실제 UTF-8 charset이 붙는 테스트 기대값 오류로, 행동 RED로 계산하지 않았다. +실제 요청의 charset을 정확히 기대하도록 수정했으며 공급자 요청 형식은 기존과 같다. + +```bash +./gradlew --no-daemon --offline test --tests 'kr.co.vividnext.sodalive.i18n.translation.LanguageProviderCacheEndToEndTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.LanguageDetectionProviderTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.PapagoLanguageDetectionProviderTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.LanguageProviderSpringConfigurationTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationCoordinatorTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.LanguageProviderPropertiesTest' +``` + +**GREEN 및 최종 회귀:** 아래 각 실행은 XML의 tests/failures/errors/skipped를 확인했다. 총 85건 = 기존 64 + 신규 21, +최종 각 실행 모두 failure/error/skipped 0, exit 0이다. 최초 package GREEN도 51건, 7m27s였다. + +| 실행 | 결과 | 소요 | +|---|---|---| +| 번역 package + 감지 cache (아래 첫 명령, 최종 재실행) | 51/51 | 45s | +| 커뮤니티 감지 + 최초 cache 경합 | 14/14 (12 + 2) | 2m23s | +| 커뮤니티 번역 service + 레거시 end-to-end | 20/20 (16 + 4) | 52s | +| ktlintCheck + bootJar | 성공, exit 0 | 1m20s | + +```bash +./gradlew --no-daemon --offline test --tests 'kr.co.vividnext.sodalive.i18n.translation.*' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' +./gradlew --no-daemon --offline test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectionCacheConcurrencyTest' +./gradlew --no-daemon --offline test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityTranslationEndToEndTest' +./gradlew --no-daemon --offline ktlintCheck bootJar +``` + +신규 21건의 구성은 TranslationCoordinator 6, 설정 unit 3, 감지 coordinator 5, Papago 감지 HTTP 3, +실제 Spring 설정 2, 실제 H2 저장/worker/materializer 2다. 기존 Papago chunk 17건은 수정 없이 통과했다. +`ktlintCheck` 초기 실행은 import/들여쓰기/줄 길이로 exit 1 (1m44s)이었으며 모든 수정을 apply_patch로 적용했다. +중간 ktlintCheck는 exit 0 (38s), 최종 ktlintCheck+bootJar도 exit 0이다. 자동 formatter는 사용하지 않았다. +기존 unrelated 테스트의 Kotlin unchecked cast/불필요한 `!!` 경고와 Gradle 9 deprecation 경고는 유지했다. + +**검증 한계/판정:** 제공된 LSP/diagnostics 도구는 없다. Kotlin 컴파일과 ktlint로 보완했다. +H2 integration은 실제 DB round-trip, worker 결과, materializer 표시와 무호출 cache 재사용을 검증하지만, +H2 미지원 MySQL claim SQL 한 곳은 test spy이며 운영 MySQL claim 동시성을 새로 증명하지 않는다. +실제 유료 API·운영 DB·전체 테스트는 실행하지 않았다. 사용자 전체 검증 명령은 `./gradlew test`다. +부모의 독립 Java driver와 ONE reviewer는 P1-GATE에서 수행하며 이 구현자가 대신 완료 표시하지 않는다. + +### 부모 Java QA용 실제 생성자/바인딩 + +`javap -classpath build/classes/kotlin/main`으로 아래 생성자와 메서드가 존재함을 확인했다. +클래스 패키지는 content service/listener를 제외하면 `kr.co.vividnext.sodalive.i18n.translation`이다. + +```text +LanguageProviderProperties(String detectionOrder, String translationOrder) +LanguageProviderProperties() // 두 기능 모두 papago +getDetectionOrder(): List +getTranslationOrder(): List +TranslationCoordinator(List, LanguageProviderProperties, TranslationMemoryRepository) +findCached(String sourceHash, String sourceLanguage, String targetLanguage): TranslationMemory? +translate(TranslateRequest): ProviderTranslationResult +LanguageDetectionCacheService(LanguageDetectionResultRepository, List, LanguageProviderProperties) +detectWithCache(String): DetectedLanguage? +PapagoLanguageDetectionProvider(String papagoClientId, String papagoClientSecret) +ProviderTranslationResult(TranslateResult, String providerName, String providerVersion) +DetectedLanguage(String detectedLanguage, String providerName) +TranslationJobWorker(TranslationJobRepository, TranslationMemoryRepository, TranslationCoordinator, + TranslationReadModelMaterializer, PlatformTransactionManager) +TranslationReadModelMaterializer(TranslationSourceExtractor, TranslationCoordinator, + ContentTranslationRepository, AiCharacterTranslationRepository, ContentThemeTranslationRepository, + SeriesTranslationRepository, SeriesGenreTranslationRepository, OriginalWorkTranslationRepository, + CategoryTranslationRepository, CreatorCommunityRepository, CreatorCommunityTranslationRepository, EntityManager) +``` + +`LanguageDetectListener`는 기존 repository/service/EntityManager/publisher 주입을 유지하고 마지막 Papago 인증 String 2개만 제거했다. +HTTP 테스트 reflection 대상은 listener가 아니라 `PapagoLanguageDetectionProvider.restTemplate`이다. +설정은 component의 두 생성자 인자에 각각 `@Value("\${sodalive.language-providers.detection-order:papago}")`, +`@Value("\${sodalive.language-providers.translation-order:papago}")`가 붙는다. +application.yml의 독립 환경변수는 `LANGUAGE_PROVIDERS_DETECTION_ORDER`, `LANGUAGE_PROVIDERS_TRANSLATION_ORDER`다. +Spring context 테스트는 두 가짜 bean의 실제 순서 변경과 빈/중복/unknown 설정 시작 실패를 확인한다. + +### 이 구현자가 변경한 전체 파일 + +생산 Kotlin 경로 prefix: `src/main/kotlin/kr/co/vividnext/sodalive/`. + +```text +A i18n/translation/DetectedLanguage.kt +A i18n/translation/LanguageDetectionProvider.kt +A i18n/translation/LanguageProviderProperties.kt +A i18n/translation/PapagoLanguageDetectionProvider.kt +A i18n/translation/ProviderTranslationResult.kt +A i18n/translation/TranslationCoordinator.kt +A i18n/translation/TranslationLanguages.kt +M content/LanguageDetectEvent.kt +M content/LanguageDetectionCacheService.kt +M i18n/translation/PapagoTranslationService.kt +M i18n/translation/ResourceTranslationJobScheduler.kt +M i18n/translation/TranslationJobScheduler.kt +M i18n/translation/TranslationJobWorker.kt +M i18n/translation/TranslationReadModelMaterializer.kt +M v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt +``` + +테스트 Kotlin 경로 prefix: `src/test/kotlin/kr/co/vividnext/sodalive/`. + +```text +A i18n/translation/LanguageDetectionProviderTest.kt +A i18n/translation/LanguageProviderCacheEndToEndTest.kt +A i18n/translation/LanguageProviderPropertiesTest.kt +A i18n/translation/LanguageProviderSpringConfigurationTest.kt +A i18n/translation/PapagoLanguageDetectionProviderTest.kt +A i18n/translation/TranslationCoordinatorTest.kt +M content/CreatorCommunityLanguageDetectTest.kt +M content/CreatorCommunityLanguageDetectionCacheConcurrencyTest.kt +M content/LanguageDetectionCacheServiceTest.kt +M i18n/translation/TranslationJobWorkerTest.kt +M v2/creator/channel/community/translation/CreatorCommunityTranslationServiceTest.kt +``` + +나머지 3개: `src/main/resources/application.yml` 수정, +`docs/20261109_감지번역공급자공통화/plan-task.md` 갱신, +`docs/20261109_감지번역공급자공통화/provider-guide.md` 추가. 합계 29개다. +부모가 만든 `prd.md`는 읽기만 했으며 이 구현자는 변경하지 않았다. +`CreatorCommunityTranslationEndToEndTest.kt`는 기존 생성자 영향이 없어 변경하지 않고 4건을 그대로 실행했다. +의존성·DDL·AWS 코드·git index/commit/branch/worktree는 변경하지 않았다. + +### 소유 임시 자원 정리 영수증 + +- 삭제 대상: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/language-providers.06uAbV/implementation.F9mSBa`. +- 모든 Gradle 명령이 종료된 뒤 `lsof -nP +D "$TMPDIR"`에서 열린 파일이 없음을 확인했다 (출력 없음, exit 1). + process 목록에서도 이 child를 사용하는 Gradle/test/compiler 프로세스가 없었다. 다른 프로젝트의 daemon은 종료하지 않았다. +- 정확한 child 경로를 `test`로 대조한 후 해당 child만 `rm -rf -- "$TMPDIR"`로 삭제했다. + 이어진 `test ! -e "$TMPDIR"`와 parent `ls -ld`가 exit 0이었다 (2026-11-09 15:22 KST). +- child의 일회성 로그/임시 파일만 제거했다. parent, 부모 QA 소유 형제 경로, 공유 Gradle/Kotlin cache는 유지했다. + 이 문서의 명령/횟수/실패 원인/최종 결과는 로그 삭제 전에 기록했다. + +### P1-GATE 부모 독립 실행 검증 + +- 부모가 최종 production/test diff, 신규 interface/coordinator/config 및 fixture 변환을 직접 읽었다. + 기존 concurrency/revision assertion은 삭제·완화되지 않았다. +- fresh 명령: 아래 실행에서 `:test`가 실제 재실행됐고 exit 0, `BUILD SUCCESSFUL in 2m`이었다. + ktlintCheck/bootJar는 직전 성공 산출물과 일치하여 UP-TO-DATE였다. + +```bash +./gradlew --no-daemon --offline test --rerun \ + --tests 'kr.co.vividnext.sodalive.i18n.translation.*' \ + --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' \ + --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' \ + --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectionCacheConcurrencyTest' \ + --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' \ + --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityTranslationEndToEndTest' \ + ktlintCheck bootJar +``` + +- 부모가 XML 15개를 직접 확인했다. 총 85건, failures/errors/skipped 모두 0. +- 별도 임시 Java `ProviderQa`를 Java 17로 컴파일하고 실제 Spring AnnotationConfigApplicationContext에서 실행했다. + 실제 공통 설정·coordinator와 테스트 전용 `papago`/`aws` adapter, 메모리 repository 대역을 사용했다. + 이 driver는 DB 검증이 아니며 DB 저장/재조회는 위 H2 테스트가 담당한다. + +| 부모 runtime QA | 관측 결과 | +|---|---| +| 감지 aws,papago / 번역 papago,aws | 감지 aws, 번역 papago만 호출 | +| 감지 papago,aws / 번역 aws,papago | 감지 papago, 번역 aws만 호출 | +| 파파고 예외 후 보조 성공 | detect:papago → detect:aws, translate:papago → translate:aws; 성공 provider는 aws | +| 보조 cache 재사용 | 감지·번역 provider 호출 0회 | +| 모든 provider 실패 | 감지 null, 번역 cause 없는 고정 실패 메시지 | +| unknown/duplicate/empty 설정 | 실제 Spring context 초기화 실패 | + +- driver 출력: `QA PASS: independent Spring-configured provider chains; all contexts closed; no external requests` (exit 0). +- 별도 `PapagoDetectionQa`에서 실제 추출된 Papago adapter와 RestTemplate을 loopback HTTP 서버에 연결했다. + test-only interceptor로 목적지를 127.0.0.1로 바꿨으며 운영 endpoint/설정은 변경하지 않았다. + 원문 공백·한글·&의 form 직렬화, POST, 가짜 인증 헤더, 성공 언어 반환, blank 결과 null, + HTTP 500 전파의 3개 응답 시나리오를 검증했다. + 출력: `QA PASS: 3 loopback HTTP scenarios; server stopped; no paid API requests` (exit 0). +- 모든 Spring context와 loopback 서버는 finally/try-with-resources에서 종료됐다. + 부모 driver·추출 라이브러리·로그는 최종 정리에서 소유 RUN_TMP만 제거한다. +- `git diff --check` exit 0. LSP·전체 테스트·실제 유료 API·운영 MySQL 미검증 범위는 기존 기록과 같다. +- 독립 최종 reviewer 판정: **APPROVE**, 확정 발견 사항 없음. 미추적 파일을 포함한 전체 변경과 + XML 15개(85건 통과)를 직접 대조했다. [리뷰 보고서](reviews/phase-1-review.md). +- 원자적 커밋은 (1) 공통 계약·순서 설정·raw 감지/coordinator와 해당 unit/HTTP 테스트, + (2) 기존 감지/worker/materializer 연결과 fixture·통합 테스트, (3) 요구사항·계획·가이드·리뷰 문서로 나눈다. + 첫 단위는 기존 호출자와 독립적으로 컴파일 가능하며, 두 번째 단위는 생성자/fixture 변경을 함께 보존한다. +- 코드 커밋: `320adb03` (공통 계약·설정·기반 테스트), `f7fadf7a` (기존 흐름 연결·캐시·통합 테스트). + 두 커밋의 변경 Kotlin pre-commit ktlint와 메시지 규칙 사전/사후 검사 모두 통과했다. + 코드 커밋 후 production/test 미커밋 diff가 없고 문서 디렉터리만 남음을 확인했다. push하지 않는다. +- 부모 소유 리소스 정리: 모든 driver/context/server/Gradle 실행이 종료됐고 `lsof -nP +D "$RUN_TMP"` 출력이 없었다. + 사전 생성·내용 확인한 `language-providers.06uAbV`만 삭제하고 `test ! -e` exit 0을 확인했다. + 출력: `CLEANUP PASS: owned language-providers.06uAbV removed; shared caches untouched`. + 임시 Java/class/추출 라이브러리/로그가 함께 제거됐으며 필요한 증거는 이 문서에 보존했다. + 작업 임시 노트도 문서에 이관 후 삭제한다. 다른 작업의 파일·프로세스·캐시에는 조치하지 않았다. diff --git a/docs/20261109_감지번역공급자공통화/prd.md b/docs/20261109_감지번역공급자공통화/prd.md new file mode 100644 index 00000000..b153495a --- /dev/null +++ b/docs/20261109_감지번역공급자공통화/prd.md @@ -0,0 +1,81 @@ +# 언어 감지·번역 공급자 공통화 PRD + +| 항목 | 내용 | +|---|---| +| 작성일 | 2026-11-09 | +| 상태 | 구현·기능 범위 검증·독립 리뷰 완료 | +| 승인 범위 | 감지·번역 별도 인터페이스, 공통 공급자 선택·실패 전환, 기능별 순서 설정 | +| 계획 | [plan-task.md](plan-task.md) | + +## 목적 + +파파고에 결합된 언어 감지·번역 호출을 분리한다. 이후 공급자는 해당 인터페이스 구현체와 빈·인증·순서 설정을 +추가하면 사용할 수 있어야 하며, 콘텐츠·댓글·커뮤니티 등 도메인 코드에 공급자 분기를 추가하지 않는다. + +## 범위와 제외 + +- 현재 실행되는 실제 구현체는 파파고뿐이다. AWS 실제 API·SDK 구현은 다음 작업이며 이번에는 추가하지 않는다. +- AWS 구현체가 등록된 뒤 기본 운영 의도는 파파고 → AWS이다. 감지와 번역 순서는 각각 독립 설정으로 바꾼다. +- 기본 실행 설정은 존재하는 파파고만 사용한다. 미구현 AWS를 등록한 것처럼 처리하거나 네트워크 호출하지 않는다. +- 기존 파파고 5,000 UTF-16 단위 분할, 지원 번역 언어 ko/en/ja, 공개 API, DB 스키마, job 재시도 정책은 유지한다. +- 감지 결과 저장 대상별 차이, 커뮤니티 원문 revision 검증, 이벤트 after-commit 처리 및 DB 경합 규칙을 유지한다. +- 실행 중 설정 변경, 관리 화면, 임의 플러그인 로딩, 새 번역 품질 정책, 기존 FAILED 작업 재처리는 제외한다. + +## 요구사항 + +| ID | 수용 기준 | +|---|---| +| LP-001 | 감지와 번역은 독립된 공급자 인터페이스이며, 둘 중 하나만 구현한 공급자도 해당 기능에 등록 가능하다. | +| LP-002 | 감지·번역의 공급자 순서를 서로 다른 설정으로 지정한다. 테스트 공급자 2개로 한쪽만 순서를 바꿔도 다른 쪽에 영향이 없음을 증명한다. | +| LP-003 | 공급자별 예외 또는 유효 결과 없음이면 다음 공급자를 순서대로 한 번 시도한다. 첫 성공 뒤 후속 공급자를 호출하지 않는다. | +| LP-004 | 모든 공급자 실패 시 감지는 기존처럼 결과 없음으로 처리하고 번역은 기존 워커 실패·재시도 경로를 따른다. | +| LP-005 | 캐시는 실제 성공 공급자 이름으로 저장한다. AWS 역할의 가짜 공급자 성공을 papago 이름으로 저장하지 않는다. | +| LP-006 | 설정된 공급자들의 유효 캐시를 우선 재사용한다. 새 외부 호출의 순서는 별도 설정 순서를 따른다. 워커와 결과 조립의 캐시 선택 정책이 같다. | +| LP-007 | 빈 원문·동일 언어·서비스 미지원 번역 언어는 불필요한 공급자 전환을 일으키지 않는다. 유효한 미지원 언어 감지 결과는 감지 장애와 구분한다. | +| LP-008 | 빈/중복/알 수 없는 공급자 순서 설정은 초기화 단계에서 명확히 거부한다. 새 구현체 추가 시 기존 선택 코드나 공급자 enum 수정이 필요 없다. | +| LP-009 | 기존 파파고 캐시·번역 데이터 및 분할 번역 동작과 감지 캐시 동시성 승자 반환을 보존한다. | +| LP-010 | 추가 방법과 기능별 순서 설정 예시, 실제 AWS 미구현 상태를 문서화한다. | + +## 보안·오류 처리 + +외부 HTTP 응답과 실행 설정이 신뢰 경계다. 부분 번역·빈 응답은 정상 결과로 저장하지 않는다. +원문, 인증정보, 공급자 응답 본문이 신규 로그나 워커 오류 문자열에 노출되지 않도록 한다. +공급자 식별자·순서 외에 임의 URL을 사용자 요청으로 받지 않는다. 유료 API 테스트는 하지 않는다. + +## 검증 시나리오 + +1. 파파고 단독 기본 설정에서 기존 감지·번역·캐시·분할·커뮤니티 동작이 유지된다. +2. 두 가짜 공급자로 감지/번역 순서 독립 변경, 첫 성공 중단, 실패 전환·전체 실패를 확인한다. +3. 가짜 보조 공급자의 감지/번역 결과가 실제 provider로 DB에 저장되고 다음 요청과 materializer가 재사용한다. +4. 잘못된 설정의 초기화 실패, 원문 정책 가드, 캐시 동시성·revision 회귀를 검증한다. + +## 검증 범위 + +변경 전 관련 focused 테스트 60건과 추가 end-to-end 4건 통과를 확인했다. +구현 후 새 계약 테스트를 포함한 85건 및 부모 런타임·HTTP 검증을 통과했다. 상세 증거는 계획 문서에 기록한다. +전체 테스트는 저장소 정책에 따라 사용자 실행(`./gradlew test`)이며, 운영 DDL·실제 AWS/Papago 계정 호출은 제외한다. +LSP 도구가 제공되지 않으면 Kotlin 컴파일과 ktlint로 보완하고 그 한계를 기록한다. + +## 확정 구현 계약 + +- 기존 `TranslationProvider`와 raw `TranslateResult`는 유지한다. +- `LanguageDetectionProvider`: `providerName: String`, `detect(query: String): String?`. +- `DetectedLanguage`: `detectedLanguage: String`, `providerName: String`. +- `ProviderTranslationResult`: `result: TranslateResult`, `providerName: String`, `providerVersion: String`. +- 기존 `LanguageDetectionCacheService.detectWithCache(query: String): DetectedLanguage?`가 감지 선택·캐시·전환을 담당한다. + 기존 callback overload는 남기지 않고 모든 테스트 fixture도 새 계약에 맞춘다. +- `TranslationCoordinator.findCached(sourceHash, sourceLanguage, targetLanguage): TranslationMemory?`를 + worker와 materializer가 함께 사용한다. 캐시 선택만 통일하며 DB 저장 트랜잭션은 기존 worker에 남긴다. +- `TranslationCoordinator.translate(request: TranslateRequest): ProviderTranslationResult`가 실제 성공 공급자를 반환한다. + 결과 개수 불일치·blank 결과·공급자 예외는 다음 공급자로 전환한다. 부분 결과는 섞지 않고 전체 요청을 넘긴다. +- 새 번역 coordinator의 빈 입력/blank 텍스트/동일 언어/미지원 언어 요청은 고정 메시지의 `IllegalArgumentException`으로 + 외부 호출 전에 거부한다. 기존 Papago raw adapter 가드는 그대로 유지한다. +- 전체 번역 공급자 실패는 원문·원시 예외를 포함하지 않는 `IllegalStateException("all translation providers failed")`이다. + 감지 전체 실패는 null이다. catch는 adapter 호출에만 적용하고 DB 실패를 공급자 실패로 처리하지 않는다. +- `sodalive.language-providers.detection-order`와 `translation-order`는 각각 기본 `papago`이다. + 순서는 재시작/재배포 시 반영한다. 미등록 공급자는 설정 오류로 거부하며 AWS 구현 전에는 `aws`를 활성화하지 않는다. +- 감지 캐시의 정규화/hash 규칙과 `insertIfAbsent` 다음 `findDetectedLanguageForUpdate` 승자 반환을 유지한다. + adapter에는 기존처럼 원래 query를 전달한다. 유효한 ko/en/ja 밖 감지 결과는 공급자 실패로 재해석하지 않는다. +- 공통 `TranslationLanguages`로 서비스 언어 정책만 옮긴다. Papago 분할은 Papago adapter에 유지한다. +- 현재 entity에는 공급자별 cache key가 있으므로 DDL 변경 없이 실제 공급자를 기록한다. + 캐시 두 개가 있으면 설정상 앞선 결과를 선택하며 특정 job의 공급자를 영구 고정하는 새 모델은 만들지 않는다. diff --git a/docs/20261109_감지번역공급자공통화/provider-guide.md b/docs/20261109_감지번역공급자공통화/provider-guide.md new file mode 100644 index 00000000..4acfe4b0 --- /dev/null +++ b/docs/20261109_감지번역공급자공통화/provider-guide.md @@ -0,0 +1,106 @@ +# 언어 감지·번역 공급자 확장 가이드 + +## 현재 실행 구조 + +운영 구현체는 `PapagoLanguageDetectionProvider`와 `PapagoTranslationService`뿐이다. +AWS 감지·번역 adapter, 해당 SDK 및 전용 인증 설정은 추가하지 않았다. 지금 `aws`를 순서에 넣으면 시작에 실패한다. + +감지와 번역은 별도 capability다. 감지만 또는 번역만 제공하는 공급자도 등록할 수 있다. +두 capability에서 같은 `providerName`을 쓰는 것은 가능하지만, 한 capability 안의 이름 중복은 시작 시 거부한다. + +| 경계 | 계약 | +|---|---| +| `LanguageDetectionProvider` | `providerName: String`, `detect(query: String): String?` | +| `LanguageDetectionCacheService` | `detectWithCache(query: String): DetectedLanguage?` | +| `DetectedLanguage` | `detectedLanguage: String`, `providerName: String` | +| `TranslationProvider` | `providerName: String`, `providerVersion: String`, `translate(TranslateRequest): TranslateResult` | +| `TranslationCoordinator` | `findCached(sourceHash, sourceLanguage, targetLanguage): TranslationMemory?`, `translate(TranslateRequest): ProviderTranslationResult` | +| `ProviderTranslationResult` | `result: TranslateResult`, `providerName: String`, `providerVersion: String` | + +인터페이스와 DTO는 `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/`에 있다. +감지 coordinator는 기존 `content/LanguageDetectionCacheService.kt`이며 별도 vendor가 아니다. +`TranslationCoordinator`도 `TranslationProvider`를 구현하지 않는다. + +## 새 공급자 추가 순서 + +1. 필요한 capability 인터페이스를 구현하고 Spring `@Component`/`@Service` 또는 `@Bean`으로 등록한다. + `providerName`은 capability 내 유일하고 재배포 후에도 안정적인 값이어야 한다. +2. 공급자의 HTTP/SDK 및 인증은 adapter 안에 둔다. 외부 응답 형식과 빈 결과를 검증하고, + 원문·인증정보·응답 본문·예외 cause를 로그로 출력하지 않는다. 번역은 입력별 결과를 같은 순서로 반환한다. +3. 구현한 capability의 설정 순서에만 이름을 넣고 재시작/재배포한다. `@Primary`, selector 분기, enum 등록은 필요 없다. +4. 아래 테스트 경계로 순서·실패 전환·실제 provider 저장·무호출 캐시 재사용·materializer 표시를 검증한다. + +콘텐츠·댓글·커뮤니티 호출자, worker, materializer, scheduler에 공급자 분기를 추가하지 않는다. +기존 두 coordinator는 주입된 구현체 목록과 설정만 사용한다. 공급자마다 다른 분할·요청 크기 제한은 adapter 책임이다. +Papago의 5,000 UTF-16 단위 분할과 서로게이트 쌍 보존은 `PapagoTranslationService`에 남아 있다. +ko/en/ja 서비스 정책은 `TranslationLanguages`에 있으며 공급자를 추가해도 자동 확장하지 않는다. + +## 기능별 독립 설정 + +`LanguageProviderProperties`는 component scan으로 등록되며 생성자 `@Value`로 문자열을 주입받는다. +별도 `@EnableConfigurationProperties` 등록은 필요 없다. 쉼표로 나누고 각 이름의 앞뒤 공백을 제거한다. +기본값은 두 기능 모두 `papago`다. + +```yaml +sodalive: + language-providers: + detection-order: ${LANGUAGE_PROVIDERS_DETECTION_ORDER:papago} + translation-order: ${LANGUAGE_PROVIDERS_TRANSLATION_ORDER:papago} +``` + +다음은 **secondary 구현체를 두 capability에 등록한 뒤에만** 적용할 수 있는 예시다. +현재 `secondary` 구현체는 테스트에만 있다. + +```bash +export LANGUAGE_PROVIDERS_DETECTION_ORDER=secondary,papago +export LANGUAGE_PROVIDERS_TRANSLATION_ORDER=papago,secondary +``` + +감지만 순서를 바꾸어도 번역 순서는 바뀌지 않는다. 반대로 번역만 바꿀 수도 있다. +Spring property를 직접 지정할 때는 `sodalive.language-providers.detection-order`와 +`sodalive.language-providers.translation-order`를 각각 설정한다. +빈 값·빈 항목·중복 이름·미등록 이름·capability 안의 중복 bean 이름표(`providerName`)는 초기화에서 거부한다. +공급자가 번역에만 등록됐다면 그 이름을 감지 순서에 넣을 수 없다. +실행 중 설정 변경은 지원하지 않는다. AWS 구현체 등록 전 `papago,aws`는 사용할 수 없다. + +## 캐시와 오류 계약 + +감지는 정규화/hash/normalization version을 기존과 같이 사용하고 adapter에는 원래 query를 전달한다. +설정된 모든 공급자의 캐시를 순서대로 조회한 뒤, 유효 캐시가 하나도 없을 때만 외부 호출을 시작한다. +저장은 실제 성공 공급자로 `insertIfAbsent`하고 이어지는 locking read의 승자를 반환한다. +ko/en/ja 밖의 유효 감지 결과도 성공이다. 커뮤니티의 기존 지원 언어 gate는 별도로 유지된다. + +번역 worker와 materializer는 동일한 `TranslationCoordinator.findCached`를 사용한다. +앞선 공급자의 캐시가 없어도 뒤 공급자의 유효 캐시가 있으면 외부 호출을 하지 않는다. +여러 공급자 캐시가 있으면 설정 순서상 첫 유효 결과를 선택한다. job을 특정 vendor에 영구 고정하지 않는다. +cache key와 DDL은 바꾸지 않았으며 `providerVersion`은 기록 필드이지 cache key가 아니다. +외부 번역은 worker의 저장 트랜잭션 밖에서 실행하고, 실제 성공 공급자로 저장 직전 재조회한다. +materializer는 외부 번역을 호출하지 않는다. + +adapter 예외·null 감지·빈 감지·번역 결과 개수 불일치·blank 번역 결과는 다음 공급자로 전환한다. +번역은 부분 결과를 섞지 않고 원래 요청 전체를 다음 공급자에게 한 번 넘긴다. 첫 성공 이후에는 중단한다. +저장소 조회·저장 오류는 adapter 실패로 catch하지 않는다. +감지가 모두 실패하면 `null`, 번역이 모두 실패하면 cause 없는 +`IllegalStateException("all translation providers failed")`를 반환하며 기존 job 재시도 경로를 따른다. +빈 목록·blank 텍스트·동일 언어(대소문자 무시)·미지원 번역 언어는 외부 호출 전에 +`IllegalArgumentException("invalid translation request")`로 거부한다. raw Papago의 기존 가드는 유지한다. + +## 검증 경계 + +| 테스트 | 검증 내용 | +|---|---| +| `LanguageProviderSpringConfigurationTest` | 실제 `@Value` binding, 두 test bean, 기능별 독립 순서, 기본값과 잘못된 설정의 시작 실패 | +| `LanguageProviderPropertiesTest` | 순서 검증 및 선택되지 않은 bean을 포함한 capability 내 중복 이름 거부 | +| `LanguageDetectionProviderTest` / `TranslationCoordinatorTest` | 전체 요청 폴백, 첫 성공 중단, cache-first, 안전한 전체 실패, DB 오류 전파 | +| `PapagoLanguageDetectionProviderTest` / `PapagoTranslationServiceTest` | 제어된 HTTP form/header/response와 기존 17개 chunk 회귀 | +| `LanguageProviderCacheEndToEndTest` | 실제 H2의 secondary 감지/번역 귀속, providerVersion, 두 번째 요청 무호출, worker와 materializer의 실제 표시 | + +H2 버전은 MySQL `FOR UPDATE SKIP LOCKED`를 지원하지 않으므로 새 H2 테스트는 claim 조회 한 곳만 +test spy로 대체한다. 나머지 repository 저장·별도 트랜잭션 재조회·materialization은 실제 H2다. +이 테스트가 운영 MySQL claim 동시성을 검증한다는 뜻은 아니다. 기존 감지 cache 경합·revision 테스트는 별도로 유지한다. + +```bash +./gradlew --no-daemon --offline test --tests 'kr.co.vividnext.sodalive.i18n.translation.*' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' +``` + +전체 실행 명령, 결과와 미검증 범위는 [plan-task.md](plan-task.md)의 실행 증거를 따른다. diff --git a/docs/20261109_감지번역공급자공통화/reviews/phase-1-review.md b/docs/20261109_감지번역공급자공통화/reviews/phase-1-review.md new file mode 100644 index 00000000..e18d053e --- /dev/null +++ b/docs/20261109_감지번역공급자공통화/reviews/phase-1-review.md @@ -0,0 +1,45 @@ +# 감지·번역 공급자 공통화 최종 리뷰 + +| 항목 | 내용 | +|---|---| +| 일자 | 2026-11-09 | +| 범위 | P1-T1/P1-T2와 전체 작업 트리 변경, 신규 미추적 파일 포함 | +| 기준 | [PRD](../prd.md) LP-001~010, [구현 계획](../plan-task.md) | +| 리뷰어 | 독립 읽기 전용 gate reviewer | +| 판정 | **APPROVE** | + +## 발견 사항 + +확정 발견 사항 없음. 추가 회귀 수정 Task로 전환할 항목 없음. + +## 수용 기준 + +| 기준 | 확인 내용 | +|---|---| +| LP-001/002/008 | 감지·번역별 인터페이스와 순서 설정, 빈/중복/미등록 이름 시작 실패, enum/분기 없이 bean 확장 | +| LP-003/004/007 | 한 번씩 순차 호출, 첫 성공 중단, 전체 요청 폴백, 입력 guard, 안전한 전체 실패 | +| LP-005/006 | 실제 provider/version 저장, 모든 활성 provider의 cache-first, worker/materializer의 공통 lookup | +| LP-009 | 기존 캐시 데이터·canonical winner·트랜잭션·revision·afterCommit·워커 재시도·Papago 분할 보존 | +| LP-010 | 실제 코드와 일치하는 확장 가이드, 기본 papago, production AWS 미구현 명시 | + +## 품질·보안 + +- catch는 adapter 호출에만 적용하며 DB 오류는 전파한다. +- 원문·인증정보·공급자 응답·원시 vendor 오류를 신규 로그에 기록하지 않는다. +- 기존 latch·경합·stale revision assertion을 삭제/완화하지 않았다. +- 번역 worker의 오류 기대값 변경은 고정 안전 메시지 계약에 따른 것이다. +- 기존 Papago 분할/HTTP 테스트 17건을 수정하지 않았다. + +## 검증 근거 + +- reviewer가 XML 15개를 직접 확인: 85건, failures/errors/skipped 0. +- reviewer `git diff --check`: exit 0. +- 부모 실행 증거: fresh filtered `test --rerun` 성공, ktlintCheck/bootJar 확인. +- 부모 독립 Spring runtime: 감지/번역 순서 각각 교체, fallback 귀속, 캐시 무호출, 전체 실패 및 잘못된 설정 거부. +- 부모 loopback HTTP: 실제 Papago 감지 adapter의 form/header/원문 보존과 성공/blank/500 응답 처리. +- 리뷰는 읽기 전용이며 추가 에이전트, 실제 API, 테스트/빌드 재실행, 파일 변경을 하지 않았다. + +## 남은 검증 한계 + +실제 유료 API·운영 MySQL·전체 테스트·LSP는 미검증이다. H2의 MySQL `SKIP LOCKED` claim SQL 한 곳은 +테스트 대역이므로 운영 claim 동시성을 검증했다고 주장하지 않는다. 이 한계는 이번 범위의 승인과 구분한다. -- 2.54.0 From 9136b0e3bb9d1fd8f9e9279db076e9a9fbec2c05 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 9 Nov 2026 18:22:50 +0900 Subject: [PATCH 04/12] =?UTF-8?q?refactor(i18n):=20=EA=B3=B5=EA=B8=89?= =?UTF-8?q?=EC=9E=90=20=EC=9A=B0=EC=84=A0=EC=88=9C=EC=9C=84=EB=A5=BC=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=AA=A9=EB=A1=9D=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A7=80=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../i18n/translation/LanguageProviderOrder.kt | 6 ++ .../translation/LanguageProviderProperties.kt | 12 +-- src/main/resources/application.yml | 5 - .../LanguageDetectionProviderTest.kt | 2 +- .../LanguageProviderCacheEndToEndTest.kt | 11 ++- .../LanguageProviderPropertiesTest.kt | 2 +- ...LanguageProviderSpringConfigurationTest.kt | 94 ++++++++++++++++--- .../translation/TranslationCoordinatorTest.kt | 2 +- 8 files changed, 101 insertions(+), 33 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderOrder.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderOrder.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderOrder.kt new file mode 100644 index 00000000..e1c4b39b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderOrder.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.i18n.translation + +object LanguageProviderOrder { + val detectionOrder = listOf("papago") + val translationOrder = listOf("papago") +} 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 index 00157910..9fdb4232 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderProperties.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderProperties.kt @@ -1,17 +1,15 @@ package kr.co.vividnext.sodalive.i18n.translation -import org.springframework.beans.factory.annotation.Value +import org.springframework.beans.factory.annotation.Autowired 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: List, + val translationOrder: List ) { - val detectionOrder = detectionOrder.split(',').map { it.trim() } - val translationOrder = translationOrder.split(',').map { it.trim() } + @Autowired + constructor() : this(LanguageProviderOrder.detectionOrder, LanguageProviderOrder.translationOrder) fun orderedProviders(order: List, providers: List, name: (T) -> String): List { require(order.isNotEmpty() && order.none { it.isBlank() }) { "language provider order must not be empty" } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3cbb4da4..f9ba76d7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -19,11 +19,6 @@ 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/LanguageDetectionProviderTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageDetectionProviderTest.kt index 9f05a9df..6fdae682 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageDetectionProviderTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageDetectionProviderTest.kt @@ -92,7 +92,7 @@ class LanguageDetectionProviderTest { } private fun service(providers: List, order: List = providers.map { it.providerName }) = - LanguageDetectionCacheService(repository, providers, LanguageProviderProperties(detectionOrder = order.joinToString(","))) + LanguageDetectionCacheService(repository, providers, LanguageProviderProperties(order, listOf("papago"))) } class RecordingDetectionProvider( 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 index 5cf02a4b..1d5e0203 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderCacheEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderCacheEndToEndTest.kt @@ -35,16 +35,13 @@ 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" + "spring.datasource.url=jdbc:h2:mem:language-provider-cache;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE" ] ) @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @Import( QueryDslConfig::class, AudioContentThemeQueryRepository::class, - LanguageProviderProperties::class, LanguageDetectionCacheService::class, TranslationCoordinator::class, TranslationJobWorker::class, @@ -163,6 +160,12 @@ class LanguageProviderCacheEndToEndTest @Autowired constructor( @TestConfiguration class Providers { + @Bean + fun languageProviderProperties() = LanguageProviderProperties( + listOf("papago", "secondary"), + listOf("papago", "secondary") + ) + @Bean fun papagoDetection() = RecordingDetectionProvider("papago") { throw IllegalStateException("sensitive provider body") } 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 index 5551576c..bc639e20 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderPropertiesTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderPropertiesTest.kt @@ -9,7 +9,7 @@ class LanguageProviderPropertiesTest { @Test @DisplayName("기능별 목록을 서로 독립된 설정 순서로 정렬한다") fun shouldOrderCapabilitiesIndependently() { - val properties = LanguageProviderProperties("second,first", "first,second") + val properties = LanguageProviderProperties(listOf("second", "first"), listOf("first", "second")) assertEquals( listOf("second", "first"), properties.orderedProviders(properties.detectionOrder, listOf("first", "second")) { it } 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 index 50af9bf0..c127f746 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderSpringConfigurationTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderSpringConfigurationTest.kt @@ -12,17 +12,63 @@ 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 +import org.springframework.core.env.SystemEnvironmentPropertySource +import java.util.function.Supplier class LanguageProviderSpringConfigurationTest { private val runner = ApplicationContextRunner().withUserConfiguration(Providers::class.java) + private val defaultRunner = runner.withUserConfiguration(LanguageProviderProperties::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" + @DisplayName("과거 Spring 우선순위 property가 있어도 production 기본 순서는 바뀌지 않는다") + fun shouldIgnoreLegacyPropertyOverrides() { + defaultRunner.withPropertyValues( + "sodalive.language-providers.detection-order=secondary,papago", + "sodalive.language-providers.translation-order=secondary,papago" + ).run { context -> + assertNull(context.startupFailure) + val properties = context.getBean(LanguageProviderProperties::class.java) + assertEquals(listOf("papago"), properties.detectionOrder) + assertEquals(listOf("papago"), properties.translationOrder) + } + } + + @Test + @DisplayName("과거 우선순위 환경변수가 property로 연결돼도 production 기본 순서는 바뀌지 않는다") + fun shouldIgnoreLegacyEnvironmentOverrides() { + defaultRunner.withInitializer { context -> + context.environment.propertySources.addFirst( + SystemEnvironmentPropertySource( + "legacyProviderOrderEnvironment", + mapOf( + "LANGUAGE_PROVIDERS_DETECTION_ORDER" to "secondary,papago", + "LANGUAGE_PROVIDERS_TRANSLATION_ORDER" to "secondary,papago" + ) + ) + ) + }.withPropertyValues( + "sodalive.language-providers.detection-order=\${LANGUAGE_PROVIDERS_DETECTION_ORDER:papago}", + "sodalive.language-providers.translation-order=\${LANGUAGE_PROVIDERS_TRANSLATION_ORDER:papago}" + ).run { context -> + assertNull(context.startupFailure) + assertEquals("secondary,papago", context.environment.getProperty("sodalive.language-providers.detection-order")) + assertEquals("secondary,papago", context.environment.getProperty("sodalive.language-providers.translation-order")) + val properties = context.getBean(LanguageProviderProperties::class.java) + assertEquals(listOf("papago"), properties.detectionOrder) + assertEquals(listOf("papago"), properties.translationOrder) + } + } + + @Test + @DisplayName("Spring 테스트 bean의 감지 목록만 바꾸어도 번역 순서는 유지한다") + fun shouldInjectIndependentCapabilityOrders() { + listOf( + listOf("papago", "secondary") to "papago", + listOf("secondary", "papago") to "secondary" + ).forEach { (order, expected) -> + runner.withBean( + LanguageProviderProperties::class.java, + Supplier { LanguageProviderProperties(order, listOf("papago", "secondary")) } ).run { context -> assertNull(context.startupFailure) val detector = context.getBean(LanguageDetectionCacheService::class.java) @@ -37,7 +83,10 @@ class LanguageProviderSpringConfigurationTest { assertEquals(0, context.getBean("secondaryTranslation", RecordingTranslationProvider::class.java).requests.size) } } - runner.withPropertyValues("sodalive.language-providers.translation-order=secondary,papago").run { context -> + runner.withBean( + LanguageProviderProperties::class.java, + Supplier { LanguageProviderProperties(listOf("papago", "secondary"), listOf("secondary", "papago")) } + ).run { context -> assertNull(context.startupFailure) assertEquals( "papago", @@ -52,17 +101,34 @@ class LanguageProviderSpringConfigurationTest { } @Test - @DisplayName("Spring 기본값은 두 기능 모두 파파고이며 잘못된 설정은 시작 시 실패한다") - fun shouldDefaultToPapagoAndRejectInvalidConfigurationAtStartup() { - runner.run { context -> + @DisplayName("실제 production component의 Spring 기본 생성자는 두 기능 모두 파파고를 사용한다") + fun shouldDefaultToPapago() { + defaultRunner.run { context -> assertNull(context.startupFailure) val properties = context.getBean(LanguageProviderProperties::class.java) assertEquals(listOf("papago"), properties.detectionOrder) assertEquals(listOf("papago"), properties.translationOrder) + assertEquals( + "papago", + context.getBean(LanguageDetectionCacheService::class.java).detectWithCache("source")?.providerName + ) + assertEquals( + "papago", + context.getBean(TranslationCoordinator::class.java) + .translate(TranslateRequest(listOf("source"), "ko", "en")).providerName + ) } - listOf("detection-order", "translation-order").forEach { key -> - listOf("", "papago,papago", "unknown").forEach { value -> - runner.withPropertyValues("sodalive.language-providers.$key=$value").run { context -> + } + + @Test + @DisplayName("Spring 테스트 bean의 빈 목록과 빈 이름과 중복 순서와 미등록 이름은 시작 시 실패한다") + fun shouldRejectInvalidOrdersAtStartup() { + listOf(emptyList(), listOf(""), listOf("papago", "papago"), listOf("unknown")).forEach { order -> + listOf( + LanguageProviderProperties(order, listOf("papago")), + LanguageProviderProperties(listOf("papago"), order) + ).forEach { properties -> + runner.withBean(LanguageProviderProperties::class.java, Supplier { properties }).run { context -> assertNotNull(context.startupFailure) } } @@ -70,7 +136,7 @@ class LanguageProviderSpringConfigurationTest { } @TestConfiguration - @Import(LanguageProviderProperties::class, TranslationCoordinator::class, LanguageDetectionCacheService::class) + @Import(TranslationCoordinator::class, LanguageDetectionCacheService::class) class Providers { @Bean fun memoryRepository(): TranslationMemoryRepository = Mockito.mock(TranslationMemoryRepository::class.java) 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 index 8dbab3a3..555ddef2 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationCoordinatorTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationCoordinatorTest.kt @@ -125,7 +125,7 @@ class TranslationCoordinatorTest { private fun coordinator( providers: List, order: List = providers.map { it.providerName } - ) = TranslationCoordinator(providers, LanguageProviderProperties(translationOrder = order.joinToString(",")), repository) + ) = TranslationCoordinator(providers, LanguageProviderProperties(listOf("papago"), order), repository) private fun memory(provider: String, text: String) = TranslationMemory( "hash", -- 2.54.0 From 6093b37747f106a81433164b4ae35820f3a04057 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 9 Nov 2026 18:24:24 +0900 Subject: [PATCH 05/12] =?UTF-8?q?docs(i18n):=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EA=B3=B5=EA=B8=89=EC=9E=90=20=EC=88=9C?= =?UTF-8?q?=EC=84=9C=20=EC=84=A4=EC=A0=95=EA=B3=BC=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=EC=9D=84=20=EA=B8=B0=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 161 +++++++++++++++++- docs/20261109_감지번역공급자공통화/prd.md | 19 ++- .../provider-guide.md | 38 +++-- .../reviews/phase-2-review.md | 31 ++++ 4 files changed, 229 insertions(+), 20 deletions(-) create mode 100644 docs/20261109_감지번역공급자공통화/reviews/phase-2-review.md diff --git a/docs/20261109_감지번역공급자공통화/plan-task.md b/docs/20261109_감지번역공급자공통화/plan-task.md index dcfbb5de..9811c43e 100644 --- a/docs/20261109_감지번역공급자공통화/plan-task.md +++ b/docs/20261109_감지번역공급자공통화/plan-task.md @@ -3,8 +3,8 @@ | 항목 | 내용 | |---|---| | 작성일 | 2026-11-09 | -| 상태 | 구현·검증·독립 리뷰·리소스 정리 완료 | -| 요구사항 | [prd.md](prd.md), LP-001~010 및 확정 구현 계약 | +| 상태 | Phase 1·2 구현·검증·리뷰·리소스 정리 완료 | +| 요구사항 | [prd.md](prd.md), LP-001~012 및 확정 구현 계약 | | 활성 Goal | 없음 | ## 목표와 제약 @@ -339,3 +339,160 @@ M v2/creator/channel/community/translation/CreatorCommunityTranslationServiceTes 출력: `CLEANUP PASS: owned language-providers.06uAbV removed; shared caches untouched`. 임시 Java/class/추출 라이브러리/로그가 함께 제거됐으며 필요한 증거는 이 문서에 보존했다. 작업 임시 노트도 문서에 이관 후 삭제한다. 다른 작업의 파일·프로세스·캐시에는 조치하지 않았다. + +### Phase 2: 코드 목록으로 공급자 순서 지정 + +- [x] **Task 2.1: 우선순위 환경 설정 제거 (P2-T1)** + +**Objective:** LP-011/012에 따라 코드의 독립 목록만 production 순서를 결정한다. +**시작 조건:** 사용자의 상수 목록 방식 승인, 기존 Phase 1 완료. +**완료 증거:** 과거 property/env 무시 회귀 RED/GREEN, typed-list 순서/검증, Spring/H2 wiring 회귀. +**범위 밖:** 공급자 선택/캐시 로직 변경, 인증 키 환경변수 제거, AWS 구현, 전역 mutable 테스트 설정. + +**Files:** +- Add: `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderOrder.kt`. +- Modify: 같은 패키지 `LanguageProviderProperties.kt`, `src/main/resources/application.yml`의 우선순위 항목만. +- Update tests: `LanguageProviderPropertiesTest.kt`, `LanguageProviderSpringConfigurationTest.kt`, + `LanguageProviderCacheEndToEndTest.kt`, `LanguageDetectionProviderTest.kt`, `TranslationCoordinatorTest.kt`. +- Update docs: 이 PRD/plan과 `provider-guide.md`. 이전 완료 이력·리뷰는 삭제하지 않는다. + +**계약:** `LanguageProviderOrder` object에 두 `val` 목록. `LanguageProviderProperties`의 주 생성자는 +두 `List`을 받고 `@Autowired`가 붙은 noarg 보조 생성자가 production object 목록으로 위임한다. +기존 `orderedProviders` 검증은 그대로 유지한다. 기본 생성자를 사용하는 호출자와 coordinator는 변경하지 않는다. + +- [x] **RED:** 실제 Spring 기본 생성에서 과거 priority property/env override를 무시해야 한다는 테스트를 추가하고 기존 binding 동작으로 실패함을 확인한다. +- [x] **GREEN:** 코드 목록·typed 생성자·Spring 기본 생성자를 적용하고 우선순위 YAML/env 연결을 삭제한다. +- [x] **GREEN 확인:** default/legacy override 무시/테스트별 독립 순서/잘못된 목록·공급자 등록 검증이 통과한다. +- [x] **REFACTOR:** 실제 H2 fallback 캐시 테스트를 property override 대신 테스트 bean으로 연결하고 기존 assertion을 유지한다. + +**명령:** +```bash +./gradlew --no-daemon --offline test --tests 'kr.co.vividnext.sodalive.i18n.translation.*' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' +./gradlew --no-daemon --offline test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectionCacheConcurrencyTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' +./gradlew --no-daemon --offline ktlintCheck bootJar +``` + +- [x] **Phase 2 Gate (P2-GATE): 코드 순서 실행·리뷰·정리** + +**Objective:** production bean이 코드 목록으로 동작하고 환경값으로 순서가 바뀌지 않음을 직접 실행 검증한다. +**시작 조건:** P2-T1 통과. +**대체 검증:** 부모 Spring driver에 충돌 property/env를 넣고 코드 목록 유지, 테스트 bean으로 목록 교체·validation을 확인한다. +**완료 증거:** focused 통과, build/lint, 독립 scoped 리뷰 승인, 소유 임시 경로 정리. +**범위 밖:** 전체 테스트·유료 API·운영 환경 조작. 전체 테스트는 사용자 `./gradlew test`. + +Phase 2 실행 증거는 아래에 누적한다. 코드 변경 전 현재 git status는 깨끗했다. + +### P2-T1 실행 증거 (2026-11-09) + +승인된 Phase 2 계약과 기존 Phase 1 결과를 재사용했다. baseline 85건을 변경 전에 다시 실행하지 않고 +실제 Spring의 과거 override 무시 회귀부터 작성했다. PRD의 승인 정정과 Phase 1 이력·리뷰는 수정하지 않았다. + +**실행 환경:** parent 존재를 `ls -ld`로 확인하고 아래 명령으로 이 구현자만 소유하는 child를 생성했다. +이후 모든 shell 명령에서 아래 세 환경변수를 이 child로 지정했다. Gradle tool timeout은 모두 `600000ms`다. + +```bash +mktemp -d /var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/provider-order-code.RRCyJ1/phase2.XXXXXX +export TMPDIR=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/provider-order-code.RRCyJ1/phase2.QPrb3i +export NODE_COMPILE_CACHE="$TMPDIR" BUN_RUNTIME_TRANSPILER_CACHE_PATH="$TMPDIR" +``` + +**RED (2건, 2실패, errors/skipped 0, exit 1, 20초):** production 코드를 바꾸기 전에 다음 명령을 실행했다. +두 테스트 모두 context는 정상 시작했지만 기대 `[papago]`, 실제 `[secondary, papago]`로 assertion 실패했다. +property 직접 지정과 `SystemEnvironmentPropertySource`의 과거 환경변수 → property placeholder 연결을 각각 검증했다. +환경변수 테스트는 실제 property가 `secondary,papago`로 해석됐다는 assertion도 통과한 뒤 실패했다. +컴파일·unresolved symbol·context 오류를 행동 RED로 계산하지 않았다. + +```bash +./gradlew --no-daemon --offline test --tests 'kr.co.vividnext.sodalive.i18n.translation.LanguageProviderSpringConfigurationTest.shouldIgnoreLegacy*Overrides' +``` + +**GREEN:** 하나의 구현 변경으로 코드 목록·생성자·다섯 테스트의 typed-list fixture·현재 가이드를 전환했다. +`LanguageProviderOrder`에는 독립 `val` 두 개만 있으며 각각 `listOf("papago")`다. 전역 mutable 상태는 추가하지 않았다. +`LanguageProviderProperties`의 `orderedProviders`는 그대로다. production 기본/negative 테스트는 +`withUserConfiguration(LanguageProviderProperties::class.java)`로 실제 component를 Spring이 생성하게 한다. +독립 순서·잘못된 목록은 테스트 bean으로 주입하고 H2는 테스트 `@Bean`으로 두 목록을 주입한다. +기존 캐시·폴백·귀속·materializer·동시성·revision assertion은 삭제하거나 완화하지 않았다. + +**결합 focused 회귀 + lint/산출물 (exit 0, 3분 14초):** 아래 명령 한 번으로 모두 실행했다. +`:test`, `:ktlintCheck`, `:bootJar`의 실제 실행과 `BUILD SUCCESSFUL`을 확인했다. + +```bash +./gradlew --no-daemon --offline test \ + --tests 'kr.co.vividnext.sodalive.i18n.translation.*' \ + --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' \ + --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' \ + --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectionCacheConcurrencyTest' \ + --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' \ + --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityTranslationEndToEndTest' \ + ktlintCheck bootJar +``` + +| XML로 확인한 범위 | 결과 | +|---|---| +| 번역 package + 감지 cache | 54/54 | +| 커뮤니티 감지 + cache 경합 | 14/14 (12 + 2) | +| 커뮤니티 번역 service + 기존 end-to-end | 20/20 (16 + 4) | +| 합계 | 15개 XML, 88/88, failures/errors/skipped 모두 0 | + +새 override 무시 회귀 2/2가 GREEN이며 Spring 클래스 전체는 5/5다. +85건 대비 증가는 회귀 2건과 기존 default/invalid 검증을 서로 다른 테스트로 분리한 1건이다. +기존 Papago chunk 17건과 H2 secondary 귀속·재사용 2건도 통과했다. +변경하지 않은 테스트의 Kotlin 경고 7개(불필요한 `!!` 4개, unchecked cast 3개)와 Gradle 9 deprecation 경고는 남아 있다. + +**부모 QA용 실제 생성자:** 아래 `javap` 명령은 exit 0이었다. 문자열 생성자와 setter는 없다. + +```bash +javap -classpath build/classes/kotlin/main kr.co.vividnext.sodalive.i18n.translation.LanguageProviderProperties kr.co.vividnext.sodalive.i18n.translation.LanguageProviderOrder +``` + +```text +LanguageProviderProperties(java.util.List, java.util.List) +LanguageProviderProperties() // @Autowired, LanguageProviderOrder의 두 목록으로 위임 +getDetectionOrder(): List +getTranslationOrder(): List +LanguageProviderOrder.INSTANCE.getDetectionOrder(): List +LanguageProviderOrder.INSTANCE.getTranslationOrder(): List +``` + +**이 구현자의 변경 파일:** production은 `i18n/translation/LanguageProviderOrder.kt` 추가, +`LanguageProviderProperties.kt` 수정과 `application.yml` 우선순위 블록 5줄 삭제뿐이다. +테스트는 같은 패키지의 `LanguageProviderPropertiesTest.kt`, `LanguageProviderSpringConfigurationTest.kt`, +`LanguageProviderCacheEndToEndTest.kt`, `LanguageDetectionProviderTest.kt`, `TranslationCoordinatorTest.kt`만 수정했다. +문서는 `provider-guide.md`와 이 `plan-task.md`를 갱신했다. 합계 10개다. +기본 생성자 기존 호출자·인증 환경변수·기타 설정·AWS/SDK·DB/schema·domain·캐시·폴백 코드는 변경하지 않았다. + +**한계/소유권:** LSP/diagnostics 도구가 제공되지 않아 Kotlin 컴파일과 ktlint로 보완했다. +H2의 기존 claim SQL test spy 한계는 유지된다. 실제 API·운영 DB·전체 테스트는 실행하지 않았다. +전체 테스트는 사용자 명령 `./gradlew test`이며 무필터 test/check/build는 실행하지 않았다. +별도 수동 Spring driver와 ONE reviewer 및 P2-GATE 완료 판정은 부모가 맡는다. +commit/staging/push/worktree/하위 에이전트 작업은 하지 않았다. + +**소유 임시 자원 정리 영수증 (2026-11-09 18:13:46 KST):** 모든 Gradle/test 실행이 종료된 뒤 +`lsof -nP +D "$TMPDIR"` 출력이 없고 Java process 목록에 이 child를 사용하는 프로세스가 없음을 확인했다. +정확한 child 경로와 존재를 `test`로 대조한 뒤 이 구현자 소유 `phase2.QPrb3i`만 `rm -rf -- "$TMPDIR"`로 삭제했다. +`test ! -e "$TMPDIR"`와 parent `ls -ld`가 포함된 정리 명령은 exit 0이었다. +parent `provider-order-code.RRCyJ1`, 부모 QA의 형제 경로, 기존 공유 daemon/cache는 건드리지 않았다. +마지막 `git diff --check`는 exit 0이었다. P2-T1만 완료 처리하고 P2-GATE는 부모 검증을 위해 미완료로 남겼다. + +### P2-GATE 부모 검증 + +- 부모가 최종 production/test/guide diff와 신규 `LanguageProviderOrder.kt`를 직접 읽었다. + 구현자 결과 XML 15개를 대조해 88건, failures/errors/skipped 0을 확인했다. +- 별도 Java 17 `CodeOrderQa`로 실제 Spring component를 생성했다. 충돌하는 과거 Spring property와 + `SystemEnvironmentPropertySource`를 주입해도 두 기본 순서는 코드의 `papago` 목록을 유지했다. + typed-list 생성자로 감지 B/A/C, 번역 C/B/A를 독립 지정했고 빈 목록·빈 이름·중복·미등록 순서 및 + 중복 공급자 이름 거부를 확인했다. 전역 목록은 변경하지 않았다. + 출력: `QA PASS: production code defaults, ignored legacy configuration, independent lists and validation; context closed`. + driver exit 0, Spring context 종료, 외부 API 요청 없음. +- 부모 fresh 명령: + `./gradlew --no-daemon --offline test --rerun --tests 'kr.co.vividnext.sodalive.i18n.translation.*' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' ktlintCheck bootJar` + → exit 0, `BUILD SUCCESSFUL in 36s`, `:test` 실제 실행. lint/build는 검증된 현재 산출물과 일치해 UP-TO-DATE였다. +- 이후 사용자가 enum의 적합성을 물어 검토 의견만 제공했다. enum 변경은 별도 구현 승인 전이므로 Phase 2에 포함하지 않는다. +- 부모 fresh XML 11개 합계 54건, failures/errors/skipped 0을 확인했다. +- [독립 Phase 2 리뷰](reviews/phase-2-review.md): **APPROVE**, 차단/수정 요구 사항 없음. + 이전 Phase 1 리뷰는 그대로 보존한다. 코드·테스트와 문서는 각각 원자적으로 커밋한다. +- 코드·테스트 커밋: `9136b0e3`. pre-commit ktlint와 커밋 메시지 사전/사후 검증을 통과했다. +- 부모 리소스 정리: `lsof -nP +D` 출력 없음으로 열린 파일이 없음을 확인하고, 소유 root + `provider-order-code.RRCyJ1`(driver/class/라이브러리/로그)만 제거했다. `test ! -e` exit 0. + 출력: `CLEANUP PASS: owned provider-order-code.RRCyJ1 removed; shared caches untouched`. + 필요한 증거는 이 문서에 이관했으며 임시 노트도 삭제한다. 다른 작업의 리소스는 건드리지 않았다. diff --git a/docs/20261109_감지번역공급자공통화/prd.md b/docs/20261109_감지번역공급자공통화/prd.md index b153495a..3eb02044 100644 --- a/docs/20261109_감지번역공급자공통화/prd.md +++ b/docs/20261109_감지번역공급자공통화/prd.md @@ -3,7 +3,7 @@ | 항목 | 내용 | |---|---| | 작성일 | 2026-11-09 | -| 상태 | 구현·기능 범위 검증·독립 리뷰 완료 | +| 상태 | Phase 1·2 구현 및 기능 범위 검증·독립 리뷰 완료 | | 승인 범위 | 감지·번역 별도 인터페이스, 공통 공급자 선택·실패 전환, 기능별 순서 설정 | | 계획 | [plan-task.md](plan-task.md) | @@ -35,6 +35,8 @@ | LP-008 | 빈/중복/알 수 없는 공급자 순서 설정은 초기화 단계에서 명확히 거부한다. 새 구현체 추가 시 기존 선택 코드나 공급자 enum 수정이 필요 없다. | | LP-009 | 기존 파파고 캐시·번역 데이터 및 분할 번역 동작과 감지 캐시 동시성 승자 반환을 보존한다. | | LP-010 | 추가 방법과 기능별 순서 설정 예시, 실제 AWS 미구현 상태를 문서화한다. | +| LP-011 | 공급자 순서는 `LanguageProviderOrder`의 독립 `val List` 두 개로 지정한다. 기본은 각각 `listOf("papago")`이다. | +| LP-012 | 우선순위 환경변수·YAML·문자열 파싱을 제거한다. 기존 우선순위 property/env 값이 있어도 production 순서를 바꾸지 않는다. 인증정보 환경변수는 유지한다. | ## 보안·오류 처리 @@ -58,6 +60,8 @@ LSP 도구가 제공되지 않으면 Kotlin 컴파일과 ktlint로 보완하고 ## 확정 구현 계약 +아래는 Phase 1 구현 계약이며, 순서 설정 방식은 하단의 Phase 2 정정 계약이 우선한다. 기존 실행 기록은 보존한다. + - 기존 `TranslationProvider`와 raw `TranslateResult`는 유지한다. - `LanguageDetectionProvider`: `providerName: String`, `detect(query: String): String?`. - `DetectedLanguage`: `detectedLanguage: String`, `providerName: String`. @@ -79,3 +83,16 @@ LSP 도구가 제공되지 않으면 Kotlin 컴파일과 ktlint로 보완하고 - 공통 `TranslationLanguages`로 서비스 언어 정책만 옮긴다. Papago 분할은 Papago adapter에 유지한다. - 현재 entity에는 공급자별 cache key가 있으므로 DDL 변경 없이 실제 공급자를 기록한다. 캐시 두 개가 있으면 설정상 앞선 결과를 선택하며 특정 job의 공급자를 영구 고정하는 새 모델은 만들지 않는다. + +## Phase 2 결정 정정 — 2026-11-09 + +사용자는 YAML·환경변수 대신 코드의 상수 목록으로 감지·번역 순서를 지정하도록 승인했다. + +- 유일한 production 순서 편집 지점은 `i18n/translation/LanguageProviderOrder.kt`의 + `detectionOrder = listOf("papago")`, `translationOrder = listOf("papago")`이다. Kotlin 목록은 `const val`이 아닌 `val`이다. +- 목록의 왼쪽부터 사용한다. 감지·번역은 각각 수정할 수 있으며 변경 후 빌드·배포한다. +- 기존 `LanguageProviderProperties`는 typed-list 생성자와 명시적 Spring 기본 생성자로 코드 목록을 읽는다. + 선택 코드·호출자의 이름 변경을 확산시키지 않는다. 문자열 생성자·`@Value`·split/trim은 제거한다. +- 테스트는 전역 목록을 바꾸지 않고 테스트 전용 bean에 typed list를 주입하여 독립 순서와 invalid order를 검증한다. +- 과거 우선순위 environment/property override는 무시한다. 다른 환경변수나 Spring 설정 체계는 변경하지 않는다. +- 실패 전환·공급자 검증·캐시·DB·인증·AWS 미구현 정책은 Phase 1을 그대로 유지한다. diff --git a/docs/20261109_감지번역공급자공통화/provider-guide.md b/docs/20261109_감지번역공급자공통화/provider-guide.md index 4acfe4b0..7370202d 100644 --- a/docs/20261109_감지번역공급자공통화/provider-guide.md +++ b/docs/20261109_감지번역공급자공통화/provider-guide.md @@ -27,7 +27,7 @@ AWS 감지·번역 adapter, 해당 SDK 및 전용 인증 설정은 추가하지 `providerName`은 capability 내 유일하고 재배포 후에도 안정적인 값이어야 한다. 2. 공급자의 HTTP/SDK 및 인증은 adapter 안에 둔다. 외부 응답 형식과 빈 결과를 검증하고, 원문·인증정보·응답 본문·예외 cause를 로그로 출력하지 않는다. 번역은 입력별 결과를 같은 순서로 반환한다. -3. 구현한 capability의 설정 순서에만 이름을 넣고 재시작/재배포한다. `@Primary`, selector 분기, enum 등록은 필요 없다. +3. `LanguageProviderOrder.kt`에서 구현한 capability의 목록에만 이름을 넣고 빌드·배포한다. `@Primary`, selector 분기, enum 등록은 필요 없다. 4. 아래 테스트 경계로 순서·실패 전환·실제 provider 저장·무호출 캐시 재사용·materializer 표시를 검증한다. 콘텐츠·댓글·커뮤니티 호출자, worker, materializer, scheduler에 공급자 분기를 추가하지 않는다. @@ -35,33 +35,37 @@ AWS 감지·번역 adapter, 해당 SDK 및 전용 인증 설정은 추가하지 Papago의 5,000 UTF-16 단위 분할과 서로게이트 쌍 보존은 `PapagoTranslationService`에 남아 있다. ko/en/ja 서비스 정책은 `TranslationLanguages`에 있으며 공급자를 추가해도 자동 확장하지 않는다. -## 기능별 독립 설정 +## 기능별 독립 코드 목록 -`LanguageProviderProperties`는 component scan으로 등록되며 생성자 `@Value`로 문자열을 주입받는다. -별도 `@EnableConfigurationProperties` 등록은 필요 없다. 쉼표로 나누고 각 이름의 앞뒤 공백을 제거한다. -기본값은 두 기능 모두 `papago`다. +production 순서의 유일한 편집 지점은 +`src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderOrder.kt`다. +목록의 왼쪽부터 사용하며 기본값은 두 기능 모두 `listOf("papago")`다. -```yaml -sodalive: - language-providers: - detection-order: ${LANGUAGE_PROVIDERS_DETECTION_ORDER:papago} - translation-order: ${LANGUAGE_PROVIDERS_TRANSLATION_ORDER:papago} +```kotlin +object LanguageProviderOrder { + val detectionOrder = listOf("papago") + val translationOrder = listOf("papago") +} ``` +Kotlin 목록은 `const val`이 아닌 `val`이다. `LanguageProviderProperties`는 기존 주입 wrapper로 유지하며, +Spring은 `@Autowired` 기본 생성자로 위 목록을 읽는다. 문자열 파싱은 하지 않는다. +테스트에서는 전역 목록을 변경하지 않고 `LanguageProviderProperties(List, List)`로 만든 테스트 bean을 주입한다. + 다음은 **secondary 구현체를 두 capability에 등록한 뒤에만** 적용할 수 있는 예시다. 현재 `secondary` 구현체는 테스트에만 있다. -```bash -export LANGUAGE_PROVIDERS_DETECTION_ORDER=secondary,papago -export LANGUAGE_PROVIDERS_TRANSLATION_ORDER=papago,secondary +```kotlin +val detectionOrder = listOf("secondary", "papago") +val translationOrder = listOf("papago", "secondary") ``` 감지만 순서를 바꾸어도 번역 순서는 바뀌지 않는다. 반대로 번역만 바꿀 수도 있다. -Spring property를 직접 지정할 때는 `sodalive.language-providers.detection-order`와 -`sodalive.language-providers.translation-order`를 각각 설정한다. +변경 후 빌드·배포해야 반영된다. 과거 우선순위 Spring property·환경변수·YAML 값이 남아 있어도 순서는 바뀌지 않는다. +공급자 인증정보는 계속 환경변수로 주입하며 이 코드 목록에 넣지 않는다. 빈 값·빈 항목·중복 이름·미등록 이름·capability 안의 중복 bean 이름표(`providerName`)는 초기화에서 거부한다. 공급자가 번역에만 등록됐다면 그 이름을 감지 순서에 넣을 수 없다. -실행 중 설정 변경은 지원하지 않는다. AWS 구현체 등록 전 `papago,aws`는 사용할 수 없다. +실행 중 설정 변경은 지원하지 않는다. AWS 구현체 등록 전 `listOf("papago", "aws")`는 사용할 수 없다. ## 캐시와 오류 계약 @@ -89,7 +93,7 @@ adapter 예외·null 감지·빈 감지·번역 결과 개수 불일치·blank | 테스트 | 검증 내용 | |---|---| -| `LanguageProviderSpringConfigurationTest` | 실제 `@Value` binding, 두 test bean, 기능별 독립 순서, 기본값과 잘못된 설정의 시작 실패 | +| `LanguageProviderSpringConfigurationTest` | 실제 production 기본 생성자, 과거 property/env 무시, typed-list 테스트 bean의 독립 순서와 잘못된 목록의 시작 실패 | | `LanguageProviderPropertiesTest` | 순서 검증 및 선택되지 않은 bean을 포함한 capability 내 중복 이름 거부 | | `LanguageDetectionProviderTest` / `TranslationCoordinatorTest` | 전체 요청 폴백, 첫 성공 중단, cache-first, 안전한 전체 실패, DB 오류 전파 | | `PapagoLanguageDetectionProviderTest` / `PapagoTranslationServiceTest` | 제어된 HTTP form/header/response와 기존 17개 chunk 회귀 | diff --git a/docs/20261109_감지번역공급자공통화/reviews/phase-2-review.md b/docs/20261109_감지번역공급자공통화/reviews/phase-2-review.md new file mode 100644 index 00000000..753ea00a --- /dev/null +++ b/docs/20261109_감지번역공급자공통화/reviews/phase-2-review.md @@ -0,0 +1,31 @@ +# 코드 기반 공급자 순서 Phase 2 리뷰 + +| 항목 | 내용 | +|---|---| +| 기준 | [PRD](../prd.md) LP-011/012, [계획](../plan-task.md) Phase 2 | +| 일자 | 2026-11-09 | +| 리뷰어 | 독립 읽기 전용 gate reviewer | +| 판정 | **APPROVE** | + +## 판정 근거 + +- `LanguageProviderOrder`의 두 독립 `val List`이 production 순서의 기준이며 기본값은 각각 papago다. +- `LanguageProviderProperties`는 typed-list 생성자와 `@Autowired` 기본 생성자만 제공한다. + 우선순위용 String overload, `@Value`, 문자열 파싱이나 mutable 전역 설정이 없다. +- YAML 삭제는 우선순위 블록 5줄뿐이다. 인증 환경변수는 유지한다. +- 실제 Spring 기본 경로에서 과거 property/env 값은 순서에 영향을 주지 않는다. +- 독립 순서·invalid 목록·중복 공급자·H2 귀속/재사용의 기존 검증을 유지한다. +- 현재 가이드가 코드 편집 지점·빌드/배포·기존 override 무시 동작과 일치한다. +- enum 변경은 사용자가 적합성만 질문한 별도 미승인 범위로, 이번 구현 누락이 아니다. + +**확정 발견 사항 없음. 수정 Task 전환 항목 없음.** + +## 검증 근거와 한계 + +변경 전 의미 있는 RED 2건, 구현 후 88/88 통과와 실제 lint/bootJar 성공 기록을 검토했다. +부모가 XML 15개를 직접 대조했고, focused 54건을 `--rerun`하여 exit 0(36초)을 확인했다. +부모의 별도 Java/Spring 실행은 충돌 property/env 무시, 감지 B/A/C·번역 C/B/A, invalid 목록/공급자 거부를 통과했다. +Spring context는 종료됐으며 외부 API 요청은 없었다. reviewer `git diff --check`는 exit 0이다. + +리뷰는 코드·테스트·설정을 변경하거나 테스트/빌드를 재실행하지 않았다. +전체 테스트·LSP·유료 API·운영 MySQL은 미검증이며 기존 H2 claim SQL 대역 한계를 그대로 명시한다. -- 2.54.0 From ec890047a5722bbbcdcd0c16460f4cff50bae0fe Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 9 Nov 2026 19:14:19 +0900 Subject: [PATCH 06/12] =?UTF-8?q?refactor(i18n):=20=EA=B3=B5=EA=B8=89?= =?UTF-8?q?=EC=9E=90=20=EC=8B=9D=EB=B3=84=EC=9E=90=EB=A5=BC=20enum?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=86=B5=EC=9D=BC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/LanguageDetectionCacheService.kt | 12 +-- .../i18n/translation/DetectedLanguage.kt | 2 +- .../translation/LanguageDetectionProvider.kt | 2 +- .../i18n/translation/LanguageProviderId.kt | 6 ++ .../i18n/translation/LanguageProviderOrder.kt | 4 +- .../translation/LanguageProviderProperties.kt | 16 ++-- .../PapagoLanguageDetectionProvider.kt | 2 +- .../translation/PapagoTranslationService.kt | 2 +- .../translation/ProviderTranslationResult.kt | 2 +- .../translation/TranslationCoordinator.kt | 6 +- .../i18n/translation/TranslationJobWorker.kt | 4 +- .../i18n/translation/TranslationProvider.kt | 2 +- .../CreatorCommunityLanguageDetectTest.kt | 9 ++- .../LanguageDetectionCacheServiceTest.kt | 7 +- .../LanguageDetectionProviderTest.kt | 52 +++++++------ .../LanguageProviderCacheEndToEndTest.kt | 34 +++++---- .../translation/LanguageProviderIdTest.kt | 56 ++++++++++++++ .../LanguageProviderPropertiesTest.kt | 24 +++--- ...LanguageProviderSpringConfigurationTest.kt | 76 ++++++++++--------- .../translation/TranslationCoordinatorTest.kt | 54 ++++++------- .../translation/TranslationJobWorkerTest.kt | 4 +- 21 files changed, 228 insertions(+), 148 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderId.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderIdTest.kt 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 57527013..b573c047 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt @@ -13,7 +13,7 @@ class LanguageDetectionCacheService( providers: List, properties: LanguageProviderProperties ) { - private val providers = properties.orderedProviders(properties.detectionOrder, providers) { it.providerName } + private val providers = properties.orderedProviders(properties.detectionOrder, providers) { it.providerId } @Transactional fun detectWithCache(query: String): DetectedLanguage? { @@ -24,11 +24,11 @@ class LanguageDetectionCacheService( providers.forEach { provider -> val cached = languageDetectionResultRepository.findBySourceHashAndProviderAndNormalizationVersion( sourceHash = sourceHash, - provider = provider.providerName, + provider = provider.providerId.code, normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION ) if (cached != null && cached.detectedLanguage.isNotBlank()) { - return DetectedLanguage(cached.detectedLanguage, provider.providerName) + return DetectedLanguage(cached.detectedLanguage, provider.providerId) } } @@ -42,18 +42,18 @@ class LanguageDetectionCacheService( sourceHash = sourceHash, sourceTextSample = normalizedQuery.take(MAX_SAMPLE_LENGTH), detectedLanguage = detectedLanguage, - provider = provider.providerName, + provider = provider.providerId.code, confidence = null, normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION ) val winner = requireNotNull( languageDetectionResultRepository.findDetectedLanguageForUpdate( sourceHash = sourceHash, - provider = provider.providerName, + provider = provider.providerId.code, normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION ) ) - return DetectedLanguage(winner, provider.providerName) + return DetectedLanguage(winner, provider.providerId) } return null } 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 index d7653ceb..2b6dc434 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/DetectedLanguage.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/DetectedLanguage.kt @@ -1,3 +1,3 @@ package kr.co.vividnext.sodalive.i18n.translation -data class DetectedLanguage(val detectedLanguage: String, val providerName: String) +data class DetectedLanguage(val detectedLanguage: String, val providerId: LanguageProviderId) 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 index 9ee23e74..9444b6ad 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageDetectionProvider.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageDetectionProvider.kt @@ -1,7 +1,7 @@ package kr.co.vividnext.sodalive.i18n.translation interface LanguageDetectionProvider { - val providerName: String + val providerId: LanguageProviderId fun detect(query: String): String? } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderId.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderId.kt new file mode 100644 index 00000000..8dffa18d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderId.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.i18n.translation + +enum class LanguageProviderId(val code: String) { + PAPAGO("papago"), + AWS("aws") +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderOrder.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderOrder.kt index e1c4b39b..445b4f75 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderOrder.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderOrder.kt @@ -1,6 +1,6 @@ package kr.co.vividnext.sodalive.i18n.translation object LanguageProviderOrder { - val detectionOrder = listOf("papago") - val translationOrder = listOf("papago") + val detectionOrder = listOf(LanguageProviderId.PAPAGO) + val translationOrder = listOf(LanguageProviderId.PAPAGO) } 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 index 9fdb4232..87fe1af1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderProperties.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderProperties.kt @@ -5,18 +5,18 @@ import org.springframework.stereotype.Component @Component class LanguageProviderProperties( - val detectionOrder: List, - val translationOrder: List + val detectionOrder: List, + val translationOrder: List ) { @Autowired constructor() : this(LanguageProviderOrder.detectionOrder, LanguageProviderOrder.translationOrder) - fun orderedProviders(order: List, providers: List, name: (T) -> String): List { - require(order.isNotEmpty() && order.none { it.isBlank() }) { "language provider order must not be empty" } + fun orderedProviders(order: List, providers: List, id: (T) -> LanguageProviderId): List { + require(order.isNotEmpty()) { "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) } + val byId = providers.associateBy(id) + require(byId.size == providers.size) { "duplicate registered language provider name" } + require(order.all { it in byId }) { "unknown language provider in order" } + return order.map { byId.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 index f0af2808..edbacd66 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoLanguageDetectionProvider.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoLanguageDetectionProvider.kt @@ -16,7 +16,7 @@ class PapagoLanguageDetectionProvider( private val papagoClientSecret: String ) : LanguageDetectionProvider { private val restTemplate = RestTemplate() - override val providerName = "papago" + override val providerId = LanguageProviderId.PAPAGO override fun detect(query: String): String? { val headers = HttpHeaders().apply { 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 75b2a903..24f79539 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 @@ -20,7 +20,7 @@ class PapagoTranslationService( private val papagoTranslateUrl = "https://papago.apigw.ntruss.com/nmt/v1/translation" - override val providerName: String = "papago" + override val providerId: LanguageProviderId = LanguageProviderId.PAPAGO override val providerVersion: String = "nmt-v1" 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 index 0e022faa..d3533fe5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/ProviderTranslationResult.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/ProviderTranslationResult.kt @@ -2,6 +2,6 @@ package kr.co.vividnext.sodalive.i18n.translation data class ProviderTranslationResult( val result: TranslateResult, - val providerName: String, + val providerId: LanguageProviderId, 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 index 4aeec3fc..d130fd7c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationCoordinator.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationCoordinator.kt @@ -8,7 +8,7 @@ class TranslationCoordinator( properties: LanguageProviderProperties, private val translationMemoryRepository: TranslationMemoryRepository ) { - private val providers = properties.orderedProviders(properties.translationOrder, providers) { it.providerName } + private val providers = properties.orderedProviders(properties.translationOrder, providers) { it.providerId } fun findCached(sourceHash: String, sourceLanguage: String, targetLanguage: String): TranslationMemory? { providers.forEach { provider -> @@ -17,7 +17,7 @@ class TranslationCoordinator( sourceHash, sourceLanguage, targetLanguage, - provider.providerName, + provider.providerId.code, SourceTextNormalizer.NORMALIZATION_VERSION ) if (memory != null && memory.translatedText.isNotBlank()) return memory @@ -42,7 +42,7 @@ class TranslationCoordinator( result != null && result.translatedText.size == request.texts.size && result.translatedText.none { it.isBlank() } ) { - return ProviderTranslationResult(result, provider.providerName, provider.providerVersion) + return ProviderTranslationResult(result, provider.providerId, provider.providerVersion) } } throw IllegalStateException("all translation providers failed") 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 46f3960c..5ea1d74e 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 @@ -87,7 +87,7 @@ class TranslationJobWorker( sourceHash = job.sourceHash, sourceLanguage = job.sourceLanguage, targetLanguage = job.targetLanguage, - provider = response.providerName, + provider = response.providerId.code, normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION ) if (memory == null) { @@ -98,7 +98,7 @@ class TranslationJobWorker( sourceLanguage = job.sourceLanguage, targetLanguage = job.targetLanguage, translatedText = translated, - provider = response.providerName, + provider = response.providerId.code, providerVersion = response.providerVersion ) ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationProvider.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationProvider.kt index ca03fdaf..a35d145d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationProvider.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationProvider.kt @@ -1,7 +1,7 @@ package kr.co.vividnext.sodalive.i18n.translation interface TranslationProvider { - val providerName: String + val providerId: LanguageProviderId val providerVersion: String fun translate(request: TranslateRequest): TranslateResult 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 fcb2c6d5..662c6ce4 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt @@ -17,6 +17,7 @@ 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.LanguageProviderId import kr.co.vividnext.sodalive.i18n.translation.LanguageProviderProperties import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService @@ -730,7 +731,7 @@ class ControlledLanguageDetectionCacheService( ) : LanguageDetectionCacheService( languageDetectionResultRepository, listOf(object : LanguageDetectionProvider { - override val providerName = "papago" + override val providerId = LanguageProviderId.PAPAGO override fun detect(query: String): String? = null }), LanguageProviderProperties() @@ -759,15 +760,15 @@ class ControlledLanguageDetectionCacheService( queries.add(query) val concurrentDetection = concurrentDetections[query] if (concurrentDetection != null) { - return DetectedLanguage(concurrentDetection.detect(), "papago") + return DetectedLanguage(concurrentDetection.detect(), LanguageProviderId.PAPAGO) } val blockingDetection = blockingDetections[query] if (blockingDetection != null) { blockingDetection.started.countDown() check(blockingDetection.release.await(5, TimeUnit.SECONDS)) - return DetectedLanguage(blockingDetection.detectedLanguage, "papago") + return DetectedLanguage(blockingDetection.detectedLanguage, LanguageProviderId.PAPAGO) } - return detections[query]?.let { DetectedLanguage(it, "papago") } + return detections[query]?.let { DetectedLanguage(it, LanguageProviderId.PAPAGO) } } } 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 25314e53..c33ab3f9 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheServiceTest.kt @@ -2,6 +2,7 @@ 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.LanguageProviderId import kr.co.vividnext.sodalive.i18n.translation.LanguageProviderProperties import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer import org.junit.jupiter.api.Assertions.assertEquals @@ -36,7 +37,7 @@ class LanguageDetectionCacheServiceTest { val detected = service.detectWithCache("Hello world") - assertEquals(DetectedLanguage("en", "papago"), detected) + assertEquals(DetectedLanguage("en", LanguageProviderId.PAPAGO), detected) assertEquals(0, providerCalls) Mockito.verify(repository).findBySourceHashAndProviderAndNormalizationVersion( sourceHash = sourceHash, @@ -80,7 +81,7 @@ class LanguageDetectionCacheServiceTest { val detected = service.detectWithCache("Hello world") - assertEquals(DetectedLanguage("ko", "papago"), detected) + assertEquals(DetectedLanguage("ko", LanguageProviderId.PAPAGO), detected) Mockito.verify(repository).findBySourceHashAndProviderAndNormalizationVersion( sourceHash = sourceHash, provider = "papago", @@ -104,7 +105,7 @@ class LanguageDetectionCacheServiceTest { private fun service(repository: LanguageDetectionResultRepository, detect: () -> String?): LanguageDetectionCacheService { val provider = object : LanguageDetectionProvider { - override val providerName = "papago" + override val providerId = LanguageProviderId.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 index 6fdae682..0e889ae1 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageDetectionProviderTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageDetectionProviderTest.kt @@ -3,6 +3,8 @@ 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 kr.co.vividnext.sodalive.i18n.translation.LanguageProviderId.AWS +import kr.co.vividnext.sodalive.i18n.translation.LanguageProviderId.PAPAGO import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertSame @@ -21,39 +23,39 @@ class LanguageDetectionProviderTest { 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") + val first = RecordingDetectionProvider(PAPAGO, failure) + val second = RecordingDetectionProvider(AWS) { "EN" } + Mockito.`when`(repository.findDetectedLanguageForUpdate(hash, "aws", "v1")).thenReturn("ko") - assertEquals(DetectedLanguage("ko", "second"), service(listOf(first, second)).detectWithCache(query)) + assertEquals(DetectedLanguage("ko", AWS), 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") + Mockito.verify(repository, Mockito.times(3)).insertIfAbsent(hash, "original source", "en", "aws", 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")) + val first = RecordingDetectionProvider(PAPAGO) { "ko" } + val second = RecordingDetectionProvider(AWS) { "en" } + Mockito.`when`(repository.findBySourceHashAndProviderAndNormalizationVersion(hash, "aws", "v1")) + .thenReturn(LanguageDetectionResult(hash, "sample", "ja", "aws", null, "v1")) - assertEquals(DetectedLanguage("ja", "second"), service(listOf(first, second)).detectWithCache(query)) + assertEquals(DetectedLanguage("ja", AWS), 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") + val first = RecordingDetectionProvider(PAPAGO) { "ko" } + val second = RecordingDetectionProvider(AWS) { "FR" } + Mockito.`when`(repository.findDetectedLanguageForUpdate(hash, "aws", "v1")).thenReturn("fr") assertEquals( - DetectedLanguage("fr", "second"), - service(listOf(first, second), listOf("second", "first")).detectWithCache(query) + DetectedLanguage("fr", AWS), + service(listOf(first, second), listOf(AWS, PAPAGO)).detectWithCache(query) ) assertEquals(emptyList(), first.queries) assertEquals(listOf(query), second.queries) @@ -62,8 +64,8 @@ class LanguageDetectionProviderTest { @Test @DisplayName("감지 전체 실패와 공백 입력은 null이고 공백은 저장소와 공급자를 호출하지 않는다") fun shouldReturnNullAfterAllFailuresAndSkipBlankInput() { - val first = RecordingDetectionProvider("first") { null } - val second = RecordingDetectionProvider("second") { null } + val first = RecordingDetectionProvider(PAPAGO) { null } + val second = RecordingDetectionProvider(AWS) { null } val service = service(listOf(first, second)) assertNull(service.detectWithCache(" \n")) Mockito.verifyNoInteractions(repository) @@ -75,28 +77,30 @@ class LanguageDetectionProviderTest { @Test @DisplayName("감지 저장소 조회와 저장 오류는 다음 공급자로 전환하지 않고 전파한다") fun shouldPropagateRepositoryFailuresWithoutFallback() { - val first = RecordingDetectionProvider("first") { "ko" } - val second = RecordingDetectionProvider("second") { "en" } + val first = RecordingDetectionProvider(PAPAGO) { "ko" } + val second = RecordingDetectionProvider(AWS) { "en" } val service = service(listOf(first, second)) val failure = IllegalStateException("database unavailable") - Mockito.`when`(repository.findBySourceHashAndProviderAndNormalizationVersion(hash, "first", "v1")) + Mockito.`when`(repository.findBySourceHashAndProviderAndNormalizationVersion(hash, "papago", "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) + Mockito.`when`(repository.insertIfAbsent(hash, "original source", "ko", "papago", 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(order, listOf("papago"))) + private fun service( + providers: List, + order: List = providers.map { it.providerId } + ) = LanguageDetectionCacheService(repository, providers, LanguageProviderProperties(order, listOf(PAPAGO))) } class RecordingDetectionProvider( - override val providerName: String, + override val providerId: LanguageProviderId, private val response: () -> String? ) : LanguageDetectionProvider { val queries = mutableListOf() 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 index 1d5e0203..c2e98f00 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderCacheEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderCacheEndToEndTest.kt @@ -6,6 +6,8 @@ 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.i18n.translation.LanguageProviderId.AWS +import kr.co.vividnext.sodalive.i18n.translation.LanguageProviderId.PAPAGO import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.MemberRole @@ -86,16 +88,16 @@ class LanguageProviderCacheEndToEndTest @Autowired constructor( @DisplayName("보조 공급자의 감지를 H2에 귀속 저장하고 외부 호출 없이 캐시를 재사용한다") fun shouldPersistSecondaryDetectionAndReuseCache() { val source = "provider source ${UUID.randomUUID()}" - assertEquals(DetectedLanguage("ko", "secondary"), detector.detectWithCache(source)) - assertEquals(DetectedLanguage("ko", "secondary"), detector.detectWithCache(source)) + assertEquals(DetectedLanguage("ko", AWS), detector.detectWithCache(source)) + assertEquals(DetectedLanguage("ko", AWS), detector.detectWithCache(source)) val detection = transaction.execute { detectionRepository.findBySourceHashAndProviderAndNormalizationVersion( SourceTextNormalizer.hash(source), - "secondary", + "aws", "v1" ) }!! - assertEquals("secondary", detection.provider) + assertEquals("aws", detection.provider) assertEquals("ko", detection.detectedLanguage) assertEquals(1, papagoDetection.queries.size) assertEquals(1, secondaryDetection.queries.size) @@ -113,19 +115,19 @@ class LanguageProviderCacheEndToEndTest @Autowired constructor( SourceTextNormalizer.hash(source), "ko", "en", - "secondary", + "aws", "v1" ) } assertNotNull(memory) - assertEquals("secondary", memory!!.provider) - assertEquals("secondary-v1", memory.providerVersion) - assertEquals("secondary:0", memory.translatedText) + assertEquals("aws", memory!!.provider) + assertEquals("AWS-v1", memory.providerVersion) + assertEquals("AWS: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) + assertEquals("AWS:0", displayService.findDisplayContents(listOf(postId), "en")[postId]?.content) transaction.executeWithoutResult { translations.deleteAll() @@ -137,7 +139,7 @@ class LanguageProviderCacheEndToEndTest @Autowired constructor( 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) + assertEquals("AWS:0", displayService.findDisplayContents(listOf(postId), "en")[postId]?.content) } private fun schedule(postId: Long, source: String) { @@ -162,20 +164,20 @@ class LanguageProviderCacheEndToEndTest @Autowired constructor( class Providers { @Bean fun languageProviderProperties() = LanguageProviderProperties( - listOf("papago", "secondary"), - listOf("papago", "secondary") + listOf(PAPAGO, AWS), + listOf(PAPAGO, AWS) ) @Bean - fun papagoDetection() = RecordingDetectionProvider("papago") { throw IllegalStateException("sensitive provider body") } + fun papagoDetection() = RecordingDetectionProvider(PAPAGO) { throw IllegalStateException("sensitive provider body") } @Bean - fun secondaryDetection() = RecordingDetectionProvider("secondary") { "ko" } + fun secondaryDetection() = RecordingDetectionProvider(AWS) { "ko" } @Bean - fun papagoTranslation() = RecordingTranslationProvider("papago") { TranslateResult(emptyList()) } + fun papagoTranslation() = RecordingTranslationProvider(PAPAGO) { TranslateResult(emptyList()) } @Bean - fun secondaryTranslation() = RecordingTranslationProvider("secondary") + fun secondaryTranslation() = RecordingTranslationProvider(AWS) } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderIdTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderIdTest.kt new file mode 100644 index 00000000..a384b7a2 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderIdTest.kt @@ -0,0 +1,56 @@ +package kr.co.vividnext.sodalive.i18n.translation + +import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.function.Executable + +class LanguageProviderIdTest { + @Test + @DisplayName("공급자 인터페이스와 성공 결과의 런타임 식별자는 enum이다") + fun shouldExposeEnumProviderIdentityOnInterfacesAndResults() { + assertAll( + *listOf( + LanguageDetectionProvider::class.java, + TranslationProvider::class.java, + DetectedLanguage::class.java, + ProviderTranslationResult::class.java + ).map { contract -> + Executable { + val getter = contract.methods.firstOrNull { it.name == "getProviderId" } + assertEquals( + "kr.co.vividnext.sodalive.i18n.translation.LanguageProviderId", + getter?.returnType?.name, + "${contract.simpleName}.getProviderId must return LanguageProviderId" + ) + assertTrue(getter?.returnType?.isEnum == true) + assertFalse(contract.methods.any { it.name == "getProviderName" }) + } + }.toTypedArray() + ) + } + + @Test + @DisplayName("공급자의 저장 code는 기존 소문자 값으로 고정한다") + fun shouldKeepStablePersistenceCodes() { + assertEquals("papago", LanguageProviderId.PAPAGO.code) + assertEquals("aws", LanguageProviderId.AWS.code) + assertEquals(listOf(LanguageProviderId.PAPAGO, LanguageProviderId.AWS), LanguageProviderId.values().toList()) + } + + @Test + @DisplayName("production 코드 목록과 실제 Papago adapter는 PAPAGO enum을 사용한다") + fun shouldUsePapagoEnumForProductionDefaultsAndAdapters() { + assertEquals(listOf(LanguageProviderId.PAPAGO), LanguageProviderOrder.detectionOrder) + assertEquals(listOf(LanguageProviderId.PAPAGO), LanguageProviderOrder.translationOrder) + assertEquals(listOf(LanguageProviderId.PAPAGO), LanguageProviderProperties().detectionOrder) + assertEquals(listOf(LanguageProviderId.PAPAGO), LanguageProviderProperties().translationOrder) + assertEquals(LanguageProviderId.PAPAGO, PapagoLanguageDetectionProvider("test-id", "test-secret").providerId) + val translator = PapagoTranslationService("test-id", "test-secret") + assertEquals(LanguageProviderId.PAPAGO, translator.providerId) + assertEquals("nmt-v1", translator.providerVersion) + } +} 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 index bc639e20..8d5f9744 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderPropertiesTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderPropertiesTest.kt @@ -1,5 +1,7 @@ package kr.co.vividnext.sodalive.i18n.translation +import kr.co.vividnext.sodalive.i18n.translation.LanguageProviderId.AWS +import kr.co.vividnext.sodalive.i18n.translation.LanguageProviderId.PAPAGO import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.DisplayName @@ -9,33 +11,33 @@ class LanguageProviderPropertiesTest { @Test @DisplayName("기능별 목록을 서로 독립된 설정 순서로 정렬한다") fun shouldOrderCapabilitiesIndependently() { - val properties = LanguageProviderProperties(listOf("second", "first"), listOf("first", "second")) + val properties = LanguageProviderProperties(listOf(AWS, PAPAGO), listOf(PAPAGO, AWS)) assertEquals( - listOf("second", "first"), - properties.orderedProviders(properties.detectionOrder, listOf("first", "second")) { it } + listOf(AWS, PAPAGO), + properties.orderedProviders(properties.detectionOrder, listOf(PAPAGO, AWS)) { it } ) assertEquals( - listOf("first", "second"), - properties.orderedProviders(properties.translationOrder, listOf("second", "first")) { it } + listOf(PAPAGO, AWS), + properties.orderedProviders(properties.translationOrder, listOf(AWS, PAPAGO)) { it } ) } @Test - @DisplayName("빈 순서와 빈 이름과 중복 순서와 미등록 공급자는 초기화에서 거부한다") + @DisplayName("빈 순서와 중복 순서와 미등록 AWS 공급자는 초기화에서 거부한다") fun shouldRejectInvalidOrders() { val properties = LanguageProviderProperties() - listOf(emptyList(), listOf(""), listOf("first", "first"), listOf("unknown")).forEach { order -> + listOf(emptyList(), listOf(PAPAGO, PAPAGO), listOf(AWS)).forEach { order -> assertThrows(IllegalArgumentException::class.java) { - properties.orderedProviders(order, listOf("first", "second")) { it } + properties.orderedProviders(order, listOf(PAPAGO)) { it } } } } @Test - @DisplayName("같은 기능 안에서 중복 등록된 공급자 이름은 초기화에서 거부한다") - fun shouldRejectDuplicateRegisteredNamesEvenWhenNotSelected() { + @DisplayName("같은 기능 안에서 선택되지 않은 공급자도 중복 등록을 거부한다") + fun shouldRejectDuplicateRegisteredIdsEvenWhenNotSelected() { assertThrows(IllegalArgumentException::class.java) { - LanguageProviderProperties().orderedProviders(listOf("papago"), listOf("papago", "other", "other")) { it } + LanguageProviderProperties().orderedProviders(listOf(PAPAGO), listOf(PAPAGO, AWS, AWS)) { it } } } } 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 index c127f746..fb11dcfc 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderSpringConfigurationTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderSpringConfigurationTest.kt @@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.i18n.translation import kr.co.vividnext.sodalive.content.LanguageDetectionCacheService import kr.co.vividnext.sodalive.content.LanguageDetectionResultRepository +import kr.co.vividnext.sodalive.i18n.translation.LanguageProviderId.AWS +import kr.co.vividnext.sodalive.i18n.translation.LanguageProviderId.PAPAGO import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull @@ -16,7 +18,8 @@ import org.springframework.core.env.SystemEnvironmentPropertySource import java.util.function.Supplier class LanguageProviderSpringConfigurationTest { - private val runner = ApplicationContextRunner().withUserConfiguration(Providers::class.java) + private val papagoRunner = ApplicationContextRunner().withUserConfiguration(Providers::class.java) + private val runner = papagoRunner.withUserConfiguration(AwsProviders::class.java) private val defaultRunner = runner.withUserConfiguration(LanguageProviderProperties::class.java) @Test @@ -28,8 +31,8 @@ class LanguageProviderSpringConfigurationTest { ).run { context -> assertNull(context.startupFailure) val properties = context.getBean(LanguageProviderProperties::class.java) - assertEquals(listOf("papago"), properties.detectionOrder) - assertEquals(listOf("papago"), properties.translationOrder) + assertEquals(listOf(PAPAGO), properties.detectionOrder) + assertEquals(listOf(PAPAGO), properties.translationOrder) } } @@ -54,8 +57,8 @@ class LanguageProviderSpringConfigurationTest { assertEquals("secondary,papago", context.environment.getProperty("sodalive.language-providers.detection-order")) assertEquals("secondary,papago", context.environment.getProperty("sodalive.language-providers.translation-order")) val properties = context.getBean(LanguageProviderProperties::class.java) - assertEquals(listOf("papago"), properties.detectionOrder) - assertEquals(listOf("papago"), properties.translationOrder) + assertEquals(listOf(PAPAGO), properties.detectionOrder) + assertEquals(listOf(PAPAGO), properties.translationOrder) } } @@ -63,39 +66,39 @@ class LanguageProviderSpringConfigurationTest { @DisplayName("Spring 테스트 bean의 감지 목록만 바꾸어도 번역 순서는 유지한다") fun shouldInjectIndependentCapabilityOrders() { listOf( - listOf("papago", "secondary") to "papago", - listOf("secondary", "papago") to "secondary" + listOf(PAPAGO, AWS) to PAPAGO, + listOf(AWS, PAPAGO) to AWS ).forEach { (order, expected) -> runner.withBean( LanguageProviderProperties::class.java, - Supplier { LanguageProviderProperties(order, listOf("papago", "secondary")) } + Supplier { LanguageProviderProperties(order, listOf(PAPAGO, AWS)) } ).run { context -> assertNull(context.startupFailure) val detector = context.getBean(LanguageDetectionCacheService::class.java) - assertEquals(expected, detector.detectWithCache("source")?.providerName) + assertEquals(expected, detector.detectWithCache("source")?.providerId) assertEquals( - "papago", + PAPAGO, context.getBean(TranslationCoordinator::class.java) - .translate(TranslateRequest(listOf("source"), "ko", "en")).providerName + .translate(TranslateRequest(listOf("source"), "ko", "en")).providerId ) val secondary = context.getBean("secondaryDetection", RecordingDetectionProvider::class.java) - assertEquals(if (expected == "secondary") 1 else 0, secondary.queries.size) + assertEquals(if (expected == AWS) 1 else 0, secondary.queries.size) assertEquals(0, context.getBean("secondaryTranslation", RecordingTranslationProvider::class.java).requests.size) } } runner.withBean( LanguageProviderProperties::class.java, - Supplier { LanguageProviderProperties(listOf("papago", "secondary"), listOf("secondary", "papago")) } + Supplier { LanguageProviderProperties(listOf(PAPAGO, AWS), listOf(AWS, PAPAGO)) } ).run { context -> assertNull(context.startupFailure) assertEquals( - "papago", - context.getBean(LanguageDetectionCacheService::class.java).detectWithCache("source")?.providerName + PAPAGO, + context.getBean(LanguageDetectionCacheService::class.java).detectWithCache("source")?.providerId ) assertEquals( - "secondary", + AWS, context.getBean(TranslationCoordinator::class.java) - .translate(TranslateRequest(listOf("source"), "ko", "en")).providerName + .translate(TranslateRequest(listOf("source"), "ko", "en")).providerId ) } } @@ -106,29 +109,29 @@ class LanguageProviderSpringConfigurationTest { defaultRunner.run { context -> assertNull(context.startupFailure) val properties = context.getBean(LanguageProviderProperties::class.java) - assertEquals(listOf("papago"), properties.detectionOrder) - assertEquals(listOf("papago"), properties.translationOrder) + assertEquals(listOf(PAPAGO), properties.detectionOrder) + assertEquals(listOf(PAPAGO), properties.translationOrder) assertEquals( - "papago", - context.getBean(LanguageDetectionCacheService::class.java).detectWithCache("source")?.providerName + PAPAGO, + context.getBean(LanguageDetectionCacheService::class.java).detectWithCache("source")?.providerId ) assertEquals( - "papago", + PAPAGO, context.getBean(TranslationCoordinator::class.java) - .translate(TranslateRequest(listOf("source"), "ko", "en")).providerName + .translate(TranslateRequest(listOf("source"), "ko", "en")).providerId ) } } @Test - @DisplayName("Spring 테스트 bean의 빈 목록과 빈 이름과 중복 순서와 미등록 이름은 시작 시 실패한다") + @DisplayName("Spring 테스트 bean의 빈 목록과 중복 순서와 해당 기능의 AWS 구현체 부재는 시작 시 실패한다") fun shouldRejectInvalidOrdersAtStartup() { - listOf(emptyList(), listOf(""), listOf("papago", "papago"), listOf("unknown")).forEach { order -> + listOf(emptyList(), listOf(PAPAGO, PAPAGO), listOf(AWS)).forEach { order -> listOf( - LanguageProviderProperties(order, listOf("papago")), - LanguageProviderProperties(listOf("papago"), order) + LanguageProviderProperties(order, listOf(PAPAGO)), + LanguageProviderProperties(listOf(PAPAGO), order) ).forEach { properties -> - runner.withBean(LanguageProviderProperties::class.java, Supplier { properties }).run { context -> + papagoRunner.withBean(LanguageProviderProperties::class.java, Supplier { properties }).run { context -> assertNotNull(context.startupFailure) } } @@ -144,7 +147,7 @@ class LanguageProviderSpringConfigurationTest { @Bean fun detectionRepository(): LanguageDetectionResultRepository { val repository = Mockito.mock(LanguageDetectionResultRepository::class.java) - listOf("papago", "secondary").forEach { provider -> + listOf("papago", "aws").forEach { provider -> Mockito.`when`(repository.findDetectedLanguageForUpdate(SourceTextNormalizer.hash("source"), provider, "v1")) .thenReturn("ko") } @@ -152,15 +155,18 @@ class LanguageProviderSpringConfigurationTest { } @Bean - fun papagoDetection() = RecordingDetectionProvider("papago") { "ko" } + fun papagoDetection() = RecordingDetectionProvider(PAPAGO) { "ko" } @Bean - fun secondaryDetection() = RecordingDetectionProvider("secondary") { "ko" } + fun papagoTranslation() = RecordingTranslationProvider(PAPAGO) + } + + @TestConfiguration + class AwsProviders { + @Bean + fun secondaryDetection() = RecordingDetectionProvider(AWS) { "ko" } @Bean - fun papagoTranslation() = RecordingTranslationProvider("papago") - - @Bean - fun secondaryTranslation() = RecordingTranslationProvider("secondary") + fun secondaryTranslation() = RecordingTranslationProvider(AWS) } } 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 index 555ddef2..b23dc76c 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationCoordinatorTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationCoordinatorTest.kt @@ -1,5 +1,7 @@ package kr.co.vividnext.sodalive.i18n.translation +import kr.co.vividnext.sodalive.i18n.translation.LanguageProviderId.AWS +import kr.co.vividnext.sodalive.i18n.translation.LanguageProviderId.PAPAGO import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertSame @@ -15,15 +17,15 @@ class TranslationCoordinatorTest { @Test @DisplayName("설정 순서의 첫 성공 공급자와 버전을 반환하고 후속 호출을 멈춘다") fun shouldUseConfiguredOrderAndStopAfterSuccess() { - val first = RecordingTranslationProvider("first") - val second = RecordingTranslationProvider("second") - val coordinator = coordinator(listOf(first, second), listOf("second", "first")) + val first = RecordingTranslationProvider(PAPAGO) + val second = RecordingTranslationProvider(AWS) + val coordinator = coordinator(listOf(first, second), listOf(AWS, PAPAGO)) val result = coordinator.translate(request) - assertEquals("second", result.providerName) - assertEquals("second-v1", result.providerVersion) - assertEquals(listOf("second:0", "second:1"), result.result.translatedText) + assertEquals(AWS, result.providerId) + assertEquals("AWS-v1", result.providerVersion) + assertEquals(listOf("AWS:0", "AWS:1"), result.result.translatedText) assertEquals(listOf(request), second.requests) assertEquals(emptyList(), first.requests) } @@ -37,11 +39,11 @@ class TranslationCoordinatorTest { { TranslateResult(listOf("translated", " ")) } ) failures.forEach { failure -> - val first = RecordingTranslationProvider("first", failure) - val second = RecordingTranslationProvider("second") + val first = RecordingTranslationProvider(PAPAGO, failure) + val second = RecordingTranslationProvider(AWS) val coordinator = coordinator(listOf(first, second)) - assertEquals("second", coordinator.translate(request).providerName) + assertEquals(AWS, coordinator.translate(request).providerId) assertEquals(listOf(request), first.requests) assertEquals(listOf(request), second.requests) } @@ -50,8 +52,8 @@ class TranslationCoordinatorTest { @Test @DisplayName("전체 공급자 실패는 원문이나 원시 예외 원인 없이 고정 메시지를 반환한다") fun shouldSanitizeAllProviderFailures() { - val first = RecordingTranslationProvider("first") { throw IllegalStateException("sensitive response body") } - val second = RecordingTranslationProvider("second") { TranslateResult(emptyList()) } + val first = RecordingTranslationProvider(PAPAGO) { throw IllegalStateException("sensitive response body") } + val second = RecordingTranslationProvider(AWS) { TranslateResult(emptyList()) } val error = assertThrows(IllegalStateException::class.java) { coordinator(listOf(first, second)).translate(request) @@ -66,7 +68,7 @@ class TranslationCoordinatorTest { @Test @DisplayName("빈 목록과 공백과 동일 언어와 미지원 언어는 공급자 호출 전에 거부한다") fun shouldRejectInvalidRequestsBeforeCallingProviders() { - val provider = RecordingTranslationProvider("first") + val provider = RecordingTranslationProvider(PAPAGO) val coordinator = coordinator(listOf(provider)) listOf( request.copy(texts = emptyList()), @@ -85,17 +87,17 @@ class TranslationCoordinatorTest { @Test @DisplayName("모든 설정 공급자 캐시를 호출보다 먼저 조회하고 순서상 첫 유효 캐시를 선택한다") fun shouldFindSecondaryCacheAndPreferConfiguredFirstValidCache() { - val first = RecordingTranslationProvider("first") - val second = RecordingTranslationProvider("second") + val first = RecordingTranslationProvider(PAPAGO) + val second = RecordingTranslationProvider(AWS) val coordinator = coordinator(listOf(first, second)) - val secondary = memory("second", "cached secondary") - givenMemory("second", secondary) + val secondary = memory("aws", "cached secondary") + givenMemory("aws", secondary) assertSame(secondary, coordinator.findCached("hash", "ko", "en")) - givenMemory("first", memory("first", " ")) + givenMemory("papago", memory("papago", " ")) assertSame(secondary, coordinator.findCached("hash", "ko", "en")) - val primary = memory("first", "cached primary") - givenMemory("first", primary) + val primary = memory("papago", "cached primary") + givenMemory("papago", primary) assertSame(primary, coordinator.findCached("hash", "ko", "en")) assertEquals(emptyList(), first.requests + second.requests) } @@ -103,14 +105,14 @@ class TranslationCoordinatorTest { @Test @DisplayName("캐시 저장소 오류는 공급자 실패로 숨기지 않는다") fun shouldPropagateCacheRepositoryFailure() { - val provider = RecordingTranslationProvider("first") + val provider = RecordingTranslationProvider(PAPAGO) val failure = IllegalStateException("database unavailable") Mockito.`when`( repository.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion( "hash", "ko", "en", - "first", + "papago", "v1" ) ).thenThrow(failure) @@ -124,8 +126,8 @@ class TranslationCoordinatorTest { private fun coordinator( providers: List, - order: List = providers.map { it.providerName } - ) = TranslationCoordinator(providers, LanguageProviderProperties(listOf("papago"), order), repository) + order: List = providers.map { it.providerId } + ) = TranslationCoordinator(providers, LanguageProviderProperties(listOf(PAPAGO), order), repository) private fun memory(provider: String, text: String) = TranslationMemory( "hash", @@ -151,12 +153,12 @@ class TranslationCoordinatorTest { } class RecordingTranslationProvider( - override val providerName: String, + override val providerId: LanguageProviderId, private val response: (TranslateRequest) -> TranslateResult = { request -> - TranslateResult(request.texts.indices.map { "$providerName:$it" }) + TranslateResult(request.texts.indices.map { "$providerId:$it" }) } ) : TranslationProvider { - override val providerVersion = "$providerName-v1" + override val providerVersion = "$providerId-v1" val requests = mutableListOf() override fun translate(request: TranslateRequest): TranslateResult { 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 b20baa7b..d98e1f72 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 @@ -236,7 +236,7 @@ class TranslationJobWorkerTest { private fun successfulProvider(): TranslationProvider { return object : TranslationProvider { - override val providerName: String = "papago" + override val providerId: LanguageProviderId = LanguageProviderId.PAPAGO override val providerVersion: String = "nmt-v1" override fun translate(request: TranslateRequest): TranslateResult { @@ -247,7 +247,7 @@ class TranslationJobWorkerTest { private fun failingProvider(): TranslationProvider { return object : TranslationProvider { - override val providerName: String = "papago" + override val providerId: LanguageProviderId = LanguageProviderId.PAPAGO override val providerVersion: String = "nmt-v1" override fun translate(request: TranslateRequest): TranslateResult { -- 2.54.0 From 2bd2328548a04a5d79bfce74911572b059529e6f Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 9 Nov 2026 19:15:27 +0900 Subject: [PATCH 07/12] =?UTF-8?q?docs(i18n):=20=EA=B3=B5=EA=B8=89=EC=9E=90?= =?UTF-8?q?=20enum=EA=B3=BC=20DB=20code=20=ED=98=B8=ED=99=98=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=EC=9D=84=20=EA=B8=B0=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 223 +++++++++++++++++- docs/20261109_감지번역공급자공통화/prd.md | 24 +- .../provider-guide.md | 60 +++-- .../reviews/phase-3-review.md | 32 +++ 4 files changed, 311 insertions(+), 28 deletions(-) create mode 100644 docs/20261109_감지번역공급자공통화/reviews/phase-3-review.md diff --git a/docs/20261109_감지번역공급자공통화/plan-task.md b/docs/20261109_감지번역공급자공통화/plan-task.md index 9811c43e..1843a6db 100644 --- a/docs/20261109_감지번역공급자공통화/plan-task.md +++ b/docs/20261109_감지번역공급자공통화/plan-task.md @@ -3,8 +3,8 @@ | 항목 | 내용 | |---|---| | 작성일 | 2026-11-09 | -| 상태 | Phase 1·2 구현·검증·리뷰·리소스 정리 완료 | -| 요구사항 | [prd.md](prd.md), LP-001~012 및 확정 구현 계약 | +| 상태 | Phase 1~3 구현·검증·독립 리뷰·리소스 정리 완료 | +| 요구사항 | [prd.md](prd.md), LP-001~015 및 최종 Phase 계약 | | 활성 Goal | 없음 | ## 목표와 제약 @@ -496,3 +496,222 @@ parent `provider-order-code.RRCyJ1`, 부모 QA의 형제 경로, 기존 공유 d `provider-order-code.RRCyJ1`(driver/class/라이브러리/로그)만 제거했다. `test ! -e` exit 0. 출력: `CLEANUP PASS: owned provider-order-code.RRCyJ1 removed; shared caches untouched`. 필요한 증거는 이 문서에 이관했으며 임시 노트도 삭제한다. 다른 작업의 리소스는 건드리지 않았다. + +### Phase 3: 공급자 enum과 DB code 경계 + +- [x] **Task 3.1: 식별자 enum 전환 (P3-T1)** + +**Objective:** LP-013~015에 따라 코드 내부 공급자 식별은 enum, 저장 값은 기존 안정 문자열로 유지한다. +**시작 조건:** 사용자 enum 구현 승인, Phase 2 완료, 변경 전 focused baseline 88건 확인. +**완료 증거:** enum 타입 계약 RED/GREEN, 기존 papago 데이터 재사용 및 code 저장 호환, focused 회귀·lint·bootJar. +**범위 밖:** AWS 실제 adapter/SDK, DDL/entity/repository 타입 변경, 공급자 정책·캐시 정책·언어 정책·분할·인증 변경. + +**Files (src/main/kotlin/kr/co/vividnext/sodalive/ 기준):** +- Add: `i18n/translation/LanguageProviderId.kt`. +- Modify: 같은 패키지 `LanguageProviderOrder.kt`, `LanguageProviderProperties.kt`, `TranslationProvider.kt`, + `LanguageDetectionProvider.kt`, `DetectedLanguage.kt`, `ProviderTranslationResult.kt`, 두 Papago adapter, + `TranslationCoordinator.kt`, `TranslationJobWorker.kt`; `content/LanguageDetectionCacheService.kt`. +- Update: 직접 영향 테스트의 fake provider/순서/결과 타입. DB 문자열 기대값은 유지한다. +- Update: 현재 `provider-guide.md`와 이 PRD/plan. 이전 완료 증거/리뷰는 보존한다. + +**계약:** PRD Phase 3 그대로 적용한다. `orderedProviders`는 enum 목록과 `(T) -> LanguageProviderId`로 +선택한다. 존재하지 않는 문자열/blank ID 테스트는 정적 타입으로 대체되며 AWS ID에 해당 구현체가 없으면 거부한다. +테스트 first/second/secondary 공급자는 PAPAGO/AWS로 옮기고, 번역 본문 fixture의 같은 문자열은 바꾸지 않는다. + +- [x] **RED:** 컴파일 가능한 기존 인터페이스/결과에 대한 runtime getter 반환 타입 assertion으로 새 enum 식별 계약의 실패를 재현한다. missing symbol 컴파일 오류를 RED로 보고하지 않는다. +- [x] **GREEN:** enum·providerId·typed 순서 및 DB 경계 `.code` 변환을 한 단위로 구현한다. +- [x] **GREEN 확인:** enum code 고정값, 원래 papago 캐시 재사용, 실제 DB의 papago/aws 문자열 저장·조회, 결과 enum 귀속이 통과한다. +- [x] **REFACTOR:** 관련 fixture만 정리하고 빈 목록/중복/미등록 AWS 검증·감지/번역 순서·실패 전환·경합·분할 회귀를 실행한다. + +**검증 명령:** Phase 2의 결합 focused 명령(번역 package·감지 cache·커뮤니티 감지/경합·번역 service·end-to-end)을 +그대로 실행하며 새 enum 테스트도 번역 package 필터에 포함한다. `--no-daemon --offline`, timeout600000 사용. +`ktlintCheck bootJar`를 함께 검증한다. 전체 테스트는 사용자 실행 `./gradlew test`로 남긴다. + +- [x] **Phase 3 Gate (P3-GATE): typed 실행·저장 호환·리뷰** + +**Objective:** production 목록/구현체/결과가 enum이며 DB 값은 lowercase code임을 독립 검증한다. +**시작 조건:** P3-T1 통과. +**완료 증거:** 부모 Java/Spring enum driver, H2 DB 호환 테스트 대조, 한 명의 독립 리뷰 승인, 소유 리소스 정리. +**TDD 예외:** 독립 실행 확인·리뷰이며 새 구현을 추가하지 않는다. +**범위 밖:** 실제 유료 API·운영 MySQL·전체 테스트. LSP 미제공은 컴파일/lint 보완과 함께 명시한다. + +### P3 초기 증거 + +- 변경 전 깨끗한 작업 트리를 확인했고 Phase 2 결합 focused baseline은 exit 0, `BUILD SUCCESSFUL in 45s`였다. +- SQL/entity의 provider String과 기존 code 값을 유지하는 계약을 확정했다. enum은 실제 AWS 연동을 의미하지 않는다. + +### P3-T1 실행 증거 (2026-11-09) + +부모가 직전에 확인한 baseline 88건(45초)을 재실행하지 않았다. 승인된 최종 Phase 3 계약으로 기존 작업을 이어갔다. +PRD는 수정하지 않았으며 Phase 1·2 실행 이력과 기존 리뷰도 보존했다. P3-T1만 완료하고 P3-GATE는 부모가 판정한다. + +**실행 환경:** parent를 read 및 `ls -ld`로 확인하고 `mktemp -d "$TMPDIR/phase3.XXXXXX"`로 +이 구현자만 소유하는 child를 생성했다. 생성 후 모든 shell 명령에서 아래 세 환경변수를 지정했다. +모든 Gradle 실행은 `--no-daemon --offline`, tool timeout `600000ms`다. + +```bash +export TMPDIR=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/provider-id-enum.jLt5Ki/phase3.eX59R8 +export NODE_COMPILE_CACHE="$TMPDIR" BUN_RUNTIME_TRANSPILER_CACHE_PATH="$TMPDIR" +``` + +**RED (1건, failures 1, errors/skipped 0, exit 1, 19초):** production 변경 전에 `LanguageProviderIdTest`를 추가했다. +reflection의 `"getProviderId"`와 반환 타입의 전체 이름 문자열을 사용하므로 구 코드에서도 정상 컴파일된다. +JUnit `assertAll`에서 두 raw interface 및 두 성공 결과의 getter가 모두 없어 expected enum type / actual null인 +`AssertionFailedError` 4개가 발생했다. missing symbol·컴파일 오류·예외 기반 실패가 아닌 런타임 assertion RED다. + +```bash +./gradlew --no-daemon --offline test --tests 'kr.co.vividnext.sodalive.i18n.translation.LanguageProviderIdTest' +``` + +**GREEN:** enum·인터페이스·adapter·결과·순서·매칭·직접 영향 fixture를 한 단위로 전환한 뒤 결합 회귀를 한 번 실행했다. +`PAPAGO("papago")`, `AWS("aws")`만 정의했고 production 기본 목록은 두 기능 모두 PAPAGO 하나다. +`providerVersion`은 String 그대로이며 `providerName` alias/호환 wrapper와 test-only enum은 없다. +`.code` 사용은 cache 조회/insert/locking read 3곳, coordinator 조회 1곳, worker 조회/저장 2곳의 DB 경계뿐이다. +production 식별자 리터럴 `"papago"`는 enum 선언 한 곳에만 남았다. HTTP URL·인증 key·과거 env 무시 문자열은 유지했다. + +**보존/저장 검증:** 아래 assertion들은 기대값을 enum에서 계산하지 않는다. + +- 기존 Papago 감지 cache hit는 `"papago"` fixture를 재사용하고 외부 호출 0회를 검증한다. + 기존 H2 최초 감지 경합 2건은 저장값 `"papago"`, 승자 반환, target·job 수와 revision을 그대로 검증한다. +- H2 가짜 보조 공급자는 AWS enum이다. 감지/번역을 별도 트랜잭션에서 `"aws"`로 재조회하고 실제 provider를 확인한다. + 번역 memory의 실제 version은 `"AWS-v1"`, 본문과 materializer 표시값은 `"AWS:0"`이며 재사용 후 호출 수는 각각 1회다. + 이는 테스트 adapter가 만드는 version/본문 fixture이고 DB provider code `"aws"`와 별도다. +- 독립 감지/번역 순서·첫 성공 중단·전체 요청 폴백·개수 불일치/blank·전체 실패·DB 오류 전파 assertion을 유지했다. + 기존 Papago HTTP/chunk 17건의 `first`/`second` 본문 fixture는 변경하지 않았다. +- 빈 문자열 ID만 enum으로 표현할 수 없어 제거했다. 빈 목록·중복 순서·미선택 구현체의 중복 ID 검증은 유지했다. + 기존 unknown 문자열 사례는 AWS enum 순서와 해당 구현체 부재로 바꾸고, 실제 Spring에서 감지/번역 각각 시작 실패를 확인했다. + +**최종 결합 focused + lint/산출물 (exit 0, 2분 45초):** `:test`, `:ktlintCheck`, `:bootJar`가 모두 실제 실행됐다. +`BUILD SUCCESSFUL`을 확인했으며 XML 16개를 직접 집계했다. 불필요한 baseline/중간 전체 컴파일 반복은 하지 않았다. + +```bash +./gradlew --no-daemon --offline test \ + --tests 'kr.co.vividnext.sodalive.i18n.translation.*' \ + --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' \ + --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' \ + --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectionCacheConcurrencyTest' \ + --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' \ + --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityTranslationEndToEndTest' \ + ktlintCheck bootJar +``` + +| XML로 확인한 범위 | 결과 | +|---|---| +| 번역 package + 감지 cache | 57/57 | +| 커뮤니티 감지 + cache 경합 | 14/14 (12 + 2) | +| 커뮤니티 번역 service + 기존 end-to-end | 20/20 (16 + 4) | +| 합계 | 91/91, failures/errors/skipped 모두 0 | + +신규 enum 테스트 3건(런타임 타입·code·production 기본값/실제 adapter ID)이 baseline 대비 증가분이다. +기존 unrelated Kotlin 경고 7개(불필요한 `!!` 4, unchecked cast 3)와 Gradle 9 deprecation 경고는 남아 있다. +제공된 LSP/diagnostics 도구는 없으며 Kotlin 컴파일·ktlint로 보완했다. 성공한 LSP 진단으로 보고하지 않는다. + +### Phase 3 부모 Java QA용 실제 생성자/Getter + +`javap -classpath build/classes/kotlin/main`으로 다음 바인딩을 확인했다(exit 0). +패키지는 content service를 제외하면 `kr.co.vividnext.sodalive.i18n.translation`이다. +Java enum 자체가 자동 생성하는 `valueOf`는 존재하지만 production의 역변환이나 저장에 사용하지 않는다. + +```text +LanguageProviderId.PAPAGO / AWS; getCode(): String +LanguageProviderProperties(List, List) +LanguageProviderProperties() // @Autowired, production object 목록으로 위임 +getDetectionOrder(): List; getTranslationOrder(): List +LanguageProviderOrder.INSTANCE.getDetectionOrder/getTranslationOrder(): List +orderedProviders(List, List, Function1) +LanguageDetectionProvider.getProviderId(): LanguageProviderId; detect(String): String? +TranslationProvider.getProviderId(): LanguageProviderId; getProviderVersion(): String +DetectedLanguage(String detectedLanguage, LanguageProviderId providerId); getProviderId(): LanguageProviderId +ProviderTranslationResult(TranslateResult result, LanguageProviderId providerId, String providerVersion) +ProviderTranslationResult.getProviderId(): LanguageProviderId; getProviderVersion(): String +PapagoLanguageDetectionProvider(String clientId, String clientSecret); getProviderId(): LanguageProviderId +PapagoTranslationService(String clientId, String clientSecret); getProviderId(): LanguageProviderId +TranslationCoordinator(List, LanguageProviderProperties, TranslationMemoryRepository) +LanguageDetectionCacheService(LanguageDetectionResultRepository, List, + LanguageProviderProperties) +``` + +### P3-T1 변경 파일 전체 (23개) + +production prefix: `src/main/kotlin/kr/co/vividnext/sodalive/` (12개). + +```text +A i18n/translation/LanguageProviderId.kt +M i18n/translation/LanguageProviderOrder.kt +M i18n/translation/LanguageProviderProperties.kt +M i18n/translation/TranslationProvider.kt +M i18n/translation/LanguageDetectionProvider.kt +M i18n/translation/DetectedLanguage.kt +M i18n/translation/ProviderTranslationResult.kt +M i18n/translation/PapagoLanguageDetectionProvider.kt +M i18n/translation/PapagoTranslationService.kt +M i18n/translation/TranslationCoordinator.kt +M i18n/translation/TranslationJobWorker.kt +M content/LanguageDetectionCacheService.kt +``` + +test prefix: `src/test/kotlin/kr/co/vividnext/sodalive/` (9개). + +```text +A i18n/translation/LanguageProviderIdTest.kt +M i18n/translation/LanguageProviderPropertiesTest.kt +M i18n/translation/LanguageProviderSpringConfigurationTest.kt +M i18n/translation/LanguageProviderCacheEndToEndTest.kt +M i18n/translation/LanguageDetectionProviderTest.kt +M i18n/translation/TranslationCoordinatorTest.kt +M i18n/translation/TranslationJobWorkerTest.kt +M content/LanguageDetectionCacheServiceTest.kt +M content/CreatorCommunityLanguageDetectTest.kt +``` + +문서는 현재 `provider-guide.md`와 이 `plan-task.md`만 변경했다(2개). +부모가 사전 갱신한 `prd.md`는 수정하지 않았다. entity/repository/DDL·설정·domain service·실제 AWS/SDK는 변경하지 않았다. +`CreatorCommunityLanguageDetectionCacheConcurrencyTest`와 community 번역 service/end-to-end는 수정 없이 회귀 실행했다. + +**한계/부모 경계:** H2의 기존 MySQL claim SQL test spy는 변경하지 않았다. 실제 DB 저장·재조회·표시는 검증했지만 +운영 MySQL claim 동시성은 새로 증명하지 않는다. 유료 API·운영 DB·전체 테스트는 실행하지 않았다. +무필터 test/check/build 대신 요청된 필터와 bootJar만 실행했고 사용자 전체 검증 명령은 `./gradlew test`다. +Java/Spring typed driver와 한 명의 독립 reviewer, P3-GATE 판정은 부모가 맡는다. +이 구현자는 staging/commit/push/worktree/하위 에이전트 작업을 하지 않았다. + +### P3-T1 소유 임시 자원 정리 + +- 정리 대상은 생성 시 확인한 child `provider-id-enum.jLt5Ki/phase3.eX59R8` 하나다. + RED/GREEN 로그의 명령·횟수·실패 원인·결과를 이 문서에 이관한 뒤 삭제한다. +- Gradle/test 실행 종료 후 `lsof -nP +D "$TMPDIR"` 출력이 없었다. Java process 목록에도 이 child를 사용하는 + Gradle/test/compiler 프로세스가 없다. 기존 shared Gradle daemon(PID 85602)은 종료하거나 변경하지 않는다. +- 정확한 child 경로와 존재를 `test`로 대조한 다음 이 child만 삭제했다. parent와 공유 cache는 유지했다. +- 완료 영수증: `2026-11-09 19:02:10 KST`, lsof exit 1(열린 파일 없음), `test ! -e "$TMPDIR"` 및 + parent `ls -ld` exit 0. 출력은 `CLEANUP PASS: owned phase3.eX59R8 removed; parent and shared caches untouched`다. + 로그/일회성 임시 파일만 제거했으며 필요한 증거는 위에 보존했다. + +### P3-GATE 부모 독립 검증 + +- 부모가 신규 enum/테스트, 모든 production/test diff와 현재 가이드를 직접 대조했다. + production의 `"papago"` 식별자 리터럴은 enum 선언 1곳, `.code`는 기존 DB 접점 6곳이다. + `providerName` alias와 providerId의 `.name`/`.ordinal`/`valueOf` 사용은 발견되지 않았다. +- Phase 3 결합 focused 명령에 `test --rerun`을 적용하고 `ktlintCheck bootJar`를 함께 실행했다. + exit 0, `BUILD SUCCESSFUL in 52s`, `:test` 실제 실행, 나머지 현재 컴파일/lint/build 18개 작업은 UP-TO-DATE였다. + 부모가 fresh XML 16개를 직접 확인: 91건, failures/errors/skipped 0. +- 별도 Java 17 `EnumProviderQa`에서 실제 컴파일 클래스와 Spring context를 실행했다. + raw interface·결과 4개 runtime getter의 enum 반환 타입, 실제 두 Papago adapter의 ID와 code 고정값, + PAPAGO-only 기본값을 확인했다. +- 메모리 repository 대역에 기존 `"papago"` 캐시를 미리 넣고 provider 호출 0회로 재사용함을 확인했다. + 가짜 PAPAGO 실패 → AWS 성공에서 결과는 AWS enum이며 감지 저장/조회 인자는 소문자 `"aws"`였다. + 모든 repository provider 인자가 `"papago"` 또는 `"aws"`임을 독립 문자열 기대값으로 검사했다. + AWS enum만 순서에 있고 해당 구현체가 없으면 Spring 초기화가 실패했다. +- driver 출력: `QA PASS: enum runtime and persistence-boundary compatibility; all Spring contexts closed; no API calls`. + exit 0이고 모든 context를 종료했다. 이 driver의 repository는 대역이며 실제 DB 저장 증거는 H2 테스트와 구분한다. +- 실제 H2 AWS code 저장/재조회·worker/materializer 표시 및 기존 Papago 경합 테스트도 위 fresh 명령에 포함됐다. + 전체 테스트·운영 MySQL·유료 API·LSP 미검증 범위는 기존대로 유지한다. +- 독립 Phase 3 gate 리뷰: **APPROVE**, 확정 발견 사항·차단 이슈 없음. + [리뷰 보고서](reviews/phase-3-review.md). XML 16개와 모든 변경을 대조했고 `git diff --check`도 통과했다. +- 인터페이스·구현체·결과·호출자·fixture의 타입 변경은 분리하면 컴파일되지 않으므로 코드/테스트를 한 원자적 커밋으로 묶는다. + 요구사항·가이드·검증·리뷰 기록은 별도 문서 커밋으로 남긴다. push하지 않는다. +- 코드·테스트 커밋: `ec890047`. 변경 Kotlin hook ktlint와 메시지 사전/사후 검증이 통과했다. + 코드 커밋 후 작업 트리에는 이 작업의 문서 변경만 남았음을 확인했다. +- 부모 최종 정리: 모든 실행이 끝난 뒤 `lsof -nP +D` 출력이 없었다. 생성·내용 확인한 소유 + `provider-id-enum.jLt5Ki`만 삭제했고 `test ! -e` exit 0을 확인했다. + 출력: `CLEANUP PASS: owned provider-id-enum.jLt5Ki removed; shared caches untouched`. + driver/class/추출 라이브러리/로그를 제거했으며 필요한 증거는 이 문서에 남겼다. + 임시 노트도 이관 후 삭제한다. 공유 cache·다른 작업의 프로세스/파일은 변경하지 않았다. diff --git a/docs/20261109_감지번역공급자공통화/prd.md b/docs/20261109_감지번역공급자공통화/prd.md index 3eb02044..294495cb 100644 --- a/docs/20261109_감지번역공급자공통화/prd.md +++ b/docs/20261109_감지번역공급자공통화/prd.md @@ -3,7 +3,7 @@ | 항목 | 내용 | |---|---| | 작성일 | 2026-11-09 | -| 상태 | Phase 1·2 구현 및 기능 범위 검증·독립 리뷰 완료 | +| 상태 | Phase 1~3 구현·기능 범위 검증·독립 리뷰 완료 | | 승인 범위 | 감지·번역 별도 인터페이스, 공통 공급자 선택·실패 전환, 기능별 순서 설정 | | 계획 | [plan-task.md](plan-task.md) | @@ -32,11 +32,14 @@ | LP-005 | 캐시는 실제 성공 공급자 이름으로 저장한다. AWS 역할의 가짜 공급자 성공을 papago 이름으로 저장하지 않는다. | | LP-006 | 설정된 공급자들의 유효 캐시를 우선 재사용한다. 새 외부 호출의 순서는 별도 설정 순서를 따른다. 워커와 결과 조립의 캐시 선택 정책이 같다. | | LP-007 | 빈 원문·동일 언어·서비스 미지원 번역 언어는 불필요한 공급자 전환을 일으키지 않는다. 유효한 미지원 언어 감지 결과는 감지 장애와 구분한다. | -| LP-008 | 빈/중복/알 수 없는 공급자 순서 설정은 초기화 단계에서 명확히 거부한다. 새 구현체 추가 시 기존 선택 코드나 공급자 enum 수정이 필요 없다. | +| LP-008 | 빈/중복/미등록 구현체의 공급자 순서는 초기화 단계에서 거부한다. 선택 코드에 공급자 분기를 추가하지 않는다. Phase 3부터 새 공급자는 enum 항목을 추가한다. | | LP-009 | 기존 파파고 캐시·번역 데이터 및 분할 번역 동작과 감지 캐시 동시성 승자 반환을 보존한다. | | LP-010 | 추가 방법과 기능별 순서 설정 예시, 실제 AWS 미구현 상태를 문서화한다. | | LP-011 | 공급자 순서는 `LanguageProviderOrder`의 독립 `val List` 두 개로 지정한다. 기본은 각각 `listOf("papago")`이다. | | LP-012 | 우선순위 환경변수·YAML·문자열 파싱을 제거한다. 기존 우선순위 property/env 값이 있어도 production 순서를 바꾸지 않는다. 인증정보 환경변수는 유지한다. | +| LP-013 | 순서 목록·감지/번역 인터페이스·구현체·성공 결과·매칭의 식별자는 `LanguageProviderId` enum으로 통일한다. | +| LP-014 | DB entity/repository의 provider는 String으로 유지하며 `.code`로만 변환한다. 기존 `"papago"` 데이터·캐시 키·중복 방지 계약을 유지한다. | +| LP-015 | enum은 `PAPAGO("papago")`, `AWS("aws")`이며 AWS 실제 구현은 추가하지 않는다. 기본 순서는 둘 다 PAPAGO 하나이고 미구현 AWS 선택은 거부한다. | ## 보안·오류 처리 @@ -96,3 +99,20 @@ LSP 도구가 제공되지 않으면 Kotlin 컴파일과 ktlint로 보완하고 - 테스트는 전역 목록을 바꾸지 않고 테스트 전용 bean에 typed list를 주입하여 독립 순서와 invalid order를 검증한다. - 과거 우선순위 environment/property override는 무시한다. 다른 환경변수나 Spring 설정 체계는 변경하지 않는다. - 실패 전환·공급자 검증·캐시·DB·인증·AWS 미구현 정책은 Phase 1을 그대로 유지한다. + +## Phase 3 결정 정정 — 2026-11-09 + +사용자는 공급자 enum 도입과 식별자 용도의 papago 리터럴 변경을 승인했다. 타입 관련 항목은 이 계약이 앞선 Phase보다 우선한다. + +- `LanguageProviderId(val code: String)`에 `PAPAGO("papago")`, `AWS("aws")`를 정의한다. +- `LanguageProviderOrder`, `LanguageProviderProperties`의 두 순서는 `List`이다. + 기본 목록은 각각 `listOf(LanguageProviderId.PAPAGO)`이며 코드에서만 순서를 변경한다. +- 두 공급자 인터페이스, 두 실제 Papago adapter, `DetectedLanguage`, `ProviderTranslationResult`는 + `providerName: String` 대신 `providerId: LanguageProviderId`를 사용한다. 버전은 기존 String을 유지한다. +- 선택/검증/coordinator는 enum으로 매칭한다. DB 조회/저장 접점에서만 `.code`로 변환한다. + entity와 repository, DDL은 변경하지 않는다. `.name`, `.ordinal`, `valueOf` 저장/변환과 불필요한 fromCode는 추가하지 않는다. +- API URL·인증 설정 키·원문/번역 fixture·DB 문자열 기대값·과거 환경변수 무시 테스트는 enum 전환 대상이 아니다. +- 기존 DB 값 호환 테스트는 기대값을 enum에서 계산하지 않고 독립 문자열 `"papago"`로 검증한다. + 가짜 보조 공급자는 AWS enum과 저장값 `"aws"`를 사용하며 test-only enum 항목을 추가하지 않는다. +- 빈 문자열 ID는 타입으로 표현할 수 없어진다. 빈 목록·중복 순서·중복 구현체·해당 capability 구현체 부재 검증은 유지한다. +- 새로운 공급자 추가 시 enum 항목·구현체·코드 순서 목록을 추가한다. 도메인 호출자와 선택 알고리즘은 변경하지 않는다. diff --git a/docs/20261109_감지번역공급자공통화/provider-guide.md b/docs/20261109_감지번역공급자공통화/provider-guide.md index 7370202d..89aaacd7 100644 --- a/docs/20261109_감지번역공급자공통화/provider-guide.md +++ b/docs/20261109_감지번역공급자공통화/provider-guide.md @@ -3,19 +3,19 @@ ## 현재 실행 구조 운영 구현체는 `PapagoLanguageDetectionProvider`와 `PapagoTranslationService`뿐이다. -AWS 감지·번역 adapter, 해당 SDK 및 전용 인증 설정은 추가하지 않았다. 지금 `aws`를 순서에 넣으면 시작에 실패한다. +AWS 감지·번역 adapter, 해당 SDK 및 전용 인증 설정은 추가하지 않았다. 지금 `LanguageProviderId.AWS`를 순서에 넣으면 시작에 실패한다. 감지와 번역은 별도 capability다. 감지만 또는 번역만 제공하는 공급자도 등록할 수 있다. -두 capability에서 같은 `providerName`을 쓰는 것은 가능하지만, 한 capability 안의 이름 중복은 시작 시 거부한다. +두 capability에서 같은 `providerId`를 쓰는 것은 가능하지만, 한 capability 안의 ID 중복은 시작 시 거부한다. | 경계 | 계약 | |---|---| -| `LanguageDetectionProvider` | `providerName: String`, `detect(query: String): String?` | +| `LanguageDetectionProvider` | `providerId: LanguageProviderId`, `detect(query: String): String?` | | `LanguageDetectionCacheService` | `detectWithCache(query: String): DetectedLanguage?` | -| `DetectedLanguage` | `detectedLanguage: String`, `providerName: String` | -| `TranslationProvider` | `providerName: String`, `providerVersion: String`, `translate(TranslateRequest): TranslateResult` | +| `DetectedLanguage` | `detectedLanguage: String`, `providerId: LanguageProviderId` | +| `TranslationProvider` | `providerId: LanguageProviderId`, `providerVersion: String`, `translate(TranslateRequest): TranslateResult` | | `TranslationCoordinator` | `findCached(sourceHash, sourceLanguage, targetLanguage): TranslationMemory?`, `translate(TranslateRequest): ProviderTranslationResult` | -| `ProviderTranslationResult` | `result: TranslateResult`, `providerName: String`, `providerVersion: String` | +| `ProviderTranslationResult` | `result: TranslateResult`, `providerId: LanguageProviderId`, `providerVersion: String` | 인터페이스와 DTO는 `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/`에 있다. 감지 coordinator는 기존 `content/LanguageDetectionCacheService.kt`이며 별도 vendor가 아니다. @@ -23,12 +23,14 @@ AWS 감지·번역 adapter, 해당 SDK 및 전용 인증 설정은 추가하지 ## 새 공급자 추가 순서 -1. 필요한 capability 인터페이스를 구현하고 Spring `@Component`/`@Service` 또는 `@Bean`으로 등록한다. - `providerName`은 capability 내 유일하고 재배포 후에도 안정적인 값이어야 한다. -2. 공급자의 HTTP/SDK 및 인증은 adapter 안에 둔다. 외부 응답 형식과 빈 결과를 검증하고, +1. `LanguageProviderId`에 공급자 enum 항목과 재배포 후에도 안정적인 DB `code`를 추가한다. + 현재 항목은 `PAPAGO("papago")`, `AWS("aws")`다. AWS는 이미 ID가 있으므로 실제 adapter만 별도로 구현한다. +2. 필요한 capability 인터페이스를 구현하고 Spring `@Component`/`@Service` 또는 `@Bean`으로 등록한다. + 예를 들어 Papago adapter의 식별자는 `override val providerId = LanguageProviderId.PAPAGO`다. +3. 공급자의 HTTP/SDK 및 인증은 adapter 안에 둔다. 외부 응답 형식과 빈 결과를 검증하고, 원문·인증정보·응답 본문·예외 cause를 로그로 출력하지 않는다. 번역은 입력별 결과를 같은 순서로 반환한다. -3. `LanguageProviderOrder.kt`에서 구현한 capability의 목록에만 이름을 넣고 빌드·배포한다. `@Primary`, selector 분기, enum 등록은 필요 없다. -4. 아래 테스트 경계로 순서·실패 전환·실제 provider 저장·무호출 캐시 재사용·materializer 표시를 검증한다. +4. `LanguageProviderOrder.kt`에서 구현한 capability의 목록에만 enum을 넣고 빌드·배포한다. `@Primary`나 selector 분기는 필요 없다. +5. 아래 테스트 경계로 순서·실패 전환·실제 provider 저장·무호출 캐시 재사용·materializer 표시를 검증한다. 콘텐츠·댓글·커뮤니티 호출자, worker, materializer, scheduler에 공급자 분기를 추가하지 않는다. 기존 두 coordinator는 주입된 구현체 목록과 설정만 사용한다. 공급자마다 다른 분할·요청 크기 제한은 adapter 책임이다. @@ -39,33 +41,42 @@ ko/en/ja 서비스 정책은 `TranslationLanguages`에 있으며 공급자를 production 순서의 유일한 편집 지점은 `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderOrder.kt`다. -목록의 왼쪽부터 사용하며 기본값은 두 기능 모두 `listOf("papago")`다. +목록의 왼쪽부터 사용하며 기본값은 두 기능 모두 `listOf(LanguageProviderId.PAPAGO)`다. ```kotlin object LanguageProviderOrder { - val detectionOrder = listOf("papago") - val translationOrder = listOf("papago") + val detectionOrder = listOf(LanguageProviderId.PAPAGO) + val translationOrder = listOf(LanguageProviderId.PAPAGO) } ``` Kotlin 목록은 `const val`이 아닌 `val`이다. `LanguageProviderProperties`는 기존 주입 wrapper로 유지하며, Spring은 `@Autowired` 기본 생성자로 위 목록을 읽는다. 문자열 파싱은 하지 않는다. -테스트에서는 전역 목록을 변경하지 않고 `LanguageProviderProperties(List, List)`로 만든 테스트 bean을 주입한다. +테스트에서는 전역 목록을 변경하지 않고 +`LanguageProviderProperties(List, List)`로 만든 테스트 bean을 주입한다. -다음은 **secondary 구현체를 두 capability에 등록한 뒤에만** 적용할 수 있는 예시다. -현재 `secondary` 구현체는 테스트에만 있다. +다음은 **실제 AWS 구현체를 두 capability에 등록한 뒤에만** 적용할 수 있는 예시다. +현재 AWS ID의 가짜 구현체는 테스트에만 있다. ```kotlin -val detectionOrder = listOf("secondary", "papago") -val translationOrder = listOf("papago", "secondary") +val detectionOrder = listOf(LanguageProviderId.AWS, LanguageProviderId.PAPAGO) +val translationOrder = listOf(LanguageProviderId.PAPAGO, LanguageProviderId.AWS) ``` 감지만 순서를 바꾸어도 번역 순서는 바뀌지 않는다. 반대로 번역만 바꿀 수도 있다. 변경 후 빌드·배포해야 반영된다. 과거 우선순위 Spring property·환경변수·YAML 값이 남아 있어도 순서는 바뀌지 않는다. 공급자 인증정보는 계속 환경변수로 주입하며 이 코드 목록에 넣지 않는다. -빈 값·빈 항목·중복 이름·미등록 이름·capability 안의 중복 bean 이름표(`providerName`)는 초기화에서 거부한다. -공급자가 번역에만 등록됐다면 그 이름을 감지 순서에 넣을 수 없다. -실행 중 설정 변경은 지원하지 않는다. AWS 구현체 등록 전 `listOf("papago", "aws")`는 사용할 수 없다. +빈 목록·중복 ID·해당 capability의 구현체 부재·capability 안의 중복 구현체 ID는 초기화에서 거부한다. +빈 문자열 ID는 enum 타입으로 표현할 수 없다. 공급자가 번역에만 등록됐다면 그 ID를 감지 순서에 넣을 수 없다. +실행 중 설정 변경은 지원하지 않는다. AWS 구현체 등록 전 +`listOf(LanguageProviderId.PAPAGO, LanguageProviderId.AWS)`는 사용할 수 없다. + +## DB code 호환성 + +내부 순서·구현체 매칭·성공 결과는 `LanguageProviderId`를 사용한다. entity/repository의 `provider`는 String 그대로다. +coordinator/cache/worker의 DB 조회·저장 접점에서만 `providerId.code`로 변환하므로 기존 `"papago"` 캐시를 재사용한다. +가짜 AWS의 저장값은 `"aws"`다. enum `.name`/`.ordinal`, `@Enumerated`/`@Convert`, 역변환 helper는 사용하지 않는다. +새 enum 항목을 추가할 때도 기존 code와 DB 스키마·캐시 키는 변경하지 않는다. `providerVersion`은 별도의 String 기록 필드다. ## 캐시와 오류 계약 @@ -94,10 +105,11 @@ adapter 예외·null 감지·빈 감지·번역 결과 개수 불일치·blank | 테스트 | 검증 내용 | |---|---| | `LanguageProviderSpringConfigurationTest` | 실제 production 기본 생성자, 과거 property/env 무시, typed-list 테스트 bean의 독립 순서와 잘못된 목록의 시작 실패 | -| `LanguageProviderPropertiesTest` | 순서 검증 및 선택되지 않은 bean을 포함한 capability 내 중복 이름 거부 | +| `LanguageProviderIdTest` | 런타임 enum getter 계약, 안정적인 papago/aws code, production 기본값과 실제 adapter ID | +| `LanguageProviderPropertiesTest` | 순서 검증 및 선택되지 않은 bean을 포함한 capability 내 중복 ID 거부 | | `LanguageDetectionProviderTest` / `TranslationCoordinatorTest` | 전체 요청 폴백, 첫 성공 중단, cache-first, 안전한 전체 실패, DB 오류 전파 | | `PapagoLanguageDetectionProviderTest` / `PapagoTranslationServiceTest` | 제어된 HTTP form/header/response와 기존 17개 chunk 회귀 | -| `LanguageProviderCacheEndToEndTest` | 실제 H2의 secondary 감지/번역 귀속, providerVersion, 두 번째 요청 무호출, worker와 materializer의 실제 표시 | +| `LanguageProviderCacheEndToEndTest` | 실제 H2의 AWS 감지/번역 귀속과 문자열 aws, providerVersion, 두 번째 요청 무호출, worker와 materializer의 실제 표시 | H2 버전은 MySQL `FOR UPDATE SKIP LOCKED`를 지원하지 않으므로 새 H2 테스트는 claim 조회 한 곳만 test spy로 대체한다. 나머지 repository 저장·별도 트랜잭션 재조회·materialization은 실제 H2다. diff --git a/docs/20261109_감지번역공급자공통화/reviews/phase-3-review.md b/docs/20261109_감지번역공급자공통화/reviews/phase-3-review.md new file mode 100644 index 00000000..382ac14a --- /dev/null +++ b/docs/20261109_감지번역공급자공통화/reviews/phase-3-review.md @@ -0,0 +1,32 @@ +# 공급자 enum 전환 Phase 3 리뷰 + +| 항목 | 내용 | +|---|---| +| 일자 | 2026-11-09 | +| 범위 | LP-013~015 및 Phase 1·2 보존 계약, 전체 미커밋 변경과 신규 파일 | +| 기준 | [PRD](../prd.md) Phase 3, [계획](../plan-task.md) P3, 현재 확장 가이드 | +| 리뷰어 | 독립 읽기 전용 gate reviewer | +| 판정 | **APPROVE** | + +## 확인 결과 + +- 공급자 인터페이스·구현체·성공 결과·순서·매칭은 enum이며 providerName alias/호환 shim이 없다. +- enum은 PAPAGO/AWS 두 항목이고 기본 순서는 PAPAGO 하나다. 실제 AWS adapter/SDK는 추가하지 않았다. +- `.code`는 DB 조회·저장 경계 6곳에 적용된다. entity/repository String, 고유 키와 schema는 그대로다. +- `.name`/`.ordinal`/`valueOf` 저장/변환이나 불필요한 fromCode가 없다. +- 기존 papago 문자열 기대값을 유지하고 AWS 저장값은 독립 문자열 aws로 확인한다. +- 빈 목록·중복 순서·중복 구현체·해당 capability의 AWS 구현체 부재를 거부한다. +- fallback·개수/blank·첫 성공 중단·DB 오류 전파·cache-first·동시성·revision·afterCommit·분할 검증을 유지한다. + +**확정 발견 사항 없음. 추가 수정 Task 전환 항목 없음.** + +## 실행 증거와 한계 + +리뷰어가 현재 XML 16개를 직접 대조하여 91/91, failures/errors/skipped 0을 확인했다. +컴파일 가능한 runtime getter assertion의 RED, 구현자의 실제 lint/bootJar 성공, +부모 fresh filtered test --rerun 성공(52초)과 별도 Java/Spring 실행 증거를 구분해 검토했다. +부모 driver는 typed getter/adapter ID/기존 문자열 cache 재사용/실패 전환/DB code 인자/미구현 AWS 거부를 확인했다. +driver repository 대역은 실제 H2 저장·재조회·표시 증거와 구분했다. 리뷰어 git diff --check는 exit 0이다. + +리뷰는 파일 변경·테스트/빌드 재실행·서버/API 호출·추가 에이전트 위임 없이 수행했다. +전체 테스트·LSP·운영 MySQL·실제 유료 API는 미검증이며 H2의 SKIP LOCKED claim은 기존 test spy 한계가 있다. -- 2.54.0 From a8e4cbac2893edddc2fca23c5b39ce4a7796ebde Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 10 Nov 2026 10:23:19 +0900 Subject: [PATCH 08/12] =?UTF-8?q?refactor(i18n):=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EA=B8=B0=EB=B0=98=20=EB=AC=B8?= =?UTF-8?q?=EC=9E=90=EC=97=B4=20=EB=B6=84=ED=95=A0=EC=9D=84=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=ED=99=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../i18n/translation/CodePointTextSplitter.kt | 41 +++++ .../translation/PapagoTranslationService.kt | 12 +- .../translation/CodePointTextSplitterTest.kt | 157 ++++++++++++++++++ .../PapagoTranslationServiceTest.kt | 9 + 4 files changed, 208 insertions(+), 11 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/CodePointTextSplitter.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/CodePointTextSplitterTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/CodePointTextSplitter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/CodePointTextSplitter.kt new file mode 100644 index 00000000..5312b954 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/CodePointTextSplitter.kt @@ -0,0 +1,41 @@ +package kr.co.vividnext.sodalive.i18n.translation + +internal fun splitTextByCodePoint(text: String, maxSize: Int, codePointSize: (Int) -> Int): List { + val chunks = mutableListOf() + var start = 0 + do { + val end = findChunkEnd(text, start, maxSize, codePointSize) + chunks.add(text.substring(start, end)) + start = end + } while (start < text.length) + return chunks +} + +internal fun takeTextPrefixByCodePoint(text: String, maxSize: Int, codePointSize: (Int) -> Int): String { + return text.substring(0, findChunkEnd(text, 0, maxSize, codePointSize)) +} + +internal fun utf8CodePointSize(codePoint: Int): Int { + return when { + codePoint <= 0x7F -> 1 + codePoint <= 0x7FF -> 2 + codePoint in 0xD800..0xDFFF -> 1 + codePoint <= 0xFFFF -> 3 + else -> 4 + } +} + +private fun findChunkEnd(text: String, start: Int, maxSize: Int, codePointSize: (Int) -> Int): Int { + var end = start + var size = 0 + while (end < text.length) { + val codePoint = text.codePointAt(end) + val nextSize = codePointSize(codePoint) + if (size + nextSize > maxSize) { + break + } + size += nextSize + end += Character.charCount(codePoint) + } + return end +} 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 24f79539..71ec3aaa 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 @@ -78,17 +78,7 @@ class PapagoTranslationService( } private fun splitText(text: String): List { - val chunks = mutableListOf() - var start = 0 - do { - var end = minOf(start + MAX_REQUEST_TEXT_LENGTH, text.length) - if (end < text.length && text[end - 1].isHighSurrogate() && text[end].isLowSurrogate()) { - end -= 1 - } - chunks.add(text.substring(start, end)) - start = end - } while (start < text.length) - return chunks + return splitTextByCodePoint(text, MAX_REQUEST_TEXT_LENGTH, Character::charCount) } private fun validateLanguages(sourceLanguage: String, targetLanguage: String): Boolean { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/CodePointTextSplitterTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/CodePointTextSplitterTest.kt new file mode 100644 index 00000000..61ec5c1d --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/CodePointTextSplitterTest.kt @@ -0,0 +1,157 @@ +package kr.co.vividnext.sodalive.i18n.translation + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +class CodePointTextSplitterTest { + @Test + @DisplayName("정확히 5000 UTF-16 단위인 한글과 이모지는 한 조각으로 유지한다") + fun shouldKeepTextAtUtf16LimitInOneChunk() { + val source = "가".repeat(4998) + "\uD83D\uDE00" + + assertEquals(listOf(source), splitTextByCodePoint(source, 5000, Character::charCount)) + } + + @Test + @DisplayName("5001 UTF-16 단위 원문은 5000 단위와 나머지로 분할한다") + fun shouldSplitTextAboveUtf16Limit() { + val first = "a".repeat(5000) + + assertEquals(listOf(first, "b"), splitTextByCodePoint(first + "b", 5000, Character::charCount)) + } + + @Test + @DisplayName("UTF-16 경계에 걸친 서로게이트 쌍을 다음 조각에 함께 둔다") + fun shouldKeepSurrogatePairTogetherAtUtf16Boundary() { + val first = "a".repeat(4999) + val second = "\uD83D\uDE00b" + + assertEquals(listOf(first, second), splitTextByCodePoint(first + second, 5000, Character::charCount)) + } + + @Test + @DisplayName("정확히 10000 UTF-8 바이트인 한글과 이모지는 한 조각으로 유지한다") + fun shouldKeepTextAtUtf8LimitInOneChunk() { + val source = "가".repeat(3332) + "\uD83D\uDE00" + + assertEquals(10000, source.toByteArray(Charsets.UTF_8).size) + assertEquals(listOf(source), splitTextByCodePoint(source, 10000, ::utf8CodePointSize)) + } + + @Test + @DisplayName("10001 UTF-8 바이트 원문은 10000 바이트와 나머지로 분할한다") + fun shouldSplitTextAboveUtf8Limit() { + val first = "a".repeat(10000) + + assertEquals(listOf(first, "b"), splitTextByCodePoint(first + "b", 10000, ::utf8CodePointSize)) + } + + @ParameterizedTest + @ValueSource(strings = ["가", "\uD83D\uDE00"]) + @DisplayName("UTF-8 경계의 한글과 이모지를 자르거나 한도를 초과하지 않는다") + fun shouldKeepWholeCodePointAtUtf8Boundary(boundaryText: String) { + val first = "a".repeat(9998) + val second = boundaryText + "b" + + assertEquals(listOf(first, second), splitTextByCodePoint(first + second, 10000, ::utf8CodePointSize)) + } + + @Test + @DisplayName("UTF-16 분할 후 한글과 이모지 및 모든 공백을 원문 그대로 재구성한다") + fun shouldReconstructOriginalTextAfterUtf16Splitting() { + val source = " \t가\uD83D\uDE00\r\n".repeat(3000) + " " + + val chunks = splitTextByCodePoint(source, 5000, Character::charCount) + + assertEquals(source, chunks.joinToString("")) + assertTrue(chunks.all { it.length in 1..5000 }) + chunks.forEach { assertEquals(it, String(it.toByteArray(Charsets.UTF_8), Charsets.UTF_8)) } + } + + @Test + @DisplayName("UTF-8 분할 후 한글과 이모지 및 모든 공백을 원문 그대로 재구성한다") + fun shouldReconstructOriginalTextAfterUtf8Splitting() { + val source = " \t가\uD83D\uDE00\r\n".repeat(3000) + " " + + val chunks = splitTextByCodePoint(source, 10000, ::utf8CodePointSize) + + assertEquals(source, chunks.joinToString("")) + assertTrue(chunks.all { it.toByteArray(Charsets.UTF_8).size in 1..10000 }) + chunks.forEach { assertEquals(it, String(it.toByteArray(Charsets.UTF_8), Charsets.UTF_8)) } + } + + @Test + @DisplayName("빈 raw 입력은 빈 문자열 하나를 가진 조각 목록을 반환한다") + fun shouldPreserveEmptyRawInputAsOneChunk() { + assertEquals(listOf(""), splitTextByCodePoint("", 5000, Character::charCount)) + assertEquals(listOf(""), splitTextByCodePoint("", 10000, ::utf8CodePointSize)) + } + + @Test + @DisplayName("감지 prefix는 정확히 100000 UTF-8 바이트를 보존하고 tail을 제외한다") + fun shouldTakeExactUtf8PrefixWithoutTail() { + val prefix = "가".repeat(33332) + "\uD83D\uDE00" + val source = prefix + "excluded-tail" + + val result = takeTextPrefixByCodePoint(source, 100000, ::utf8CodePointSize) + + assertEquals(prefix, result) + assertEquals(100000, result.toByteArray(Charsets.UTF_8).size) + } + + @ParameterizedTest + @ValueSource(strings = ["가", "\uD83D\uDE00"]) + @DisplayName("감지 prefix 경계의 코드포인트가 넘치면 남은 바이트를 채우지 않고 멈춘다") + fun shouldStopPrefixBeforeOverflowingCodePoint(boundaryText: String) { + val prefix = "a".repeat(99999) + + assertEquals(prefix, takeTextPrefixByCodePoint(prefix + boundaryText + "tail", 100000, ::utf8CodePointSize)) + } + + @ParameterizedTest + @ValueSource(strings = ["", " \t\n", "한글\uD83D\uDE00"]) + @DisplayName("한도보다 짧은 prefix는 빈 문자열과 공백도 변경하지 않는다") + fun shouldPreserveTextWithinPrefixLimit(source: String) { + assertEquals(source, takeTextPrefixByCodePoint(source, 100000, ::utf8CodePointSize)) + } + + @Test + @DisplayName("prefix는 첫 조각의 경계만 검사하고 나머지 원문을 순회하지 않는다") + fun shouldStopScanningAfterFirstPrefixBoundary() { + val prefix = "a".repeat(100000) + val source = prefix + "가" + "tail".repeat(25000) + var measuredCodePoints = 0 + + val result = takeTextPrefixByCodePoint(source, 100000) { codePoint -> + measuredCodePoints += 1 + utf8CodePointSize(codePoint) + } + + assertEquals(prefix, result) + assertEquals(100001, measuredCodePoints) + } + + @ParameterizedTest + @ValueSource( + ints = [0, 0x7F, 0x80, 0x7FF, 0x800, 0xD7FF, 0xD800, 0xDBFF, 0xDC00, 0xDFFF, 0xE000, 0xFFFF, 0x10000, 0x1F600, 0x10FFFF] + ) + @DisplayName("UTF-8 코드포인트 크기는 JVM 인코딩의 바이트 수와 같다") + fun shouldMatchUtf8EncodedCodePointSize(codePoint: Int) { + val text = String(Character.toChars(codePoint)) + + assertEquals(text.toByteArray(Charsets.UTF_8).size, utf8CodePointSize(codePoint)) + } + + @Test + @DisplayName("짝 없는 surrogate도 JVM UTF-8 인코딩 크기로 계산하며 원문을 보존한다") + fun shouldPreserveUnpairedSurrogateWithItsEncodedSize() { + val first = "a".repeat(9999) + "\uD800" + + assertEquals(10000, first.toByteArray(Charsets.UTF_8).size) + assertEquals(listOf(first, "b"), splitTextByCodePoint(first + "b", 10000, ::utf8CodePointSize)) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationServiceTest.kt index f75db2c6..35e0ad76 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationServiceTest.kt @@ -61,6 +61,15 @@ class PapagoTranslationServiceTest @Autowired constructor( assertEquals(listOf("translated"), translate(source).translatedText) } + @ParameterizedTest + @ValueSource(strings = ["", " \t\n"]) + @DisplayName("빈 raw 문자열과 공백 원문도 변경 없이 한 번 요청한다") + fun shouldPreserveEmptyAndWhitespaceRawText(source: String) { + expectTranslation(source, "translated") + + assertEquals(listOf("translated"), translate(source).translatedText) + } + @Test @DisplayName("5,001자 원문은 두 번 요청하고 번역을 구분자 없이 결합한다") fun shouldSplit5001UnitsAndJoinTranslationsWithoutSeparator() { -- 2.54.0 From 7cac09e39c95a1af59411b70df5381297e81a395 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 10 Nov 2026 10:23:32 +0900 Subject: [PATCH 09/12] =?UTF-8?q?feat(i18n):=20AWS=20=EC=96=B8=EC=96=B4=20?= =?UTF-8?q?=EA=B0=90=EC=A7=80=EC=99=80=20=EB=B6=84=ED=95=A0=20=EB=B2=88?= =?UTF-8?q?=EC=97=AD=20=EA=B3=B5=EA=B8=89=EC=9E=90=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 6 + .../AwsLanguageDetectionProvider.kt | 27 +++ .../AwsLanguageProviderConfiguration.kt | 32 +++ .../i18n/translation/AwsTranslationService.kt | 48 ++++ .../AwsLanguageDetectionProviderTest.kt | 134 +++++++++++ .../AwsLanguageProviderConfigurationTest.kt | 127 +++++++++++ .../translation/AwsTranslationServiceTest.kt | 209 ++++++++++++++++++ src/test/resources/application.yml | 4 +- 8 files changed, 585 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageDetectionProvider.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageProviderConfiguration.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsTranslationService.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageDetectionProviderTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageProviderConfigurationTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsTranslationServiceTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 5bae987e..0552043c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,6 +16,8 @@ plugins { group = "kr.co.vividnext" version = "0.0.1-SNAPSHOT" val querydslVersion = "5.0.0" +extra["httpclient5.version"] = "5.6.4" +extra["httpcore5.version"] = "5.4.3" java { sourceCompatibility = JavaVersion.VERSION_17 @@ -56,6 +58,10 @@ dependencies { implementation("com.amazonaws:aws-java-sdk-s3:1.12.380") implementation("com.amazonaws:aws-java-sdk-cloudfront:1.12.380") + implementation(platform("software.amazon.awssdk:bom:2.57.11")) + implementation("software.amazon.awssdk:comprehend") + implementation("software.amazon.awssdk:translate") + // bootpay implementation("io.github.bootpay:backend:2.2.1") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageDetectionProvider.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageDetectionProvider.kt new file mode 100644 index 00000000..3cd81f59 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageDetectionProvider.kt @@ -0,0 +1,27 @@ +package kr.co.vividnext.sodalive.i18n.translation + +import org.springframework.stereotype.Component +import software.amazon.awssdk.services.comprehend.ComprehendClient +import software.amazon.awssdk.services.comprehend.model.DetectDominantLanguageRequest + +@Component +class AwsLanguageDetectionProvider(private val client: ComprehendClient) : LanguageDetectionProvider { + override val providerId = LanguageProviderId.AWS + + override fun detect(query: String): String? { + if (query.isBlank()) return null + + return try { + // shortcut: 감지는 앞 100000 UTF-8 바이트만 판정하므로 전체 본문과 결과가 다를 수 있음, 전체 본문 기준 품질이 요구되면 별도 감지 정책 승인 후 업그레이드 + val text = takeTextPrefixByCodePoint(query, MAX_REQUEST_TEXT_BYTES, ::utf8CodePointSize) + val request = DetectDominantLanguageRequest.builder().text(text).build() + client.detectDominantLanguage(request).languages().firstOrNull()?.languageCode()?.takeIf { it.isNotBlank() } + } catch (_: Exception) { + null + } + } + + companion object { + private const val MAX_REQUEST_TEXT_BYTES = 100000 + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageProviderConfiguration.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageProviderConfiguration.kt new file mode 100644 index 00000000..d6359905 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageProviderConfiguration.kt @@ -0,0 +1,32 @@ +package kr.co.vividnext.sodalive.i18n.translation + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.comprehend.ComprehendClient +import software.amazon.awssdk.services.translate.TranslateClient + +@Configuration +class AwsLanguageProviderConfiguration( + @Value("\${cloud.aws.credentials.access-key}") accessKey: String, + @Value("\${cloud.aws.credentials.secret-key}") secretKey: String, + @Value("\${cloud.aws.region.static}") region: String +) { + private val credentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)) + private val region = Region.of(region) + + @Bean(destroyMethod = "close") + fun comprehendClient(): ComprehendClient = ComprehendClient.builder() + .credentialsProvider(credentialsProvider) + .region(region) + .build() + + @Bean(destroyMethod = "close") + fun translateClient(): TranslateClient = TranslateClient.builder() + .credentialsProvider(credentialsProvider) + .region(region) + .build() +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsTranslationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsTranslationService.kt new file mode 100644 index 00000000..33e8dd57 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsTranslationService.kt @@ -0,0 +1,48 @@ +package kr.co.vividnext.sodalive.i18n.translation + +import org.springframework.stereotype.Service +import software.amazon.awssdk.services.translate.TranslateClient +import software.amazon.awssdk.services.translate.model.TranslateTextRequest + +@Service +class AwsTranslationService(private val client: TranslateClient) : TranslationProvider { + override val providerId = LanguageProviderId.AWS + override val providerVersion = "translate-2017-07-01" + + override fun translate(request: TranslateRequest): TranslateResult { + if (request.texts.isEmpty() || request.sourceLanguage == request.targetLanguage) { + return TranslateResult(emptyList()) + } + if ( + !TranslationLanguages.isSupported(request.sourceLanguage) || + !TranslationLanguages.isSupported(request.targetLanguage) + ) { + return TranslateResult(emptyList()) + } + + val translatedTexts = mutableListOf() + try { + for (text in request.texts) { + val translatedText = StringBuilder() + for (chunk in splitTextByCodePoint(text, MAX_REQUEST_TEXT_BYTES, ::utf8CodePointSize)) { + val sdkRequest = TranslateTextRequest.builder() + .sourceLanguageCode(request.sourceLanguage) + .targetLanguageCode(request.targetLanguage) + .text(chunk) + .build() + val translated = client.translateText(sdkRequest)?.translatedText() + if (translated.isNullOrBlank()) return TranslateResult(emptyList()) + translatedText.append(translated) + } + translatedTexts.add(translatedText.toString()) + } + } catch (_: Exception) { + return TranslateResult(emptyList()) + } + return TranslateResult(translatedTexts) + } + + companion object { + private const val MAX_REQUEST_TEXT_BYTES = 10000 + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageDetectionProviderTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageDetectionProviderTest.kt new file mode 100644 index 00000000..90c0f00a --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageDetectionProviderTest.kt @@ -0,0 +1,134 @@ +package kr.co.vividnext.sodalive.i18n.translation + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.NullAndEmptySource +import org.junit.jupiter.params.provider.ValueSource +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.boot.test.system.CapturedOutput +import org.springframework.boot.test.system.OutputCaptureExtension +import software.amazon.awssdk.core.exception.SdkClientException +import software.amazon.awssdk.services.comprehend.ComprehendClient +import software.amazon.awssdk.services.comprehend.model.DetectDominantLanguageRequest +import software.amazon.awssdk.services.comprehend.model.DetectDominantLanguageResponse +import software.amazon.awssdk.services.comprehend.model.DominantLanguage +import java.util.stream.Stream + +@SpringBootTest(classes = [AwsLanguageDetectionProvider::class], webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ExtendWith(OutputCaptureExtension::class) +class AwsLanguageDetectionProviderTest @Autowired constructor( + private val provider: AwsLanguageDetectionProvider +) { + @MockBean + private lateinit var client: ComprehendClient + + @ParameterizedTest + @ValueSource(strings = ["hi", " 안녕\t\n", "\uD83D\uDE00"]) + @DisplayName("짧은 원문도 공백 변경 없이 감지하고 첫 후보를 반환한다") + fun shouldSendShortTextUnchangedAndUseFirstCandidate(query: String) { + respond(language("ja", 0.1f), language("en", 0.9f)) + + assertEquals("ja", provider.detect(query)) + assertEquals(LanguageProviderId.AWS, provider.providerId) + assertRequestedText(query) + } + + @ParameterizedTest + @ValueSource(strings = ["", " ", "\t\n"]) + @DisplayName("빈 감지 입력과 공백은 SDK를 호출하지 않는다") + fun shouldSkipBlankText(query: String) { + assertNull(provider.detect(query)) + Mockito.verifyNoInteractions(client) + } + + @ParameterizedTest + @MethodSource("prefixCases") + @DisplayName("감지 입력을 100000 UTF-8 바이트 이내의 정확한 code point prefix로 제한한다") + fun shouldSendExactCodePointPrefix(query: String, expected: String) { + respond(language("ko", 0.8f)) + + assertEquals("ko", provider.detect(query)) + assertRequestedText(expected) + } + + @Test + @DisplayName("낮은 confidence의 미지원 첫 언어 코드도 그대로 반환한다") + fun shouldAcceptUnsupportedFirstLanguageWithoutConfidenceThreshold() { + respond(language("fr", 0.001f), language("en", 0.999f)) + + assertEquals("fr", provider.detect("bonjour")) + assertRequestedText("bonjour") + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = [" \t\n"]) + @DisplayName("첫 후보 코드가 없거나 공백이면 유효한 후속 후보도 선택하지 않는다") + fun shouldRejectInvalidFirstCandidateWithoutScanningLaterCandidates(code: String?) { + respond(language(code, 0.9f), language("en", 0.8f)) + + assertNull(provider.detect("source")) + assertRequestedText("source") + } + + @Test + @DisplayName("감지 후보 목록이 없으면 null을 반환한다") + fun shouldReturnNullForMissingCandidates() { + respond() + + assertNull(provider.detect("source")) + assertRequestedText("source") + } + + @Test + @DisplayName("SDK 실패는 원문과 오류를 로그에 노출하지 않고 null로 변환한다") + fun shouldReturnNullWithoutLeakingSdkFailure(output: CapturedOutput) { + Mockito.`when`(client.detectDominantLanguage(Mockito.any(DetectDominantLanguageRequest::class.java))) + .thenThrow(SdkClientException.create("sensitive-sdk-marker")) + + assertNull(provider.detect("sensitive-source-marker")) + assertRequestedText("sensitive-source-marker") + assertFalse(output.all.contains("sensitive-sdk-marker")) + assertFalse(output.all.contains("sensitive-source-marker")) + } + + private fun respond(vararg languages: DominantLanguage) { + Mockito.`when`(client.detectDominantLanguage(Mockito.any(DetectDominantLanguageRequest::class.java))) + .thenReturn(DetectDominantLanguageResponse.builder().languages(*languages).build()) + } + + private fun language(code: String?, score: Float): DominantLanguage { + return DominantLanguage.builder().languageCode(code).score(score).build() + } + + private fun assertRequestedText(expected: String) { + val captor = ArgumentCaptor.forClass(DetectDominantLanguageRequest::class.java) + Mockito.verify(client).detectDominantLanguage(captor.capture()) + assertEquals(expected, captor.value.text()) + Mockito.verifyNoMoreInteractions(client) + } + + companion object { + @JvmStatic + fun prefixCases(): Stream = Stream.of( + Arguments.of("a".repeat(100000), "a".repeat(100000)), + Arguments.of("가".repeat(33333) + "x", "가".repeat(33333) + "x"), + Arguments.of("\uD83D\uDE00".repeat(25000), "\uD83D\uDE00".repeat(25000)), + Arguments.of("a".repeat(100000) + "excluded-tail", "a".repeat(100000)), + Arguments.of("a".repeat(99999) + "가tail", "a".repeat(99999)), + Arguments.of("a".repeat(99998) + "\uD83D\uDE00tail", "a".repeat(99998)), + Arguments.of("a".repeat(99996) + "\uD83D\uDE00tail", "a".repeat(99996) + "\uD83D\uDE00") + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageProviderConfigurationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageProviderConfigurationTest.kt new file mode 100644 index 00000000..5f1814d7 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageProviderConfigurationTest.kt @@ -0,0 +1,127 @@ +package kr.co.vividnext.sodalive.i18n.translation + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer +import org.springframework.boot.test.context.runner.ApplicationContextRunner +import org.springframework.test.util.ReflectionTestUtils +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.awscore.AwsClient +import software.amazon.awssdk.core.client.config.SdkClientConfiguration +import software.amazon.awssdk.core.client.config.SdkClientOption +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.comprehend.ComprehendClient +import software.amazon.awssdk.services.translate.TranslateClient + +class AwsLanguageProviderConfigurationTest { + @Test + @DisplayName("공용 테스트 YAML의 합성 키와 서울 리전으로 실제 AWS clients와 providers를 생성한다") + fun shouldLoadRealClientsAndProvidersFromSharedTestConfiguration() { + ApplicationContextRunner() + .withInitializer(ConfigDataApplicationContextInitializer()) + .withUserConfiguration( + AwsLanguageProviderConfiguration::class.java, + AwsLanguageDetectionProvider::class.java, + AwsTranslationService::class.java + ) + .run { context -> + assertNull(context.startupFailure) + assertEquals(1, context.getBeansOfType(ComprehendClient::class.java).size) + assertEquals(1, context.getBeansOfType(TranslateClient::class.java).size) + assertEquals(1, context.getBeansOfType(LanguageDetectionProvider::class.java).size) + assertEquals(1, context.getBeansOfType(TranslationProvider::class.java).size) + assertEquals(LanguageProviderId.AWS, context.getBean(AwsLanguageDetectionProvider::class.java).providerId) + assertEquals(LanguageProviderId.AWS, context.getBean(AwsTranslationService::class.java).providerId) + val clients = listOf( + context.getBean(ComprehendClient::class.java), + context.getBean(TranslateClient::class.java) + ) + clients.forEach { client -> + val configuration = client.serviceClientConfiguration() + assertEquals(Region.AP_NORTHEAST_2, configuration.region()) + assertTrue(configuration.credentialsProvider() is StaticCredentialsProvider) + val credentials = (configuration.credentialsProvider() as StaticCredentialsProvider).resolveCredentials() + assertEquals("test-access-key", credentials.accessKeyId()) + assertEquals("test-secret-key", credentials.secretAccessKey()) + } + } + } + + @ParameterizedTest + @CsvSource( + "'',synthetic-secret-key,Access key ID cannot be blank.", + "synthetic-access-key,'',Secret access key cannot be blank." + ) + @DisplayName("운영 AWS 설정은 access key 또는 secret key가 비어 있으면 시작을 거부한다") + fun shouldRejectBlankCredentialsAtStartup(accessKey: String, secretKey: String, expectedMessage: String) { + ApplicationContextRunner() + .withUserConfiguration(AwsLanguageProviderConfiguration::class.java) + .withPropertyValues( + "cloud.aws.credentials.access-key=$accessKey", + "cloud.aws.credentials.secret-key=$secretKey", + "cloud.aws.region.static=ap-northeast-2" + ) + .run { context -> + assertNotNull(context.startupFailure) + val rootCause = generateSequence(context.startupFailure) { it.cause }.last() + assertTrue(rootCause is NullPointerException) + assertEquals(expectedMessage, rootCause.message) + } + } + + @ParameterizedTest + @ValueSource(strings = ["ap-northeast-2", "eu-west-1"]) + @DisplayName("기존 명시 설정으로 실제 동기 SDK clients를 생성하고 Spring 종료 시 둘 다 닫는다") + fun shouldConfigureRealClientsAndCloseThemAtShutdown(region: String) { + val poolClosedChecks = mutableListOf<() -> Boolean>() + ApplicationContextRunner() + .withUserConfiguration(AwsLanguageProviderConfiguration::class.java) + .withPropertyValues( + "cloud.aws.credentials.access-key=synthetic-access-key", + "cloud.aws.credentials.secret-key=synthetic-secret-key", + "cloud.aws.region.static=$region" + ) + .run { context -> + assertNull(context.startupFailure) + assertEquals(1, context.getBeansOfType(ComprehendClient::class.java).size) + assertEquals(1, context.getBeansOfType(TranslateClient::class.java).size) + val clients = listOf( + context.getBean(ComprehendClient::class.java), + context.getBean(TranslateClient::class.java) + ) + clients.forEach { client -> + val configuration = client.serviceClientConfiguration() + assertEquals(Region.of(region), configuration.region()) + assertTrue(configuration.credentialsProvider() is StaticCredentialsProvider) + val credentials = (configuration.credentialsProvider() as StaticCredentialsProvider).resolveCredentials() + assertEquals("synthetic-access-key", credentials.accessKeyId()) + assertEquals("synthetic-secret-key", credentials.secretAccessKey()) + val isClosed = connectionPoolClosedCheck(client) + assertFalse(isClosed()) + poolClosedChecks.add(isClosed) + } + } + assertEquals(2, poolClosedChecks.size) + poolClosedChecks.forEach { assertTrue(it()) } + } + + private fun connectionPoolClosedCheck(client: AwsClient): () -> Boolean { + val configuration = ReflectionTestUtils.getField(client, "clientConfiguration") as SdkClientConfiguration + val httpClient = configuration.option(SdkClientOption.SYNC_HTTP_CLIENT) + val apacheClient = requireNotNull(ReflectionTestUtils.getField(httpClient, "httpClient")) + val connectionManager = requireNotNull( + ReflectionTestUtils.invokeMethod(apacheClient, "getHttpClientConnectionManager") + ) + return { + requireNotNull(ReflectionTestUtils.invokeMethod(connectionManager, "isClosed")) + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsTranslationServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsTranslationServiceTest.kt new file mode 100644 index 00000000..f7cc3d28 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsTranslationServiceTest.kt @@ -0,0 +1,209 @@ +package kr.co.vividnext.sodalive.i18n.translation + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.boot.test.system.CapturedOutput +import org.springframework.boot.test.system.OutputCaptureExtension +import software.amazon.awssdk.core.exception.SdkClientException +import software.amazon.awssdk.services.translate.TranslateClient +import software.amazon.awssdk.services.translate.model.TranslateTextRequest +import software.amazon.awssdk.services.translate.model.TranslateTextResponse + +@SpringBootTest(classes = [AwsTranslationService::class], webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ExtendWith(OutputCaptureExtension::class) +class AwsTranslationServiceTest @Autowired constructor( + private val service: AwsTranslationService +) { + @MockBean + private lateinit var client: TranslateClient + private val requests = mutableListOf() + + @BeforeEach + fun setUp() { + requests.clear() + } + + @ParameterizedTest + @ValueSource(ints = [12, 10000]) + @DisplayName("10000 UTF-8 바이트 이하 원문은 한 번 요청하고 AWS 결과를 반환한다") + fun shouldTranslateTextWithinByteLimit(length: Int) { + respondWith { response("translated") } + val source = "a".repeat(length) + + assertEquals(listOf("translated"), translate(source).translatedText) + assertEquals(LanguageProviderId.AWS, service.providerId) + assertEquals("translate-2017-07-01", service.providerVersion) + assertRequests(source) + } + + @Test + @DisplayName("10001바이트 원문을 순서대로 나누고 구분자 없이 결합한다") + fun shouldSplitTextOverByteLimitAndJoinWithoutSeparator() { + val first = "a".repeat(10000) + respondWith { response(if (it.text() == first) "first " else "tail") } + + assertEquals(listOf("first tail"), translate(first + "b").translatedText) + assertRequests(first, "b") + } + + @Test + @DisplayName("한글 code point와 공백을 UTF-8 한도에 맞춰 손실 없이 분할한다") + fun shouldPreserveKoreanAndWhitespaceAcrossChunks() { + val first = "가".repeat(3333) + "\n" + val second = " 나\t" + respondWith { response(if (it.text() == first) "first" else "second") } + + assertEquals(listOf("firstsecond"), translate(first + second).translatedText) + assertRequests(first, second) + } + + @Test + @DisplayName("경계에 걸친 보조 평면 문자를 다음 조각에 함께 보존한다") + fun shouldKeepSupplementaryCodePointTogetherAtBoundary() { + val first = "a".repeat(9998) + val second = "\uD83D\uDE00b" + respondWith { response(if (it.text() == first) "first" else "second") } + + assertEquals(listOf("firstsecond"), translate(first + second).translatedText) + assertRequests(first, second) + } + + @Test + @DisplayName("정확히 10000바이트인 보조 평면 원문은 분할하지 않는다") + fun shouldKeepExactSupplementaryByteLimitInOneRequest() { + val source = "\uD83D\uDE00".repeat(2500) + respondWith { response("translated") } + + assertEquals(listOf("translated"), translate(source).translatedText) + assertRequests(source) + } + + @Test + @DisplayName("여러 원문의 조각을 순차 호출하고 입력 순서대로 원문당 결과 하나를 반환한다") + fun shouldReturnOneJoinedOutputPerOriginalInOrder() { + val first = "a".repeat(10000) + val second = "b".repeat(10000) + val translated = listOf("first-", "tail", "second-", "tail") + respondWith { response(translated[requests.size - 1]) } + + assertEquals(listOf("first-tail", "second-tail"), translate(first + "a", second + "b").translatedText) + assertRequests(first, "a", second, "b") + } + + @ParameterizedTest + @CsvSource("ko,en", "en,ja", "ja,ko") + @DisplayName("기존 지원 언어의 source와 target을 SDK 요청에 명시한다") + fun shouldSendExplicitSupportedLanguages(source: String, target: String) { + respondWith { response("translated") } + + assertEquals(listOf("translated"), service.translate(TranslateRequest(listOf("source"), source, target)).translatedText) + assertEquals(1, requests.size) + assertEquals("source", requests.single().text()) + assertEquals(source, requests.single().sourceLanguageCode()) + assertEquals(target, requests.single().targetLanguageCode()) + } + + @ParameterizedTest + @CsvSource( + "middle,exception", "middle,null-response", "middle,missing-text", "middle,empty", "middle,blank", + "later,exception", "later,null-response", "later,missing-text", "later,empty", "later,blank" + ) + @DisplayName("중간 조각이나 다음 원문의 실패는 앞선 성공을 폐기하고 후속 호출을 중단한다") + fun shouldDiscardEntireRequestAndStopAfterAnyFailure(position: String, failure: String, output: CapturedOutput) { + val first = "a".repeat(10000) + val broken = "b".repeat(10000) + val failingText = if (position == "middle") broken else "sensitive-source-marker" + respondWith { + if (it.text() != failingText) { + response("partial success") + } else { + when (failure) { + "exception" -> throw SdkClientException.create("sensitive-sdk-marker") + "null-response" -> null + "missing-text" -> response(null) + "empty" -> response("") + else -> response(" \t\n") + } + } + } + val texts = if (position == "middle") { + listOf("earlier", first + broken + "c", "later") + } else { + listOf("earlier", failingText, "later") + } + + assertEquals(emptyList(), service.translate(TranslateRequest(texts, "ko", "en")).translatedText) + if (position == "middle") assertRequests("earlier", first, broken) else assertRequests("earlier", failingText) + assertFalse(output.all.contains("sensitive-sdk-marker")) + assertFalse(output.all.contains("sensitive-source-marker")) + } + + @ParameterizedTest + @ValueSource(strings = ["", " \t\n"]) + @DisplayName("빈 raw 문자열과 공백도 원문 그대로 SDK에 전달한다") + fun shouldPreserveEmptyAndWhitespaceRawText(source: String) { + respondWith { response("translated") } + + assertEquals(listOf("translated"), translate(source).translatedText) + assertRequests(source) + } + + @Test + @DisplayName("빈 원문 목록이면 SDK를 호출하지 않는다") + fun shouldSkipEmptyList() { + assertEquals(emptyList(), translate().translatedText) + Mockito.verifyNoInteractions(client) + } + + @Test + @DisplayName("같은 언어이면 SDK를 호출하지 않는다") + fun shouldSkipSameLanguage() { + assertEquals(emptyList(), service.translate(TranslateRequest(listOf("source"), "ko", "ko")).translatedText) + Mockito.verifyNoInteractions(client) + } + + @ParameterizedTest + @CsvSource("fr,en", "ko,fr", "auto,en") + @DisplayName("미지원 source 또는 target이면 SDK를 호출하지 않는다") + fun shouldSkipUnsupportedLanguage(source: String, target: String) { + assertEquals(emptyList(), service.translate(TranslateRequest(listOf("source"), source, target)).translatedText) + Mockito.verifyNoInteractions(client) + } + + private fun translate(vararg texts: String): TranslateResult { + return service.translate(TranslateRequest(texts.toList(), "ko", "en")) + } + + private fun respondWith(handler: (TranslateTextRequest) -> TranslateTextResponse?) { + Mockito.`when`(client.translateText(Mockito.any(TranslateTextRequest::class.java))).thenAnswer { + val request = it.getArgument(0) + requests.add(request) + handler(request) + } + } + + private fun response(text: String?): TranslateTextResponse { + return TranslateTextResponse.builder().sourceLanguageCode("ko").targetLanguageCode("en").translatedText(text).build() + } + + private fun assertRequests(vararg texts: String) { + assertEquals(texts.toList(), requests.map { it.text() }) + requests.forEach { + assertEquals("ko", it.sourceLanguageCode()) + assertEquals("en", it.targetLanguageCode()) + } + Mockito.verify(client, Mockito.times(texts.size)).translateText(Mockito.any(TranslateTextRequest::class.java)) + Mockito.verifyNoMoreInteractions(client) + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 2992752c..8fe31993 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -78,8 +78,8 @@ cloud: aws: credentials: - accessKey: "" - secretKey: "" + accessKey: "test-access-key" + secretKey: "test-secret-key" s3: contentBucket: "" bucket: "" -- 2.54.0 From 992844df6688d35df77683b64d655ad90f40d72f Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 10 Nov 2026 10:24:06 +0900 Subject: [PATCH 10/12] =?UTF-8?q?feat(i18n):=20Papago=20=EB=8B=A4=EC=9D=8C?= =?UTF-8?q?=20=EC=88=9C=EC=84=9C=EC=97=90=20AWS=20=EA=B3=B5=EA=B8=89?= =?UTF-8?q?=EC=9E=90=EB=A5=BC=20=EB=93=B1=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../i18n/translation/LanguageProviderOrder.kt | 4 +- .../CreatorCommunityLanguageDetectTest.kt | 9 +- ...tyLanguageDetectionCacheConcurrencyTest.kt | 11 +- .../LanguageDetectionCacheServiceTest.kt | 6 +- .../translation/LanguageProviderIdTest.kt | 22 +++- ...LanguageProviderSpringConfigurationTest.kt | 121 +++++++++++++++++- .../translation/TranslationJobWorkerTest.kt | 37 +++++- .../CreatorCommunityTranslationServiceTest.kt | 3 +- 8 files changed, 188 insertions(+), 25 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderOrder.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderOrder.kt index 445b4f75..2c89e06c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderOrder.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderOrder.kt @@ -1,6 +1,6 @@ package kr.co.vividnext.sodalive.i18n.translation object LanguageProviderOrder { - val detectionOrder = listOf(LanguageProviderId.PAPAGO) - val translationOrder = listOf(LanguageProviderId.PAPAGO) + val detectionOrder = listOf(LanguageProviderId.PAPAGO, LanguageProviderId.AWS) + val translationOrder = listOf(LanguageProviderId.PAPAGO, LanguageProviderId.AWS) } 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 662c6ce4..acfe3c16 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt @@ -90,7 +90,6 @@ import javax.persistence.EntityManager TranslationSourceExtractor::class, TranslationReadModelMaterializer::class, TranslationCoordinator::class, - LanguageProviderProperties::class, PapagoTranslationService::class, ResourceTranslationJobScheduler::class, TranslationJobScheduler::class, @@ -678,6 +677,12 @@ class CreatorCommunityLanguageDetectTest @Autowired constructor( @TestConfiguration @EnableAsync class AsyncConfiguration { + @Bean + fun languageProviderProperties() = LanguageProviderProperties( + listOf(LanguageProviderId.PAPAGO), + listOf(LanguageProviderId.PAPAGO) + ) + @Bean(name = ["taskExecutor"]) fun taskExecutor(): DetailPipelineTaskExecutor { return DetailPipelineTaskExecutor() @@ -734,7 +739,7 @@ class ControlledLanguageDetectionCacheService( override val providerId = LanguageProviderId.PAPAGO override fun detect(query: String): String? = null }), - LanguageProviderProperties() + LanguageProviderProperties(listOf(LanguageProviderId.PAPAGO), listOf(LanguageProviderId.PAPAGO)) ) { val detections = ConcurrentHashMap() val queries = ConcurrentLinkedQueue() 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 8382c191..9f239128 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectionCacheConcurrencyTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectionCacheConcurrencyTest.kt @@ -4,6 +4,7 @@ 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.LanguageProviderId.PAPAGO import kr.co.vividnext.sodalive.i18n.translation.LanguageProviderProperties import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType import kr.co.vividnext.sodalive.i18n.translation.PapagoLanguageDetectionProvider @@ -29,6 +30,8 @@ import org.springframework.aop.support.AopUtils 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.context.annotation.Bean import org.springframework.context.annotation.Import import org.springframework.http.HttpHeaders import org.springframework.http.HttpMethod @@ -71,7 +74,7 @@ import java.util.concurrent.atomic.AtomicInteger QueryDslConfig::class, AudioContentThemeQueryRepository::class, LanguageDetectionCacheService::class, - LanguageProviderProperties::class, + CreatorCommunityLanguageDetectionCacheConcurrencyTest.PapagoConfiguration::class, PapagoLanguageDetectionProvider::class, PapagoTranslationService::class, TranslationCoordinator::class, @@ -98,6 +101,12 @@ class CreatorCommunityLanguageDetectionCacheConcurrencyTest @Autowired construct } private lateinit var papagoRequests: ControlledPapagoRequestFactory + @TestConfiguration + class PapagoConfiguration { + @Bean + fun languageProviderProperties() = LanguageProviderProperties(listOf(PAPAGO), listOf(PAPAGO)) + } + @BeforeEach fun setUp() { papagoRequests = ControlledPapagoRequestFactory() 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 c33ab3f9..a5abf075 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheServiceTest.kt @@ -108,6 +108,10 @@ class LanguageDetectionCacheServiceTest { override val providerId = LanguageProviderId.PAPAGO override fun detect(query: String) = detect() } - return LanguageDetectionCacheService(repository, listOf(provider), LanguageProviderProperties()) + return LanguageDetectionCacheService( + repository, + listOf(provider), + LanguageProviderProperties(listOf(LanguageProviderId.PAPAGO), listOf(LanguageProviderId.PAPAGO)) + ) } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderIdTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderIdTest.kt index a384b7a2..e10110af 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderIdTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderIdTest.kt @@ -42,12 +42,24 @@ class LanguageProviderIdTest { } @Test - @DisplayName("production 코드 목록과 실제 Papago adapter는 PAPAGO enum을 사용한다") + @DisplayName("production 기본 순서는 PAPAGO 다음 AWS이며 실제 Papago adapter 식별자는 유지한다") fun shouldUsePapagoEnumForProductionDefaultsAndAdapters() { - assertEquals(listOf(LanguageProviderId.PAPAGO), LanguageProviderOrder.detectionOrder) - assertEquals(listOf(LanguageProviderId.PAPAGO), LanguageProviderOrder.translationOrder) - assertEquals(listOf(LanguageProviderId.PAPAGO), LanguageProviderProperties().detectionOrder) - assertEquals(listOf(LanguageProviderId.PAPAGO), LanguageProviderProperties().translationOrder) + assertAll( + { assertEquals(listOf(LanguageProviderId.PAPAGO, LanguageProviderId.AWS), LanguageProviderOrder.detectionOrder) }, + { assertEquals(listOf(LanguageProviderId.PAPAGO, LanguageProviderId.AWS), LanguageProviderOrder.translationOrder) }, + { + assertEquals( + listOf(LanguageProviderId.PAPAGO, LanguageProviderId.AWS), + LanguageProviderProperties().detectionOrder + ) + }, + { + assertEquals( + listOf(LanguageProviderId.PAPAGO, LanguageProviderId.AWS), + LanguageProviderProperties().translationOrder + ) + } + ) assertEquals(LanguageProviderId.PAPAGO, PapagoLanguageDetectionProvider("test-id", "test-secret").providerId) val translator = PapagoTranslationService("test-id", "test-secret") assertEquals(LanguageProviderId.PAPAGO, translator.providerId) 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 index fb11dcfc..c652ac43 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderSpringConfigurationTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderSpringConfigurationTest.kt @@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.content.LanguageDetectionCacheService import kr.co.vividnext.sodalive.content.LanguageDetectionResultRepository import kr.co.vividnext.sodalive.i18n.translation.LanguageProviderId.AWS import kr.co.vividnext.sodalive.i18n.translation.LanguageProviderId.PAPAGO +import org.junit.jupiter.api.Assertions.assertAll import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull @@ -15,12 +16,20 @@ import org.springframework.boot.test.context.runner.ApplicationContextRunner import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Import import org.springframework.core.env.SystemEnvironmentPropertySource +import software.amazon.awssdk.services.comprehend.ComprehendClient +import software.amazon.awssdk.services.comprehend.model.DetectDominantLanguageRequest +import software.amazon.awssdk.services.comprehend.model.DetectDominantLanguageResponse +import software.amazon.awssdk.services.comprehend.model.DominantLanguage +import software.amazon.awssdk.services.translate.TranslateClient +import software.amazon.awssdk.services.translate.model.TranslateTextRequest +import software.amazon.awssdk.services.translate.model.TranslateTextResponse import java.util.function.Supplier class LanguageProviderSpringConfigurationTest { private val papagoRunner = ApplicationContextRunner().withUserConfiguration(Providers::class.java) private val runner = papagoRunner.withUserConfiguration(AwsProviders::class.java) private val defaultRunner = runner.withUserConfiguration(LanguageProviderProperties::class.java) + private val actualRunner = papagoRunner.withUserConfiguration(ActualAwsProviders::class.java) @Test @DisplayName("과거 Spring 우선순위 property가 있어도 production 기본 순서는 바뀌지 않는다") @@ -31,8 +40,18 @@ class LanguageProviderSpringConfigurationTest { ).run { context -> assertNull(context.startupFailure) val properties = context.getBean(LanguageProviderProperties::class.java) - assertEquals(listOf(PAPAGO), properties.detectionOrder) - assertEquals(listOf(PAPAGO), properties.translationOrder) + assertAll( + { assertEquals(listOf(PAPAGO, AWS), properties.detectionOrder) }, + { assertEquals(listOf(PAPAGO, AWS), properties.translationOrder) } + ) + assertEquals(PAPAGO, context.getBean(LanguageDetectionCacheService::class.java).detectWithCache("source")?.providerId) + assertEquals( + PAPAGO, + context.getBean(TranslationCoordinator::class.java) + .translate(TranslateRequest(listOf("source"), "ko", "en")).providerId + ) + assertEquals(0, context.getBean("secondaryDetection", RecordingDetectionProvider::class.java).queries.size) + assertEquals(0, context.getBean("secondaryTranslation", RecordingTranslationProvider::class.java).requests.size) } } @@ -57,8 +76,18 @@ class LanguageProviderSpringConfigurationTest { assertEquals("secondary,papago", context.environment.getProperty("sodalive.language-providers.detection-order")) assertEquals("secondary,papago", context.environment.getProperty("sodalive.language-providers.translation-order")) val properties = context.getBean(LanguageProviderProperties::class.java) - assertEquals(listOf(PAPAGO), properties.detectionOrder) - assertEquals(listOf(PAPAGO), properties.translationOrder) + assertAll( + { assertEquals(listOf(PAPAGO, AWS), properties.detectionOrder) }, + { assertEquals(listOf(PAPAGO, AWS), properties.translationOrder) } + ) + assertEquals(PAPAGO, context.getBean(LanguageDetectionCacheService::class.java).detectWithCache("source")?.providerId) + assertEquals( + PAPAGO, + context.getBean(TranslationCoordinator::class.java) + .translate(TranslateRequest(listOf("source"), "ko", "en")).providerId + ) + assertEquals(0, context.getBean("secondaryDetection", RecordingDetectionProvider::class.java).queries.size) + assertEquals(0, context.getBean("secondaryTranslation", RecordingTranslationProvider::class.java).requests.size) } } @@ -104,13 +133,15 @@ class LanguageProviderSpringConfigurationTest { } @Test - @DisplayName("실제 production component의 Spring 기본 생성자는 두 기능 모두 파파고를 사용한다") + @DisplayName("Spring 기본 생성자는 두 기능 모두 PAPAGO 다음 AWS를 등록하고 첫 PAPAGO 성공 뒤 멈춘다") fun shouldDefaultToPapago() { defaultRunner.run { context -> assertNull(context.startupFailure) val properties = context.getBean(LanguageProviderProperties::class.java) - assertEquals(listOf(PAPAGO), properties.detectionOrder) - assertEquals(listOf(PAPAGO), properties.translationOrder) + assertAll( + { assertEquals(listOf(PAPAGO, AWS), properties.detectionOrder) }, + { assertEquals(listOf(PAPAGO, AWS), properties.translationOrder) } + ) assertEquals( PAPAGO, context.getBean(LanguageDetectionCacheService::class.java).detectWithCache("source")?.providerId @@ -120,6 +151,72 @@ class LanguageProviderSpringConfigurationTest { context.getBean(TranslationCoordinator::class.java) .translate(TranslateRequest(listOf("source"), "ko", "en")).providerId ) + assertEquals(listOf("source"), context.getBean("papagoDetection", RecordingDetectionProvider::class.java).queries) + assertEquals(1, context.getBean("papagoTranslation", RecordingTranslationProvider::class.java).requests.size) + assertEquals(0, context.getBean("secondaryDetection", RecordingDetectionProvider::class.java).queries.size) + assertEquals(0, context.getBean("secondaryTranslation", RecordingTranslationProvider::class.java).requests.size) + } + } + + @Test + @DisplayName("실제 AWS adapter와 SDK client만 등록한 context도 기본 순서에서 PAPAGO 첫 성공 뒤 SDK를 호출하지 않는다") + fun shouldWireActualAwsAdaptersWithoutDuplicateIds() { + actualRunner.withUserConfiguration(LanguageProviderProperties::class.java).run { context -> + assertNull(context.startupFailure) + assertEquals(2, context.getBeansOfType(LanguageDetectionProvider::class.java).size) + assertEquals(2, context.getBeansOfType(TranslationProvider::class.java).size) + assertEquals(AWS, context.getBean(AwsLanguageDetectionProvider::class.java).providerId) + val translator = context.getBean(AwsTranslationService::class.java) + assertEquals(AWS, translator.providerId) + assertEquals("translate-2017-07-01", translator.providerVersion) + val properties = context.getBean(LanguageProviderProperties::class.java) + assertAll( + { assertEquals(listOf(PAPAGO, AWS), properties.detectionOrder) }, + { assertEquals(listOf(PAPAGO, AWS), properties.translationOrder) } + ) + assertEquals(PAPAGO, context.getBean(LanguageDetectionCacheService::class.java).detectWithCache("source")?.providerId) + assertEquals( + PAPAGO, + context.getBean(TranslationCoordinator::class.java) + .translate(TranslateRequest(listOf("source"), "ko", "en")).providerId + ) + Mockito.verifyNoInteractions( + context.getBean(ComprehendClient::class.java), + context.getBean(TranslateClient::class.java) + ) + } + } + + @Test + @DisplayName("실제 AWS adapter는 별도 context의 명시 AWS 우선 순서로 SDK 응답을 반환한다") + fun shouldInvokeActualAwsAdaptersThroughSpringCoordinators() { + actualRunner.withBean( + LanguageProviderProperties::class.java, + Supplier { LanguageProviderProperties(listOf(AWS, PAPAGO), listOf(AWS, PAPAGO)) } + ).run { context -> + assertNull(context.startupFailure) + val detectionRequest = DetectDominantLanguageRequest.builder().text("source").build() + val translationRequest = TranslateTextRequest.builder() + .text("source").sourceLanguageCode("ko").targetLanguageCode("en").build() + val detector = context.getBean(ComprehendClient::class.java) + val translator = context.getBean(TranslateClient::class.java) + Mockito.`when`(detector.detectDominantLanguage(detectionRequest)).thenReturn( + DetectDominantLanguageResponse.builder() + .languages(DominantLanguage.builder().languageCode("ko").build()).build() + ) + Mockito.`when`(translator.translateText(translationRequest)) + .thenReturn(TranslateTextResponse.builder().translatedText("translated").build()) + + assertEquals(AWS, context.getBean(LanguageDetectionCacheService::class.java).detectWithCache("source")?.providerId) + val result = context.getBean(TranslationCoordinator::class.java) + .translate(TranslateRequest(listOf("source"), "ko", "en")) + assertEquals(AWS, result.providerId) + assertEquals("translate-2017-07-01", result.providerVersion) + assertEquals(listOf("translated"), result.result.translatedText) + Mockito.verify(detector).detectDominantLanguage(detectionRequest) + Mockito.verify(translator).translateText(translationRequest) + assertEquals(0, context.getBean("papagoDetection", RecordingDetectionProvider::class.java).queries.size) + assertEquals(0, context.getBean("papagoTranslation", RecordingTranslationProvider::class.java).requests.size) } } @@ -169,4 +266,14 @@ class LanguageProviderSpringConfigurationTest { @Bean fun secondaryTranslation() = RecordingTranslationProvider(AWS) } + + @TestConfiguration + @Import(AwsLanguageDetectionProvider::class, AwsTranslationService::class) + class ActualAwsProviders { + @Bean + fun comprehendClient(): ComprehendClient = Mockito.mock(ComprehendClient::class.java) + + @Bean + fun translateClient(): TranslateClient = Mockito.mock(TranslateClient::class.java) + } } 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 d98e1f72..f5bb3916 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 @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.i18n.translation +import kr.co.vividnext.sodalive.i18n.translation.LanguageProviderId.PAPAGO import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -30,7 +31,11 @@ class TranslationJobWorkerTest { val worker = TranslationJobWorker( translationJobRepository = jobRepository, translationMemoryRepository = memoryRepository, - translationCoordinator = TranslationCoordinator(listOf(provider), LanguageProviderProperties(), memoryRepository), + translationCoordinator = TranslationCoordinator( + listOf(provider), + LanguageProviderProperties(listOf(PAPAGO), listOf(PAPAGO)), + memoryRepository + ), materializer = materializer, transactionManager = TestTransactionManager() ) @@ -54,7 +59,11 @@ class TranslationJobWorkerTest { val worker = TranslationJobWorker( translationJobRepository = jobRepository, translationMemoryRepository = memoryRepository, - translationCoordinator = TranslationCoordinator(listOf(provider), LanguageProviderProperties(), memoryRepository), + translationCoordinator = TranslationCoordinator( + listOf(provider), + LanguageProviderProperties(listOf(PAPAGO), listOf(PAPAGO)), + memoryRepository + ), materializer = materializer, transactionManager = TestTransactionManager() ) @@ -84,7 +93,11 @@ class TranslationJobWorkerTest { val worker = TranslationJobWorker( translationJobRepository = jobRepository, translationMemoryRepository = memoryRepository, - translationCoordinator = TranslationCoordinator(listOf(provider), LanguageProviderProperties(), memoryRepository), + translationCoordinator = TranslationCoordinator( + listOf(provider), + LanguageProviderProperties(listOf(PAPAGO), listOf(PAPAGO)), + memoryRepository + ), materializer = materializer, transactionManager = TestTransactionManager() ) @@ -129,7 +142,11 @@ class TranslationJobWorkerTest { val worker = TranslationJobWorker( translationJobRepository = jobRepository, translationMemoryRepository = memoryRepository, - translationCoordinator = TranslationCoordinator(listOf(provider), LanguageProviderProperties(), memoryRepository), + translationCoordinator = TranslationCoordinator( + listOf(provider), + LanguageProviderProperties(listOf(PAPAGO), listOf(PAPAGO)), + memoryRepository + ), materializer = materializer, transactionManager = TestTransactionManager() ) @@ -165,7 +182,11 @@ class TranslationJobWorkerTest { val worker = TranslationJobWorker( translationJobRepository = jobRepository, translationMemoryRepository = memoryRepository, - translationCoordinator = TranslationCoordinator(listOf(provider), LanguageProviderProperties(), memoryRepository), + translationCoordinator = TranslationCoordinator( + listOf(provider), + LanguageProviderProperties(listOf(PAPAGO), listOf(PAPAGO)), + memoryRepository + ), materializer = materializer, transactionManager = TestTransactionManager() ) @@ -198,7 +219,11 @@ class TranslationJobWorkerTest { val worker = TranslationJobWorker( translationJobRepository = jobRepository, translationMemoryRepository = memoryRepository, - translationCoordinator = TranslationCoordinator(listOf(provider), LanguageProviderProperties(), memoryRepository), + translationCoordinator = TranslationCoordinator( + listOf(provider), + LanguageProviderProperties(listOf(PAPAGO), listOf(PAPAGO)), + memoryRepository + ), materializer = materializer, transactionManager = TestTransactionManager() ) 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 33d29e76..77856bae 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,6 +19,7 @@ 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.LanguageProviderId.PAPAGO import kr.co.vividnext.sodalive.i18n.translation.LanguageProviderProperties import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService @@ -673,7 +674,7 @@ class CreatorCommunityTranslationServiceTest @Autowired constructor( sourceExtractor = sourceExtractor, translationCoordinator = TranslationCoordinator( listOf(PapagoTranslationService("test-id", "test-secret")), - LanguageProviderProperties(), + LanguageProviderProperties(listOf(PAPAGO), listOf(PAPAGO)), memoryRepository ), contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java), -- 2.54.0 From 9d29355bf268ffcb8455454ff2e86d8e2afe35ae Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 10 Nov 2026 10:24:38 +0900 Subject: [PATCH 11/12] =?UTF-8?q?test(i18n):=20AWS=20=EA=B3=B5=EA=B8=89?= =?UTF-8?q?=EC=9E=90=EC=9D=98=20=EC=BA=90=EC=8B=9C=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EA=B3=BC=20=EC=8B=A4=ED=8C=A8=20=EC=A0=84=ED=99=98=EC=9D=84=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LanguageProviderCacheEndToEndTest.kt | 236 +++++++++++++++--- 1 file changed, 204 insertions(+), 32 deletions(-) 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 index c2e98f00..14be1f68 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderCacheEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderCacheEndToEndTest.kt @@ -14,23 +14,40 @@ 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.assertFalse +import org.junit.jupiter.api.Assertions.assertNotEquals import org.junit.jupiter.api.Assertions.assertNotNull +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.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.ArgumentCaptor 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.boot.test.system.CapturedOutput +import org.springframework.boot.test.system.OutputCaptureExtension 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 software.amazon.awssdk.core.exception.SdkClientException +import software.amazon.awssdk.services.comprehend.ComprehendClient +import software.amazon.awssdk.services.comprehend.model.DetectDominantLanguageRequest +import software.amazon.awssdk.services.comprehend.model.DetectDominantLanguageResponse +import software.amazon.awssdk.services.comprehend.model.DominantLanguage +import software.amazon.awssdk.services.translate.TranslateClient +import software.amazon.awssdk.services.translate.model.TranslateTextRequest +import software.amazon.awssdk.services.translate.model.TranslateTextResponse import java.time.LocalDateTime import java.util.UUID @@ -52,9 +69,12 @@ import java.util.UUID TranslationJobScheduler::class, ResourceTranslationJobScheduler::class, CreatorCommunityTranslationService::class, + AwsLanguageDetectionProvider::class, + AwsTranslationService::class, LanguageProviderCacheEndToEndTest.Providers::class ) @Transactional(propagation = Propagation.NOT_SUPPORTED) +@ExtendWith(OutputCaptureExtension::class) class LanguageProviderCacheEndToEndTest @Autowired constructor( private val detector: LanguageDetectionCacheService, private val detectionRepository: LanguageDetectionResultRepository, @@ -67,9 +87,11 @@ class LanguageProviderCacheEndToEndTest @Autowired constructor( private val members: MemberRepository, private val translations: CreatorCommunityTranslationRepository, private val papagoDetection: RecordingDetectionProvider, - private val secondaryDetection: RecordingDetectionProvider, private val papagoTranslation: RecordingTranslationProvider, - private val secondaryTranslation: RecordingTranslationProvider, + private val comprehendClient: ComprehendClient, + private val translateClient: TranslateClient, + private val awsTranslation: AwsTranslationService, + private val coordinator: TranslationCoordinator, transactionManager: PlatformTransactionManager ) { private val transaction = TransactionTemplate(transactionManager) @@ -79,34 +101,70 @@ class LanguageProviderCacheEndToEndTest @Autowired constructor( @BeforeEach fun useH2CompatibleClaimQuery() { + Mockito.reset(comprehendClient, translateClient) + papagoDetection.queries.clear() + papagoTranslation.requests.clear() + transaction.executeWithoutResult { + translations.deleteAll() + jobRepository.deleteAll() + memoryRepository.deleteAll() + detectionRepository.deleteAll() + } 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에 귀속 저장하고 외부 호출 없이 캐시를 재사용한다") + @DisplayName("동일 AWS 감지 prefix의 서로 다른 전체 원문을 H2에 구분 저장하고 무호출 캐시를 재사용한다") fun shouldPersistSecondaryDetectionAndReuseCache() { - val source = "provider source ${UUID.randomUUID()}" - assertEquals(DetectedLanguage("ko", AWS), detector.detectWithCache(source)) - assertEquals(DetectedLanguage("ko", AWS), detector.detectWithCache(source)) - val detection = transaction.execute { - detectionRepository.findBySourceHashAndProviderAndNormalizationVersion( - SourceTextNormalizer.hash(source), - "aws", - "v1" - ) - }!! - assertEquals("aws", detection.provider) - assertEquals("ko", detection.detectedLanguage) - assertEquals(1, papagoDetection.queries.size) - assertEquals(1, secondaryDetection.queries.size) + val prefix = "가".repeat(33332) + "\uD83D\uDE00" + val sources = listOf(prefix + " first ${UUID.randomUUID()}", prefix + " second ${UUID.randomUUID()}") + Mockito.`when`(comprehendClient.detectDominantLanguage(Mockito.any(DetectDominantLanguageRequest::class.java))) + .thenReturn(detectionResponse("ko")) + + sources.forEach { assertEquals(DetectedLanguage("ko", AWS), detector.detectWithCache(it)) } + sources.forEach { assertEquals(DetectedLanguage("ko", AWS), detector.detectWithCache(it)) } + + val detections = transaction.execute { detectionRepository.findAll() }!! + assertEquals(100000, prefix.toByteArray(Charsets.UTF_8).size) + assertNotEquals(SourceTextNormalizer.hash(sources[0]), SourceTextNormalizer.hash(sources[1])) + assertEquals(2, detections.size) + assertEquals(2, detections.map { it.id }.distinct().size) + assertEquals(sources.map { SourceTextNormalizer.hash(it) }.toSet(), detections.map { it.sourceHash }.toSet()) + detections.forEach { + assertEquals("aws", it.provider) + assertEquals("ko", it.detectedLanguage) + assertEquals("v1", it.normalizationVersion) + assertNotEquals(SourceTextNormalizer.hash(prefix), it.sourceHash) + } + assertEquals(sources, papagoDetection.queries) + assertDetectionRequests(prefix, prefix) } @Test - @DisplayName("보조 공급자의 번역을 H2에 귀속 저장하고 무호출 캐시와 실제 표시를 재사용한다") + @DisplayName("AWS 첫 감지 후보가 공백이면 뒤 후보를 쓰거나 H2에 캐시하지 않고 다음 요청도 다시 호출한다") + fun shouldNotCacheBlankFirstDetectionCandidate() { + val source = "uncached detection ${UUID.randomUUID()}" + Mockito.`when`(comprehendClient.detectDominantLanguage(Mockito.any(DetectDominantLanguageRequest::class.java))) + .thenReturn(detectionResponse(" ", "ko")) + + assertNull(detector.detectWithCache(source)) + assertNull(detector.detectWithCache(source)) + + assertEquals(0L, transaction.execute { detectionRepository.count() }) + assertEquals(listOf(source, source), papagoDetection.queries) + assertDetectionRequests(source, source) + } + + @Test + @DisplayName("실제 AWS 분할 번역을 H2에 전체 원문으로 저장하고 worker 완료 및 무호출 표시를 재사용한다") fun shouldPersistSecondaryTranslationAndReuseCacheThroughWorkerAndMaterializer() { - val source = "translation source ${UUID.randomUUID()}" + val first = "가".repeat(3332) + "\uD83D\uDE00" + val tail = "나 tail ${UUID.randomUUID()}" + val source = first + tail + Mockito.`when`(translateClient.translateText(Mockito.any(TranslateTextRequest::class.java))) + .thenReturn(translationResponse("first "), translationResponse("tail")) val postId = persistPost(source) schedule(postId, source) assertTrue(worker.processNextGroup()) @@ -120,14 +178,23 @@ class LanguageProviderCacheEndToEndTest @Autowired constructor( ) } assertNotNull(memory) - assertEquals("aws", memory!!.provider) - assertEquals("AWS-v1", memory.providerVersion) - assertEquals("AWS:0", memory.translatedText) + assertEquals(10000, first.toByteArray(Charsets.UTF_8).size) + assertTrue(source.toByteArray(Charsets.UTF_8).size > 10000) + assertEquals(1L, transaction.execute { memoryRepository.count() }) + assertEquals(source, memory!!.sourceText) + assertEquals(SourceTextNormalizer.hash(source), memory.sourceHash) + assertEquals("ko", memory.sourceLanguage) + assertEquals("en", memory.targetLanguage) + assertEquals("aws", memory.provider) + assertEquals("v1", memory.normalizationVersion) + assertEquals("translate-2017-07-01", memory.providerVersion) + assertEquals("first tail", memory.translatedText) assertEquals( TranslationJobStatus.COMPLETED, transaction.execute { jobRepository.findAll().single { it.resourceId == postId }.status } ) - assertEquals("AWS:0", displayService.findDisplayContents(listOf(postId), "en")[postId]?.content) + assertEquals("first tail", displayService.findDisplayContents(listOf(postId), "en")[postId]?.content) + assertEquals(true, displayService.findDisplayContents(listOf(postId), "en")[postId]?.hasValidTranslation) transaction.executeWithoutResult { translations.deleteAll() @@ -137,9 +204,117 @@ class LanguageProviderCacheEndToEndTest @Autowired constructor( schedule(postId, source) assertTrue(worker.processNextGroup()) assertEquals(1, papagoTranslation.requests.size) - assertEquals(1, secondaryTranslation.requests.size) - assertEquals(listOf(source), secondaryTranslation.requests.single().texts) - assertEquals("AWS:0", displayService.findDisplayContents(listOf(postId), "en")[postId]?.content) + assertEquals(listOf(source), papagoTranslation.requests.single().texts) + assertTranslationRequests(first, tail) + assertEquals(1L, transaction.execute { memoryRepository.count() }) + assertEquals( + TranslationJobStatus.COMPLETED, + transaction.execute { jobRepository.findAll().single { it.resourceId == postId }.status } + ) + assertEquals("first tail", displayService.findDisplayContents(listOf(postId), "en")[postId]?.content) + assertEquals(true, displayService.findDisplayContents(listOf(postId), "en")[postId]?.hasValidTranslation) + } + + @Test + @DisplayName("AWS 중간 조각 오류는 부분 결과와 저장을 버리고 worker에 원인 없는 안전한 오류만 남긴다") + fun shouldDiscardPartialAwsTranslationAndPersistSanitizedWorkerFailure(output: CapturedOutput) { + val first = "가".repeat(3332) + "\uD83D\uDE00" + val broken = "나".repeat(3333) + "b" + val source = first + broken + "synthetic-source-marker ${UUID.randomUUID()}" + val request = TranslateRequest(listOf(source), "ko", "en") + failTranslationAfter(first) + + assertEquals(emptyList(), awsTranslation.translate(request).translatedText) + assertTranslationRequests(first, broken) + val error = assertThrows(IllegalStateException::class.java) { coordinator.translate(request) } + assertEquals("all translation providers failed", error.message) + assertNull(error.cause) + assertTranslationRequests(first, broken, first, broken) + + val postId = persistPost(source) + schedule(postId, source) + assertTrue(worker.processNextGroup()) + + val job = transaction.execute { jobRepository.findAll().single { it.resourceId == postId } }!! + assertEquals(TranslationJobStatus.PENDING, job.status) + assertEquals(1, job.retryCount) + assertEquals("all translation providers failed", job.lastErrorMessage) + assertEquals(0L, transaction.execute { memoryRepository.count() }) + assertEquals(0L, transaction.execute { translations.count() }) + assertFalse(materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, "en")) + assertEquals(source, displayService.findDisplayContents(listOf(postId), "en")[postId]?.content) + assertEquals(false, displayService.findDisplayContents(listOf(postId), "en")[postId]?.hasValidTranslation) + assertEquals(listOf(request, request), papagoTranslation.requests) + assertTranslationRequests(first, broken, first, broken, first, broken) + assertFalse(output.all.contains("synthetic-sensitive-sdk-marker")) + assertFalse(output.all.contains("synthetic-source-marker")) + assertFalse(output.all.contains(source)) + } + + @Test + @DisplayName("테스트 전용 AWS 우선 순서에서 실제 부분 실패 뒤 Papago에 여러 원문 전체 요청을 그대로 넘긴다") + fun shouldFallbackEntireOriginalRequestAfterActualAwsPartialFailure(output: CapturedOutput) { + val first = "가".repeat(3332) + "\uD83D\uDE00" + val broken = "나".repeat(3333) + "b" + val source = first + broken + "synthetic-source-marker ${UUID.randomUUID()}" + val request = TranslateRequest(listOf(source, "second original"), "ko", "en") + failTranslationAfter(first) + val fallback = RecordingTranslationProvider(PAPAGO) { TranslateResult(listOf("papago first", "papago second")) } + val awsFirst = TranslationCoordinator( + listOf(fallback, awsTranslation), + LanguageProviderProperties(listOf(PAPAGO, AWS), listOf(AWS, PAPAGO)), + memoryRepository + ) + + val result = awsFirst.translate(request) + + assertEquals(PAPAGO, result.providerId) + assertEquals("PAPAGO-v1", result.providerVersion) + assertEquals(listOf("papago first", "papago second"), result.result.translatedText) + assertSame(request, fallback.requests.single()) + assertTranslationRequests(first, broken) + assertEquals(0L, transaction.execute { memoryRepository.count() }) + assertFalse(output.all.contains("synthetic-sensitive-sdk-marker")) + assertFalse(output.all.contains("synthetic-source-marker")) + } + + private fun failTranslationAfter(first: String) { + Mockito.`when`(translateClient.translateText(Mockito.any(TranslateTextRequest::class.java))).thenAnswer { + if (it.getArgument(0).text() == first) { + translationResponse("partial success") + } else { + throw SdkClientException.create("synthetic-sensitive-sdk-marker") + } + } + } + + private fun detectionResponse(vararg codes: String): DetectDominantLanguageResponse { + return DetectDominantLanguageResponse.builder() + .languages(codes.map { DominantLanguage.builder().languageCode(it).score(0.9f).build() }) + .build() + } + + private fun translationResponse(text: String): TranslateTextResponse { + return TranslateTextResponse.builder().sourceLanguageCode("ko").targetLanguageCode("en").translatedText(text).build() + } + + private fun assertDetectionRequests(vararg texts: String) { + val captor = ArgumentCaptor.forClass(DetectDominantLanguageRequest::class.java) + Mockito.verify(comprehendClient, Mockito.times(texts.size)).detectDominantLanguage(captor.capture()) + assertEquals(texts.toList(), captor.allValues.map { it.text() }) + Mockito.verifyNoMoreInteractions(comprehendClient) + } + + private fun assertTranslationRequests(vararg texts: String) { + val captor = ArgumentCaptor.forClass(TranslateTextRequest::class.java) + Mockito.verify(translateClient, Mockito.times(texts.size)).translateText(captor.capture()) + assertEquals(texts.toList(), captor.allValues.map { it.text() }) + captor.allValues.forEach { + assertEquals("ko", it.sourceLanguageCode()) + assertEquals("en", it.targetLanguageCode()) + assertTrue(it.text().toByteArray(Charsets.UTF_8).size <= 10000) + } + Mockito.verifyNoMoreInteractions(translateClient) } private fun schedule(postId: Long, source: String) { @@ -163,21 +338,18 @@ class LanguageProviderCacheEndToEndTest @Autowired constructor( @TestConfiguration class Providers { @Bean - fun languageProviderProperties() = LanguageProviderProperties( - listOf(PAPAGO, AWS), - listOf(PAPAGO, AWS) - ) + fun languageProviderProperties() = LanguageProviderProperties() @Bean fun papagoDetection() = RecordingDetectionProvider(PAPAGO) { throw IllegalStateException("sensitive provider body") } @Bean - fun secondaryDetection() = RecordingDetectionProvider(AWS) { "ko" } + fun comprehendClient(): ComprehendClient = Mockito.mock(ComprehendClient::class.java) @Bean fun papagoTranslation() = RecordingTranslationProvider(PAPAGO) { TranslateResult(emptyList()) } @Bean - fun secondaryTranslation() = RecordingTranslationProvider(AWS) + fun translateClient(): TranslateClient = Mockito.mock(TranslateClient::class.java) } } -- 2.54.0 From fe620aa9c0802776751a0c3613be86546c2694d6 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 10 Nov 2026 10:25:07 +0900 Subject: [PATCH 12/12] =?UTF-8?q?docs(i18n):=20AWS=20=EA=B3=B5=EA=B8=89?= =?UTF-8?q?=EC=9E=90=20=EA=B5=AC=ED=98=84=EA=B3=BC=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=EA=B8=B0=EB=A1=9D=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20261109_AWS언어감지번역/plan-task.md | 429 ++++++++++++++++++ docs/20261109_AWS언어감지번역/prd.md | 111 +++++ .../provider-guide.md | 64 ++- docs/agent-guides/실행명령어.md | 14 + 4 files changed, 599 insertions(+), 19 deletions(-) create mode 100644 docs/20261109_AWS언어감지번역/plan-task.md create mode 100644 docs/20261109_AWS언어감지번역/prd.md diff --git a/docs/20261109_AWS언어감지번역/plan-task.md b/docs/20261109_AWS언어감지번역/plan-task.md new file mode 100644 index 00000000..70a7b211 --- /dev/null +++ b/docs/20261109_AWS언어감지번역/plan-task.md @@ -0,0 +1,429 @@ +# AWS 언어 감지·번역 Provider 구현 계획 + +> **실행 방식:** 사용자 승인 Subagent-driven 실행 완료. 기능 범위 검증과 독립 최종 리뷰를 통과했다. +> **후속 회귀:** 2026-11-10 사용자 보고로 추가한 P4-R1 및 P4-GATE까지 완료했다. 기존 P1~P3 완료·검증 기록은 보존한다. +> **실행 skill:** `subagent-driven-development`. + +**Goal:** 기존 언어 감지·번역 계약과 캐시를 유지하면서 AWS Comprehend 및 Translate를 Papago 다음 순서로 사용할 수 있게 한다. + +**Architecture:** 공급자에 종속되지 않는 UTF-8/UTF-16 code-point splitter를 공통 패키지에 추가하고 Papago 분할을 해당 helper로 옮긴다. AWS SDK for Java v2의 동기 client는 기존 명시적 AWS 자격 증명과 리전을 쓰는 lifecycle Bean으로 만들며, 두 adapter를 기존 coordinator 순서에 등록한다. 끝으로 실제 adapter를 SDK mock과 H2 저장 흐름에 연결해 캐시·worker·표시 경계를 검증한다. + +**Tech Stack:** Kotlin 1.6.21, Java 17, Spring Boot 2.7.14, Gradle, JUnit 5, AWS SDK for Java v2 BOM 2.57.11, Comprehend 및 Translate 모듈. +**Spec:** [prd.md](prd.md), 승인 결정 DEC-006. 제공 API 근거는 PRD의 공식 Comprehend 및 Translate 문서 링크를 따른다. + +## Global Constraints + +- SDK v2 BOM은 `2.57.11`로 고정하고 `software.amazon.awssdk:comprehend`, `software.amazon.awssdk:translate`만 새 SDK 모듈로 추가한다. 기존 AWS SDK v1 의존성은 유지한다. +- 기존 `cloud.aws.credentials.access-key`, `cloud.aws.credentials.secret-key`, `cloud.aws.region.static` 설정을 사용한다. AWS 자격 증명 기본 체인, 환경변수 인증 설계, IAM 설정은 변경하지 않는다. +- AWS 동기 clients는 Spring `@Bean`과 `destroyMethod = "close"`로 관리한다. 기본 동기 HTTP runtime은 SDK의 전이 의존성으로 둔다. +- 모든 production 변경은 `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/`에, 대응 테스트는 `src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/` 및 실제 영향받는 기존 테스트 디렉터리에 둔다. +- splitter는 Unicode code point 경계에서 자른다. Papago 크기는 UTF-16 code unit `5000`, AWS Translate 크기는 UTF-8 byte `10000`, 감지 prefix는 UTF-8 byte 최대 `100000`이다. +- 빈 raw Papago 문자열과 whitespace 보존을 포함한 기존 동작을 유지한다. 감지 prefix는 전체 원문 hash/cache key와 번역 입력을 바꾸지 않는다. +- 감지 순서 및 번역 순서는 각각 `PAPAGO, AWS`로 둔다. 지원 번역 언어는 기존 ko/en/ja만 허용한다. +- 공개 API, DB schema, provider interface, retry 동작, AWS IAM, 실계정 자격 증명, 유료 API 호출은 범위 밖이다. +- 구현 전에 각 구현 Task의 RED 테스트가 의도된 assertion 실패임을 확인한다. test/compiler setup 오류는 RED 증거가 아니다. +- 직접 영향받는 테스트만 `--tests`로 지정한다. 무필터 `test`, `check`, `build`, `clean build`는 자동 실행하지 않는다. 필요한 최종 산출물 확인은 `./gradlew bootJar`로 한다. +- 이후 `build.gradle.kts` 변경 시 `docs/agent-guides/실행명령어.md`의 AWS dependency/build 예시를, 신규 테스트 추가 시 해당 문서의 단일 테스트 예시를 갱신한다. +- 어떤 응답 원문, 인증정보, 민감 오류 메시지도 로그, fixture 또는 worker 오류 문자열에 노출하지 않는다. + +## Review Focus +1. 경계 바로 앞의 한글 및 supplementary-plane 문자는 잘리지 않고 한 조각 안에 남으며, byte/UTF-16 한도는 초과하지 않는다. `CodePointTextSplitterTest`에서 각 공급자 한도 경계, 재구성 및 100000 byte prefix와 제외 tail을 고정한다. +2. blank 입력, 짧은 감지 입력, blank/누락 후보와 AWS 오류가 감지 cache에 잘못된 값으로 저장되지 않는다. `AwsLanguageDetectionProviderTest`와 `LanguageProviderCacheEndToEndTest`에서 호출 수와 결과를 확인한다. +3. AWS 번역 일부 조각의 오류 또는 공백 응답이 앞선 조각·원문의 성공값과 섞이거나 후속 호출을 유발하지 않는다. `AwsTranslationServiceTest` 및 필요 시 `TranslationCoordinatorTest`에서 전체 요청 폐기와 원문 단위 fallback을 확인한다. +4. AWS 기본 순서 전환 후 Papago 전용 fixture가 실제 AWS adapter를 가짜 AWS provider와 함께 로드하거나 PAPAGO 전용 검증을 바꾸지 않는다. `LanguageProviderSpringConfigurationTest` 및 아래 명시한 영향 테스트를 확인한다. +5. 서로 prefix가 같은 긴 본문도 원문 전체 hash/cache key로 구분되고, 성공 AWS 결과는 `aws` 및 `translate-2017-07-01`로 저장되며 재호출되지 않는다. `LanguageProviderCacheEndToEndTest`에서 H2 row, SDK 호출 수, 화면 결과를 확인한다. +--- + +## 현재 상태 +| Phase | 상태 | Task | 다음 Goal | 차단 조건 | +|---:|---|---:|---|---| +| 1 | 완료 | 1/1 | 없음 | 없음 | +| 2 | 완료 | 2/2 | 없음 | 없음 | +| 3 | 완료 | 1/1 | 없음 | 없음 | +| 4 | 완료 | 1/1 | 없음 | 없음 | + +P1~P4의 모든 Task/Gate가 완료됐고 활성 Goal은 없다. 실제 IAM·유료 API·운영 DB 및 사용자 전체 테스트 검증은 아래 미검증 범위로 구분한다. +## 범위와 파일 책임 + +### 포함 +- 공통 Unicode code point splitter 및 기존 Papago 분할의 helper 이전. +- AWS Comprehend 감지 adapter, AWS Translate adapter, AWS SDK v2 client 설정 Bean. +- 감지/번역 기본 순서를 PAPAGO 다음 AWS로 등록하고 PAPAGO 전용 테스트 fixture를 명시 설정으로 고정. +- 실제 adapter, cache, worker, H2 persistence, materializer 표시 및 현재 provider guide의 구현 상태 갱신. +### 제외 +- 공개 interface나 API/DB schema 변경, 기존 AWS SDK v1 마이그레이션, AWS 실계정 호출/IAM 변경. +- AWS를 이유로 한 지원 언어 확대, confidence threshold, 다중 조각 감지 집계, retry·logging 확장. +- 일반적인 provider-guide 재작성 또는 역사적 PRD/계획/결정 기록 수정. +### 파일 맵 +| 책임 | 파일 | +|---|---| +| 공유 code point 분할과 UTF-8 길이 계산 | `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/CodePointTextSplitter.kt` | +| Papago splitter 사용 | `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt` | +| AWS detection/translation adapter | `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageDetectionProvider.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsTranslationService.kt` | +| client 생성 및 종료 | `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageProviderConfiguration.kt` | +| adapter 순서와 직접 영향 테스트 | `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderOrder.kt`, 아래 Task별 명시 파일 | +| 의존성과 실행 예시 | `build.gradle.kts`, `docs/agent-guides/실행명령어.md` | +| AWS 검증 및 가이드 | `src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderCacheEndToEndTest.kt`, `docs/20261109_감지번역공급자공통화/provider-guide.md` | +## 요구사항 추적 +| PRD 요구사항 | 구현 Goal | 검증 핵심 | +|---|---|---| +| AWS-001 | `P2-T1` | client configuration의 synthetic credentials, `Region`, 종료 | +| AWS-002, AWS-003, AWS-004 | `P2-T1` | blank/짧은 입력, 100000 byte prefix, 첫 후보만 검사하고 blank면 실패 null | +| AWS-005, AWS-007 | `P2-T1` | 명시 source/target, 순차 chunk, 전체 결과/중단, 안정 version | +| AWS-006 | `P1-T1` | Papago 5000 UTF-16 회귀와 AWS UTF-8/code point 경계 | +| 순서·실제 provider cache 귀속·전체 fallback | `P2-T2`, `P3-T1` | PAPAGO → AWS, H2 저장 및 재사용, 전체 원문 fallback | +| 보안·기존 API 및 DB 계약 | 모든 Goal, 특히 `P3-T1` | cause/error marker 비노출, 기존 hash 및 저장 계약 | +## TDD 및 실행 규칙 +- 구현 Task는 각각 정확히 다섯 체크박스인 `RED`, `RED 확인`, `GREEN`, `GREEN 확인`, `REFACTOR`를 가진다. +- RED 테스트를 작성한 뒤 필요한 production type의 컴파일용 빈 shell만 추가할 수 있다. shell은 요구 동작을 구현하지 않으며, intended assertion failure를 확인한 뒤에만 행동 코드를 쓴다. +- RED 확인은 해당 focused test가 테스트 누락/compile failure가 아닌 요구 동작 부재 assertion으로 실패했음을 기록한다. +- Gate는 통합 검증용 대체 절차를 따르고 TDD exception을 명시한다. P3-T1은 test-only wiring 작업이므로 첫 실행이 통과할 수 있다. 실패를 만들기 위해 production이나 응답을 고의 변경하거나 assertion을 약화하지 않는다. +- 모든 향후 명령은 저장소 루트에서 실행한다. 신규 명령은 아래 Task의 focused 명령 및 Gate 범위에 한정한다. +### Phase 1: 공통 code point 분할 + +**Phase 결과:** Papago의 기존 동작을 보존하며 공급자별 크기 측정 함수를 받는 내부 splitter 계약을 갖는다. +### Task 1.1: 공통 splitter와 Papago 이전 + +- [x] **Task 1.1: 공통 splitter와 Papago 이전** + +**Goal `P1-T1`:** Papago의 기존 분할 경계를 유지하면서 code point 단위 공통 분할 및 prefix helper를 제공한다. + +- **시작 조건:** 사용자 승인된 PRD 및 DEC-004/DEC-005 확인, 계획 검토와 실행 방식 선택 완료. 변경 전 `./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationServiceTest'`로 기존 동작의 통과를 기록한다. 기존 실패가 있으면 변경 영향과 분리해 먼저 보고한다. +- **완료 증거:** 3개 helper의 단위 테스트, Papago 기존 assertion 보존, focused 테스트 및 lint 성공. +- **범위 밖:** AWS adapter 구현, Papago HTTP 흐름·응답 계약 변경. +**Files:** +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/CodePointTextSplitter.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt` +- Create: `src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/CodePointTextSplitterTest.kt` +- Preserve and verify: `src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationServiceTest.kt` +**Interfaces:** +- **Consumes:** 기존 Papago의 `MAX_REQUEST_TEXT_LENGTH = 5000` 및 `String.length` 기준. +- **Produces:** `internal fun splitTextByCodePoint(text: String, maxSize: Int, codePointSize: (Int) -> Int): List`; `internal fun takeTextPrefixByCodePoint(text: String, maxSize: Int, codePointSize: (Int) -> Int): String`; `internal fun utf8CodePointSize(codePoint: Int): Int`. + +- [x] **RED:** `CodePointTextSplitterTest`에 정확 한도/초과, Korean, supplementary character, whitespace 포함 재구성, empty input (`listOf("")`), 100000 UTF-8 byte prefix와 제외 tail 테스트를 작성한다. Papago 5001/10001 및 경계 surrogate pair 기대도 기존 `PapagoTranslationServiceTest`에 유지한다. +- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.CodePointTextSplitterTest'`를 실행한다. helper 미구현에 따른 compile failure가 아니라 splitter 요구 결과 assertion 실패를 기록한다. 테스트 실행 명령 오류는 RED가 아니다. +- [x] **GREEN:** 위 세 internal 함수를 `CodePointTextSplitter.kt`에 구현한다. UTF-16 계수 callback에는 `Character.charCount(codePoint)`, UTF-8 계수에는 `utf8CodePointSize`를 쓴다. Prefix는 첫 허용 경계에서 멈춰 전체 입력을 split하지 않는다. 공통 boundary scanning은 중복이 실제 생길 때만 private helper로 공유한다. +- [x] **GREEN 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.CodePointTextSplitterTest'`를 실행해 경계·재구성 assertion이 통과함을 확인한다. +- [x] **REFACTOR:** `PapagoTranslationService.splitText`를 helper 호출로 바꾸고 5000 UTF-16 callback을 지정한다. `./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.CodePointTextSplitterTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationServiceTest'`와 `./gradlew ktlintCheck`로 Papago 전체 HTTP 회귀를 확인한다. + +**P1-T1 검증 기록 (2026-11-09):** +- 변경 전 `./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationServiceTest'`: exit 0, 기존 17개 통과. 기존 assertion은 수정하지 않았고 빈 raw 문자열/공백 HTTP 회귀 2개만 추가했다. +- RED `./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.i18n.translation.CodePointTextSplitterTest'`: 최초 실행은 테스트 컴파일 중 120초 timeout으로 RED에서 제외했다. 300초 제한 재시도로 compile 후 33개 중 32개 assertion 실패를 관찰했고, 같은 명령 재실행에서 `COMMAND_EXIT_CODE=1`을 확인했다. UTF-8 크기 assertion 예: `expected: <1> but was: <0>`. 빈 prefix 1개만 기존 shell에서도 통과했다. +- GREEN 같은 splitter focused 명령: exit 0, 33개 통과. 공통 경계 scanner를 split과 prefix가 공유하며 prefix는 전체 tail을 순회하지 않는다. JVM UTF-8 인코딩과 일치하도록 짝 없는 surrogate의 1바이트 대체 인코딩도 검증했다. +- Papago 이전 후 `./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.i18n.translation.CodePointTextSplitterTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationServiceTest'`: 최초 실행은 테스트 컴파일 중 120초 timeout, 300초 제한 재시도는 exit 0. splitter 33개와 Papago 19개, 합계 52개 통과(실패/오류/skip 모두 0). +- `./gradlew --no-daemon ktlintCheck --continue`: exit 0. main/test/Kotlin script lint 모두 성공. LSP 도구 대신 Kotlin compile 및 lint를 사용했다. 기존 Gradle deprecation, 중복 `org.json.JSONObject` 및 변경하지 않은 테스트의 compiler warning은 수정하지 않았다. +- 신규 단일 테스트 예시만 `docs/agent-guides/실행명령어.md`에 추가했다. 상세 보고서: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/aws-implementation.7dG8YS/p1.W0mxDa/report.md`. 전체 테스트·빌드·외부 API는 실행하지 않았다. 사용자 전체 테스트 명령은 `./gradlew test`다. `P1-GATE`는 부모 reviewer 판정 전이며 완료 처리하지 않는다. + +### Phase 1 Gate + +- [x] **Phase 1 Gate: Papago 호환성·분할 경계 검증** + +2026-11-09 Gate: 독립 reviewer 명세·품질 모두 Approved. 부모가 두 focused class 명령을 재실행해 exit 0(UP-TO-DATE)을 확인했다. 직전 실제 실행은 helper 33개 + Papago 19개, 총 52개 통과이며 ktlintCheck도 exit 0이다. LSP 도구 부재는 Kotlin 컴파일·lint로 보완했고 전체 테스트는 미실행이다. + +**Goal `P1-GATE`:** 공통 helper가 Papago 호환성과 Unicode/byte 경계를 만족하는지 판정한다. + +- **시작 조건:** `P1-T1`의 다섯 체크와 결과 기록 완료. +- **완료 증거:** 두 focused test class와 ktlint 성공, assertion 및 실제 명령 결과가 기록됨. +- **범위 밖:** AWS SDK dependency 또는 adapter 추가. +- **TDD 예외 사유:** 구현을 추가하지 않는 단계별 통합 검증이다. +- **대체 검증 방법:** 아래 명령 결과와 splitter 경계 assertion을 대조하고, 기존 Papago 5000/5001/10001 및 surrogate 요청/결합 검증이 유지되는지 확인한다. +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.CodePointTextSplitterTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationServiceTest' +./gradlew ktlintCheck +``` + +**Expected:** 두 테스트 범위와 lint가 exit code 0. 실패가 있으면 해당 Task의 미완료 상태를 보존한다. +### Phase 2: AWS adapters와 순서 등록 + +**Phase 결과:** Comprehend 및 Translate가 기존 provider contract를 구현하고 production 기본 순서 PAPAGO 다음에 AWS가 온다. +### Task 2.1: AWS SDK v2 clients와 adapters + +- [x] **Task 2.1: AWS SDK v2 clients와 adapters** + +**Goal `P2-T1`:** 명시된 AWS 설정으로 client lifecycle을 관리하고 기존 provider contract에 맞는 감지·번역 adapter를 추가한다. + +- **시작 조건:** `P1-GATE` 통과 및 `CodePointTextSplitter` 계약 확정. +- **완료 증거:** SDK dependency, 세 production 클래스, 세 focused test class, 실행 명령 문서 갱신과 해당 테스트 성공. +- **범위 밖:** production provider order 변경, 실 AWS 연결, IAM 권한 검증. +**Files:** +- Modify: `build.gradle.kts` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageDetectionProvider.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsTranslationService.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageProviderConfiguration.kt` +- Create: `src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageDetectionProviderTest.kt` +- Create: `src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsTranslationServiceTest.kt` +- Create: `src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageProviderConfigurationTest.kt` +- Modify: `docs/agent-guides/실행명령어.md` +**Interfaces:** +- **Consumes:** `LanguageDetectionProvider.detect(query: String): String?`; `TranslationProvider.translate(request: TranslateRequest): TranslateResult`; P1 splitter three functions; existing cloud AWS `@Value` keys. +- **Produces:** `AwsLanguageDetectionProvider(ComprehendClient) : LanguageDetectionProvider`; `AwsTranslationService(TranslateClient) : TranslationProvider`; `@Configuration` bean methods `comprehendClient(): ComprehendClient`, `translateClient(): TranslateClient`, each with `@Bean(destroyMethod = "close")`. + +**테스트 실행 준비:** RED 테스트를 먼저 작성하고 SDK 타입을 컴파일하는 데 필요한 BOM `2.57.11`, `comprehend`, `translate` 의존성과 비동작 shell만 준비한다. dependency resolution 및 compile 성공 자체는 RED 증거가 아니다. 생산 동작은 아래 RED 확인 뒤 구현한다. + +- [x] **RED:** 신규 세 테스트 클래스에 성공·실패 assertion을 작성한다. 감지는 짧은 입력 그대로 전달, blank 무호출, 100000바이트 prefix, 첫 후보의 낮은 score/미지원 코드 허용을 검증한다. 첫 후보가 blank이고 두 번째가 유효해도 결과는 null이며 빈 후보 목록·SDK 예외도 null이다. 번역은 10000바이트 정확 경계와 초과, 한글·보조 평면 문자·공백, source/target, 순서·원문별 결과 하나, 중간 조각 또는 다음 원문의 예외/blank/null 응답 시 전체 결과 폐기와 이후 무호출을 검증한다. 빈 목록·동일 언어·미지원 언어 가드도 검증한다. Configuration은 synthetic credentials/region과 실제 client 생성·종료를 좁은 Spring context에서 검증하고, adapter는 SDK interface만 mock한다. +- [x] **RED 확인:** 세 신규 test class를 `./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.AwsLanguageDetectionProviderTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.AwsTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.AwsLanguageProviderConfigurationTest'`로 실행한다. 각 intended assertion 실패를 확인하며 test compile 오류는 거부한다. +- [x] **GREEN:** static credentials provider에 기존 access/secret 키를 연결하고 `Region.of`와 종료 관리 client Bean을 구현한다. 감지는 blank 무호출·짧은 입력·100000바이트 prefix를 적용하고 첫 후보만 검사한다. 첫 후보의 blank/missing 또는 SDK 예외는 null이며 후속 후보를 대신 선택하지 않는다. PRD의 prefix 한계 `shortcut:` 주석을 남긴다. 번역은 source/target을 명시하고 10000 UTF-8 바이트 이하 조각을 순서대로 번역해 원문별로 결합한다. 예외·null/blank 번역이면 전체 요청 결과를 폐기하고 이후 호출을 중단한다. 기존 언어 가드와 `translate-2017-07-01` 버전을 적용한다. +- [x] **GREEN 확인:** 동일한 세 `--tests` 대상으로 실행해 client 설정/종료, 감지 boundary 및 응답 매핑, 번역 요청 assertions가 통과함을 확인한다. +- [x] **REFACTOR:** 통과한 동작을 바꾸지 않고 이번 Task가 만든 중복만 정리한다. `docs/agent-guides/실행명령어.md`에 신규 focused 테스트 예시를 추가하고 Papago 및 AWS 공통 focused suite와 `./gradlew ktlintCheck` 결과를 기록한다. + +**P2-T1 검증 기록 (2026-11-09):** +- 아래 명령에 모두 `--no-daemon`을 추가하고 600000ms timeout 및 전용 TMPDIR/cache 경로를 사용했다. 테스트 세 클래스를 먼저 작성한 뒤 dependency와 비동작 shell만 추가했다. 최초 세 클래스 RED는 main/test compile 성공 후 48개 중 40개 실패(감지 19/16 실패, 번역 27/22 실패, 설정 2/2 실패), exit 1이었다. 실제 assertion은 감지 `expected: but was: `, 번역 `expected: <[translated]> but was: <[]>`, 설정 Bean 수 `expected: <1> but was: <0>`이며 compiler/setup 오류가 아니다. 빈 입력/언어 가드 8개는 shell에서도 통과했다. 숫자 exit marker만 얻기 위한 동일 RED 재실행은 하지 않았다. +- 첫 구현 후 세 클래스 실행은 exit 1(48개 중 설정 2개 실패). 실제 client 생성에서 `BeanCreationException → BeanInstantiationException → NoClassDefFoundError: org/apache/hc/client5/http/ssl/TlsSocketStrategy`를 발견했다. `dependencyInsight --dependency httpclient5` 및 `--dependency httpcore5`를 `--configuration runtimeClasspath`로 실행(각 exit 0)해 Boot의 `5.1.4`/`5.1.5` 하향 관리를 확인했다. 공식 SDK POM의 `5.6.4`/`5.4.3`으로 build extra 두 값만 정렬했으며 transport 추가/제외와 v1 변경은 없다. 정렬 후 `dependencyInsight --dependency org.apache.httpcomponents --configuration runtimeClasspath`는 exit 0이며 HTTP4는 기존 `4.5.14`/`4.4.16`, SDK v1은 `1.12.380`이다. +- HTTP5 정렬 후 실행은 실제 생성/인증/region을 통과했으나 test proxy의 종료 관찰 assertion 2개가 실패해 exit 1이었다. Spring의 원래 Bean destruction 경로에 맞춰 proxy 대신 실제 두 Apache5 pool의 종료 전 false/종료 후 true를 관찰하도록 테스트만 수정했다. 동일 세 클래스 최종 GREEN은 exit 0, 48개 통과(감지 19, 번역 27, 설정 2; failure/error/skip 0). 실제 client는 synthetic credentials와 서울/대체 region으로 생성하며 네트워크 요청 없이 종료를 검증한다. +- 위 다섯 클래스 공통 focused 명령은 exit 0, 총 100개 통과(splitter 33, Papago 19, 감지 19, 번역 27, 설정 2; failure/error/skip 0). `./gradlew --no-daemon ktlintCheck --continue`는 신규 번역 클래스 한 줄 길이로 exit 1, 신규 설정 테스트 개행으로 exit 1, 두 줄만 정리한 최종 실행은 exit 0이다. 실행 문서는 새 세 단일 테스트 및 AWS dependency/build 예시만 추가했다. +- 원문·합성 민감 오류 marker의 신규 로그 비노출 assertion을 확인했다. LSP 도구가 없어 Kotlin compiler와 lint를 사용했다. 전체 테스트, build/check/clean, bootJar, 실 AWS/Papago 호출, 실제 자격 증명/IAM 및 SDK wire/signing은 미검증이다. 사용자 전체 테스트 명령은 `./gradlew test`다. 상세 명령·실패 이력·변경 파일 보고서는 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/aws-implementation.7dG8YS/p2-adapters.wy5Eu6/report.md`에 저장했다. Provider order와 다른 Task/Gate는 변경하지 않았다. + +**검증 명령:** + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.AwsLanguageDetectionProviderTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.AwsTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.AwsLanguageProviderConfigurationTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.CodePointTextSplitterTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.AwsLanguageDetectionProviderTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.AwsTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.AwsLanguageProviderConfigurationTest' +./gradlew ktlintCheck +``` + +Maven Central BOM POM 확인 URL: `https://repo.maven.apache.org/maven2/software/amazon/awssdk/bom/2.57.11/bom-2.57.11.pom`. SDK 2.57.11 BOM/service POM과 Java 17 호환성은 부모 조사에서 확인된 근거다. 실제 Gradle dependency resolution 및 프로젝트 통합 호환성은 이 Task 실행 시 검증하며, 현재 이미 통과했다고 기록하지 않는다. 전이 기본 HTTP runtime 외의 transport 의존성/제외는 추가하지 않는다. +### Task 2.2: AWS 기본 순서와 Papago 전용 fixture + +- [x] **Task 2.2: AWS 기본 순서와 Papago 전용 fixture** + +**Goal `P2-T2`:** 실제 두 AWS adapter를 등록하고 Papago 전용 테스트는 의도한 한 provider만 주입하도록 고정한다. + +- **시작 조건:** `P2-T1`의 adapter 테스트와 configuration 성공. +- **완료 증거:** 두 default list의 순서, 영향 테스트의 PAPAGO 전용 설정, 실제 AWS와 fake AWS bean의 중복 없는 Spring wiring을 검증. +- **범위 밖:** 공개 provider ID/code, properties 검증 규칙 및 기존 PAPAGO 우선순위 변경. +**Files:** +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderOrder.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderIdTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderSpringConfigurationTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorkerTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheServiceTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectionCacheConcurrencyTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/CreatorCommunityTranslationServiceTest.kt` +**Interfaces:** +- **Consumes:** `AwsLanguageDetectionProvider` 및 `AwsTranslationService` 실제 Spring components, `LanguageProviderProperties(List, List)`. +- **Produces:** production `detectionOrder`와 `translationOrder` 각각 `listOf(PAPAGO, AWS)`; fake-only test runner와 actual-adapter runner가 구분된 Spring wiring. + +- [x] **RED:** `LanguageProviderIdTest`의 기본 순서 assertion을 `[PAPAGO, AWS]`로 기대하고 production adapter identity 및 안정 code assertion은 보존한다. Spring 기본 순서/legacy override 테스트에 두 provider와 PAPAGO 첫 성공, AWS 미호출 assertion을 추가한다. +- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.LanguageProviderIdTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.LanguageProviderSpringConfigurationTest'` 실행 결과가 production default가 아직 `[PAPAGO]`인 데서 비롯된 intended assertion failure인지 확인한다. +- [x] **GREEN:** `LanguageProviderOrder.kt`의 두 목록에 AWS를 Papago 다음에 추가한다. Spring 테스트의 legacy property/env default 기대를 새 순서로 갱신하며 PAPAGO 우선 성공과 AWS 미호출을 검증한다. actual adapter를 검증할 runner는 SDK mock과 함께 별도로 구성하고 fake `AwsProviders`를 같은 capability에 로드하지 않는다. +- [x] **GREEN 확인:** 위 두 class를 다시 실행한다. startup uniqueness assertion을 유지하고 `shouldRejectInvalidOrdersAtStartup`의 `papagoRunner`가 AWS 없는 `[AWS]` 설정에서 실패하는 검증을 그대로 보존한다. +- [x] **REFACTOR:** 아래 영향 테스트 helper만 `LanguageProviderProperties(listOf(PAPAGO), listOf(PAPAGO))`로 명시해 기존 단일 Papago 기대를 유지한다. `LanguageProviderPropertiesTest`의 invalid inventory 검증은 변경하지 않는다. 전체 지정 suite와 ktlint 결과를 기록한다. + +PAPAGO-only fixture 대상은 `TranslationJobWorkerTest`의 여섯 coordinator 생성 위치, `LanguageDetectionCacheServiceTest.service`, `CreatorCommunityLanguageDetectTest`의 production 기본 properties import 및 `ControlledLanguageDetectionCacheService` super 생성자, `CreatorCommunityLanguageDetectionCacheConcurrencyTest`의 imported default properties, `CreatorCommunityTranslationServiceTest.materializer` helper다. +**검증 명령:** +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.*' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectionCacheConcurrencyTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' +./gradlew ktlintCheck +``` + +**P2-T2 검증 기록 (2026-11-10):** +- 모든 Gradle 명령은 저장소 루트에서 `--no-daemon`, timeout 600000ms 및 전용 TMPDIR/runtime cache 환경으로 실행했다. 두 default assertion 테스트부터 작성한 최초 RED는 exit 1, 10개 중 5개 실패였다(ID 3/1 실패, Spring 7/4 실패; 오류/skip 0). 실패는 모두 `expected: <[PAPAGO, AWS]> but was: <[PAPAGO]>`이며 compiler/setup 오류가 아니다. 두 production 목록 변경 후 같은 명령의 GREEN은 exit 0, 10개 전체 통과다. +- Production 변경은 `LanguageProviderOrder.kt` 두 목록뿐이다. Spring fake AWS runner와 실제 `AwsLanguageDetectionProvider`/`AwsTranslationService` + SDK interface mock runner를 분리했다. 실제 capability별 두 Bean, AWS enum/version, PAPAGO 첫 성공 뒤 AWS 무호출, 명시 AWS 우선 SDK 요청/응답을 확인했다. legacy property/env 무시, PAPAGO adapter identity/code 및 `papagoRunner`의 AWS 없는 inventory 거부는 유지했다. `LanguageProviderPropertiesTest`는 수정하지 않았다. +- 지정 Papago-only fixture만 명시 목록으로 격리했다(worker 여섯 coordinator, content cache helper, community 감지 테스트의 imported properties와 controlled service super 생성자, concurrency imported properties, community translation materializer). 기존 기대와 assertion은 유지했다. `i18n.translation.*` + `content.LanguageDetectionCacheServiceTest` 명령은 최초/최종 모두 exit 0, 각 142개 통과다. 별도 세 community class 명령도 최초/최종 모두 exit 0, 각 30개 통과(감지 12, concurrency 2, translation 16)이며 두 최종 실행의 실패/오류/skip은 모두 0이다. +- `./gradlew --no-daemon ktlintCheck`는 최초에 신규 테스트 네 파일의 인자 개행/130자 위반으로 exit 1이었다. 해당 변경 줄만 개행한 최종 실행은 exit 0이며 format 명령은 실행하지 않았다. `./gradlew --no-daemon bootJar`는 exit 0이다. 최종 포맷 뒤 두 focused suite를 각각 다시 실제 실행했다. 산출물의 production 클래스와 실제 AWS 번역 adapter를 호출한 Java 드라이버는 exit 0으로 기본 순서, 첫 성공 무호출, AWS fallback, blank 입력 및 AWS 누락 거부를 확인했다. 네트워크 요청은 없다. +- 기존 Gradle deprecation, 중복 `org.json.JSONObject`, invalid-order startup 및 worker 실패 시나리오의 예상 WARN은 유지했다. LSP가 없어 Kotlin main/test compile 및 lint로 검증했다. 전체 테스트·실 AWS/Papago·실제 credentials/IAM·운영 DB는 미검증이며 사용자 전체 명령은 `./gradlew test`다. 상세 count/명령/로그 및 보고서는 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/aws-implementation.7dG8YS/p2-order.NhauNM/report.md`에 기록했다. `P2-GATE`는 부모 독립 review 전이며 체크하지 않았다. 다른 Task/Gate와 현재 상태 표는 변경하지 않았다. + +### Phase 2 Gate + +- [x] **Phase 2 Gate: AWS 동작·순서·직접 영향 회귀 검증** + +2026-11-10 Gate: 독립 reviewer 명세·품질 Approved. 부모가 모든 변경 diff와 suite/lint/build 증거를 대조했다. translation/cache 142개와 community 30개 모두 실패·오류·skip 0, ktlintCheck 및 bootJar exit 0. 실제 AWS adapter의 localhost SDK 요청 검증도 완료했다. 실 AWS IAM·번역 품질·전체 테스트는 미검증이다. + +**Goal `P2-GATE`:** AWS adapter 동작, default provider order, Papago 전용 회귀 및 두 capability wiring을 판정한다. + +- **시작 조건:** `P2-T1`, `P2-T2` 완료 증거와 Progress 기록. +- **완료 증거:** 아래 두 focused suite, ktlint 및 `bootJar` exit code 0. Gradle에서 실제 dependency resolution도 성공. +- **범위 밖:** 전체 테스트, live cloud/API, IAM 권한 확인. +- **TDD 예외 사유:** 구현 결과에 대한 검증 gate다. +- **대체 검증 방법:** focused suites를 각각 실행하고 Papago-only fixtures, 실제 AWS adapter, provider uniqueness와 저장 계약 경계를 대조한다. 실제 AWS 권한은 미검증으로 기록한다. + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.*' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectionCacheConcurrencyTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' +./gradlew ktlintCheck +./gradlew bootJar +``` + +Phase 2 관련 추가 회귀는 coordinator/worker/cache/end-to-end 테스트에 한정한다. 무필터 `test`나 `check`/`build`는 실행하지 않는다. +### Phase 3: 실제 adapter의 저장·표시 경로 검증 + +**Phase 결과:** SDK mock으로 실제 AWS adapter를 H2 기반 기존 worker/cache/materializer 경로에서 실행하고 현재 확장 가이드를 실제 구현과 일치시킨다. + +### Task 3.1: adapter end-to-end와 현재 가이드 + +- [x] **Task 3.1: 실제 adapter 통합 검증과 현재 가이드** + +**Goal `P3-T1`:** 실제 AWS adapter 성공·cache 재사용·fallback·표시를 H2에서 검증하고 현재 provider guide 설명을 갱신한다. + +- **시작 조건:** `P2-GATE` 통과. +- **완료 증거:** actual SDK adapter mock을 이용한 H2 검증, 원문 full hash 및 output/providerVersion 저장 assertion, guide 대조 완료. +- **범위 밖:** 실 AWS 연결, 운영 MySQL claim query의 SKIP LOCKED 동작 증명, 역사적 PRD 변경. + +**Files:** + +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderCacheEndToEndTest.kt` +- Modify if required by a proven gap: `src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationCoordinatorTest.kt` +- Modify if required by a proven gap: `src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorkerTest.kt` +- Modify: `docs/20261109_감지번역공급자공통화/provider-guide.md` + +**Interfaces:** + +- **Consumes:** `AwsLanguageDetectionProvider(ComprehendClient)`, `AwsTranslationService(TranslateClient)`, production order `[PAPAGO, AWS]`, 기존 H2 cache/worker/materializer wiring. +- **Produces:** H2 integration evidence for original full-source cache identity, successful AWS provider/version attribution, SDK call count and displayed output; current guide description of AWS adapters, order, splitter and version. + +- [x] **TDD (명시적 예외):** test-only integration wiring 및 검증이다. 새 전체 경로 검증은 첫 실행에 통과할 수 있으므로 인위적 RED를 만들지 않는다. Production behavior, SDK response 또는 assertion을 sabotage/약화하지 않는다. +- [x] **TDD 예외 대체 검증:** `LanguageProviderCacheEndToEndTest`에서 실제 adapters를 사용하고 AWS SDK interface만 mock한다. 중복 fake AWS beans/casts를 제거하고 assertion을 actual SDK call counts로 바꾼다. Comprehend에 동일 prefix와 서로 다른 tail을 가진 긴 원문 두 개를 보내 full hash/cache row가 구분되는지 확인하고 반복 조회는 AWS 호출이 없는지 확인한다. +- [x] **TDD 예외 대체 검증:** 번역 경로에서 Papago 실패 후 실제 AWS adapter의 성공 결과가 `aws`, `translate-2017-07-01`, 정확한 joined output으로 저장되고 worker 완료 및 materializer 화면에 보이는지 검증한다. cache 재사용 시 AWS 재호출이 없고, generic `Recording` provider의 version rewriting이 없음을 확인한다. +- [x] **TDD 예외 대체 검증:** 기존 `PAPAGO → AWS` production order를 보존한다. 실제 AWS adapter의 중간 조각 실패를 명시적 테스트 순서 `AWS → PAPAGO`로 실행해 다음 공급자에 원래 요청 전체가 전달됨을 검증한다. 모든 공급자 실패 시 cause 없는 고정 오류와 worker 오류 저장을 확인하고, 실패한 AWS의 부분 번역 memory가 저장되지 않음을 H2에서 확인한다. 기존 테스트가 이를 이미 검증하면 재사용하고 없으면 명시된 coordinator/worker 테스트에 추가한다. 합성 민감 오류 marker와 원문은 최종 오류·신규 로그에 없어야 한다. +- [x] **TDD 예외 대체 검증:** `provider-guide.md`의 현재 구현, default order, helper와 provider version 문장을 코드와 대조해 갱신한다. H2의 claim query spy는 기존 `FOR UPDATE SKIP LOCKED` 한정으로만 설명한다. Progress에 명령, 실제 결과, 운영 DB/live API 미검증을 기록한다. + +**P3-T1 검증 기록 (2026-11-10):** +- Test-only 예외대로 production 변경이나 인위적 RED 없이 기존 E2E를 실제 `AwsLanguageDetectionProvider`/`AwsTranslationService`와 mock `ComprehendClient`/`TranslateClient`로 교체했다. Fake AWS 두 Bean과 해당 recording 주입을 제거하고 Papago 실패 fixture, 실제 H2 저장소·worker·materializer·표시 및 기존 claim query 한 곳의 spy를 유지했다. SDK mock은 매 테스트 `Mockito.reset`, Papago 기록 목록과 cache/job/read model은 매 테스트 비워 context 재사용을 격리한다. +- 최초 `./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.i18n.translation.LanguageProviderCacheEndToEndTest'`는 exit 0, 32초, 5개 전체 통과(실패/오류/skip 0)다. 같은 100000 UTF-8 바이트 prefix의 서로 다른 전체 원문 두 개가 같은 SDK prefix 두 번, 서로 다른 전체 hash 및 H2 aws row 두 개를 만들고 반복 조회에는 추가 SDK/Papago 호출이 없다. 첫 후보가 blank이고 둘째가 유효한 응답은 두 번 모두 null이며 H2 row는 0이다. +- 10000 UTF-8 바이트를 넘는 한글·보조 평면 원문은 Papago 실패 뒤 실제 AWS 두 조각으로 번역된다. H2 memory 한 개에 전체 원문/hash, `aws`, `translate-2017-07-01`, 정확한 `first tail` 결과를 저장하고 worker 완료와 유효 표시를 확인했다. read model/job을 삭제한 뒤 materializer 재생성과 새 job도 같은 memory를 사용하며 총 SDK 2회/Papago 1회가 늘지 않는다. 실제 중간 SDK 오류는 앞선 성공까지 버려 빈 결과를 반환하고 후속 조각/원문을 호출하지 않는다. 전체 실패의 cause는 null, worker 재시도 오류는 `all translation providers failed`, memory/read model은 0이며 합성 민감 marker와 원문은 출력에 없다. 별도 로컬 `AWS → PAPAGO` 순서에서는 여러 원문을 포함한 원래 요청 객체 전체가 Papago에 전달된다. 모든 경계를 E2E에서 검증하므로 coordinator/worker 단위 테스트의 중복 추가는 필요하지 않았다. +- 위 지정 translation/cache 명령에 `--no-daemon`을 추가한 실행은 exit 0, 25초, 145개 통과다(기존 142개에서 E2E 2→5로 3개 추가). 별도 세 community class 명령은 exit 0, 32초, 30개 통과(감지 12, concurrency 2, translation 16)다. 두 실행 모두 `:test`가 실제 실행됐고 실패/오류/skip은 0이다. `./gradlew --no-daemon ktlintCheck`는 exit 0, 24초(test lint 실제 실행), `./gradlew --no-daemon bootJar`는 exit 0, 11초(`UP-TO-DATE`)다. 모든 Gradle 명령은 timeout 600000ms와 전용 TMPDIR/runtime cache 환경으로 실행했다. +- 현재 `provider-guide.md`만 실제 adapter 가용성·기본 순서·공유 splitter·기존 명시 credentials/region·SDK/HTTP5 버전·providerVersion·테스트 경계에 맞췄고 역사적 PRD/계획은 수정하지 않았다. 기존 Gradle deprecation, 중복 `org.json.JSONObject`, negative startup/worker 시나리오의 예상 WARN은 유지했다. LSP 도구가 없어 Kotlin test compile과 lint로 보완했다. 전체 테스트·실 AWS/Papago·credentials/IAM·운영 MySQL claim 동시성 및 별도 SDK wire QA는 이 Task에서 실행하지 않았다. 사용자 전체 테스트 명령은 `./gradlew test`다. 상세 명령/count/파일/로그는 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/aws-implementation.7dG8YS/p3-integration.QpAQTJ/report.md`에 기록했다. `P3-GATE`, 상단 상태 표와 다른 Task/Gate는 부모 소유로 남겼다. + +**검증 명령:** + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.*' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectionCacheConcurrencyTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' +./gradlew ktlintCheck +./gradlew bootJar +``` + +**H2 검증 경계:** repository 저장·별도 transaction 조회·materializer/display는 실제 H2다. Claim query만 H2 호환 spy로 교체한다. 이 경로는 SDK serialization/signing, 실제 AWS 품질·권한, 운영 MySQL `SKIP LOCKED` 동시성을 증명하지 않는다. + +### Phase 3 Gate + +- [x] **Phase 3 Gate: 기능 범위 최종 검증** + +2026-11-10 Gate: Oracle이 P3 명세·품질과 전체 변경을 승인했다. 부모가 별도 fresh 실행한 translation/cache 145건, community 30건 모두 통과했고 lint·bootJar·최종 산출물 기반 실제 SDK localhost QA도 성공했다. 실패·오류·skip 0이며 코드·가이드·PRD를 대조했다. 실제 IAM·AWS/Papago 품질·과금·운영 MySQL 및 전체 테스트는 미검증이며 기능 범위 완료와 구분한다. + +**Goal `P3-GATE`:** 세 Phase의 범위, 실제 adapter 사용자 흐름, 문서 최신성 및 검증 증거를 최종 판정한다. + +- **시작 조건:** `P3-T1`과 모든 이전 Goal 완료 증거 기록. +- **완료 증거:** 아래 focused suite 각각 성공, ktlint와 bootJar 성공, trace table의 요구사항과 실제 결과 대조, 전체 테스트 미실행 표시. +- **범위 밖:** test 전체 실행, 실 AWS 호출 및 PRD의 IAM/live 품질 미검증 상태 변경. +- **TDD 예외 사유:** 구현을 새로 하지 않는 final verification gate다. +- **대체 검증 방법:** 아래 명령 결과, H2 assertion, guide/current code/PRD 대조 및 문서 공백 검사를 Progress에 기록한다. 사용자의 전체 검증 명령은 별도로 안내한다. + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.*' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectionCacheConcurrencyTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' +./gradlew ktlintCheck +./gradlew bootJar +``` + +**Expected:** 각 명령이 exit code 0이고 문서/코드 대조에서 미해결 contract 차이가 없다. 전체 테스트는 사용자가 `./gradlew test`로 실행하며, 이 계획의 focused 성공을 전체 성공으로 표현하지 않는다. + +## Goal 순서와 의존성 + +### Phase 4: 공용 테스트 AWS 초기화 회귀 보정 + +- [x] **Task 4.1: 테스트 전용 인증정보와 공유 설정 회귀 (`P4-R1`)** +- **목표:** 공용 테스트 YAML로 전체 Spring 앱이 시작되며 운영의 빈 키 거부는 그대로 유지한다. +- **시작 조건:** `SpringBootIntegrationSampleTest`가 `Access key ID cannot be blank` 및 `AwsLanguageProviderConfiguration.kt:18`에서 실패함을 수정 전에 재현. +- **완료 증거:** 아래 RED/GREEN, 실제 H2·Redis 및 로컬 HTTP 통합 검증, 독립 리뷰와 결과 기록. +- **범위 밖:** 운영 코드·인증정보·Provider 순서·lazy/profile 우회·전체 테스트·실 AWS 호출 변경. +- **Files:** 수정 `src/test/resources/application.yml`, `src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/AwsLanguageProviderConfigurationTest.kt`; 검증 `src/test/kotlin/kr/co/vividnext/sodalive/support/SpringBootIntegrationSampleTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointIntegrationTest.kt`. +- **계약:** 공용 test YAML을 실제로 로드하고 인증정보 property를 테스트에서 덮어쓰지 않는다. 두 client와 두 provider가 생성되며 합성 static credentials/서울 설정을 사용한다. 운영 빈 access/secret 키는 계속 실패한다. +- [x] **RED:** 공용 YAML 기반 AWS 설정·provider 생성 assertion과 빈 키 거부 회귀를 기존 설정 테스트에 추가한다. +- [x] **RED 확인:** `./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.i18n.translation.AwsLanguageProviderConfigurationTest'`에서 공용 빈 키가 원인인 실패를 확인한다. +- [x] **GREEN:** 공용 test YAML의 accessKey/secretKey 두 값만 명백한 테스트 전용 가짜 값으로 교체한다. production 설정·검증은 수정하지 않는다. +- [x] **GREEN 확인:** 같은 설정 테스트와 `SpringBootIntegrationSampleTest`, `DeploymentEndpointIntegrationTest`를 각각 focused 실행한다. +- [x] **REFACTOR:** 불필요한 추가 변경 없이 `i18n.translation.*` 회귀·`ktlintCheck`와 변경 공백 검사를 수행하고 실패·성공 증거를 기록한다. + +**P4-R1 검증 기록 (2026-11-10):** +- `ConfigDataApplicationContextInitializer`로 실제 공용 `application.yml`을 읽는 설정 회귀를 기존 클래스에 추가했다. 이 회귀는 인증정보 property override나 SDK/provider mock 없이 실제 configuration·두 client·두 adapter를 로드하고 capability별 Bean 수, AWS ID, static synthetic credentials와 서울 리전을 확인한다. 기존 두 region의 실제 client 생성·종료 테스트는 수정하지 않았다. 별도 parameterized negative 2건은 동일 production configuration에서 blank access/secret 각각의 startup failure와 SDK root cause를 확인한다. +- YAML 수정 전 `./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.i18n.translation.AwsLanguageProviderConfigurationTest'`: exit 1, 19초, 5건 중 공유 YAML 회귀 1건 실패(오류/skip 0). `assertNull(context.startupFailure)`가 `BeanCreationException → BeanInstantiationException → NullPointerException: Access key ID cannot be blank.`로 실패했다. main/test compile은 성공했으며 기존 lifecycle 2건과 빈 키 거부 2건은 통과했다. 원본 RED assertion/count는 아래 보고서 경로의 `01-config-red-evidence.log`에 보존했다. +- `src/test/resources/application.yml`의 두 값만 `test-access-key`/`test-secret-key`로 교체했다. 동일 설정 focused 명령은 exit 0, 13초, 5건 전체 통과다. 두 negative는 각각 `Access key ID cannot be blank.`와 `Secret access key cannot be blank.`를 계속 확인한다. production 코드·설정·build와 다른 도메인 테스트는 이번 수정에서 변경하지 않았다. +- `./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.support.SpringBootIntegrationSampleTest'`: exit 0, 35초, 1건 통과. 실제 전체 Spring 앱에서 H2 `select 1`과 embedded Redis set/get을 확인했다. `./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.deployment.DeploymentEndpointIntegrationTest'`: exit 0, 35초, 4건 통과. 실제 Tomcat 앱/management 포트 `65041`/`65042`에서 TestRestTemplate HTTP로 readiness 200 두 경로, deployment 기본 응답 및 앱 포트 비노출을 검증했다. 두 기존 클래스와 credential override는 추가·변경하지 않았다. +- `./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.i18n.translation.*'`: exit 0, 26초, 15개 클래스 146건 통과(AWS 설정/감지/번역 5/19/27건 포함). GREEN 실행은 모두 실패/오류/skip 0이며 `:test`가 실제 실행됐다. 중복 없는 검증 범위는 translation 146건과 전체 앱/HTTP 5건, 총 151건이다. `./gradlew --no-daemon ktlintCheck`: exit 0, 20초(test lint 실제 실행); `git diff --check`: exit 0. 불필요한 refactor/format과 bootJar는 실행하지 않았다. +- 모든 Gradle 명령은 timeout 600000ms와 지정 TMPDIR/cache export로 실행했다. 테스트 후 lsof/ps에서 해당 Spring test workers, Redis `65026`, HTTP `65041`/`65042`와 잔존 Redis listener가 없었으며 프로세스를 kill하지 않았다. LSP 도구가 없어 Kotlin compile·ktlint로 보완했다. Gradle deprecation, 기존 중복 JSONObject 경고와 JVM 종료 시 Redis 재접속 WARN은 수정하지 않았다. 전체 테스트·실 AWS/Papago·실계정/IAM은 미검증이며 사용자 전체 명령은 `./gradlew test`다. +- 상세 명령/exit/count/RED 증거 보고서: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/aws-test-fix.We4KIU/fix.4xaIEt/report.md`. 다섯 실행 체크는 완료했으며 완료 증거의 독립 리뷰 및 Task 최상위 완료 판정은 부모에게 남긴다. `P4-GATE`, 상단 상태 표, PRD와 기존 P1~P3 기록은 수정하지 않았다. + +- [x] **Phase 4 Gate (`P4-GATE`): 회귀 수정·운영 검증 보존 승인** +- **시작 조건:** P4-R1 결과 및 독립 리뷰 확인. +- **완료 증거:** 설정 회귀, H2/Redis 전체 앱, 실제 로컬 HTTP 테스트가 통과하고 production diff가 이번 수정에 없음을 확인한다. +- **범위 밖:** 전체 테스트 성공 또는 실 IAM 검증으로 확대 해석하지 않는다. +- **TDD 예외:** Gate는 검증·문서 마감이며 새 동작을 구현하지 않는다. +- **대체 검증:** 위 focused 명령의 실제 실행 결과, runtime context·HTTP 응답, diff·리뷰 대조. +- **진행 기록:** 2026-11-10 수정 전 `./gradlew --no-daemon test --rerun --tests 'kr.co.vividnext.sodalive.support.SpringBootIntegrationSampleTest'` 실패. root cause는 공용 test YAML의 빈 accessKey로 SDK v2의 eager credential 생성이 거부되는 것이다. 앞선 전용 AWS 설정 테스트는 합성 property를 직접 주입해 이 경로를 놓쳤다. 전체 테스트는 사용자 실행 `./gradlew test`로 남긴다. +- **부모 fresh 검증:** `./gradlew --no-daemon test --rerun --tests 'kr.co.vividnext.sodalive.i18n.translation.*' --tests 'kr.co.vividnext.sodalive.support.SpringBootIntegrationSampleTest' --tests 'kr.co.vividnext.sodalive.deployment.DeploymentEndpointIntegrationTest'` exit 0, 실제 재실행 17개 클래스 151건 실패·오류·skip 0을 XML에서 확인했다. `./gradlew --no-daemon ktlintCheck` 및 `git diff --check` exit 0. 실제 H2/Redis와 앱·management HTTP 경로가 포함되며 변경한 테스트 외 credential override는 없다. +- **독립 리뷰:** 명세·품질 및 S1(공용 설정 로딩), S2(빈 운영 키 거부), S3(실제 앱·HTTP) 모두 승인, 차단 사항 없음. H2가 지원하지 않는 background scheduler `SKIP LOCKED` 오류는 별도 기존 한계로 관찰했으며 이번 통과가 scheduler SQL의 정상 동작을 증명하는 것은 아니다. +- **실행 경계:** test worker PID 70305와 HTTP 포트 65088/65089가 종료됐음을 확인했다. production 코드·운영 설정·빌드에는 이번 수정이 없고, 전체 테스트·실 IAM·외부 API는 미검증이다. 위 임시 보고서의 핵심 RED/GREEN 증거는 이 문서에 보존하고 임시 산출물은 작업 종료 시 정리한다. +- **정리 영수증:** 소유한 회귀 조사·구현·리뷰 임시 root의 열린 파일이 없음을 확인하고 하위 로그·캐시·노트를 제거했다. 경로 부재 검사 exit 0. 공유 캐시·기존 프로세스·저장소 파일은 제거하지 않았다. + +| 순서 | Goal | 의존성 | 실행 방식 | +|---:|---|---|---| +| 1 | `P1-T1` | 시작 조건 충족 | splitter 단일 Task | +| 2 | `P1-GATE` | `P1-T1` | focused 검증 | +| 3 | `P2-T1` | `P1-GATE` | SDK dependency 및 adapter | +| 4 | `P2-T2` | `P2-T1` | order 및 영향 fixture | +| 5 | `P2-GATE` | `P2-T1`, `P2-T2` | focused suite/lint/bootJar | +| 6 | `P3-T1` | `P2-GATE` | H2 adapter 통합 및 guide | +| 7 | `P3-GATE` | `P3-T1` | focused 최종 검증 | +| 8 | `P4-R1` | 사용자 후속 오류 재현 | 공용 테스트 설정 회귀 보정 | +| 9 | `P4-GATE` | `P4-R1` | 실제 앱·HTTP 및 리뷰 판정 | + +`P1-T1 → P1-GATE → P2-T1 → P2-T2 → P2-GATE → P3-T1 → P3-GATE`. 한 번에 하나의 goal만 active로 둔다. 선행 goal이 완료되지 않으면 뒤 goal을 시작하지 않는다. + +## 문서 및 실행 기록 + +### 계획 작성 기록 + +- 상태: 계획 문서만 작성 완료, 구현 대기. +- 무엇을: 승인 PRD의 확정 요구사항을 세 Phase와 일곱 Goal로 추적하고 정확한 구현/테스트 경로 및 focused 명령을 계획했다. +- 어떻게: 필수 작업/문서/실행/테스트 가이드, 샘플, 현재 PRD, build 설정, splitter/order 및 직접 영향 테스트를 읽어 파일과 계약을 확인했다. +- 실행 검증: 문서 전용 변경이므로 Gradle, 설치, AWS/API 호출을 실행하지 않는다. 향후 구현 명령은 계획 예시이며 아직 실행하지 않았다. +- 다음 행동: 사용자가 계획을 검토하고 실행 방법을 선택한 뒤에만 첫 Goal을 시작한다. + +### Verification Record + +#### 부모 최종 실행 검증 — 2026-11-10 + +- `./gradlew --no-daemon test --rerun --tests 'kr.co.vividnext.sodalive.i18n.translation.*' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest'`: exit 0, 실제 test 재실행. 16개 클래스 145건, 실패·오류·skip 0을 XML에서 확인했다. +- `./gradlew --no-daemon test --rerun --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectionCacheConcurrencyTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest'`: exit 0, 실제 test 재실행. 각각 12·2·16건, 총 30건 모두 통과했다. 두 범위 합계는 중복 없는 175건이다. +- `./gradlew --no-daemon ktlintCheck bootJar --rerun`: exit 0. 변경 없는 lint는 UP-TO-DATE, bootJar는 재생성했다. 앞선 실제 lint 실행도 통과했다. 산출물은 `build/libs/sodalive-0.0.1-SNAPSHOT.jar`다. +- 부모가 산출물의 classes/libs를 임시 경로에 추출하고 `javac`/`java`로 로컬 HTTP 드라이버를 실행했다. 실제 SDK의 Comprehend JSON, Translate rpc-v2-cbor 요청·응답과 서울 리전 서명, 100000바이트 prefix, 10000바이트 번역 분할·재구성·결합, blank 응답 후 중단이 모두 PASS(exit 0)였다. 합성 인증정보와 localhost만 사용했고 SDK clients와 HTTP 서버 종료를 확인했다. +- `git diff --check`: exit 0. LSP 도구가 제공되지 않아 Kotlin 컴파일과 ktlint로 보완했다. 전체 테스트 `./gradlew test`, 실제 IAM·AWS/Papago 품질·과금, 운영 MySQL claim 동시성은 미검증이다. Gradle deprecation 및 기존 중복 `org.json` 경고는 범위 밖으로 유지했다. +- 전체 변경 최종 리뷰: Oracle이 P3 명세·품질 및 전체 변경을 승인했다. 구체적 차단 사항 없음. SDK lifecycle 테스트의 내부 reflection은 버전 변경 시 유지보수가 필요하다는 비차단 제약으로 기록했다. +- 실행 중 판단: 재현된 HTTP5 충돌만 두 버전 정렬로 수정했다. 기존 HTTP4·SDK v1은 그대로이며 다른 HTTP5 사용 경로의 전체 회귀는 사용자 전체 테스트 범위다. 자동 worktree·커밋·push 없이 기존 `test` 브랜치에 변경을 남긴다. +- 2026-11-10 정리 영수증: 실제 SDK QA 서버·clients가 종료된 뒤 작업 root의 열린 파일이 없음을 `lsof`로 확인했다. 소유한 임시 root와 모든 하위 작업 캐시·드라이버·추출 jar·임시 로그를 제거하고 경로 부재를 확인했다. 저장소의 구현 코드·문서·최종 `build/libs/sodalive-0.0.1-SNAPSHOT.jar`는 유지했다. 핵심 명령·결과·실패 이력은 이 계획에 보존했다. + +- P2-T1 부모 검증: 독립 reviewer 명세·품질 Approved. `./gradlew --no-daemon bootJar` exit 0. 산출물 라이브러리로 Java 드라이버를 컴파일하고 실제 SDK를 localhost HTTP 서버에 연결해 감지 prefix·서울 서명·번역 바이트 분할·결합·blank 응답 후 중단을 검증했다(exit 0). 서버 종료·client close 영수증을 확인했다. 첫 QA는 Translate가 JSON이라는 드라이버 가정으로 실패했으며 실제 rpc-v2-cbor에 맞춰 드라이버만 수정 후 통과했다. 실 AWS 호출은 없다. +- P2-T1 통합 결정: 재현된 `TlsSocketStrategy` 누락을 해결하기 위해 Spring Boot의 HTTP5 관리 버전만 SDK POM 기준 `httpclient5=5.6.4`, `httpcore5=5.4.3`으로 정렬했다. 기존 HTTP4·AWS SDK v1 선언은 유지한다. + +계획 작성 시 문서 간 정합성 및 경로 대조만 수행한다. 구현 전 테스트 결과나 실행 count는 없다. 구현 시 각 Goal의 실제 명령·결과를 해당 Goal에 누적하고, 공통 회귀·문서 검사는 여기에 추가한다. 전체 테스트는 사용자가 `./gradlew test`로 실행한다. + +- 2026-11-09 문서 검토: 첫 후보만 검사하는 감지 계약, SDK 준비와 RED/GREEN 순서, 기존 Papago-only fixture·미등록 공급자 거부 유지의 세 시나리오를 독립 검토해 모두 PASS를 받았다. 중요한 차단 사유 없음. +- 실제 파일 경로와 PRD 수용 기준을 대조하고 공백 검사를 수행했다. 문서 전용 변경이므로 테스트·빌드·LSP 진단·실제 AWS 호출은 수행하지 않았으며, 이 PASS는 구현 성공을 뜻하지 않는다. + +### Self-review + +- **Coverage:** AWS-001 client credentials/region은 P2-T1; AWS-002~004 감지 입력/후보는 P2-T1 및 P3-T1; AWS-005 chunk/atomic fallback 및 AWS-007 version은 P2-T1/P3-T1; AWS-006 Papago 및 code-point 경계는 P1-T1; 순서/cache/schema/security 요구는 P2-T2/P3-T1에 추적했다. +- **Step scan:** 모든 구현 Task에 RED, assertion 중심 RED 확인, GREEN, GREEN 확인, REFACTOR가 다섯 개씩 있다. P3 test-only와 Gate는 TDD 예외 사유 및 대체 검증을 둔다. +- **Type consistency:** splitter는 `String`, `Int`, `(Int) -> Int`, `List`/`String`을 사용한다. AWS client 생성 및 constructor 주입 타입은 AWS SDK v2의 `ComprehendClient`/`TranslateClient`다. +- **Review focus:** 다섯 항목이 owner test와 task에 각각 매핑돼 있다. 빈 Papago 원문, blank 입력, Unicode 경계, full hash, 에러 민감정보를 포함한다. +- **Proportion:** plan은 테스트 대상·contract·실행 명령·dependency 및 영향 fixture를 확정하는 범위다. 별도 subsystem 분할은 필요하지 않다. + +## 실행 승인 기록 + +요구사항과 SDK v2 방향은 DEC-006으로 승인됐다. 실행 전 이 계획의 Task 경로와 범위를 검토한다. 구현 방식은 두 선택지 중 하나를 정한다. + +- **Subagent-driven:** 각 Task를 독립 실행하고 단계별 fresh review를 거친다. Goal 사이 의존성과 AWS 설정/실제 provider 등록 회귀 위험이 있어 권장한다. +- **Native:** 한 구현자가 모든 Goal을 순서대로 실행하고 마지막에 전체 변경 review를 한다. + +2026-11-09 사용자가 "진행"으로 계획과 Subagent-driven 실행을 승인했다. 기존 `test` 브랜치에서 구현하며 자동 worktree 생성, 커밋·push, 실 AWS 호출은 하지 않는다. + +### 커밋 전 검증 — 2026-11-10 + +- 사용자 `/commit-with-validation` 요청으로 공용 커밋 절차를 적용했다. 기존 구현은 변경하지 않고 의존 순서별 구현·테스트 및 문서 커밋을 준비했다. +- `./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.i18n.translation.*' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest'`: exit 0, 33초, 16개 클래스 148건 통과. +- `./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectionCacheConcurrencyTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest'`: exit 0, 41초, 30건 통과. +- `./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.support.SpringBootIntegrationSampleTest' --tests 'kr.co.vividnext.sodalive.deployment.DeploymentEndpointIntegrationTest' ktlintCheck bootJar`: exit 0, 55초, 5건 통과. lint와 bootJar는 UP-TO-DATE다. +- 세 실행의 `:test`가 실제 실행됐고 XML 기준 총 183건, 실패·오류·skip 모두 0이다. `git diff --check`도 exit 0이다. 전체 테스트·실 AWS/Papago·IAM·운영 MySQL은 미검증이며 전체 테스트는 사용자가 `./gradlew test`로 실행한다. diff --git a/docs/20261109_AWS언어감지번역/prd.md b/docs/20261109_AWS언어감지번역/prd.md new file mode 100644 index 00000000..2dd7650e --- /dev/null +++ b/docs/20261109_AWS언어감지번역/prd.md @@ -0,0 +1,111 @@ +# AWS 언어 감지·번역 Provider PRD + +| 항목 | 내용 | +|---|---| +| 작성일 | 2026-11-09 | +| 최종 수정일 | 2026-11-10 | +| 문서 상태 | 기능 구현 및 공용 테스트 설정 회귀 보정 완료 | +| 구현 상태 | P4-R1 검증·리뷰 완료, 실제 AWS/IAM 및 전체 테스트 미검증 | +| 구현 계획 | [plan-task.md](plan-task.md), P1~P4 완료 및 기존 이력 보존 | +| 근거 문서 | [공급자 공통화 PRD](../20261109_감지번역공급자공통화/prd.md), [공급자 확장 가이드](../20261109_감지번역공급자공통화/provider-guide.md) | + +## 목표와 범위 + +공통 언어 감지·번역 구조에 AWS Comprehend 감지 Provider와 AWS Translate 번역 Provider를 추가한다. 콘텐츠 등 도메인 호출자는 기존 인터페이스와 coordinator만 사용하며 AWS 분기를 추가하지 않는다. 감지와 번역은 각각 독립적으로 등록하고 기존 캐시, 실패 전환, 저장 계약을 보존한다. + +구현 범위는 두 AWS adapter와 필요한 내부 분할 로직이다. 기존 `LanguageDetectionProvider`, `TranslationProvider`, coordinator 경계, `LanguageProviderId.AWS` 및 안정 code `aws`를 유지한다. 지원 번역 언어는 기존 ko/en/ja이며 AWS를 이유로 확장하지 않는다. 공개 API, DB 스키마, 재시도 계약은 변경하지 않는다. + +## 확정 요구사항 + +| ID | 상태 | 요구사항 | 관찰 가능한 수용 기준 | +|---|---|---|---| +| AWS-001 | 확정 | Comprehend와 Translate는 기존 `cloud.aws.credentials.access-key`, `cloud.aws.credentials.secret-key`, 서울 리전 `cloud.aws.region.static` (`ap-northeast-2`)을 재사용한다. Provider 전용 자격 증명 설정은 추가하지 않는다. | adapter 요청 테스트에서 설정된 자격 증명 공급자와 리전을 사용한다. IAM 정책을 변경하지 않으며 실제 권한은 별도로 검증되지 않았다고 표시한다. | +| AWS-002 | 확정 | 감지는 `DetectDominantLanguage`를 호출한다. 짧은 비어 있지 않은 입력도 길이로 사전 차단하지 않고, blank 입력은 기존 가드를 따른다. | 20자 미만 입력이 adapter까지 전달된다. blank 입력은 AWS 호출이 없다. | +| AWS-003 | 확정 | 감지 텍스트가 100KB를 넘으면 원문 앞부분의 최대 100000 UTF-8 바이트를 코드포인트 경계에서 잘라 보낸다. 여러 조각의 감지 결과를 합산하지 않는다. 원문 hash/cache key와 번역 원문은 바꾸지 않는다. | prefix가 UTF-8 유효하고 크기 한도를 넘지 않는다. 원문 hash/cache key와 번역 입력은 원문 기준 그대로다. 이 보수적 구현값은 공식 문서의 “100KB”를 구현하는 선택이며 공식 서비스 한도가 정확히 100000바이트라고 주장하지 않는다. | +| AWS-004 | 확정 | Comprehend 응답의 내림차순 정렬 결과에서 첫 dominant-language 후보를 사용한다. 언어 코드는 blank가 아니어야 유효하다. 임의 confidence 임계값을 두거나 공개 타입을 변경하지 않는다. | 후보가 없거나 언어 코드가 blank이면 기존 공급자 실패 전환으로 넘어가며, 유효 결과는 첫 후보 코드다. | +| AWS-005 | 확정 | Translate에는 source와 target 언어를 명시한다. 입력은 UTF-8 기준 10000바이트 이하 조각으로 나누어 원래 순서대로 번역하고 결합한다. 조각 하나라도 실패하면 해당 Provider의 부분 결과를 버리고 기존 전체 요청 단위 fallback을 따른다. | 경계·순서·결과 개수·전체 실패 동작을 검증한다. 원문과 조각 사이 whitespace를 손실 없이 재구성하고 별도 구분자를 삽입하지 않는다. | +| AWS-006 | 확정 | Papago의 기존 5000 UTF-16 코드 단위 분할은 유지한다. 내부 공통 splitter는 완전한 Unicode 코드포인트만 조각 경계로 삼고 공급자별 크기 측정 함수를 적용한다. | Papago 5000 UTF-16 단위 동작과 회귀 테스트가 유지된다. AWS 조각은 UTF-8 바이트 기준이며 한글·보조 평면 문자 경계가 유효하다. | +| AWS-007 | 확정 | 새 AWS 번역 결과의 기존 `providerVersion` 필드에는 안정 값 `translate-2017-07-01`을 사용한다. 실제 성공 Provider 귀속, 캐시 우선순위, 부분 성공 금지는 기존 공통화 계약을 따른다. | 캐시 미스 호출 성공 시 저장 귀속은 `aws`; 유효 AWS 캐시 재사용 시 외부 AWS 호출은 없다. 버전 값은 지정한 문자열이다. | +| AWS-008 | 확정 | 공용 테스트 설정만 사용하는 Spring 컨텍스트도 AWS Bean을 생성할 수 있도록 테스트 전용 합성 키를 제공한다. 운영의 빈 키 거부와 실계정 호출 금지 경계는 유지한다. | 공용 YAML을 덮어쓰지 않는 설정 회귀 및 기존 전체 앱 컨텍스트 테스트 통과. 빈 운영 키는 계속 초기화 실패. | + +## 동작과 데이터 보존 + +- 감지 순서와 번역 순서는 독립이다. AWS가 등록된 이후의 production 순서는 기존 공통화 PRD에서 상속한 PAPAGO → AWS다. 이번 PRD를 위해 순서를 다시 사용자에게 묻지 않는다. 미구현 AWS는 현재 실행 설정에 추가된 것처럼 동작하지 않는다. +- 기존 캐시 조회 순서와 실제 성공 Provider 귀속을 유지한다. 캐시 미스에서 앞선 Provider가 실패하면 다음 Provider로 전환하고, 첫 성공 뒤에는 더 호출하지 않는다. 전체 실패는 기존 감지 null 및 번역 worker 오류 흐름을 따른다. +- AWS 번역은 결과를 Papago와 섞지 않는다. 분할 중 하나라도 실패하면 AWS의 부분 결과를 버린 뒤 전체 입력을 다음 Provider에 전달한다. +- 원문, 인증 정보, 공급자 원시 응답·오류를 새 로그나 worker 오류 문자열에 남기지 않는다. 호출자 공개 타입, API schema, DB schema는 변경하지 않는다. +- IAM 변경, 자격 증명 발급, 실제 AWS 유료 호출은 이 문서나 구현 승인에 포함되지 않는다. +- 필요한 IAM 작업은 `comprehend:DetectDominantLanguage`, `translate:TranslateText`다. API 호출만 하는 이번 범위에 `TranslateFullAccess`는 필수가 아니다. [공식 API 권한 안내](https://docs.aws.amazon.com/translate/latest/dg/security_iam_id-based-policy-examples.html#security_iam_id-based-policy-examples-console)를 따르며 실제 권한은 미검증이다. + +## 승인된 구현 기술 + +다음 기술 설계는 DEC-006에서 승인됐다. 구현 계획에서 버전 pin과 파일 배치를 구체화한다. + +- 새 Comprehend/Translate 모듈에만 AWS SDK for Java v2를 사용한다. 기존 SQS, SES, S3, CloudFront SDK v1은 마이그레이션하지 않는다. +- v2의 static credentials provider에 기존 명시적 `cloud.aws` 자격 증명을 연결하고 기존 region 설정을 사용한다. +- 동기 SDK client는 Spring lifecycle 관리 Bean으로 제공하고 adapter에 주입한다. 애플리케이션 종료 시 client를 닫는다. +- SDK 정확한 버전 pin과 파일·클래스 이름은 제품 결정이 아니라 구현 세부이며, 계획 단계에서 저장소 관례에 맞춰 정한다. +- splitter만 내부에서 공유한다. HTTP/SDK adapter는 별도 구현하며 `TranslationProvider` 공개 메서드는 추가하지 않는다. +- `providerVersion`은 기존 필드에 `translate-2017-07-01`을 사용한다. + +AWS SDK for Java v1은 2025-12-31에 지원 종료가 공지됐다. 새 모듈에서 v2를 택하는 근거이며 기존 v1 전환 범위를 넓히지는 않는다. AWS는 v1/v2 공존 방식을 공식 지원한다. + +- [AWS SDK for Java v1 지원 종료 안내](https://aws.amazon.com/blogs/developer/announcing-end-of-support-for-aws-sdk-for-java-v1-x-on-december-31-2025/) +- [SDK v1/v2 side-by-side 사용 안내](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/migration-side-by-side.html) + +## 공식 API 근거 + +- [Comprehend DetectDominantLanguage](https://docs.aws.amazon.com/comprehend/latest/APIReference/API_DetectDominantLanguage.html): 감지 API, 100KB 입력 한도. 긴 입력 prefix 정책은 DEC-005에서 확정한다. +- [Comprehend 언어 감지 품질 안내](https://docs.aws.amazon.com/comprehend/latest/dg/how-languages.html): 20자 이상 권장. 20자 미만을 서비스의 확정 거부 조건으로 해석하지 않는다. +- [Translate TranslateText](https://docs.aws.amazon.com/translate/latest/APIReference/API_TranslateText.html): source/target 명시 및 입력 최대 10000 UTF-8 바이트. +- [Papago 번역 요청 규격](https://api.ncloud-docs.com/docs/en/ai-naver-papagonmt-translation): 요청당 최대 5000 characters. 정확한 Unicode 계수 단위는 명시되지 않는다. 기존 구현의 UTF-16 `String.length` 상한을 유지하며 실제 Papago 호출은 미검증이다. + +## 검증 기준 및 실행 계획 + +아래 검증은 실제 AWS/Papago 계정 호출 없이 완료했다. 2026-11-10 부모 fresh 실행에서 관련 테스트 175건이 통과했고, lint·bootJar 및 실제 SDK localhost 호출도 성공했다. 상세 명령과 실패·재시도 이력은 구현 계획에 보존한다. + +- 내부 splitter 단위 테스트: 정확한 한도와 한도 초과, 한글, supplementary-plane 문자의 UTF-8/UTF-16 경계, 공백을 포함한 원문 손실 없는 재구성, Papago 기존 회귀. +- 감지 테스트: prefix 최대 바이트, UTF-8 코드포인트 경계 보존, prefix가 원문의 끝부분을 포함하지 않음, 응답 첫 후보 선택, blank/없음 실패 전환, 짧은 입력 전달, 원문 hash/cache key 보존. +- SDK adapter 테스트: 통제된 로컬 transport 또는 외부 경계만 대체한 SDK mock으로 요청 필드와 응답 매핑을 확인한다. 유료 API 호출은 하지 않는다. +- coordinator/캐시 테스트: PAPAGO → AWS 순서, 첫 성공 시 후속 호출 없음, 전체 입력 단위 fallback, 부분 번역 폐기, AWS 성공 캐시 귀속, 실제 AWS 캐시 재사용 시 AWS 네트워크 호출 없음. +- 기능 회귀 명령은 `./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.*' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest'`이며 community 관련 3개 클래스는 별도 명령으로 검증했다. 산출물 검증은 `./gradlew bootJar`다. 전체 테스트 `./gradlew test`는 사용자가 실행한다. + +## 제외 및 안전 경계 + +- IAM 정책/role 변경, 운영 자격 증명 교체, 실제 유료 AWS/Papago 호출. +- 공개 API, DB schema, `TranslationProvider` 공개 메서드, 번역 언어 정책 변경. +- 새 재시도, 캐시 계층, 품질 임계값, 다중 조각 감지 집계, 관측성 확장. +- 원문·인증정보·공급자 원시 본문 또는 오류의 신규 로그 기록. +- v1 SDK를 사용하는 기존 서비스의 v2 마이그레이션. + +## Open Questions 및 승인 상태 + +| ID | 상태 | 내용 | 영향 | +|---|---|---|---| +| OQ-001~004 | 해결, DEC-001~003 | 서울 리전/기존 자격 증명 재사용, 짧은 감지 입력, 번역 분할 | 요구사항에 반영됨 | +| OQ-005 | 해결, DEC-006 | 새 AWS 모듈에 SDK v2 사용. 기존 SDK v1 유지 | 전체 PRD와 SDK v2 제안 승인 완료 | +| OQ-006 | 해결, DEC-005 | 100KB 초과 감지 입력을 UTF-8 코드포인트 경계 prefix로 제한하고 조각별 감지 집계 없음 | 혼합 언어 입력에서는 prefix의 감지 결과가 전체 원문의 dominant language와 다를 수 있음. 구현 코드에 `shortcut: 감지는 앞 100000 UTF-8 바이트만 판정하므로 전체 본문과 결과가 다를 수 있음, 전체 본문 기준 품질이 요구되면 별도 감지 정책 승인 후 업그레이드` 형식의 주석을 둔다. | +| OQ-007 | 해결, DEC-004 | 인터페이스 변경 없이 공통 internal 분할 함수와 공급자별 길이 계산 사용 | Papago와 AWS가 공통 분할 로직을 사용함 | + +요구사항 인터뷰 종료 시 가중 모호도는 약 0.03이었으며 열린 제품 질문은 없다. 승인된 전체 설계와 SDK v2 구현의 기능 범위 검증이 완료됐다. IAM의 실제 권한과 외부 API 품질 확인은 별도이며 운영 검증 완료로 간주하지 않는다. + +### 문서 검증 기록 + +- 2026-11-09: 독립 builder 검토 결과, 제안 설계 승인 후 계획 수립을 막는 중요한 미결 사항 없음(`nothing important left`). SDK 버전 pin과 클래스 이름은 구현 계획 단계에서 결정한다. +- 문서의 결정 상태·수용 기준·공백 및 placeholder 잔존 여부를 대조했다. 문서 전용 변경이므로 Gradle 테스트·빌드와 실제 외부 API 호출은 실행하지 않았다. +- 2026-11-10 구현 후 검증: 모든 Task/Gate 완료, 관련 테스트 175건·lint·bootJar 및 실제 SDK localhost QA 통과, Oracle 전체 변경 리뷰 승인. 문서 작성 시점의 위 미실행 기록은 과거 이력이며 실제 실행 결과는 구현 계획을 따른다. +- 2026-11-10 후속 회귀: 공용 YAML의 빈 키가 실제 전체 앱 시작을 막는 실패를 재현했다. 테스트 전용 키 두 값과 공유 설정 회귀를 보완한 뒤 부모 fresh 검증 151건(번역 146건, 전체 앱·HTTP 5건)·lint 및 독립 리뷰를 통과했다. 앞선 175건과 다른 검증 범위이며 전체 테스트 성공으로 해석하지 않는다. 운영의 빈 키 거부는 그대로 유지한다. + +## 결정 기록 + +| 날짜 | ID | 상태 | 결정 | 근거 | +|---|---|---|---|---| +| 2026-11-09 | DEC-001 | 확정 | Comprehend·Translate에 기존 자격 증명과 서울 리전 설정을 재사용한다. | 사용자: “기존 인증정보와 서울 리전을 재사용한다.” | +| 2026-11-09 | DEC-002 | 확정 | 20자 미만도 AWS 감지를 요청하고 실패·결과 없음은 기존 실패 정책을 따른다. | 사용자 답변: “네” | +| 2026-11-09 | DEC-003 | 확정 | 긴 AWS 번역은 Papago와 같은 분할·순차 번역·결합 흐름으로 구현한다. Papago 길이 단위를 공식 가이드와 대조하고 불일치가 확인되면 함께 수정한다. 공통 분할 함수 배치는 조사 후 설계한다. | 사용자: “파파고 번역이 어떻게 되어 있는지 확인하고 동일하게 처리하자”, 공식 가이드와 다르면 변경 요청 | +| 2026-11-09 | DEC-004 | 확정 | 공개 인터페이스는 유지한다. 공통 internal splitter와 공급자별 길이 계산을 사용하고 Papago 5000 UTF-16 단위 및 AWS 10000 UTF-8 바이트를 적용한다. | 사용자 답변: “그렇게 진행하자” | +| 2026-11-09 | DEC-005 | 확정 | 긴 감지 텍스트는 처음 최대 100KB만 사용한다. UTF-8 prefix를 코드포인트 경계에서 자르고 여러 조각의 언어를 합산하지 않는다. 원문 hash/cache key와 번역 입력은 그대로 유지한다. | 사용자 답변: “확정” | +| 2026-11-09 | OQ-005 | 제안 | 신규 AWS 모듈만 SDK v2 사용. 기존 SQS/SES/S3/CloudFront v1은 유지한다. | 기술 제안, 전체 설계 및 SDK 제안 승인 대기 | +| 2026-11-09 | DEC-006 | 확정 | 이 PRD의 전체 설계와 신규 AWS Provider의 SDK v2 사용을 승인하고 구현 계획 작성으로 진행한다. 기존 SDK v1 연동은 유지한다. | 최종 PRD·SDK v2 제안 승인 및 구현 계획 작성 질문에 대한 사용자 답변: "진행" | +| 2026-11-09 | DEC-007 | 확정 | 구현 계획과 Subagent-driven 실행을 승인한다. | 계획 검토·실행 방식 질문에 대한 사용자 답변: "진행" | +| 2026-11-10 | DEC-008 | 회귀 보정 | 공용 테스트 YAML의 빈 AWS 키로 전체 앱 테스트 시작이 실패함을 재현했다. 테스트 전용 합성 값과 공유 설정 로딩 회귀를 추가하며 운영 코드는 변경하지 않는다. | 사용자 오류 보고 및 SpringBootIntegrationSampleTest의 AwsLanguageProviderConfiguration:18 동일 오류 재현 | diff --git a/docs/20261109_감지번역공급자공통화/provider-guide.md b/docs/20261109_감지번역공급자공통화/provider-guide.md index 89aaacd7..c5d5a67d 100644 --- a/docs/20261109_감지번역공급자공통화/provider-guide.md +++ b/docs/20261109_감지번역공급자공통화/provider-guide.md @@ -2,8 +2,9 @@ ## 현재 실행 구조 -운영 구현체는 `PapagoLanguageDetectionProvider`와 `PapagoTranslationService`뿐이다. -AWS 감지·번역 adapter, 해당 SDK 및 전용 인증 설정은 추가하지 않았다. 지금 `LanguageProviderId.AWS`를 순서에 넣으면 시작에 실패한다. +감지 구현체는 `PapagoLanguageDetectionProvider`와 `AwsLanguageDetectionProvider`, +번역 구현체는 `PapagoTranslationService`와 `AwsTranslationService`다. +두 기능의 production 기본 순서는 모두 `PAPAGO → AWS`다. AWS는 기존 자격 증명과 리전 설정을 재사용한다. 감지와 번역은 별도 capability다. 감지만 또는 번역만 제공하는 공급자도 등록할 수 있다. 두 capability에서 같은 `providerId`를 쓰는 것은 가능하지만, 한 capability 안의 ID 중복은 시작 시 거부한다. @@ -24,7 +25,7 @@ AWS 감지·번역 adapter, 해당 SDK 및 전용 인증 설정은 추가하지 ## 새 공급자 추가 순서 1. `LanguageProviderId`에 공급자 enum 항목과 재배포 후에도 안정적인 DB `code`를 추가한다. - 현재 항목은 `PAPAGO("papago")`, `AWS("aws")`다. AWS는 이미 ID가 있으므로 실제 adapter만 별도로 구현한다. + 현재 항목은 `PAPAGO("papago")`, `AWS("aws")`이며 두 공급자의 실제 adapter가 등록돼 있다. 2. 필요한 capability 인터페이스를 구현하고 Spring `@Component`/`@Service` 또는 `@Bean`으로 등록한다. 예를 들어 Papago adapter의 식별자는 `override val providerId = LanguageProviderId.PAPAGO`다. 3. 공급자의 HTTP/SDK 및 인증은 adapter 안에 둔다. 외부 응답 형식과 빈 결과를 검증하고, @@ -34,19 +35,24 @@ AWS 감지·번역 adapter, 해당 SDK 및 전용 인증 설정은 추가하지 콘텐츠·댓글·커뮤니티 호출자, worker, materializer, scheduler에 공급자 분기를 추가하지 않는다. 기존 두 coordinator는 주입된 구현체 목록과 설정만 사용한다. 공급자마다 다른 분할·요청 크기 제한은 adapter 책임이다. -Papago의 5,000 UTF-16 단위 분할과 서로게이트 쌍 보존은 `PapagoTranslationService`에 남아 있다. +`CodePointTextSplitter.kt`의 internal helper가 code point 경계의 분할·prefix와 UTF-8 길이 계산을 공유한다. +Papago는 기존 5,000 UTF-16 코드 단위(`Character.charCount`)로, AWS Translate는 10,000 UTF-8 바이트로 분할한다. +각 adapter는 조각을 순차 호출해 원문별로 구분자 없이 결합하며, 조각 하나라도 실패하면 전체 요청의 부분 결과를 버린다. +AWS Comprehend는 앞 최대 100,000 UTF-8 바이트만 감지한다. prefix는 원문 전체 hash/cache key나 번역 원문을 바꾸지 않는다. +혼합 언어 입력은 prefix와 전체 본문의 감지 결과가 다를 수 있다. blank 입력은 호출하지 않으며 짧은 비어 있지 않은 입력은 전달한다. +응답은 첫 후보만 사용하고 첫 코드가 blank/누락이면 뒤 후보를 대신 선택하지 않는다. 별도 confidence threshold는 없다. ko/en/ja 서비스 정책은 `TranslationLanguages`에 있으며 공급자를 추가해도 자동 확장하지 않는다. ## 기능별 독립 코드 목록 production 순서의 유일한 편집 지점은 `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageProviderOrder.kt`다. -목록의 왼쪽부터 사용하며 기본값은 두 기능 모두 `listOf(LanguageProviderId.PAPAGO)`다. +목록의 왼쪽부터 사용하며 기본값은 두 기능 모두 `listOf(LanguageProviderId.PAPAGO, LanguageProviderId.AWS)`다. ```kotlin object LanguageProviderOrder { - val detectionOrder = listOf(LanguageProviderId.PAPAGO) - val translationOrder = listOf(LanguageProviderId.PAPAGO) + val detectionOrder = listOf(LanguageProviderId.PAPAGO, LanguageProviderId.AWS) + val translationOrder = listOf(LanguageProviderId.PAPAGO, LanguageProviderId.AWS) } ``` @@ -55,8 +61,7 @@ Spring은 `@Autowired` 기본 생성자로 위 목록을 읽는다. 문자열 테스트에서는 전역 목록을 변경하지 않고 `LanguageProviderProperties(List, List)`로 만든 테스트 bean을 주입한다. -다음은 **실제 AWS 구현체를 두 capability에 등록한 뒤에만** 적용할 수 있는 예시다. -현재 AWS ID의 가짜 구현체는 테스트에만 있다. +다음은 감지만 AWS 우선으로 바꾸는 예시다. 현재 기본 순서를 뜻하지 않는다. ```kotlin val detectionOrder = listOf(LanguageProviderId.AWS, LanguageProviderId.PAPAGO) @@ -65,17 +70,31 @@ val translationOrder = listOf(LanguageProviderId.PAPAGO, LanguageProviderId.AWS) 감지만 순서를 바꾸어도 번역 순서는 바뀌지 않는다. 반대로 번역만 바꿀 수도 있다. 변경 후 빌드·배포해야 반영된다. 과거 우선순위 Spring property·환경변수·YAML 값이 남아 있어도 순서는 바뀌지 않는다. -공급자 인증정보는 계속 환경변수로 주입하며 이 코드 목록에 넣지 않는다. +공급자 인증정보는 기존 설정 경계로 주입하며 이 코드 목록에 넣지 않는다. 빈 목록·중복 ID·해당 capability의 구현체 부재·capability 안의 중복 구현체 ID는 초기화에서 거부한다. 빈 문자열 ID는 enum 타입으로 표현할 수 없다. 공급자가 번역에만 등록됐다면 그 ID를 감지 순서에 넣을 수 없다. -실행 중 설정 변경은 지원하지 않는다. AWS 구현체 등록 전 -`listOf(LanguageProviderId.PAPAGO, LanguageProviderId.AWS)`는 사용할 수 없다. +실행 중 설정 변경은 지원하지 않는다. 테스트의 Papago-only inventory는 두 순서를 명시적으로 `[PAPAGO]`로 제한한다. + +## AWS SDK와 설정 + +Comprehend와 Translate만 AWS SDK for Java v2 BOM `2.57.11`을 사용하며 기존 SDK v1 의존성은 유지한다. +동기 HTTP runtime은 SDK의 전이 의존성이다. Spring Boot 2.7의 하향 버전 관리를 피하도록 +`httpclient5.version=5.6.4`, `httpcore5.version=5.4.3`을 SDK POM 기준으로 고정한다. + +`AwsLanguageProviderConfiguration`은 기존 `cloud.aws.credentials.access-key`, `cloud.aws.credentials.secret-key`를 +`StaticCredentialsProvider`에 연결하고 `cloud.aws.region.static`을 `Region.of`에 전달한다(현재 설정은 `ap-northeast-2`). +기본 credentials chain이나 AWS 전용 새 인증 설정을 추가하지 않는다. 두 동기 client는 Spring Bean이며 종료 시 `close`한다. +AWS 번역은 source/target을 명시하며 `providerVersion`은 안정 값 `translate-2017-07-01`이다. +Papago의 `providerVersion`은 기존 `nmt-v1`이다. 두 값은 SDK 라이브러리 버전과 별개다. + +실 AWS 호출·IAM·품질·과금은 자동 테스트에서 확인하지 않는다. 필요한 IAM 작업은 +`comprehend:DetectDominantLanguage`, `translate:TranslateText`이며 이 가이드는 권한 변경을 승인하지 않는다. ## DB code 호환성 내부 순서·구현체 매칭·성공 결과는 `LanguageProviderId`를 사용한다. entity/repository의 `provider`는 String 그대로다. coordinator/cache/worker의 DB 조회·저장 접점에서만 `providerId.code`로 변환하므로 기존 `"papago"` 캐시를 재사용한다. -가짜 AWS의 저장값은 `"aws"`다. enum `.name`/`.ordinal`, `@Enumerated`/`@Convert`, 역변환 helper는 사용하지 않는다. +실제 AWS 성공 결과의 저장값은 `"aws"`다. enum `.name`/`.ordinal`, `@Enumerated`/`@Convert`, 역변환 helper는 사용하지 않는다. 새 enum 항목을 추가할 때도 기존 code와 DB 스키마·캐시 키는 변경하지 않는다. `providerVersion`은 별도의 String 기록 필드다. ## 캐시와 오류 계약 @@ -104,19 +123,26 @@ adapter 예외·null 감지·빈 감지·번역 결과 개수 불일치·blank | 테스트 | 검증 내용 | |---|---| -| `LanguageProviderSpringConfigurationTest` | 실제 production 기본 생성자, 과거 property/env 무시, typed-list 테스트 bean의 독립 순서와 잘못된 목록의 시작 실패 | +| `LanguageProviderSpringConfigurationTest` | production 기본 생성자와 PAPAGO 우선 성공, 과거 property/env 무시, 독립 순서와 잘못된 목록의 시작 실패; fake AWS context와 실제 adapter + SDK mock context 분리 | | `LanguageProviderIdTest` | 런타임 enum getter 계약, 안정적인 papago/aws code, production 기본값과 실제 adapter ID | | `LanguageProviderPropertiesTest` | 순서 검증 및 선택되지 않은 bean을 포함한 capability 내 중복 ID 거부 | | `LanguageDetectionProviderTest` / `TranslationCoordinatorTest` | 전체 요청 폴백, 첫 성공 중단, cache-first, 안전한 전체 실패, DB 오류 전파 | -| `PapagoLanguageDetectionProviderTest` / `PapagoTranslationServiceTest` | 제어된 HTTP form/header/response와 기존 17개 chunk 회귀 | -| `LanguageProviderCacheEndToEndTest` | 실제 H2의 AWS 감지/번역 귀속과 문자열 aws, providerVersion, 두 번째 요청 무호출, worker와 materializer의 실제 표시 | +| `PapagoLanguageDetectionProviderTest` / `PapagoTranslationServiceTest` | 제어된 HTTP form/header/response와 빈 raw 입력·공백을 포함한 19개 번역 회귀 | +| `CodePointTextSplitterTest` | UTF-16/UTF-8 한도, 한글·보조 평면 문자·공백 보존, prefix와 전체 원문의 분리 | +| `AwsLanguageDetectionProviderTest` / `AwsTranslationServiceTest` | 실제 adapter + SDK mock의 요청·응답, prefix/분할·결합, 첫 후보, 중간 실패 폐기와 호출 중단, 민감 오류 비노출 | +| `AwsLanguageProviderConfigurationTest` | 합성 credentials/region으로 실제 client 생성과 종료, 네트워크 무호출 | +| `LanguageProviderCacheEndToEndTest` | 실제 AWS adapters + SDK mock + H2의 5개 시나리오: 동일 prefix·서로 다른 전체 hash, 첫 blank 후보 미저장, aws/version/결합 결과 저장과 무호출 재사용·표시, 부분 실패 미저장과 안전한 worker 오류, 테스트 전용 AWS → PAPAGO의 전체 요청 fallback | H2 버전은 MySQL `FOR UPDATE SKIP LOCKED`를 지원하지 않으므로 새 H2 테스트는 claim 조회 한 곳만 test spy로 대체한다. 나머지 repository 저장·별도 트랜잭션 재조회·materialization은 실제 H2다. -이 테스트가 운영 MySQL claim 동시성을 검증한다는 뜻은 아니다. 기존 감지 cache 경합·revision 테스트는 별도로 유지한다. +이 테스트가 운영 MySQL claim 동시성이나 SDK serialization/signing·실 AWS 권한/품질을 검증한다는 뜻은 아니다. +SDK mocks는 테스트마다 reset하고 Papago 기록 목록과 cache/job/read model을 비워 context 재사용을 격리한다. +기존 감지 cache 경합·revision 테스트는 별도로 유지한다. ```bash -./gradlew --no-daemon --offline test --tests 'kr.co.vividnext.sodalive.i18n.translation.*' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' +./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.i18n.translation.LanguageProviderCacheEndToEndTest' +./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.i18n.translation.*' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' ``` -전체 실행 명령, 결과와 미검증 범위는 [plan-task.md](plan-task.md)의 실행 증거를 따른다. +AWS 구현의 실행 명령, 결과와 미검증 범위는 [AWS 구현 계획](../20261109_AWS언어감지번역/plan-task.md)의 실행 증거를 따른다. +이전 공통화 기록은 [공통화 계획](plan-task.md)에 보존한다. 전체 테스트는 사용자가 `./gradlew test`로 실행한다. diff --git a/docs/agent-guides/실행명령어.md b/docs/agent-guides/실행명령어.md index f4880b61..91509019 100644 --- a/docs/agent-guides/실행명령어.md +++ b/docs/agent-guides/실행명령어.md @@ -18,6 +18,10 @@ ./gradlew tasks --all ./gradlew bootRun ./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.*' +./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.i18n.translation.CodePointTextSplitterTest' +./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.i18n.translation.AwsLanguageDetectionProviderTest' +./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.i18n.translation.AwsTranslationServiceTest' +./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.i18n.translation.AwsLanguageProviderConfigurationTest' ./gradlew bootJar ./gradlew ktlintCheck ./gradlew ktlintFormat @@ -33,3 +37,13 @@ ``` 위 명령은 목적에 맞게 하나를 선택한다. 전체 테스트 확인만 필요하면 `./gradlew test`로 충분하다. + +## AWS 의존성 확인 + +기존 SDK v1은 유지하고 Comprehend/Translate만 SDK v2 BOM `2.57.11`을 사용한다. 기본 동기 HTTP runtime은 전이 의존성이다. +Spring Boot 2.7의 HTTP5 하향 관리를 피하도록 SDK POM 기준 `httpclient5.version=5.6.4`, `httpcore5.version=5.4.3`을 맞춘다. + +```bash +./gradlew --no-daemon dependencyInsight --dependency org.apache.httpcomponents --configuration runtimeClasspath +./gradlew bootJar +``` -- 2.54.0