파파고 번역 요청을 길제 제한에 맞춰서 분할 #468

Merged
klaus merged 2 commits from test into main 2026-11-09 09:06:23 +00:00
5 changed files with 489 additions and 15 deletions
@@ -0,0 +1,154 @@
# 파파고 번역 길이 분할 구현 계획
| 항목 | 내용 |
|---|---|
| 상태 | 구현·기능 범위 검증·리소스 정리 완료 |
| 요구사항 | [prd.md](prd.md), PC-001~005 |
| 현재 Phase | 1: 분할 호출과 완전한 결과 반환 |
| 활성 Goal | 없음 |
## 목표와 제약
긴 필드도 Papago 요청 길이를 넘지 않고 전체 번역 결과로 반환한다.
Kotlin 1.6.21, Java 17, Spring Boot 2.7.14와 기존 RestTemplate을 사용한다.
서비스 내부에 최소 분할 로직만 추가하며 인터페이스, 의존성, 언어 감지, AWS, DB, 워커는 변경하지 않는다.
작업 시작 시 트리는 깨끗했으며 기존 디렉터리에서 작업한다. 별도 worktree·배포·push는 하지 않는다.
사용자의 ULW Commit Discipline에 따라 검증 후 코드·테스트와 관련 문서를 각각 원자적으로 커밋한다.
### Phase 1: 분할 번역
- [x] **Task 1.1: 파파고 분할 호출 구현 (P1-T1)**
**Objective:** 요청당 5,000 이하, 입력 손실 없음, 결과 순서 유지, 부분 실패 시 성공 결과 없음.
**시작 조건:** PRD 확정 및 기존 테스트 확인.
**완료 증거:** RED/GREEN, focused 회귀, HTTP 표면 QA, 빌드, 변경 파일 정적 검사.
**범위 밖:** 문장 경계 최적화, AWS, 감지, 재시도 정책·공개 API 변경.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationServiceTest.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorkerTest.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobSchedulerTest.kt`
**Interfaces:** `TranslationProvider.translate(TranslateRequest): TranslateResult` 유지. 성공 시 원문별 결과 한 개,
조각 실패 시 빈 결과로 기존 워커의 실패 처리를 사용한다.
- [x] **RED:** 요청 길이 경계, 이모지 경계, 입력 순서, 중간 HTTP 실패·빈 응답 회귀 테스트 작성.
- [x] **RED 확인:** 새 focused test에서 미분할/부분 성공으로 인한 assertion 실패 확인.
- [x] **GREEN:** 최대 5,000 UTF-16 길이 및 서로게이트 보존 분할, 순차 호출·결합, 부분 결과 폐기.
- [x] **GREEN 확인:** 동일 focused test 통과.
- [x] **REFACTOR:** 이번 변경의 중복만 정리하고 직접 영향 테스트·빌드·정적 검사 수행.
**시나리오 계약:**
1. S1: 짧은 원문 및 5,000/5,001/10,001 길이에서 요청당 상한을 지키고 입력 전체와 결과 순서를 보존한다.
2. S2: 한글·경계 이모지·다중 입력에서 문자를 손상시키지 않고 결과 개수와 대응을 보존한다.
3. S3: 중간 HTTP 오류·빈 번역은 부분 성공이 되지 않으며 기존 무호출 가드는 유지된다.
**P1-T1 실행 증거:**
- RED (production 수정 전):
`./gradlew --no-daemon --offline test --tests 'kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationServiceTest'`
→ exit 1, `17 tests completed, 11 failed`, XML `tests="17" failures="11" errors="0" skipped="0"`.
5,001/10,001자·한글·서로게이트·다중 원문의 미분할 JSON 요청이 assertion으로 실패했다.
짧은 원문의 HTTP 오류 뒤 `No further requests expected`로 불필요한 후속 호출을 재현했고,
본문 없는 응답에서는 빈 전체 결과 대신 앞선 번역과 빈 문자열을 반환했다. 가드·짧은 입력 6건은 통과했다.
- 구현: UTF-16 인덱스로 최대 5,000씩 자르되 high/low surrogate 사이의 경계는 한 단위 앞당긴다.
각 원문의 번역을 순차 누적해 한 결과로 반환하며, HTTP 오류·예외·null/blank 번역은 전체 빈 결과로 즉시 반환한다.
빈 원문 요소도 기존처럼 한 번 요청하도록 분할 helper의 `do/while`을 사용한다.
- GREEN:
`./gradlew --no-daemon --offline test --tests 'kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationServiceTest'`
→ exit 0, `BUILD SUCCESSFUL in 1m 42s`, XML `tests="17" failures="0" errors="0" skipped="0"`.
모든 기대 JSON 요청을 순서대로 검증하고, 미등록 후속 요청은 assertion 실패로 처리하므로 실패 후 호출 중단도 검증했다.
- 변경 파일 정적 검사:
`./gradlew --no-daemon --offline -I "$run_dir/papago-lint.init.gradle" ktlintMainSourceSetCheck ktlintTestSourceSetCheck`
→ exit 0, `BUILD SUCCESSFUL in 2m 39s`. 소유 임시 init script에서 `ktlint.filter.include`를
`**/PapagoTranslationService.kt`, `**/PapagoTranslationServiceTest.kt`로 제한했다.
LSP는 이 구현 세션에도 제공되지 않아 실행하지 못했다. Kotlin production/test 컴파일과 파일 한정 ktlint로 보완했다.
- 회귀·빌드:
`./gradlew --no-daemon --offline test --tests 'kr.co.vividnext.sodalive.i18n.translation.*' bootJar`
→ exit 0, `BUILD SUCCESSFUL in 1m 38s`, `:bootJar`와 `:test` 모두 실행됐다.
XML 합계 28건 통과, 실패/오류/skip 0건: Papago 17, Worker 7, Scheduler 3, Normalizer 1.
`build/libs/sodalive-0.0.1-SNAPSHOT.jar` 생성을 확인했다. 범위 밖 리팩터링은 하지 않았다.
위임 범위인 구현·자동 검증으로 P1-T1을 완료하며, loopback HTTP 수동 QA와 독립 리뷰는 부모 세션의 P1-GATE에 남긴다.
**실행 명령:**
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationServiceTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
./gradlew bootJar
```
**수동 표면 검증:** 실제 서비스와 loopback HTTP 서버를 연결해 S1~S3의 요청 JSON·결과를 검증한다.
임시 driver 사용 시 작업 소유 디렉터리에만 두고 서버·파일을 종료/정리한다. 유료 외부 API는 호출하지 않는다.
- [x] **Phase Gate (P1-GATE): 리뷰·증거·정리**
**Objective:** 요구사항과 diff를 독립 리뷰하고 모든 실행 결과·미검증 범위를 기록한다.
**시작 조건:** P1-T1의 focused 검증 완료.
**완료 증거:** 리뷰의 차단 이슈 0개, 범위 외 변경 없음, 임시 리소스 정리 확인.
**TDD 예외:** 읽기 전용 리뷰와 정리이며 코드 변경이 아니다.
**대체 검증:** diff/파일 직접 확인과 테스트 결과 대조.
## 검증 기록
- 초기 상태: `git status --short` 출력 없음. 기존 Papago 전용 테스트는 검색에서 발견되지 않았다.
- 전체 테스트는 실행하지 않는다. 사용자 실행 명령: `./gradlew test`.
- LSP 도구는 현재 세션 도구 목록에 없어 실행할 수 없다. Kotlin 컴파일과 가능한 정적 검사로 보완하고 한계를 보고한다.
- 구현 실행 소유 디렉터리: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/papago-chunk.3apfr5/implement.j3Yd7s`.
첫 shell에서 `mktemp -d`로 생성했으며 모든 shell 명령은 `TMPDIR`, `NODE_COMPILE_CACHE`,
`BUN_RUNTIME_TRANSPILER_CACHE_PATH`를 이 디렉터리로 export한다. 공유 캐시·부모 디렉터리는 정리하지 않는다.
- 테스트는 서비스만 로드하는 `@SpringBootTest(classes = [PapagoTranslationService::class])`와
기존 `ReflectionTestUtils.setField(..., "restTemplate", ...)` seam을 사용한다.
`MockRestServiceServer`에서 실제 RestTemplate의 JSON·헤더·순서를 검증하므로 외부 Papago/AWS 요청은 없다.
별도 loopback HTTP 수동 QA와 독립 리뷰/P1-GATE 판정은 부모 세션이 수행한다.
- RED/GREEN/회귀/빌드 결과는 위 P1-T1 실행 증거에 기록했다. 전체 테스트와 실서비스 호출은 실행하지 않았다.
- GREEN 및 파일 한정 lint의 첫 실행은 각각 shell 도구의 120,000ms 제한으로 종료됐다.
테스트 실패나 컴파일 오류로 판정하지 않았으며, 제한을 600,000ms로 늘린 재실행에서 위 GREEN/정적 검사 성공을 확인했다.
기존 Gradle deprecation 경고(Gradle 9 비호환 안내)는 남아 있으며 이번 범위에서 수정하지 않는다.
- 리소스 정리 영수증: 모든 실행 명령 종료 후 `lsof +D "$run_dir"` 출력 없음으로 열린 파일이 없음을 확인했다.
소유 init script를 삭제하고 `rmdir "$run_dir" && test ! -e "$run_dir"`가 exit 0으로 끝났다.
최종 출력: `REMOVED owned directory: .../papago-chunk.3apfr5/implement.j3Yd7s; shared parent and caches untouched`.
공유 캐시·부모 디렉터리·다른 작업의 프로세스는 건드리지 않았다. 커밋·staging·push·worktree·subagent는 사용하지 않았다.
### P1-GATE 부모 세션 검증 — 2026-11-09
- 초기 부모 `./gradlew ktlintCheck`는 새 테스트의 import 순서로 exit 1이었다.
구현자가 `ReflectionTestUtils` import 순서를 바로잡았고 변경 파일 검사 성공을 확인했다.
- 부모 최종 `./gradlew --no-daemon --offline test --tests 'kr.co.vividnext.sodalive.i18n.translation.*' ktlintCheck bootJar`
→ exit 0, `BUILD SUCCESSFUL in 43s`, 19개 작업 UP-TO-DATE. 캐시 확인과 실제 재실행을 구분한다.
- 부모 fresh 실행 `./gradlew --no-daemon --offline test --rerun --tests 'kr.co.vividnext.sodalive.i18n.translation.*'`
→ exit 0, `BUILD SUCCESSFUL in 57s`, `:test` 실제 실행, 다른 9개 작업 UP-TO-DATE.
- 로컬 수동 QA: Java 17 임시 `PapagoChunkQa` driver에서 실제 `PapagoTranslationService`를 생성하고,
test-only reflection으로 endpoint를 `127.0.0.1` HTTP 서버로 바꾸었다. 가짜 인증 정보만 사용했다.
JSON 요청의 길이·원문 전체 복원·POST·언어·헤더와 결과 순서를 검사했다.
초기 기존 컴파일 결과는 5,001자에서 미분할로 실패했고, 수정된 컴파일 결과는 아래 9건 모두 통과했다.
| 시나리오 | 관측 요청 길이 (UTF-16) | 결과 |
|---|---|---|
| 짧은 원문 | 5 | PASS, 결과 1개 |
| 정확히 상한 | 5000 | PASS, 결과 1개 |
| 상한 초과 | 5000, 1 | PASS, 순서대로 결합 |
| 세 조각 | 5000, 5000, 1 | PASS, 순서대로 결합 |
| 한글 | 5000, 1 | PASS, 원문 복원 |
| 경계 이모지 | 4999, 3 | PASS, 서로게이트 보존 |
| 다중 입력 | 5000, 1, 4 | PASS, 원문별 결과 2개 |
| 중간 HTTP 500 | 5000, 5000 | PASS, 빈 전체 결과·후속 호출 없음 |
| 중간 빈 번역 | 5000, 5000 | PASS, 빈 전체 결과·후속 호출 없음 |
- driver 출력: `QA PASS: 9 scenarios; all loopback servers stopped; no external API calls`.
모든 서버를 `finally`에서 `stop(0)`으로 종료했고 driver 프로세스 exit 0을 확인했다.
- 독립 리뷰 1차: PC-001~005 정적 검토 통과, 기능·보안 차단 이슈 없음.
당시 미확인이던 import 정렬과 실행 결과는 위 부모 검증으로 보완했다. 최종 확인은 별도 기록한다.
- 미검증: 도구 미제공으로 LSP 실행 불가. 전체 테스트는 사용자 실행(`./gradlew test`).
실제 Papago 번역 품질·유료 API·운영 DB·이미 FAILED인 작업 재처리는 이번 범위가 아니다.
- 커밋 방침 정정: 초기 문서의 커밋 제외는 기본 정책 적용이었으나, 현재 사용자 ULW 지시에 명시된
검증 단위 커밋 요청을 반영한다. 구현 에이전트는 커밋하지 않았고 부모 세션이 검증 후 진행한다.
- fresh XML 확인: Papago 17, Normalizer 1, Scheduler 3, Worker 7, 합계 28건 모두 실패·오류·skip 0건.
- [독립 리뷰와 부모 검증 판정](reviews/phase-1-review.md): 기능·보안 차단 사항 없음.
리뷰 세션 재연결 도구 실패로 추가 독립 승인 응답은 받지 못했다. 최초 정적 리뷰와 부모 fresh 증거를 구분해 기록했다.
- 최종 리소스 영수증: 소유 Gradle daemon PID 99003의 TMPDIR가 작업 root와 일치함을 확인한 후 종료했다.
`ps -p 99003`은 프로세스가 없음을 확인했고 `lsof +D`는 열린 파일이 없었다.
소유 `papago-chunk.3apfr5` root(로컬 driver·class·추출 라이브러리 포함)를 제거하고 `test ! -e` exit 0을 확인했다.
출력: `CLEANUP PASS: owned run root removed; owned daemon absent; loopback servers stopped`.
노트의 필요한 근거는 이 문서에 옮겼고 임시 노트도 삭제한다. 공유 캐시·다른 프로세스는 유지했다.
- 코드·테스트 커밋: `b3c9b9bb`, `fix(i18n): 파파고 번역 요청을 길이 제한에 맞춰 분할한다`.
pre-commit hook의 변경 파일 ktlint와 커밋 메시지 사전·사후 검증을 통과했다. push는 하지 않는다.
@@ -0,0 +1,44 @@
# 파파고 번역 요청 길이 분할 PRD
| 항목 | 내용 |
|---|---|
| 상태 | 구현·기능 범위 검증 완료 |
| 작성일 | 2026-11-09 |
| 범위 승인 | 사용자: 파파고 번역만 요청당 길이 제한에 맞춰 분할 호출 |
| 구현 계획 | [plan-task.md](plan-task.md) |
## 목적과 범위
현재 `PapagoTranslationService`는 각 필드를 한 번에 전송하여 5,000자를 넘으면 번역에 실패한다.
서비스 내부에서 긴 원문을 순서대로 분할 호출하고 결과를 하나로 합쳐 기존 호출자가 그대로 사용하게 한다.
AWS 추가, 언어 감지, DB 구조, 워커 재시도 정책, 공개 API, 새 의존성은 변경하지 않는다.
## 요구사항
| ID | 요구사항과 수용 기준 |
|---|---|
| PC-001 | 각 HTTP 요청의 `text`는 최대 5,000 UTF-16 코드 단위이다. 5,000 이하는 한 번, 5,001 이상은 여러 번 호출한다. |
| PC-002 | 분할은 원문 순서와 모든 문자를 보존하며 유효한 서로게이트 쌍을 끊지 않는다. 요청 텍스트를 결합하면 원문과 정확히 같다. |
| PC-003 | 각 입력 텍스트의 번역 조각을 순서대로 구분자 없이 결합하여 기존 `TranslateResult`의 텍스트별 대응을 유지한다. |
| PC-004 | 어느 조각이든 HTTP 오류 또는 빈 번역 결과이면 부분 번역을 성공 결과로 반환하지 않는다. 기존 워커가 실패로 판단할 빈 결과를 반환하고 이후 조각은 호출하지 않는다. |
| PC-005 | 빈 입력 목록, 동일 언어, 미지원 언어의 기존 무호출 동작 및 짧은 텍스트 호출 형식을 유지한다. |
## 기술 결정과 한계
- 공식 문서는 요청당 최대 5,000 characters를 명시하지만 Unicode 계산 방식은 명시하지 않는다.
구현은 보수적으로 UTF-16 길이를 제한하고 서로게이트 경계만 조정한다.
- 길이 기준 고정 분할을 사용한다. 문장 분석, 문맥 품질 보정, 조각 캐시, 병렬 호출은 이번 범위가 아니다.
- 모든 조각 성공 후에만 기존 전체 원문 기준 번역 캐시에 저장할 수 있도록 한다.
- 분할 시 HTTP 호출 수가 증가하며 중간 실패 후 기존 워커 재시도에서는 앞선 조각도 다시 호출될 수 있다.
- 원문과 자격증명을 오류 로그나 문서에 기록하지 않는다.
## 검증 기준
- 실제 서비스의 HTTP 요청 경계에서 5,000/5,001/10,001자, 한글, 이모지, 여러 입력, 중간 오류와 빈 응답을 검증한다.
- 기존 번역 워커·스케줄러 회귀 테스트와 `bootJar`를 실행한다. 전체 테스트는 저장소 정책대로 사용자 실행이다.
- 실제 유료 Papago 호출 대신 가짜 자격증명과 로컬 HTTP 서버를 사용하며 실서비스 번역 품질 검증과 구분한다.
## 근거
- [Papago Text Translation 요청 규격](https://api.ncloud-docs.com/docs/en/ai-naver-papagonmt-translation)
- [Papago 오류 코드](https://api.ncloud-docs.com/docs/en/ai-naver-papagonmt): `N2MT08`, `text parameter exceeds max length`.
@@ -0,0 +1,45 @@
# 파파고 분할 번역 리뷰
## 리뷰 정보
| 항목 | 내용 |
|---|---|
| 대상 | P1-T1, 작업 트리의 서비스·테스트 변경 |
| 기준 | [PRD](../prd.md) PC-001~005, [계획](../plan-task.md) |
| 날짜 | 2026-11-09 |
| 리뷰어 | 독립 읽기 전용 에이전트, 실행 증거 확인은 부모 세션 |
| 상태 | 정적 리뷰 통과, 부모 세션 실행 검증 완료 |
## 범위와 판정
- `PapagoTranslationService.kt`: UTF-16 상한 5,000, 서로게이트 보존, 조각 순서와 원문 전체 보존을 확인했다.
- `PapagoTranslationServiceTest.kt`: 정확한 요청 JSON·헤더·순서, 5,000/5,001/10,001 경계,
한글·공백·이모지·다중 입력·HTTP 오류·빈 결과·기존 가드의 의미 있는 assertion을 확인했다.
- `TranslateRequest.kt`, `TranslationJobWorker.kt`: 기존 타입과 호출 계약을 유지하며 빈 결과는 저장 전에 실패 처리된다.
- HTTP 오류 또는 빈 조각이면 전체 빈 결과를 즉시 반환하여 앞선 입력·조각의 부분 결과가 성공으로 저장되지 않는다.
- 새 원문·인증정보 로그, 의존성, AWS·감지·DB·워커 변경은 없다.
**확정 기능·보안 차단 발견 사항 없음. 회귀 수정 Task 전환 항목 없음.**
## 실행 증거와 열린 항목 해소
| 검증 | 결과 |
|---|---|
| TDD RED | production 변경 전 17건 중 11건 assertion 실패, errors 0 |
| focused GREEN | 17건 통과 |
| 부모 fresh 번역 패키지 테스트 | `test --rerun --tests 'kr.co.vividnext.sodalive.i18n.translation.*'`, 28건 통과 |
| 로컬 HTTP QA | 실제 RestTemplate과 loopback 서버로 9개 시나리오 통과, 모든 서버 종료 |
| 정적 검사 | 초기 import 순서 오류 수정 후 변경 파일 ktlint 통과, 부모 최종 ktlintCheck 성공 |
| 빌드 | 구현 세션 bootJar 실제 생성, 부모 최종 bootJar UP-TO-DATE 성공 |
리뷰 당시 미확인이던 import 정렬과 실행 결과는 부모 세션이 최종 파일·XML·실행 출력으로 확인했다.
같은 리뷰 세션에 최종 증거를 전달하려 했으나 background continuation은 `Task not found`,
synchronous continuation은 inactivity timeout으로 응답하지 않았다. 이를 새 독립 승인으로 표기하지 않는다.
독립 정적 리뷰의 차단 사항은 없으며, 실행 증거 종결은 부모 세션 판정이다.
## 범위 밖과 한계
- 문장·문단 경계 최적화와 자동 구분자 삽입은 이번 요구 범위가 아니므로 고정 분할·무구분자 결합을 유지했다.
- 실제 유료 Papago 번역 품질과 운영 DB는 검증하지 않았다.
- LSP 도구 미제공으로 진단을 실행하지 못했다. Kotlin 컴파일과 ktlint로 보완했다.
- 전체 테스트는 저장소 정책에 따라 사용자 실행(`./gradlew test`)으로 남긴다.
@@ -43,32 +43,54 @@ class PapagoTranslationService(
request.texts.forEach { text -> request.texts.forEach { text ->
try { try {
val body = mapOf( val translatedText = StringBuilder()
"source" to request.sourceLanguage, for (chunk in splitText(text)) {
"target" to request.targetLanguage, val body = mapOf(
"text" to text "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<PapagoTranslationResponse>( val response = restTemplate.postForEntity<PapagoTranslationResponse>(
papagoTranslateUrl, papagoTranslateUrl,
requestEntity requestEntity
) )
if (!response.statusCode.is2xxSuccessful) { if (!response.statusCode.is2xxSuccessful) {
return@forEach return TranslateResult(emptyList())
}
val translated = response.body?.message?.result?.translatedText
if (translated.isNullOrBlank()) {
return TranslateResult(emptyList())
}
translatedText.append(translated)
} }
translatedTexts.add(translatedText.toString())
val translated = response.body?.message?.result?.translatedText
translatedTexts.add(translated ?: "")
} catch (_: Exception) { } catch (_: Exception) {
return TranslateResult(emptyList())
} }
} }
return TranslateResult(translatedTexts) 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 { private fun validateLanguages(sourceLanguage: String, targetLanguage: String): Boolean {
return requireSupportedLanguage(sourceLanguage) && requireSupportedLanguage(targetLanguage) return requireSupportedLanguage(sourceLanguage) && requireSupportedLanguage(targetLanguage)
} }
@@ -79,6 +101,8 @@ class PapagoTranslationService(
} }
companion object { companion object {
private const val MAX_REQUEST_TEXT_LENGTH = 5000
private val SUPPORTED_LANGUAGE_CODES = setOf( private val SUPPORTED_LANGUAGE_CODES = setOf(
"ko", "ko",
"en", "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))
}
}