fix(i18n): 파파고 번역 요청을 길이 제한에 맞춰 분할한다

This commit is contained in:
2026-11-09 12:23:19 +09:00
parent 1578503072
commit b3c9b9bb2e
2 changed files with 246 additions and 15 deletions
@@ -43,10 +43,12 @@ class PapagoTranslationService(
request.texts.forEach { text ->
try {
val translatedText = StringBuilder()
for (chunk in splitText(text)) {
val body = mapOf(
"source" to request.sourceLanguage,
"target" to request.targetLanguage,
"text" to text
"text" to chunk
)
val requestEntity = HttpEntity(body, headers)
@@ -57,18 +59,38 @@ class PapagoTranslationService(
)
if (!response.statusCode.is2xxSuccessful) {
return@forEach
return TranslateResult(emptyList())
}
val translated = response.body?.message?.result?.translatedText
translatedTexts.add(translated ?: "")
if (translated.isNullOrBlank()) {
return TranslateResult(emptyList())
}
translatedText.append(translated)
}
translatedTexts.add(translatedText.toString())
} catch (_: Exception) {
return TranslateResult(emptyList())
}
}
return TranslateResult(translatedTexts)
}
private fun splitText(text: String): List<String> {
val chunks = mutableListOf<String>()
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",
@@ -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<String>(), 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<String>(), 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<String>(), translate("earlier", first + second + "c", "later").translatedText)
}
@Test
@DisplayName("응답 본문이 없으면 앞선 성공 결과를 포함하지 않는 빈 결과를 반환한다")
fun shouldDiscardAllTranslationsWhenResponseBodyIsMissing() {
expectTranslation("earlier", "earlier translation")
expectRequest("empty").andRespond(withSuccess("", MediaType.APPLICATION_JSON))
assertEquals(emptyList<String>(), translate("earlier", "empty").translatedText)
}
@Test
@DisplayName("빈 입력 목록은 HTTP 요청 없이 빈 결과를 반환한다")
fun shouldNotRequestTranslationForEmptySources() {
assertEquals(emptyList<String>(), translate().translatedText)
}
@Test
@DisplayName("동일한 출발 언어와 도착 언어는 HTTP 요청 없이 빈 결과를 반환한다")
fun shouldNotRequestTranslationForSameLanguage() {
val result = service.translate(TranslateRequest(listOf("source"), "ko", "ko"))
assertEquals(emptyList<String>(), 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<String>(), 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))
}
}