Merge pull request '파파고 번역 요청을 길제 제한에 맞춰서 분할' (#468) from test into main
Reviewed-on: #468
This commit was merged in pull request #468.
This commit is contained in:
@@ -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`)으로 남긴다.
|
||||||
+39
-15
@@ -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",
|
||||||
|
|||||||
+207
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user