From b3c9b9bb2ea6bdc3fdf99863ae3fe9ca7eb40998 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 9 Nov 2026 12:23:19 +0900 Subject: [PATCH] =?UTF-8?q?fix(i18n):=20=ED=8C=8C=ED=8C=8C=EA=B3=A0=20?= =?UTF-8?q?=EB=B2=88=EC=97=AD=20=EC=9A=94=EC=B2=AD=EC=9D=84=20=EA=B8=B8?= =?UTF-8?q?=EC=9D=B4=20=EC=A0=9C=ED=95=9C=EC=97=90=20=EB=A7=9E=EC=B6=B0=20?= =?UTF-8?q?=EB=B6=84=ED=95=A0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../translation/PapagoTranslationService.kt | 54 +++-- .../PapagoTranslationServiceTest.kt | 207 ++++++++++++++++++ 2 files changed, 246 insertions(+), 15 deletions(-) create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationServiceTest.kt 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 260bf37e..7a7ffe43 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 @@ -43,32 +43,54 @@ class PapagoTranslationService( request.texts.forEach { text -> try { - val body = mapOf( - "source" to request.sourceLanguage, - "target" to request.targetLanguage, - "text" to text - ) + val translatedText = StringBuilder() + for (chunk in splitText(text)) { + val body = mapOf( + "source" to request.sourceLanguage, + "target" to request.targetLanguage, + "text" to chunk + ) - val requestEntity = HttpEntity(body, headers) + val requestEntity = HttpEntity(body, headers) - val response = restTemplate.postForEntity( - papagoTranslateUrl, - requestEntity - ) + val response = restTemplate.postForEntity( + papagoTranslateUrl, + requestEntity + ) - if (!response.statusCode.is2xxSuccessful) { - return@forEach + if (!response.statusCode.is2xxSuccessful) { + return TranslateResult(emptyList()) + } + + val translated = response.body?.message?.result?.translatedText + if (translated.isNullOrBlank()) { + return TranslateResult(emptyList()) + } + translatedText.append(translated) } - - val translated = response.body?.message?.result?.translatedText - translatedTexts.add(translated ?: "") + translatedTexts.add(translatedText.toString()) } catch (_: Exception) { + return TranslateResult(emptyList()) } } return TranslateResult(translatedTexts) } + 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 + } + private fun validateLanguages(sourceLanguage: String, targetLanguage: String): Boolean { return requireSupportedLanguage(sourceLanguage) && requireSupportedLanguage(targetLanguage) } @@ -79,6 +101,8 @@ class PapagoTranslationService( } companion object { + private const val MAX_REQUEST_TEXT_LENGTH = 5000 + private val SUPPORTED_LANGUAGE_CODES = setOf( "ko", "en", 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 new file mode 100644 index 00000000..f75db2c6 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationServiceTest.kt @@ -0,0 +1,207 @@ +package kr.co.vividnext.sodalive.i18n.translation + +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +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.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +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.ResponseActions +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.web.client.RestTemplate + +@SpringBootTest( + classes = [PapagoTranslationService::class], + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = [ + "cloud.naver.papago-client-id=test-client-id", + "cloud.naver.papago-client-secret=test-client-secret" + ] +) +class PapagoTranslationServiceTest @Autowired constructor( + private val service: PapagoTranslationService +) { + private val objectMapper = ObjectMapper() + private lateinit var server: MockRestServiceServer + + @BeforeEach + fun setUp() { + val restTemplate = RestTemplate() + server = MockRestServiceServer.bindTo(restTemplate).build() + ReflectionTestUtils.setField(service, "restTemplate", restTemplate) + } + + @AfterEach + fun verifyRequests() { + server.verify() + } + + @ParameterizedTest + @ValueSource(ints = [12, 5000]) + @DisplayName("5,000자 이하 원문은 기존 형식으로 한 번만 요청한다") + fun shouldTranslateTextWithinLimitInOneRequest(length: Int) { + val source = "a".repeat(length) + expectTranslation(source, "translated") + + assertEquals(listOf("translated"), translate(source).translatedText) + } + + @Test + @DisplayName("5,001자 원문은 두 번 요청하고 번역을 구분자 없이 결합한다") + fun shouldSplit5001UnitsAndJoinTranslationsWithoutSeparator() { + val first = "a".repeat(5000) + expectTranslation(first, "first") + expectTranslation("b", "second") + + assertEquals(listOf("firstsecond"), translate(first + "b").translatedText) + } + + @Test + @DisplayName("10,001자 원문은 순서대로 세 번 요청하고 결과 하나를 반환한다") + fun shouldSplit10001UnitsInSourceOrder() { + val first = "a".repeat(5000) + val second = "b".repeat(5000) + expectTranslation(first, "first") + expectTranslation(second, "second") + expectTranslation("c", "third") + + assertEquals(listOf("firstsecondthird"), translate(first + second + "c").translatedText) + } + + @Test + @DisplayName("한글 원문의 줄바꿈과 공백을 분할 요청에 그대로 보존한다") + fun shouldPreserveKoreanTextAndWhitespaceAcrossChunks() { + val first = "가".repeat(4999) + "\n" + expectTranslation(first, "translated ") + expectTranslation(" ", "tail") + + assertEquals(listOf("translated tail"), translate(first + " ").translatedText) + } + + @Test + @DisplayName("5,000자 경계의 서로게이트 쌍은 다음 조각에 함께 포함한다") + fun shouldKeepSurrogatePairTogetherAtChunkBoundary() { + val first = "a".repeat(4999) + val second = "\uD83D\uDE00b" + expectTranslation(first, "first") + expectTranslation(second, "second") + + assertEquals(listOf("firstsecond"), translate(first + second).translatedText) + } + + @Test + @DisplayName("여러 원문의 조각을 순차 요청하고 원문별 결과 대응을 유지한다") + fun shouldReturnOneJoinedTranslationPerOriginalSource() { + val first = "a".repeat(5000) + val second = "b".repeat(5000) + expectTranslation(first, "first-") + expectTranslation("a", "tail") + expectTranslation(second, "second-") + expectTranslation("b", "tail") + + assertEquals(listOf("first-tail", "second-tail"), translate(first + "a", second + "b").translatedText) + } + + @ParameterizedTest + @ValueSource(ints = [400, 500]) + @DisplayName("중간 조각의 HTTP 오류는 전체 결과를 버리고 이후 조각과 원문을 요청하지 않는다") + fun shouldAbortAllTranslationsAfterHttpErrorInMiddleChunk(status: Int) { + val first = "a".repeat(5000) + val second = "b".repeat(5000) + expectTranslation("earlier", "earlier translation") + expectTranslation(first, "partial") + expectRequest(second).andRespond(withStatus(HttpStatus.valueOf(status))) + + assertEquals(emptyList(), translate("earlier", first + second + "c", "later").translatedText) + } + + @Test + @DisplayName("다음 원문의 HTTP 오류도 앞선 성공 결과를 버리고 후속 요청을 중단한다") + fun shouldDiscardEarlierSourcesAndStopAfterHttpFailure() { + expectTranslation("earlier", "earlier translation") + expectRequest("broken").andRespond(withStatus(HttpStatus.INTERNAL_SERVER_ERROR)) + + assertEquals(emptyList(), translate("earlier", "broken", "later").translatedText) + } + + @ParameterizedTest + @ValueSource(strings = ["", " \t\n"]) + @DisplayName("중간 조각의 번역이 비었거나 공백이면 전체 결과를 버리고 후속 요청을 중단한다") + fun shouldAbortAllTranslationsAfterBlankMiddleChunk(translated: String) { + val first = "a".repeat(5000) + val second = "b".repeat(5000) + expectTranslation("earlier", "earlier translation") + expectTranslation(first, "partial") + expectTranslation(second, translated) + + assertEquals(emptyList(), translate("earlier", first + second + "c", "later").translatedText) + } + + @Test + @DisplayName("응답 본문이 없으면 앞선 성공 결과를 포함하지 않는 빈 결과를 반환한다") + fun shouldDiscardAllTranslationsWhenResponseBodyIsMissing() { + expectTranslation("earlier", "earlier translation") + expectRequest("empty").andRespond(withSuccess("", MediaType.APPLICATION_JSON)) + + assertEquals(emptyList(), translate("earlier", "empty").translatedText) + } + + @Test + @DisplayName("빈 입력 목록은 HTTP 요청 없이 빈 결과를 반환한다") + fun shouldNotRequestTranslationForEmptySources() { + assertEquals(emptyList(), translate().translatedText) + } + + @Test + @DisplayName("동일한 출발 언어와 도착 언어는 HTTP 요청 없이 빈 결과를 반환한다") + fun shouldNotRequestTranslationForSameLanguage() { + val result = service.translate(TranslateRequest(listOf("source"), "ko", "ko")) + + assertEquals(emptyList(), result.translatedText) + } + + @ParameterizedTest + @CsvSource("fr,en", "ko,fr") + @DisplayName("미지원 출발 언어 또는 도착 언어는 HTTP 요청 없이 빈 결과를 반환한다") + fun shouldNotRequestTranslationForUnsupportedLanguage(sourceLanguage: String, targetLanguage: String) { + val result = service.translate(TranslateRequest(listOf("source"), sourceLanguage, targetLanguage)) + + assertEquals(emptyList(), result.translatedText) + } + + private fun translate(vararg texts: String): TranslateResult { + return service.translate(TranslateRequest(texts.toList(), "ko", "en")) + } + + private fun expectTranslation(source: String, translated: String) { + val response = PapagoTranslationResponse( + PapagoTranslationResponse.Message(PapagoTranslationResponse.Result("ko", "en", translated)) + ) + expectRequest(source).andRespond(withSuccess(objectMapper.writeValueAsString(response), MediaType.APPLICATION_JSON)) + } + + private fun expectRequest(source: String): ResponseActions { + val body = objectMapper.writeValueAsString(mapOf("source" to "ko", "target" to "en", "text" to source)) + return server.expect(requestTo("https://papago.apigw.ntruss.com/nmt/v1/translation")) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("X-NCP-APIGW-API-KEY-ID", "test-client-id")) + .andExpect(header("X-NCP-APIGW-API-KEY", "test-client-secret")) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(body, true)) + } +}