AWS 번역 공급자 추가 #469
@@ -16,6 +16,8 @@ plugins {
|
|||||||
group = "kr.co.vividnext"
|
group = "kr.co.vividnext"
|
||||||
version = "0.0.1-SNAPSHOT"
|
version = "0.0.1-SNAPSHOT"
|
||||||
val querydslVersion = "5.0.0"
|
val querydslVersion = "5.0.0"
|
||||||
|
extra["httpclient5.version"] = "5.6.4"
|
||||||
|
extra["httpcore5.version"] = "5.4.3"
|
||||||
|
|
||||||
java {
|
java {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
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-s3:1.12.380")
|
||||||
implementation("com.amazonaws:aws-java-sdk-cloudfront: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
|
// bootpay
|
||||||
implementation("io.github.bootpay:backend:2.2.1")
|
implementation("io.github.bootpay:backend:2.2.1")
|
||||||
|
|
||||||
|
|||||||
@@ -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<String>`; `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: <ko> but was: <null>`, 번역 `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<LanguageProviderId>, List<LanguageProviderId>)`.
|
||||||
|
- **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>`/`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`로 실행한다.
|
||||||
@@ -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 동일 오류 재현 |
|
||||||
@@ -0,0 +1,717 @@
|
|||||||
|
# 언어 감지·번역 공급자 공통화 구현 계획
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 작성일 | 2026-11-09 |
|
||||||
|
| 상태 | Phase 1~3 구현·검증·독립 리뷰·리소스 정리 완료 |
|
||||||
|
| 요구사항 | [prd.md](prd.md), LP-001~015 및 최종 Phase 계약 |
|
||||||
|
| 활성 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<String>
|
||||||
|
getTranslationOrder(): List<String>
|
||||||
|
TranslationCoordinator(List<TranslationProvider>, LanguageProviderProperties, TranslationMemoryRepository)
|
||||||
|
findCached(String sourceHash, String sourceLanguage, String targetLanguage): TranslationMemory?
|
||||||
|
translate(TranslateRequest): ProviderTranslationResult
|
||||||
|
LanguageDetectionCacheService(LanguageDetectionResultRepository, List<LanguageDetectionProvider>, 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/추출 라이브러리/로그가 함께 제거됐으며 필요한 증거는 이 문서에 보존했다.
|
||||||
|
작업 임시 노트도 문서에 이관 후 삭제한다. 다른 작업의 파일·프로세스·캐시에는 조치하지 않았다.
|
||||||
|
|
||||||
|
### 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<String>`을 받고 `@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.lang.String>, java.util.List<java.lang.String>)
|
||||||
|
LanguageProviderProperties() // @Autowired, LanguageProviderOrder의 두 목록으로 위임
|
||||||
|
getDetectionOrder(): List<String>
|
||||||
|
getTranslationOrder(): List<String>
|
||||||
|
LanguageProviderOrder.INSTANCE.getDetectionOrder(): List<String>
|
||||||
|
LanguageProviderOrder.INSTANCE.getTranslationOrder(): List<String>
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 구현자의 변경 파일:** 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`.
|
||||||
|
필요한 증거는 이 문서에 이관했으며 임시 노트도 삭제한다. 다른 작업의 리소스는 건드리지 않았다.
|
||||||
|
|
||||||
|
### 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<? extends LanguageProviderId>, List<? extends LanguageProviderId>)
|
||||||
|
LanguageProviderProperties() // @Autowired, production object 목록으로 위임
|
||||||
|
getDetectionOrder(): List<LanguageProviderId>; getTranslationOrder(): List<LanguageProviderId>
|
||||||
|
LanguageProviderOrder.INSTANCE.getDetectionOrder/getTranslationOrder(): List<LanguageProviderId>
|
||||||
|
orderedProviders(List<? extends LanguageProviderId>, List<? extends T>, Function1<? super T, ? extends LanguageProviderId>)
|
||||||
|
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<? extends TranslationProvider>, LanguageProviderProperties, TranslationMemoryRepository)
|
||||||
|
LanguageDetectionCacheService(LanguageDetectionResultRepository, List<? extends LanguageDetectionProvider>,
|
||||||
|
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·다른 작업의 프로세스/파일은 변경하지 않았다.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# 언어 감지·번역 공급자 공통화 PRD
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 작성일 | 2026-11-09 |
|
||||||
|
| 상태 | Phase 1~3 구현·기능 범위 검증·독립 리뷰 완료 |
|
||||||
|
| 승인 범위 | 감지·번역 별도 인터페이스, 공통 공급자 선택·실패 전환, 기능별 순서 설정 |
|
||||||
|
| 계획 | [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 | 빈/중복/미등록 구현체의 공급자 순서는 초기화 단계에서 거부한다. 선택 코드에 공급자 분기를 추가하지 않는다. Phase 3부터 새 공급자는 enum 항목을 추가한다. |
|
||||||
|
| LP-009 | 기존 파파고 캐시·번역 데이터 및 분할 번역 동작과 감지 캐시 동시성 승자 반환을 보존한다. |
|
||||||
|
| LP-010 | 추가 방법과 기능별 순서 설정 예시, 실제 AWS 미구현 상태를 문서화한다. |
|
||||||
|
| LP-011 | 공급자 순서는 `LanguageProviderOrder`의 독립 `val List<String>` 두 개로 지정한다. 기본은 각각 `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 선택은 거부한다. |
|
||||||
|
|
||||||
|
## 보안·오류 처리
|
||||||
|
|
||||||
|
외부 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로 보완하고 그 한계를 기록한다.
|
||||||
|
|
||||||
|
## 확정 구현 계약
|
||||||
|
|
||||||
|
아래는 Phase 1 구현 계약이며, 순서 설정 방식은 하단의 Phase 2 정정 계약이 우선한다. 기존 실행 기록은 보존한다.
|
||||||
|
|
||||||
|
- 기존 `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의 공급자를 영구 고정하는 새 모델은 만들지 않는다.
|
||||||
|
|
||||||
|
## 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을 그대로 유지한다.
|
||||||
|
|
||||||
|
## Phase 3 결정 정정 — 2026-11-09
|
||||||
|
|
||||||
|
사용자는 공급자 enum 도입과 식별자 용도의 papago 리터럴 변경을 승인했다. 타입 관련 항목은 이 계약이 앞선 Phase보다 우선한다.
|
||||||
|
|
||||||
|
- `LanguageProviderId(val code: String)`에 `PAPAGO("papago")`, `AWS("aws")`를 정의한다.
|
||||||
|
- `LanguageProviderOrder`, `LanguageProviderProperties`의 두 순서는 `List<LanguageProviderId>`이다.
|
||||||
|
기본 목록은 각각 `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 항목·구현체·코드 순서 목록을 추가한다. 도메인 호출자와 선택 알고리즘은 변경하지 않는다.
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# 언어 감지·번역 공급자 확장 가이드
|
||||||
|
|
||||||
|
## 현재 실행 구조
|
||||||
|
|
||||||
|
감지 구현체는 `PapagoLanguageDetectionProvider`와 `AwsLanguageDetectionProvider`,
|
||||||
|
번역 구현체는 `PapagoTranslationService`와 `AwsTranslationService`다.
|
||||||
|
두 기능의 production 기본 순서는 모두 `PAPAGO → AWS`다. AWS는 기존 자격 증명과 리전 설정을 재사용한다.
|
||||||
|
|
||||||
|
감지와 번역은 별도 capability다. 감지만 또는 번역만 제공하는 공급자도 등록할 수 있다.
|
||||||
|
두 capability에서 같은 `providerId`를 쓰는 것은 가능하지만, 한 capability 안의 ID 중복은 시작 시 거부한다.
|
||||||
|
|
||||||
|
| 경계 | 계약 |
|
||||||
|
|---|---|
|
||||||
|
| `LanguageDetectionProvider` | `providerId: LanguageProviderId`, `detect(query: String): String?` |
|
||||||
|
| `LanguageDetectionCacheService` | `detectWithCache(query: String): DetectedLanguage?` |
|
||||||
|
| `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`, `providerId: LanguageProviderId`, `providerVersion: String` |
|
||||||
|
|
||||||
|
인터페이스와 DTO는 `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/`에 있다.
|
||||||
|
감지 coordinator는 기존 `content/LanguageDetectionCacheService.kt`이며 별도 vendor가 아니다.
|
||||||
|
`TranslationCoordinator`도 `TranslationProvider`를 구현하지 않는다.
|
||||||
|
|
||||||
|
## 새 공급자 추가 순서
|
||||||
|
|
||||||
|
1. `LanguageProviderId`에 공급자 enum 항목과 재배포 후에도 안정적인 DB `code`를 추가한다.
|
||||||
|
현재 항목은 `PAPAGO("papago")`, `AWS("aws")`이며 두 공급자의 실제 adapter가 등록돼 있다.
|
||||||
|
2. 필요한 capability 인터페이스를 구현하고 Spring `@Component`/`@Service` 또는 `@Bean`으로 등록한다.
|
||||||
|
예를 들어 Papago adapter의 식별자는 `override val providerId = LanguageProviderId.PAPAGO`다.
|
||||||
|
3. 공급자의 HTTP/SDK 및 인증은 adapter 안에 둔다. 외부 응답 형식과 빈 결과를 검증하고,
|
||||||
|
원문·인증정보·응답 본문·예외 cause를 로그로 출력하지 않는다. 번역은 입력별 결과를 같은 순서로 반환한다.
|
||||||
|
4. `LanguageProviderOrder.kt`에서 구현한 capability의 목록에만 enum을 넣고 빌드·배포한다. `@Primary`나 selector 분기는 필요 없다.
|
||||||
|
5. 아래 테스트 경계로 순서·실패 전환·실제 provider 저장·무호출 캐시 재사용·materializer 표시를 검증한다.
|
||||||
|
|
||||||
|
콘텐츠·댓글·커뮤니티 호출자, worker, materializer, scheduler에 공급자 분기를 추가하지 않는다.
|
||||||
|
기존 두 coordinator는 주입된 구현체 목록과 설정만 사용한다. 공급자마다 다른 분할·요청 크기 제한은 adapter 책임이다.
|
||||||
|
`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, LanguageProviderId.AWS)`다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
object LanguageProviderOrder {
|
||||||
|
val detectionOrder = listOf(LanguageProviderId.PAPAGO, LanguageProviderId.AWS)
|
||||||
|
val translationOrder = listOf(LanguageProviderId.PAPAGO, LanguageProviderId.AWS)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Kotlin 목록은 `const val`이 아닌 `val`이다. `LanguageProviderProperties`는 기존 주입 wrapper로 유지하며,
|
||||||
|
Spring은 `@Autowired` 기본 생성자로 위 목록을 읽는다. 문자열 파싱은 하지 않는다.
|
||||||
|
테스트에서는 전역 목록을 변경하지 않고
|
||||||
|
`LanguageProviderProperties(List<LanguageProviderId>, List<LanguageProviderId>)`로 만든 테스트 bean을 주입한다.
|
||||||
|
|
||||||
|
다음은 감지만 AWS 우선으로 바꾸는 예시다. 현재 기본 순서를 뜻하지 않는다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val detectionOrder = listOf(LanguageProviderId.AWS, LanguageProviderId.PAPAGO)
|
||||||
|
val translationOrder = listOf(LanguageProviderId.PAPAGO, LanguageProviderId.AWS)
|
||||||
|
```
|
||||||
|
|
||||||
|
감지만 순서를 바꾸어도 번역 순서는 바뀌지 않는다. 반대로 번역만 바꿀 수도 있다.
|
||||||
|
변경 후 빌드·배포해야 반영된다. 과거 우선순위 Spring property·환경변수·YAML 값이 남아 있어도 순서는 바뀌지 않는다.
|
||||||
|
공급자 인증정보는 기존 설정 경계로 주입하며 이 코드 목록에 넣지 않는다.
|
||||||
|
빈 목록·중복 ID·해당 capability의 구현체 부재·capability 안의 중복 구현체 ID는 초기화에서 거부한다.
|
||||||
|
빈 문자열 ID는 enum 타입으로 표현할 수 없다. 공급자가 번역에만 등록됐다면 그 ID를 감지 순서에 넣을 수 없다.
|
||||||
|
실행 중 설정 변경은 지원하지 않는다. 테스트의 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는 사용하지 않는다.
|
||||||
|
새 enum 항목을 추가할 때도 기존 code와 DB 스키마·캐시 키는 변경하지 않는다. `providerVersion`은 별도의 String 기록 필드다.
|
||||||
|
|
||||||
|
## 캐시와 오류 계약
|
||||||
|
|
||||||
|
감지는 정규화/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` | 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와 빈 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 동시성이나 SDK serialization/signing·실 AWS 권한/품질을 검증한다는 뜻은 아니다.
|
||||||
|
SDK mocks는 테스트마다 reset하고 Papago 기록 목록과 cache/job/read model을 비워 context 재사용을 격리한다.
|
||||||
|
기존 감지 cache 경합·revision 테스트는 별도로 유지한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./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'
|
||||||
|
```
|
||||||
|
|
||||||
|
AWS 구현의 실행 명령, 결과와 미검증 범위는 [AWS 구현 계획](../20261109_AWS언어감지번역/plan-task.md)의 실행 증거를 따른다.
|
||||||
|
이전 공통화 기록은 [공통화 계획](plan-task.md)에 보존한다. 전체 테스트는 사용자가 `./gradlew test`로 실행한다.
|
||||||
@@ -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 동시성을 검증했다고 주장하지 않는다. 이 한계는 이번 범위의 승인과 구분한다.
|
||||||
@@ -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<String>`이 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 대역 한계를 그대로 명시한다.
|
||||||
@@ -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 한계가 있다.
|
||||||
@@ -18,6 +18,10 @@
|
|||||||
./gradlew tasks --all
|
./gradlew tasks --all
|
||||||
./gradlew bootRun
|
./gradlew bootRun
|
||||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.*'
|
./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 bootJar
|
||||||
./gradlew ktlintCheck
|
./gradlew ktlintCheck
|
||||||
./gradlew ktlintFormat
|
./gradlew ktlintFormat
|
||||||
@@ -33,3 +37,13 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
위 명령은 목적에 맞게 하나를 선택한다. 전체 테스트 확인만 필요하면 `./gradlew test`로 충분하다.
|
위 명령은 목적에 맞게 하나를 선택한다. 전체 테스트 확인만 필요하면 `./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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
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 kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
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.scheduling.annotation.Async
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import org.springframework.transaction.annotation.Propagation
|
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.event.TransactionalEventListener
|
||||||
import org.springframework.transaction.support.TransactionSynchronization
|
import org.springframework.transaction.support.TransactionSynchronization
|
||||||
import org.springframework.transaction.support.TransactionSynchronizationManager
|
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||||
import org.springframework.util.LinkedMultiValueMap
|
|
||||||
import org.springframework.web.client.RestTemplate
|
|
||||||
import javax.persistence.EntityManager
|
import javax.persistence.EntityManager
|
||||||
import javax.persistence.LockModeType
|
import javax.persistence.LockModeType
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 텍스트 기반 데이터(콘텐츠, 댓글 등)에 대해 파파고 언어 감지를 요청하기 위한 이벤트.
|
* 텍스트 기반 데이터(콘텐츠, 댓글 등)에 대해 언어 감지를 요청하기 위한 이벤트.
|
||||||
*/
|
*/
|
||||||
enum class LanguageDetectTargetType {
|
enum class LanguageDetectTargetType {
|
||||||
CONTENT,
|
CONTENT,
|
||||||
@@ -56,10 +50,6 @@ class LanguageDetectEvent(
|
|||||||
val targetLanguage: String? = null
|
val targetLanguage: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class PapagoLanguageDetectResponse(
|
|
||||||
val langCode: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
class LanguageDetectListener(
|
class LanguageDetectListener(
|
||||||
private val audioContentRepository: AudioContentRepository,
|
private val audioContentRepository: AudioContentRepository,
|
||||||
@@ -75,27 +65,17 @@ class LanguageDetectListener(
|
|||||||
private val creatorCommunityTranslationService: CreatorCommunityTranslationService,
|
private val creatorCommunityTranslationService: CreatorCommunityTranslationService,
|
||||||
private val entityManager: EntityManager,
|
private val entityManager: EntityManager,
|
||||||
|
|
||||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
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 log = LoggerFactory.getLogger(LanguageDetectListener::class.java)
|
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
|
@Async
|
||||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
fun detectLanguage(event: LanguageDetectEvent) {
|
fun detectLanguage(event: LanguageDetectEvent) {
|
||||||
if (event.query.isBlank()) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,21 +97,21 @@ class LanguageDetectListener(
|
|||||||
|
|
||||||
val character = chatCharacterRepository.findById(characterId).orElse(null)
|
val character = chatCharacterRepository.findById(characterId).orElse(null)
|
||||||
if (character == null) {
|
if (character == null) {
|
||||||
log.warn("[PapagoLanguageDetect] ChatCharacter not found. characterId={}", characterId)
|
log.warn("[LanguageDetect] ChatCharacter not found. characterId={}", characterId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||||
if (!character.languageCode.isNullOrBlank()) {
|
if (!character.languageCode.isNullOrBlank()) {
|
||||||
log.debug(
|
log.debug(
|
||||||
"[PapagoLanguageDetect] languageCode already set. Skip language detection. characterId={}, languageCode={}",
|
"[LanguageDetect] languageCode already set. Skip language detection. characterId={}, languageCode={}",
|
||||||
characterId,
|
characterId,
|
||||||
character.languageCode
|
character.languageCode
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val langCode = detectLanguageCode(event, characterId) ?: return
|
val langCode = detectLanguageCode(event) ?: return
|
||||||
|
|
||||||
character.languageCode = langCode
|
character.languageCode = langCode
|
||||||
chatCharacterRepository.save(character)
|
chatCharacterRepository.save(character)
|
||||||
@@ -144,7 +124,7 @@ class LanguageDetectListener(
|
|||||||
)
|
)
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"[PapagoLanguageDetect] languageCode updated from Papago. characterId={}, langCode={}",
|
"[LanguageDetect] languageCode updated. characterId={}, langCode={}",
|
||||||
characterId,
|
characterId,
|
||||||
langCode
|
langCode
|
||||||
)
|
)
|
||||||
@@ -155,21 +135,21 @@ class LanguageDetectListener(
|
|||||||
|
|
||||||
val audioContent = audioContentRepository.findById(contentId).orElse(null)
|
val audioContent = audioContentRepository.findById(contentId).orElse(null)
|
||||||
if (audioContent == null) {
|
if (audioContent == null) {
|
||||||
log.warn("[PapagoLanguageDetect] AudioContent not found. contentId={}", contentId)
|
log.warn("[LanguageDetect] AudioContent not found. contentId={}", contentId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||||
if (!audioContent.languageCode.isNullOrBlank()) {
|
if (!audioContent.languageCode.isNullOrBlank()) {
|
||||||
log.debug(
|
log.debug(
|
||||||
"[PapagoLanguageDetect] languageCode already set. Skip language detection. contentId={}, languageCode={}",
|
"[LanguageDetect] languageCode already set. Skip language detection. contentId={}, languageCode={}",
|
||||||
contentId,
|
contentId,
|
||||||
audioContent.languageCode
|
audioContent.languageCode
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val langCode = detectLanguageCode(event, contentId) ?: return
|
val langCode = detectLanguageCode(event) ?: return
|
||||||
|
|
||||||
audioContent.languageCode = langCode
|
audioContent.languageCode = langCode
|
||||||
|
|
||||||
@@ -184,7 +164,7 @@ class LanguageDetectListener(
|
|||||||
)
|
)
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"[PapagoLanguageDetect] languageCode updated from Papago. contentId={}, langCode={}",
|
"[LanguageDetect] languageCode updated. contentId={}, langCode={}",
|
||||||
contentId,
|
contentId,
|
||||||
langCode
|
langCode
|
||||||
)
|
)
|
||||||
@@ -195,27 +175,27 @@ class LanguageDetectListener(
|
|||||||
|
|
||||||
val comment = audioContentCommentRepository.findById(commentId).orElse(null)
|
val comment = audioContentCommentRepository.findById(commentId).orElse(null)
|
||||||
if (comment == null) {
|
if (comment == null) {
|
||||||
log.warn("[PapagoLanguageDetect] AudioContentComment not found. commentId={}", commentId)
|
log.warn("[LanguageDetect] AudioContentComment not found. commentId={}", commentId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||||
if (!comment.languageCode.isNullOrBlank()) {
|
if (!comment.languageCode.isNullOrBlank()) {
|
||||||
log.debug(
|
log.debug(
|
||||||
"[PapagoLanguageDetect] languageCode already set. Skip language detection. commentId={}, languageCode={}",
|
"[LanguageDetect] languageCode already set. Skip language detection. commentId={}, languageCode={}",
|
||||||
commentId,
|
commentId,
|
||||||
comment.languageCode
|
comment.languageCode
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val langCode = detectLanguageCode(event, commentId) ?: return
|
val langCode = detectLanguageCode(event) ?: return
|
||||||
|
|
||||||
comment.languageCode = langCode
|
comment.languageCode = langCode
|
||||||
audioContentCommentRepository.save(comment)
|
audioContentCommentRepository.save(comment)
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"[PapagoLanguageDetect] languageCode updated from Papago. commentId={}, langCode={}",
|
"[LanguageDetect] languageCode updated. commentId={}, langCode={}",
|
||||||
commentId,
|
commentId,
|
||||||
langCode
|
langCode
|
||||||
)
|
)
|
||||||
@@ -226,14 +206,14 @@ class LanguageDetectListener(
|
|||||||
|
|
||||||
val comment = characterCommentRepository.findById(commentId).orElse(null)
|
val comment = characterCommentRepository.findById(commentId).orElse(null)
|
||||||
if (comment == null) {
|
if (comment == null) {
|
||||||
log.warn("[PapagoLanguageDetect] CharacterComment not found. commentId={}", commentId)
|
log.warn("[LanguageDetect] CharacterComment not found. commentId={}", commentId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||||
if (!comment.languageCode.isNullOrBlank()) {
|
if (!comment.languageCode.isNullOrBlank()) {
|
||||||
log.debug(
|
log.debug(
|
||||||
"[PapagoLanguageDetect] languageCode already set. Skip language detection. " +
|
"[LanguageDetect] languageCode already set. Skip language detection. " +
|
||||||
"characterCommentId={}, languageCode={}",
|
"characterCommentId={}, languageCode={}",
|
||||||
commentId,
|
commentId,
|
||||||
comment.languageCode
|
comment.languageCode
|
||||||
@@ -241,13 +221,13 @@ class LanguageDetectListener(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val langCode = detectLanguageCode(event, commentId) ?: return
|
val langCode = detectLanguageCode(event) ?: return
|
||||||
|
|
||||||
comment.languageCode = langCode
|
comment.languageCode = langCode
|
||||||
characterCommentRepository.save(comment)
|
characterCommentRepository.save(comment)
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"[PapagoLanguageDetect] languageCode updated from Papago. characterCommentId={}, langCode={}",
|
"[LanguageDetect] languageCode updated. characterCommentId={}, langCode={}",
|
||||||
commentId,
|
commentId,
|
||||||
langCode
|
langCode
|
||||||
)
|
)
|
||||||
@@ -258,27 +238,27 @@ class LanguageDetectListener(
|
|||||||
|
|
||||||
val cheers = creatorCheersRepository.findById(cheersId).orElse(null)
|
val cheers = creatorCheersRepository.findById(cheersId).orElse(null)
|
||||||
if (cheers == null) {
|
if (cheers == null) {
|
||||||
log.warn("[PapagoLanguageDetect] CreatorCheers not found. cheersId={}", cheersId)
|
log.warn("[LanguageDetect] CreatorCheers not found. cheersId={}", cheersId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||||
if (!cheers.languageCode.isNullOrBlank()) {
|
if (!cheers.languageCode.isNullOrBlank()) {
|
||||||
log.debug(
|
log.debug(
|
||||||
"[PapagoLanguageDetect] languageCode already set. Skip language detection. cheersId={}, languageCode={}",
|
"[LanguageDetect] languageCode already set. Skip language detection. cheersId={}, languageCode={}",
|
||||||
cheersId,
|
cheersId,
|
||||||
cheers.languageCode
|
cheers.languageCode
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val langCode = detectLanguageCode(event, cheersId) ?: return
|
val langCode = detectLanguageCode(event) ?: return
|
||||||
|
|
||||||
cheers.languageCode = langCode
|
cheers.languageCode = langCode
|
||||||
creatorCheersRepository.save(cheers)
|
creatorCheersRepository.save(cheers)
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"[PapagoLanguageDetect] languageCode updated from Papago. cheersId={}, langCode={}",
|
"[LanguageDetect] languageCode updated. cheersId={}, langCode={}",
|
||||||
cheersId,
|
cheersId,
|
||||||
langCode
|
langCode
|
||||||
)
|
)
|
||||||
@@ -289,21 +269,21 @@ class LanguageDetectListener(
|
|||||||
|
|
||||||
val series = seriesRepository.findByIdOrNull(seriesId)
|
val series = seriesRepository.findByIdOrNull(seriesId)
|
||||||
if (series == null) {
|
if (series == null) {
|
||||||
log.warn("[PapagoLanguageDetect] Series not found. seriesId={}", seriesId)
|
log.warn("[LanguageDetect] Series not found. seriesId={}", seriesId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||||
if (!series.languageCode.isNullOrBlank()) {
|
if (!series.languageCode.isNullOrBlank()) {
|
||||||
log.debug(
|
log.debug(
|
||||||
"[PapagoLanguageDetect] languageCode already set. Skip language detection. seriesId={}, languageCode={}",
|
"[LanguageDetect] languageCode already set. Skip language detection. seriesId={}, languageCode={}",
|
||||||
seriesId,
|
seriesId,
|
||||||
series.languageCode
|
series.languageCode
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val langCode = detectLanguageCode(event, seriesId) ?: return
|
val langCode = detectLanguageCode(event) ?: return
|
||||||
|
|
||||||
series.languageCode = langCode
|
series.languageCode = langCode
|
||||||
seriesRepository.save(series)
|
seriesRepository.save(series)
|
||||||
@@ -316,7 +296,7 @@ class LanguageDetectListener(
|
|||||||
)
|
)
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"[PapagoLanguageDetect] languageCode updated from Papago. seriesId={}, langCode={}",
|
"[LanguageDetect] languageCode updated. seriesId={}, langCode={}",
|
||||||
seriesId,
|
seriesId,
|
||||||
langCode
|
langCode
|
||||||
)
|
)
|
||||||
@@ -327,21 +307,21 @@ class LanguageDetectListener(
|
|||||||
|
|
||||||
val originalWork = originalWorkRepository.findByIdOrNull(originalWorkId)
|
val originalWork = originalWorkRepository.findByIdOrNull(originalWorkId)
|
||||||
if (originalWork == null) {
|
if (originalWork == null) {
|
||||||
log.warn("[PapagoLanguageDetect] OriginalWork not found. originalWorkId={}", originalWorkId)
|
log.warn("[LanguageDetect] OriginalWork not found. originalWorkId={}", originalWorkId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||||
if (!originalWork.languageCode.isNullOrBlank()) {
|
if (!originalWork.languageCode.isNullOrBlank()) {
|
||||||
log.debug(
|
log.debug(
|
||||||
"[PapagoLanguageDetect] languageCode already set. Skip language detection. originalWorkId={}, languageCode={}",
|
"[LanguageDetect] languageCode already set. Skip language detection. originalWorkId={}, languageCode={}",
|
||||||
originalWorkId,
|
originalWorkId,
|
||||||
originalWork.languageCode
|
originalWork.languageCode
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val langCode = detectLanguageCode(event, originalWorkId) ?: return
|
val langCode = detectLanguageCode(event) ?: return
|
||||||
|
|
||||||
originalWork.languageCode = langCode
|
originalWork.languageCode = langCode
|
||||||
originalWorkRepository.save(originalWork)
|
originalWorkRepository.save(originalWork)
|
||||||
@@ -355,7 +335,7 @@ class LanguageDetectListener(
|
|||||||
)
|
)
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"[PapagoLanguageDetect] languageCode updated from Papago. originalWorkId={}, langCode={}",
|
"[LanguageDetect] languageCode updated. originalWorkId={}, langCode={}",
|
||||||
originalWorkId,
|
originalWorkId,
|
||||||
langCode
|
langCode
|
||||||
)
|
)
|
||||||
@@ -367,7 +347,7 @@ class LanguageDetectListener(
|
|||||||
val category = categoryRepository.findByIdOrNull(categoryId) ?: return
|
val category = categoryRepository.findByIdOrNull(categoryId) ?: return
|
||||||
if (!category.languageCode.isNullOrBlank()) return
|
if (!category.languageCode.isNullOrBlank()) return
|
||||||
|
|
||||||
val langCode = detectLanguageCode(event, categoryId) ?: return
|
val langCode = detectLanguageCode(event) ?: return
|
||||||
|
|
||||||
category.languageCode = langCode
|
category.languageCode = langCode
|
||||||
categoryRepository.save(category)
|
categoryRepository.save(category)
|
||||||
@@ -395,7 +375,7 @@ class LanguageDetectListener(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val languageCode = detectLanguageCode(event, event.id) ?: return
|
val languageCode = detectLanguageCode(event) ?: return
|
||||||
if (languageCode !in getTranslatableLanguageCodes(null)) return
|
if (languageCode !in getTranslatableLanguageCodes(null)) return
|
||||||
|
|
||||||
val currentCreatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(event.id) ?: return
|
val currentCreatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(event.id) ?: return
|
||||||
@@ -427,60 +407,7 @@ class LanguageDetectListener(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun detectLanguageCode(event: LanguageDetectEvent, targetIdForLog: Long): String? {
|
private fun detectLanguageCode(event: LanguageDetectEvent): String? {
|
||||||
return languageDetectionCacheService.detectWithCache(event.query) {
|
return languageDetectionCacheService.detectWithCache(event.query)?.detectedLanguage
|
||||||
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<String, String>().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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +1,64 @@
|
|||||||
package kr.co.vividnext.sodalive.content
|
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 kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class LanguageDetectionCacheService(
|
class LanguageDetectionCacheService(
|
||||||
private val languageDetectionResultRepository: LanguageDetectionResultRepository
|
private val languageDetectionResultRepository: LanguageDetectionResultRepository,
|
||||||
|
providers: List<LanguageDetectionProvider>,
|
||||||
|
properties: LanguageProviderProperties
|
||||||
) {
|
) {
|
||||||
|
private val providers = properties.orderedProviders(properties.detectionOrder, providers) { it.providerId }
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun detectWithCache(
|
fun detectWithCache(query: String): DetectedLanguage? {
|
||||||
query: String,
|
|
||||||
provider: String = DEFAULT_PROVIDER,
|
|
||||||
detector: () -> String?
|
|
||||||
): String? {
|
|
||||||
val normalizedQuery = SourceTextNormalizer.normalize(query)
|
val normalizedQuery = SourceTextNormalizer.normalize(query)
|
||||||
if (normalizedQuery.isBlank()) return null
|
if (normalizedQuery.isBlank()) return null
|
||||||
|
|
||||||
val sourceHash = SourceTextNormalizer.hash(normalizedQuery)
|
val sourceHash = SourceTextNormalizer.hash(normalizedQuery)
|
||||||
val cached = languageDetectionResultRepository.findBySourceHashAndProviderAndNormalizationVersion(
|
providers.forEach { provider ->
|
||||||
sourceHash = sourceHash,
|
val cached = languageDetectionResultRepository.findBySourceHashAndProviderAndNormalizationVersion(
|
||||||
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(
|
|
||||||
sourceHash = sourceHash,
|
sourceHash = sourceHash,
|
||||||
provider = provider,
|
provider = provider.providerId.code,
|
||||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||||
)
|
)
|
||||||
)
|
if (cached != null && cached.detectedLanguage.isNotBlank()) {
|
||||||
|
return DetectedLanguage(cached.detectedLanguage, provider.providerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.providerId.code,
|
||||||
|
confidence = null,
|
||||||
|
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||||
|
)
|
||||||
|
val winner = requireNotNull(
|
||||||
|
languageDetectionResultRepository.findDetectedLanguageForUpdate(
|
||||||
|
sourceHash = sourceHash,
|
||||||
|
provider = provider.providerId.code,
|
||||||
|
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return DetectedLanguage(winner, provider.providerId)
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val DEFAULT_PROVIDER = "papago"
|
|
||||||
private const val MAX_SAMPLE_LENGTH = 500
|
private const val MAX_SAMPLE_LENGTH = 500
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -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()
|
||||||
|
}
|
||||||
@@ -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<String>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package kr.co.vividnext.sodalive.i18n.translation
|
||||||
|
|
||||||
|
internal fun splitTextByCodePoint(text: String, maxSize: Int, codePointSize: (Int) -> Int): List<String> {
|
||||||
|
val chunks = mutableListOf<String>()
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package kr.co.vividnext.sodalive.i18n.translation
|
||||||
|
|
||||||
|
data class DetectedLanguage(val detectedLanguage: String, val providerId: LanguageProviderId)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package kr.co.vividnext.sodalive.i18n.translation
|
||||||
|
|
||||||
|
interface LanguageDetectionProvider {
|
||||||
|
val providerId: LanguageProviderId
|
||||||
|
|
||||||
|
fun detect(query: String): String?
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package kr.co.vividnext.sodalive.i18n.translation
|
||||||
|
|
||||||
|
enum class LanguageProviderId(val code: String) {
|
||||||
|
PAPAGO("papago"),
|
||||||
|
AWS("aws")
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package kr.co.vividnext.sodalive.i18n.translation
|
||||||
|
|
||||||
|
object LanguageProviderOrder {
|
||||||
|
val detectionOrder = listOf(LanguageProviderId.PAPAGO, LanguageProviderId.AWS)
|
||||||
|
val translationOrder = listOf(LanguageProviderId.PAPAGO, LanguageProviderId.AWS)
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package kr.co.vividnext.sodalive.i18n.translation
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class LanguageProviderProperties(
|
||||||
|
val detectionOrder: List<LanguageProviderId>,
|
||||||
|
val translationOrder: List<LanguageProviderId>
|
||||||
|
) {
|
||||||
|
@Autowired
|
||||||
|
constructor() : this(LanguageProviderOrder.detectionOrder, LanguageProviderOrder.translationOrder)
|
||||||
|
|
||||||
|
fun <T> orderedProviders(order: List<LanguageProviderId>, providers: List<T>, id: (T) -> LanguageProviderId): List<T> {
|
||||||
|
require(order.isNotEmpty()) { "language provider order must not be empty" }
|
||||||
|
require(order.distinct().size == order.size) { "duplicate language provider order" }
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
@@ -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 providerId = LanguageProviderId.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<String, String>().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?)
|
||||||
+3
-35
@@ -20,7 +20,7 @@ class PapagoTranslationService(
|
|||||||
|
|
||||||
private val papagoTranslateUrl = "https://papago.apigw.ntruss.com/nmt/v1/translation"
|
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"
|
override val providerVersion: String = "nmt-v1"
|
||||||
|
|
||||||
@@ -78,46 +78,14 @@ class PapagoTranslationService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun splitText(text: String): List<String> {
|
private fun splitText(text: String): List<String> {
|
||||||
val chunks = mutableListOf<String>()
|
return splitTextByCodePoint(text, MAX_REQUEST_TEXT_LENGTH, Character::charCount)
|
||||||
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 TranslationLanguages.isSupported(sourceLanguage) && TranslationLanguages.isSupported(targetLanguage)
|
||||||
}
|
|
||||||
|
|
||||||
private fun requireSupportedLanguage(language: String): Boolean {
|
|
||||||
val normalized = language.lowercase()
|
|
||||||
return SUPPORTED_LANGUAGE_CODES.contains(normalized)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val MAX_REQUEST_TEXT_LENGTH = 5000
|
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<String> {
|
|
||||||
val normalized = excludedLanguageCode?.lowercase()
|
|
||||||
return SUPPORTED_LANGUAGE_CODES.filterNot { it == normalized }.toSet()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package kr.co.vividnext.sodalive.i18n.translation
|
||||||
|
|
||||||
|
data class ProviderTranslationResult(
|
||||||
|
val result: TranslateResult,
|
||||||
|
val providerId: LanguageProviderId,
|
||||||
|
val providerVersion: String
|
||||||
|
)
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.i18n.translation
|
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.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package kr.co.vividnext.sodalive.i18n.translation
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class TranslationCoordinator(
|
||||||
|
providers: List<TranslationProvider>,
|
||||||
|
properties: LanguageProviderProperties,
|
||||||
|
private val translationMemoryRepository: TranslationMemoryRepository
|
||||||
|
) {
|
||||||
|
private val providers = properties.orderedProviders(properties.translationOrder, providers) { it.providerId }
|
||||||
|
|
||||||
|
fun findCached(sourceHash: String, sourceLanguage: String, targetLanguage: String): TranslationMemory? {
|
||||||
|
providers.forEach { provider ->
|
||||||
|
val memory = translationMemoryRepository
|
||||||
|
.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
|
||||||
|
sourceHash,
|
||||||
|
sourceLanguage,
|
||||||
|
targetLanguage,
|
||||||
|
provider.providerId.code,
|
||||||
|
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.providerId, provider.providerVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw IllegalStateException("all translation providers failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-5
@@ -21,7 +21,7 @@ class TranslationJobScheduler(
|
|||||||
if (normalizedText.isBlank()) return
|
if (normalizedText.isBlank()) return
|
||||||
|
|
||||||
val normalizedSourceLanguage = sourceLanguage.lowercase()
|
val normalizedSourceLanguage = sourceLanguage.lowercase()
|
||||||
if (!SUPPORTED_SOURCE_LANGUAGE_CODES.contains(normalizedSourceLanguage)) return
|
if (!TranslationLanguages.isSupported(normalizedSourceLanguage)) return
|
||||||
|
|
||||||
val normalizedTargetLanguage = targetLanguage.lowercase()
|
val normalizedTargetLanguage = targetLanguage.lowercase()
|
||||||
if (normalizedSourceLanguage == normalizedTargetLanguage) return
|
if (normalizedSourceLanguage == normalizedTargetLanguage) return
|
||||||
@@ -49,8 +49,4 @@ class TranslationJobScheduler(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val SUPPORTED_SOURCE_LANGUAGE_CODES = setOf("ko", "en", "ja")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import java.time.LocalDateTime
|
|||||||
class TranslationJobWorker(
|
class TranslationJobWorker(
|
||||||
private val translationJobRepository: TranslationJobRepository,
|
private val translationJobRepository: TranslationJobRepository,
|
||||||
private val translationMemoryRepository: TranslationMemoryRepository,
|
private val translationMemoryRepository: TranslationMemoryRepository,
|
||||||
private val translationProvider: TranslationProvider,
|
private val translationCoordinator: TranslationCoordinator,
|
||||||
private val materializer: TranslationReadModelMaterializer,
|
private val materializer: TranslationReadModelMaterializer,
|
||||||
transactionManager: PlatformTransactionManager
|
transactionManager: PlatformTransactionManager
|
||||||
) {
|
) {
|
||||||
@@ -69,25 +69,17 @@ class TranslationJobWorker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureMemory(job: TranslationJob) {
|
private fun ensureMemory(job: TranslationJob) {
|
||||||
val existing = translationMemoryRepository
|
val existing = translationCoordinator.findCached(job.sourceHash, job.sourceLanguage, job.targetLanguage)
|
||||||
.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
|
|
||||||
sourceHash = job.sourceHash,
|
|
||||||
sourceLanguage = job.sourceLanguage,
|
|
||||||
targetLanguage = job.targetLanguage,
|
|
||||||
provider = translationProvider.providerName,
|
|
||||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
|
||||||
)
|
|
||||||
if (existing != null) return
|
if (existing != null) return
|
||||||
|
|
||||||
val response = translationProvider.translate(
|
val response = translationCoordinator.translate(
|
||||||
TranslateRequest(
|
TranslateRequest(
|
||||||
texts = listOf(job.sourceText),
|
texts = listOf(job.sourceText),
|
||||||
sourceLanguage = job.sourceLanguage,
|
sourceLanguage = job.sourceLanguage,
|
||||||
targetLanguage = job.targetLanguage
|
targetLanguage = job.targetLanguage
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val translated = response.translatedText.firstOrNull()?.takeIf { it.isNotBlank() }
|
val translated = response.result.translatedText.single()
|
||||||
?: throw IllegalStateException("empty translation result")
|
|
||||||
|
|
||||||
transactionTemplate.executeWithoutResult {
|
transactionTemplate.executeWithoutResult {
|
||||||
val memory = translationMemoryRepository
|
val memory = translationMemoryRepository
|
||||||
@@ -95,7 +87,7 @@ class TranslationJobWorker(
|
|||||||
sourceHash = job.sourceHash,
|
sourceHash = job.sourceHash,
|
||||||
sourceLanguage = job.sourceLanguage,
|
sourceLanguage = job.sourceLanguage,
|
||||||
targetLanguage = job.targetLanguage,
|
targetLanguage = job.targetLanguage,
|
||||||
provider = translationProvider.providerName,
|
provider = response.providerId.code,
|
||||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||||
)
|
)
|
||||||
if (memory == null) {
|
if (memory == null) {
|
||||||
@@ -106,8 +98,8 @@ class TranslationJobWorker(
|
|||||||
sourceLanguage = job.sourceLanguage,
|
sourceLanguage = job.sourceLanguage,
|
||||||
targetLanguage = job.targetLanguage,
|
targetLanguage = job.targetLanguage,
|
||||||
translatedText = translated,
|
translatedText = translated,
|
||||||
provider = translationProvider.providerName,
|
provider = response.providerId.code,
|
||||||
providerVersion = translationProvider.providerVersion
|
providerVersion = response.providerVersion
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String> {
|
||||||
|
val normalized = excludedLanguageCode?.lowercase()
|
||||||
|
return supportedLanguageCodes.filterNot { it == normalized }.toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.i18n.translation
|
package kr.co.vividnext.sodalive.i18n.translation
|
||||||
|
|
||||||
interface TranslationProvider {
|
interface TranslationProvider {
|
||||||
val providerName: String
|
val providerId: LanguageProviderId
|
||||||
val providerVersion: String
|
val providerVersion: String
|
||||||
|
|
||||||
fun translate(request: TranslateRequest): TranslateResult
|
fun translate(request: TranslateRequest): TranslateResult
|
||||||
|
|||||||
+6
-13
@@ -29,7 +29,7 @@ import javax.persistence.LockModeType
|
|||||||
@Service
|
@Service
|
||||||
class TranslationReadModelMaterializer(
|
class TranslationReadModelMaterializer(
|
||||||
private val sourceExtractor: TranslationSourceExtractor,
|
private val sourceExtractor: TranslationSourceExtractor,
|
||||||
private val translationMemoryRepository: TranslationMemoryRepository,
|
private val translationCoordinator: TranslationCoordinator,
|
||||||
private val contentTranslationRepository: ContentTranslationRepository,
|
private val contentTranslationRepository: ContentTranslationRepository,
|
||||||
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
|
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
|
||||||
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
|
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
|
||||||
@@ -71,14 +71,11 @@ class TranslationReadModelMaterializer(
|
|||||||
return@forEach
|
return@forEach
|
||||||
}
|
}
|
||||||
|
|
||||||
val memory = translationMemoryRepository
|
val memory = translationCoordinator.findCached(
|
||||||
.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
|
sourceHash = SourceTextNormalizer.hash(normalizedText),
|
||||||
sourceHash = SourceTextNormalizer.hash(normalizedText),
|
sourceLanguage = source.sourceLanguage.lowercase(),
|
||||||
sourceLanguage = source.sourceLanguage.lowercase(),
|
targetLanguage = targetLanguage
|
||||||
targetLanguage = targetLanguage,
|
) ?: return null
|
||||||
provider = DEFAULT_PROVIDER,
|
|
||||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
|
||||||
) ?: return null
|
|
||||||
|
|
||||||
result[field.fieldKey] = memory.translatedText
|
result[field.fieldKey] = memory.translatedText
|
||||||
}
|
}
|
||||||
@@ -231,8 +228,4 @@ class TranslationReadModelMaterializer(
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val DEFAULT_PROVIDER = "papago"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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.CreatorCommunity
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
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.ResourceTranslationJobScheduler
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
|
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.TranslationReadModelMaterializer
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceExtractor
|
import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceExtractor
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslation
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslation
|
||||||
|
|||||||
+27
-6
@@ -15,9 +15,15 @@ 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.explorer.profile.creatorCommunity.like.CreatorCommunityLikeRepository
|
||||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
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.LanguageTranslationTargetType
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
|
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.TranslationJobRepository
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobScheduler
|
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobScheduler
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationMemory
|
import kr.co.vividnext.sodalive.i18n.translation.TranslationMemory
|
||||||
@@ -83,6 +89,8 @@ import javax.persistence.EntityManager
|
|||||||
CreatorCommunityTranslationService::class,
|
CreatorCommunityTranslationService::class,
|
||||||
TranslationSourceExtractor::class,
|
TranslationSourceExtractor::class,
|
||||||
TranslationReadModelMaterializer::class,
|
TranslationReadModelMaterializer::class,
|
||||||
|
TranslationCoordinator::class,
|
||||||
|
PapagoTranslationService::class,
|
||||||
ResourceTranslationJobScheduler::class,
|
ResourceTranslationJobScheduler::class,
|
||||||
TranslationJobScheduler::class,
|
TranslationJobScheduler::class,
|
||||||
CreatorCommunityLanguageDetectTest.AsyncConfiguration::class
|
CreatorCommunityLanguageDetectTest.AsyncConfiguration::class
|
||||||
@@ -255,7 +263,7 @@ class CreatorCommunityLanguageDetectTest @Autowired constructor(
|
|||||||
sourceLanguage = "en",
|
sourceLanguage = "en",
|
||||||
targetLanguage = "ko",
|
targetLanguage = "ko",
|
||||||
translatedText = "translated detail content",
|
translatedText = "translated detail content",
|
||||||
provider = TranslationReadModelMaterializer.DEFAULT_PROVIDER,
|
provider = "papago",
|
||||||
providerVersion = "nmt-v1"
|
providerVersion = "nmt-v1"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -669,6 +677,12 @@ class CreatorCommunityLanguageDetectTest @Autowired constructor(
|
|||||||
@TestConfiguration
|
@TestConfiguration
|
||||||
@EnableAsync
|
@EnableAsync
|
||||||
class AsyncConfiguration {
|
class AsyncConfiguration {
|
||||||
|
@Bean
|
||||||
|
fun languageProviderProperties() = LanguageProviderProperties(
|
||||||
|
listOf(LanguageProviderId.PAPAGO),
|
||||||
|
listOf(LanguageProviderId.PAPAGO)
|
||||||
|
)
|
||||||
|
|
||||||
@Bean(name = ["taskExecutor"])
|
@Bean(name = ["taskExecutor"])
|
||||||
fun taskExecutor(): DetailPipelineTaskExecutor {
|
fun taskExecutor(): DetailPipelineTaskExecutor {
|
||||||
return DetailPipelineTaskExecutor()
|
return DetailPipelineTaskExecutor()
|
||||||
@@ -719,7 +733,14 @@ class CreatorCommunityLanguageDetectTest @Autowired constructor(
|
|||||||
|
|
||||||
class ControlledLanguageDetectionCacheService(
|
class ControlledLanguageDetectionCacheService(
|
||||||
languageDetectionResultRepository: LanguageDetectionResultRepository
|
languageDetectionResultRepository: LanguageDetectionResultRepository
|
||||||
) : LanguageDetectionCacheService(languageDetectionResultRepository) {
|
) : LanguageDetectionCacheService(
|
||||||
|
languageDetectionResultRepository,
|
||||||
|
listOf(object : LanguageDetectionProvider {
|
||||||
|
override val providerId = LanguageProviderId.PAPAGO
|
||||||
|
override fun detect(query: String): String? = null
|
||||||
|
}),
|
||||||
|
LanguageProviderProperties(listOf(LanguageProviderId.PAPAGO), listOf(LanguageProviderId.PAPAGO))
|
||||||
|
) {
|
||||||
val detections = ConcurrentHashMap<String, String>()
|
val detections = ConcurrentHashMap<String, String>()
|
||||||
val queries = ConcurrentLinkedQueue<String>()
|
val queries = ConcurrentLinkedQueue<String>()
|
||||||
private val blockingDetections = ConcurrentHashMap<String, BlockingDetection>()
|
private val blockingDetections = ConcurrentHashMap<String, BlockingDetection>()
|
||||||
@@ -740,19 +761,19 @@ class ControlledLanguageDetectionCacheService(
|
|||||||
return ConcurrentDetection(detectedLanguage).also { concurrentDetections[query] = it }
|
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)
|
queries.add(query)
|
||||||
val concurrentDetection = concurrentDetections[query]
|
val concurrentDetection = concurrentDetections[query]
|
||||||
if (concurrentDetection != null) {
|
if (concurrentDetection != null) {
|
||||||
return concurrentDetection.detect()
|
return DetectedLanguage(concurrentDetection.detect(), LanguageProviderId.PAPAGO)
|
||||||
}
|
}
|
||||||
val blockingDetection = blockingDetections[query]
|
val blockingDetection = blockingDetections[query]
|
||||||
if (blockingDetection != null) {
|
if (blockingDetection != null) {
|
||||||
blockingDetection.started.countDown()
|
blockingDetection.started.countDown()
|
||||||
check(blockingDetection.release.await(5, TimeUnit.SECONDS))
|
check(blockingDetection.release.await(5, TimeUnit.SECONDS))
|
||||||
return blockingDetection.detectedLanguage
|
return DetectedLanguage(blockingDetection.detectedLanguage, LanguageProviderId.PAPAGO)
|
||||||
}
|
}
|
||||||
return detections[query]
|
return detections[query]?.let { DetectedLanguage(it, LanguageProviderId.PAPAGO) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-3
@@ -4,9 +4,14 @@ import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
|||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
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.CreatorCommunity
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
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.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.ResourceTranslationJobScheduler
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
|
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.TranslationJobRepository
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobScheduler
|
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobScheduler
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobStatus
|
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobStatus
|
||||||
@@ -25,6 +30,8 @@ import org.springframework.aop.support.AopUtils
|
|||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
|
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
|
||||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
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.context.annotation.Import
|
||||||
import org.springframework.http.HttpHeaders
|
import org.springframework.http.HttpHeaders
|
||||||
import org.springframework.http.HttpMethod
|
import org.springframework.http.HttpMethod
|
||||||
@@ -67,6 +74,10 @@ import java.util.concurrent.atomic.AtomicInteger
|
|||||||
QueryDslConfig::class,
|
QueryDslConfig::class,
|
||||||
AudioContentThemeQueryRepository::class,
|
AudioContentThemeQueryRepository::class,
|
||||||
LanguageDetectionCacheService::class,
|
LanguageDetectionCacheService::class,
|
||||||
|
CreatorCommunityLanguageDetectionCacheConcurrencyTest.PapagoConfiguration::class,
|
||||||
|
PapagoLanguageDetectionProvider::class,
|
||||||
|
PapagoTranslationService::class,
|
||||||
|
TranslationCoordinator::class,
|
||||||
LanguageDetectListener::class,
|
LanguageDetectListener::class,
|
||||||
CreatorCommunityTranslationService::class,
|
CreatorCommunityTranslationService::class,
|
||||||
TranslationSourceExtractor::class,
|
TranslationSourceExtractor::class,
|
||||||
@@ -82,6 +93,7 @@ class CreatorCommunityLanguageDetectionCacheConcurrencyTest @Autowired construct
|
|||||||
private val translationJobRepository: TranslationJobRepository,
|
private val translationJobRepository: TranslationJobRepository,
|
||||||
private val languageDetectionCacheService: LanguageDetectionCacheService,
|
private val languageDetectionCacheService: LanguageDetectionCacheService,
|
||||||
private val languageDetectListener: LanguageDetectListener,
|
private val languageDetectListener: LanguageDetectListener,
|
||||||
|
private val papagoLanguageDetectionProvider: PapagoLanguageDetectionProvider,
|
||||||
transactionManager: PlatformTransactionManager
|
transactionManager: PlatformTransactionManager
|
||||||
) {
|
) {
|
||||||
private val requiresNewTransactionTemplate = TransactionTemplate(transactionManager).apply {
|
private val requiresNewTransactionTemplate = TransactionTemplate(transactionManager).apply {
|
||||||
@@ -89,11 +101,16 @@ class CreatorCommunityLanguageDetectionCacheConcurrencyTest @Autowired construct
|
|||||||
}
|
}
|
||||||
private lateinit var papagoRequests: ControlledPapagoRequestFactory
|
private lateinit var papagoRequests: ControlledPapagoRequestFactory
|
||||||
|
|
||||||
|
@TestConfiguration
|
||||||
|
class PapagoConfiguration {
|
||||||
|
@Bean
|
||||||
|
fun languageProviderProperties() = LanguageProviderProperties(listOf(PAPAGO), listOf(PAPAGO))
|
||||||
|
}
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
papagoRequests = ControlledPapagoRequestFactory()
|
papagoRequests = ControlledPapagoRequestFactory()
|
||||||
val listener: LanguageDetectListener = AopTestUtils.getTargetObject(languageDetectListener)
|
ReflectionTestUtils.setField(papagoLanguageDetectionProvider, "restTemplate", RestTemplate(papagoRequests))
|
||||||
ReflectionTestUtils.setField(listener, "restTemplate", RestTemplate(papagoRequests))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -232,7 +249,7 @@ class CreatorCommunityLanguageDetectionCacheConcurrencyTest @Autowired construct
|
|||||||
val rows = requiresNewTransactionTemplate.execute {
|
val rows = requiresNewTransactionTemplate.execute {
|
||||||
languageDetectionResultRepository.findAll().filter {
|
languageDetectionResultRepository.findAll().filter {
|
||||||
it.sourceHash == SourceTextNormalizer.hash(content) &&
|
it.sourceHash == SourceTextNormalizer.hash(content) &&
|
||||||
it.provider == LanguageDetectionCacheService.DEFAULT_PROVIDER &&
|
it.provider == "papago" &&
|
||||||
it.normalizationVersion == SourceTextNormalizer.NORMALIZATION_VERSION
|
it.normalizationVersion == SourceTextNormalizer.NORMALIZATION_VERSION
|
||||||
}
|
}
|
||||||
}.orEmpty()
|
}.orEmpty()
|
||||||
|
|||||||
+23
-10
@@ -1,5 +1,9 @@
|
|||||||
package kr.co.vividnext.sodalive.content
|
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 kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
@@ -10,7 +14,8 @@ class LanguageDetectionCacheServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
fun shouldReuseCachedLanguageDetectionForSameNormalizedText() {
|
fun shouldReuseCachedLanguageDetectionForSameNormalizedText() {
|
||||||
val repository = Mockito.mock(LanguageDetectionResultRepository::class.java)
|
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")
|
val sourceHash = SourceTextNormalizer.hash("Hello world")
|
||||||
|
|
||||||
Mockito.`when`(
|
Mockito.`when`(
|
||||||
@@ -30,13 +35,9 @@ class LanguageDetectionCacheServiceTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
var providerCalls = 0
|
val detected = service.detectWithCache("Hello world")
|
||||||
val detected = service.detectWithCache("Hello world") {
|
|
||||||
providerCalls++
|
|
||||||
"ko"
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("en", detected)
|
assertEquals(DetectedLanguage("en", LanguageProviderId.PAPAGO), detected)
|
||||||
assertEquals(0, providerCalls)
|
assertEquals(0, providerCalls)
|
||||||
Mockito.verify(repository).findBySourceHashAndProviderAndNormalizationVersion(
|
Mockito.verify(repository).findBySourceHashAndProviderAndNormalizationVersion(
|
||||||
sourceHash = sourceHash,
|
sourceHash = sourceHash,
|
||||||
@@ -50,7 +51,7 @@ class LanguageDetectionCacheServiceTest {
|
|||||||
@DisplayName("최초 캐시 저장 경합에서는 저장된 승자의 감지 언어를 반환한다")
|
@DisplayName("최초 캐시 저장 경합에서는 저장된 승자의 감지 언어를 반환한다")
|
||||||
fun shouldReturnStoredWinnerAfterInitialCacheInsert() {
|
fun shouldReturnStoredWinnerAfterInitialCacheInsert() {
|
||||||
val repository = Mockito.mock(LanguageDetectionResultRepository::class.java)
|
val repository = Mockito.mock(LanguageDetectionResultRepository::class.java)
|
||||||
val service = LanguageDetectionCacheService(repository)
|
val service = service(repository) { "en" }
|
||||||
val sourceHash = SourceTextNormalizer.hash("Hello world")
|
val sourceHash = SourceTextNormalizer.hash("Hello world")
|
||||||
|
|
||||||
Mockito.`when`(
|
Mockito.`when`(
|
||||||
@@ -78,9 +79,9 @@ class LanguageDetectionCacheServiceTest {
|
|||||||
)
|
)
|
||||||
).thenReturn("ko")
|
).thenReturn("ko")
|
||||||
|
|
||||||
val detected = service.detectWithCache("Hello world") { "en" }
|
val detected = service.detectWithCache("Hello world")
|
||||||
|
|
||||||
assertEquals("ko", detected)
|
assertEquals(DetectedLanguage("ko", LanguageProviderId.PAPAGO), detected)
|
||||||
Mockito.verify(repository).findBySourceHashAndProviderAndNormalizationVersion(
|
Mockito.verify(repository).findBySourceHashAndProviderAndNormalizationVersion(
|
||||||
sourceHash = sourceHash,
|
sourceHash = sourceHash,
|
||||||
provider = "papago",
|
provider = "papago",
|
||||||
@@ -101,4 +102,16 @@ class LanguageDetectionCacheServiceTest {
|
|||||||
)
|
)
|
||||||
Mockito.verifyNoMoreInteractions(repository)
|
Mockito.verifyNoMoreInteractions(repository)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun service(repository: LanguageDetectionResultRepository, detect: () -> String?): LanguageDetectionCacheService {
|
||||||
|
val provider = object : LanguageDetectionProvider {
|
||||||
|
override val providerId = LanguageProviderId.PAPAGO
|
||||||
|
override fun detect(query: String) = detect()
|
||||||
|
}
|
||||||
|
return LanguageDetectionCacheService(
|
||||||
|
repository,
|
||||||
|
listOf(provider),
|
||||||
|
LanguageProviderProperties(listOf(LanguageProviderId.PAPAGO), listOf(LanguageProviderId.PAPAGO))
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+134
@@ -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<Arguments> = 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")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+127
@@ -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<Any>(apacheClient, "getHttpClientConnectionManager")
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
requireNotNull(ReflectionTestUtils.invokeMethod<Boolean>(connectionManager, "isClosed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+209
@@ -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<TranslateTextRequest>()
|
||||||
|
|
||||||
|
@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<String>(), 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<String>(), translate().translatedText)
|
||||||
|
Mockito.verifyNoInteractions(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("같은 언어이면 SDK를 호출하지 않는다")
|
||||||
|
fun shouldSkipSameLanguage() {
|
||||||
|
assertEquals(emptyList<String>(), 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<String>(), 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<TranslateTextRequest>(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+157
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
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
|
||||||
|
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(PAPAGO, failure)
|
||||||
|
val second = RecordingDetectionProvider(AWS) { "EN" }
|
||||||
|
Mockito.`when`(repository.findDetectedLanguageForUpdate(hash, "aws", "v1")).thenReturn("ko")
|
||||||
|
|
||||||
|
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", "aws", null, "v1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("보조 공급자의 유효 캐시가 있으면 첫 공급자도 호출하지 않는다")
|
||||||
|
fun shouldCheckAllConfiguredCachesBeforeCallingAnyProvider() {
|
||||||
|
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", AWS), service(listOf(first, second)).detectWithCache(query))
|
||||||
|
assertEquals(emptyList<String>(), first.queries + second.queries)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("정상 미지원 언어 감지는 성공이며 후속 공급자를 호출하지 않는다")
|
||||||
|
fun shouldKeepUnsupportedDetectionAsSuccessAndHonorOrder() {
|
||||||
|
val first = RecordingDetectionProvider(PAPAGO) { "ko" }
|
||||||
|
val second = RecordingDetectionProvider(AWS) { "FR" }
|
||||||
|
Mockito.`when`(repository.findDetectedLanguageForUpdate(hash, "aws", "v1")).thenReturn("fr")
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
DetectedLanguage("fr", AWS),
|
||||||
|
service(listOf(first, second), listOf(AWS, PAPAGO)).detectWithCache(query)
|
||||||
|
)
|
||||||
|
assertEquals(emptyList<String>(), first.queries)
|
||||||
|
assertEquals(listOf(query), second.queries)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("감지 전체 실패와 공백 입력은 null이고 공백은 저장소와 공급자를 호출하지 않는다")
|
||||||
|
fun shouldReturnNullAfterAllFailuresAndSkipBlankInput() {
|
||||||
|
val first = RecordingDetectionProvider(PAPAGO) { null }
|
||||||
|
val second = RecordingDetectionProvider(AWS) { 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(PAPAGO) { "ko" }
|
||||||
|
val second = RecordingDetectionProvider(AWS) { "en" }
|
||||||
|
val service = service(listOf(first, second))
|
||||||
|
val failure = IllegalStateException("database unavailable")
|
||||||
|
Mockito.`when`(repository.findBySourceHashAndProviderAndNormalizationVersion(hash, "papago", "v1"))
|
||||||
|
.thenThrow(failure)
|
||||||
|
assertSame(failure, assertThrows(IllegalStateException::class.java) { service.detectWithCache(query) })
|
||||||
|
assertEquals(emptyList<String>(), first.queries + second.queries)
|
||||||
|
|
||||||
|
Mockito.reset(repository)
|
||||||
|
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<String>(), second.queries)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun service(
|
||||||
|
providers: List<LanguageDetectionProvider>,
|
||||||
|
order: List<LanguageProviderId> = providers.map { it.providerId }
|
||||||
|
) = LanguageDetectionCacheService(repository, providers, LanguageProviderProperties(order, listOf(PAPAGO)))
|
||||||
|
}
|
||||||
|
|
||||||
|
class RecordingDetectionProvider(
|
||||||
|
override val providerId: LanguageProviderId,
|
||||||
|
private val response: () -> String?
|
||||||
|
) : LanguageDetectionProvider {
|
||||||
|
val queries = mutableListOf<String>()
|
||||||
|
|
||||||
|
override fun detect(query: String): String? {
|
||||||
|
queries.add(query)
|
||||||
|
return response()
|
||||||
|
}
|
||||||
|
}
|
||||||
+355
@@ -0,0 +1,355 @@
|
|||||||
|
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.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
|
||||||
|
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
|
||||||
|
|
||||||
|
@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"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||||
|
@Import(
|
||||||
|
QueryDslConfig::class,
|
||||||
|
AudioContentThemeQueryRepository::class,
|
||||||
|
LanguageDetectionCacheService::class,
|
||||||
|
TranslationCoordinator::class,
|
||||||
|
TranslationJobWorker::class,
|
||||||
|
TranslationReadModelMaterializer::class,
|
||||||
|
TranslationSourceExtractor::class,
|
||||||
|
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,
|
||||||
|
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 papagoTranslation: RecordingTranslationProvider,
|
||||||
|
private val comprehendClient: ComprehendClient,
|
||||||
|
private val translateClient: TranslateClient,
|
||||||
|
private val awsTranslation: AwsTranslationService,
|
||||||
|
private val coordinator: TranslationCoordinator,
|
||||||
|
transactionManager: PlatformTransactionManager
|
||||||
|
) {
|
||||||
|
private val transaction = TransactionTemplate(transactionManager)
|
||||||
|
|
||||||
|
@SpyBean
|
||||||
|
private lateinit var jobRepository: TranslationJobRepository
|
||||||
|
|
||||||
|
@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("동일 AWS 감지 prefix의 서로 다른 전체 원문을 H2에 구분 저장하고 무호출 캐시를 재사용한다")
|
||||||
|
fun shouldPersistSecondaryDetectionAndReuseCache() {
|
||||||
|
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("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 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())
|
||||||
|
val memory = transaction.execute {
|
||||||
|
memoryRepository.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
|
||||||
|
SourceTextNormalizer.hash(source),
|
||||||
|
"ko",
|
||||||
|
"en",
|
||||||
|
"aws",
|
||||||
|
"v1"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertNotNull(memory)
|
||||||
|
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("first tail", displayService.findDisplayContents(listOf(postId), "en")[postId]?.content)
|
||||||
|
assertEquals(true, displayService.findDisplayContents(listOf(postId), "en")[postId]?.hasValidTranslation)
|
||||||
|
|
||||||
|
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(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<String>(), 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<TranslateTextRequest>(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) {
|
||||||
|
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 languageProviderProperties() = LanguageProviderProperties()
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun papagoDetection() = RecordingDetectionProvider(PAPAGO) { throw IllegalStateException("sensitive provider body") }
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun comprehendClient(): ComprehendClient = Mockito.mock(ComprehendClient::class.java)
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun papagoTranslation() = RecordingTranslationProvider(PAPAGO) { TranslateResult(emptyList()) }
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun translateClient(): TranslateClient = Mockito.mock(TranslateClient::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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 다음 AWS이며 실제 Papago adapter 식별자는 유지한다")
|
||||||
|
fun shouldUsePapagoEnumForProductionDefaultsAndAdapters() {
|
||||||
|
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)
|
||||||
|
assertEquals("nmt-v1", translator.providerVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
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
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class LanguageProviderPropertiesTest {
|
||||||
|
@Test
|
||||||
|
@DisplayName("기능별 목록을 서로 독립된 설정 순서로 정렬한다")
|
||||||
|
fun shouldOrderCapabilitiesIndependently() {
|
||||||
|
val properties = LanguageProviderProperties(listOf(AWS, PAPAGO), listOf(PAPAGO, AWS))
|
||||||
|
assertEquals(
|
||||||
|
listOf(AWS, PAPAGO),
|
||||||
|
properties.orderedProviders(properties.detectionOrder, listOf(PAPAGO, AWS)) { it }
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf(PAPAGO, AWS),
|
||||||
|
properties.orderedProviders(properties.translationOrder, listOf(AWS, PAPAGO)) { it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("빈 순서와 중복 순서와 미등록 AWS 공급자는 초기화에서 거부한다")
|
||||||
|
fun shouldRejectInvalidOrders() {
|
||||||
|
val properties = LanguageProviderProperties()
|
||||||
|
listOf(emptyList(), listOf(PAPAGO, PAPAGO), listOf(AWS)).forEach { order ->
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
properties.orderedProviders(order, listOf(PAPAGO)) { it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("같은 기능 안에서 선택되지 않은 공급자도 중복 등록을 거부한다")
|
||||||
|
fun shouldRejectDuplicateRegisteredIdsEvenWhenNotSelected() {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
LanguageProviderProperties().orderedProviders(listOf(PAPAGO), listOf(PAPAGO, AWS, AWS)) { it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+279
@@ -0,0 +1,279 @@
|
|||||||
|
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.assertAll
|
||||||
|
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
|
||||||
|
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 기본 순서는 바뀌지 않는다")
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Spring 테스트 bean의 감지 목록만 바꾸어도 번역 순서는 유지한다")
|
||||||
|
fun shouldInjectIndependentCapabilityOrders() {
|
||||||
|
listOf(
|
||||||
|
listOf(PAPAGO, AWS) to PAPAGO,
|
||||||
|
listOf(AWS, PAPAGO) to AWS
|
||||||
|
).forEach { (order, expected) ->
|
||||||
|
runner.withBean(
|
||||||
|
LanguageProviderProperties::class.java,
|
||||||
|
Supplier { LanguageProviderProperties(order, listOf(PAPAGO, AWS)) }
|
||||||
|
).run { context ->
|
||||||
|
assertNull(context.startupFailure)
|
||||||
|
val detector = context.getBean(LanguageDetectionCacheService::class.java)
|
||||||
|
assertEquals(expected, detector.detectWithCache("source")?.providerId)
|
||||||
|
assertEquals(
|
||||||
|
PAPAGO,
|
||||||
|
context.getBean(TranslationCoordinator::class.java)
|
||||||
|
.translate(TranslateRequest(listOf("source"), "ko", "en")).providerId
|
||||||
|
)
|
||||||
|
val secondary = context.getBean("secondaryDetection", RecordingDetectionProvider::class.java)
|
||||||
|
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, AWS), listOf(AWS, PAPAGO)) }
|
||||||
|
).run { context ->
|
||||||
|
assertNull(context.startupFailure)
|
||||||
|
assertEquals(
|
||||||
|
PAPAGO,
|
||||||
|
context.getBean(LanguageDetectionCacheService::class.java).detectWithCache("source")?.providerId
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
AWS,
|
||||||
|
context.getBean(TranslationCoordinator::class.java)
|
||||||
|
.translate(TranslateRequest(listOf("source"), "ko", "en")).providerId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Spring 기본 생성자는 두 기능 모두 PAPAGO 다음 AWS를 등록하고 첫 PAPAGO 성공 뒤 멈춘다")
|
||||||
|
fun shouldDefaultToPapago() {
|
||||||
|
defaultRunner.run { context ->
|
||||||
|
assertNull(context.startupFailure)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Spring 테스트 bean의 빈 목록과 중복 순서와 해당 기능의 AWS 구현체 부재는 시작 시 실패한다")
|
||||||
|
fun shouldRejectInvalidOrdersAtStartup() {
|
||||||
|
listOf(emptyList(), listOf(PAPAGO, PAPAGO), listOf(AWS)).forEach { order ->
|
||||||
|
listOf(
|
||||||
|
LanguageProviderProperties(order, listOf(PAPAGO)),
|
||||||
|
LanguageProviderProperties(listOf(PAPAGO), order)
|
||||||
|
).forEach { properties ->
|
||||||
|
papagoRunner.withBean(LanguageProviderProperties::class.java, Supplier { properties }).run { context ->
|
||||||
|
assertNotNull(context.startupFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestConfiguration
|
||||||
|
@Import(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", "aws").forEach { provider ->
|
||||||
|
Mockito.`when`(repository.findDetectedLanguageForUpdate(SourceTextNormalizer.hash("source"), provider, "v1"))
|
||||||
|
.thenReturn("ko")
|
||||||
|
}
|
||||||
|
return repository
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun papagoDetection() = RecordingDetectionProvider(PAPAGO) { "ko" }
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun papagoTranslation() = RecordingTranslationProvider(PAPAGO)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestConfiguration
|
||||||
|
class AwsProviders {
|
||||||
|
@Bean
|
||||||
|
fun secondaryDetection() = RecordingDetectionProvider(AWS) { "ko" }
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -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<String, String>().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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -61,6 +61,15 @@ class PapagoTranslationServiceTest @Autowired constructor(
|
|||||||
assertEquals(listOf("translated"), translate(source).translatedText)
|
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
|
@Test
|
||||||
@DisplayName("5,001자 원문은 두 번 요청하고 번역을 구분자 없이 결합한다")
|
@DisplayName("5,001자 원문은 두 번 요청하고 번역을 구분자 없이 결합한다")
|
||||||
fun shouldSplit5001UnitsAndJoinTranslationsWithoutSeparator() {
|
fun shouldSplit5001UnitsAndJoinTranslationsWithoutSeparator() {
|
||||||
|
|||||||
+168
@@ -0,0 +1,168 @@
|
|||||||
|
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
|
||||||
|
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(PAPAGO)
|
||||||
|
val second = RecordingTranslationProvider(AWS)
|
||||||
|
val coordinator = coordinator(listOf(first, second), listOf(AWS, PAPAGO))
|
||||||
|
|
||||||
|
val result = coordinator.translate(request)
|
||||||
|
|
||||||
|
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<TranslateRequest>(), 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(PAPAGO, failure)
|
||||||
|
val second = RecordingTranslationProvider(AWS)
|
||||||
|
val coordinator = coordinator(listOf(first, second))
|
||||||
|
|
||||||
|
assertEquals(AWS, coordinator.translate(request).providerId)
|
||||||
|
assertEquals(listOf(request), first.requests)
|
||||||
|
assertEquals(listOf(request), second.requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("전체 공급자 실패는 원문이나 원시 예외 원인 없이 고정 메시지를 반환한다")
|
||||||
|
fun shouldSanitizeAllProviderFailures() {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(PAPAGO)
|
||||||
|
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<TranslateRequest>(), provider.requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("모든 설정 공급자 캐시를 호출보다 먼저 조회하고 순서상 첫 유효 캐시를 선택한다")
|
||||||
|
fun shouldFindSecondaryCacheAndPreferConfiguredFirstValidCache() {
|
||||||
|
val first = RecordingTranslationProvider(PAPAGO)
|
||||||
|
val second = RecordingTranslationProvider(AWS)
|
||||||
|
val coordinator = coordinator(listOf(first, second))
|
||||||
|
val secondary = memory("aws", "cached secondary")
|
||||||
|
givenMemory("aws", secondary)
|
||||||
|
|
||||||
|
assertSame(secondary, coordinator.findCached("hash", "ko", "en"))
|
||||||
|
givenMemory("papago", memory("papago", " "))
|
||||||
|
assertSame(secondary, coordinator.findCached("hash", "ko", "en"))
|
||||||
|
val primary = memory("papago", "cached primary")
|
||||||
|
givenMemory("papago", primary)
|
||||||
|
assertSame(primary, coordinator.findCached("hash", "ko", "en"))
|
||||||
|
assertEquals(emptyList<TranslateRequest>(), first.requests + second.requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("캐시 저장소 오류는 공급자 실패로 숨기지 않는다")
|
||||||
|
fun shouldPropagateCacheRepositoryFailure() {
|
||||||
|
val provider = RecordingTranslationProvider(PAPAGO)
|
||||||
|
val failure = IllegalStateException("database unavailable")
|
||||||
|
Mockito.`when`(
|
||||||
|
repository.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
|
||||||
|
"hash",
|
||||||
|
"ko",
|
||||||
|
"en",
|
||||||
|
"papago",
|
||||||
|
"v1"
|
||||||
|
)
|
||||||
|
).thenThrow(failure)
|
||||||
|
|
||||||
|
assertSame(
|
||||||
|
failure,
|
||||||
|
assertThrows(IllegalStateException::class.java) { coordinator(listOf(provider)).findCached("hash", "ko", "en") }
|
||||||
|
)
|
||||||
|
assertEquals(emptyList<TranslateRequest>(), provider.requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun coordinator(
|
||||||
|
providers: List<TranslationProvider>,
|
||||||
|
order: List<LanguageProviderId> = providers.map { it.providerId }
|
||||||
|
) = TranslationCoordinator(providers, LanguageProviderProperties(listOf(PAPAGO), order), 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 providerId: LanguageProviderId,
|
||||||
|
private val response: (TranslateRequest) -> TranslateResult = { request ->
|
||||||
|
TranslateResult(request.texts.indices.map { "$providerId:$it" })
|
||||||
|
}
|
||||||
|
) : TranslationProvider {
|
||||||
|
override val providerVersion = "$providerId-v1"
|
||||||
|
val requests = mutableListOf<TranslateRequest>()
|
||||||
|
|
||||||
|
override fun translate(request: TranslateRequest): TranslateResult {
|
||||||
|
requests.add(request)
|
||||||
|
return response(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-9
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.i18n.translation
|
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.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
@@ -30,7 +31,11 @@ class TranslationJobWorkerTest {
|
|||||||
val worker = TranslationJobWorker(
|
val worker = TranslationJobWorker(
|
||||||
translationJobRepository = jobRepository,
|
translationJobRepository = jobRepository,
|
||||||
translationMemoryRepository = memoryRepository,
|
translationMemoryRepository = memoryRepository,
|
||||||
translationProvider = provider,
|
translationCoordinator = TranslationCoordinator(
|
||||||
|
listOf(provider),
|
||||||
|
LanguageProviderProperties(listOf(PAPAGO), listOf(PAPAGO)),
|
||||||
|
memoryRepository
|
||||||
|
),
|
||||||
materializer = materializer,
|
materializer = materializer,
|
||||||
transactionManager = TestTransactionManager()
|
transactionManager = TestTransactionManager()
|
||||||
)
|
)
|
||||||
@@ -54,7 +59,11 @@ class TranslationJobWorkerTest {
|
|||||||
val worker = TranslationJobWorker(
|
val worker = TranslationJobWorker(
|
||||||
translationJobRepository = jobRepository,
|
translationJobRepository = jobRepository,
|
||||||
translationMemoryRepository = memoryRepository,
|
translationMemoryRepository = memoryRepository,
|
||||||
translationProvider = provider,
|
translationCoordinator = TranslationCoordinator(
|
||||||
|
listOf(provider),
|
||||||
|
LanguageProviderProperties(listOf(PAPAGO), listOf(PAPAGO)),
|
||||||
|
memoryRepository
|
||||||
|
),
|
||||||
materializer = materializer,
|
materializer = materializer,
|
||||||
transactionManager = TestTransactionManager()
|
transactionManager = TestTransactionManager()
|
||||||
)
|
)
|
||||||
@@ -84,7 +93,11 @@ class TranslationJobWorkerTest {
|
|||||||
val worker = TranslationJobWorker(
|
val worker = TranslationJobWorker(
|
||||||
translationJobRepository = jobRepository,
|
translationJobRepository = jobRepository,
|
||||||
translationMemoryRepository = memoryRepository,
|
translationMemoryRepository = memoryRepository,
|
||||||
translationProvider = provider,
|
translationCoordinator = TranslationCoordinator(
|
||||||
|
listOf(provider),
|
||||||
|
LanguageProviderProperties(listOf(PAPAGO), listOf(PAPAGO)),
|
||||||
|
memoryRepository
|
||||||
|
),
|
||||||
materializer = materializer,
|
materializer = materializer,
|
||||||
transactionManager = TestTransactionManager()
|
transactionManager = TestTransactionManager()
|
||||||
)
|
)
|
||||||
@@ -129,7 +142,11 @@ class TranslationJobWorkerTest {
|
|||||||
val worker = TranslationJobWorker(
|
val worker = TranslationJobWorker(
|
||||||
translationJobRepository = jobRepository,
|
translationJobRepository = jobRepository,
|
||||||
translationMemoryRepository = memoryRepository,
|
translationMemoryRepository = memoryRepository,
|
||||||
translationProvider = provider,
|
translationCoordinator = TranslationCoordinator(
|
||||||
|
listOf(provider),
|
||||||
|
LanguageProviderProperties(listOf(PAPAGO), listOf(PAPAGO)),
|
||||||
|
memoryRepository
|
||||||
|
),
|
||||||
materializer = materializer,
|
materializer = materializer,
|
||||||
transactionManager = TestTransactionManager()
|
transactionManager = TestTransactionManager()
|
||||||
)
|
)
|
||||||
@@ -165,7 +182,11 @@ class TranslationJobWorkerTest {
|
|||||||
val worker = TranslationJobWorker(
|
val worker = TranslationJobWorker(
|
||||||
translationJobRepository = jobRepository,
|
translationJobRepository = jobRepository,
|
||||||
translationMemoryRepository = memoryRepository,
|
translationMemoryRepository = memoryRepository,
|
||||||
translationProvider = provider,
|
translationCoordinator = TranslationCoordinator(
|
||||||
|
listOf(provider),
|
||||||
|
LanguageProviderProperties(listOf(PAPAGO), listOf(PAPAGO)),
|
||||||
|
memoryRepository
|
||||||
|
),
|
||||||
materializer = materializer,
|
materializer = materializer,
|
||||||
transactionManager = TestTransactionManager()
|
transactionManager = TestTransactionManager()
|
||||||
)
|
)
|
||||||
@@ -198,7 +219,11 @@ class TranslationJobWorkerTest {
|
|||||||
val worker = TranslationJobWorker(
|
val worker = TranslationJobWorker(
|
||||||
translationJobRepository = jobRepository,
|
translationJobRepository = jobRepository,
|
||||||
translationMemoryRepository = memoryRepository,
|
translationMemoryRepository = memoryRepository,
|
||||||
translationProvider = provider,
|
translationCoordinator = TranslationCoordinator(
|
||||||
|
listOf(provider),
|
||||||
|
LanguageProviderProperties(listOf(PAPAGO), listOf(PAPAGO)),
|
||||||
|
memoryRepository
|
||||||
|
),
|
||||||
materializer = materializer,
|
materializer = materializer,
|
||||||
transactionManager = TestTransactionManager()
|
transactionManager = TestTransactionManager()
|
||||||
)
|
)
|
||||||
@@ -213,7 +238,7 @@ class TranslationJobWorkerTest {
|
|||||||
|
|
||||||
assertEquals(TranslationJobStatus.PENDING, job.status)
|
assertEquals(TranslationJobStatus.PENDING, job.status)
|
||||||
assertEquals(1, job.retryCount)
|
assertEquals(1, job.retryCount)
|
||||||
assertEquals("provider down", job.lastErrorMessage)
|
assertEquals("all translation providers failed", job.lastErrorMessage)
|
||||||
assertTrue(job.nextRetryAt.isAfter(beforeRetryAt))
|
assertTrue(job.nextRetryAt.isAfter(beforeRetryAt))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +261,7 @@ class TranslationJobWorkerTest {
|
|||||||
|
|
||||||
private fun successfulProvider(): TranslationProvider {
|
private fun successfulProvider(): TranslationProvider {
|
||||||
return object : TranslationProvider {
|
return object : TranslationProvider {
|
||||||
override val providerName: String = "papago"
|
override val providerId: LanguageProviderId = LanguageProviderId.PAPAGO
|
||||||
override val providerVersion: String = "nmt-v1"
|
override val providerVersion: String = "nmt-v1"
|
||||||
|
|
||||||
override fun translate(request: TranslateRequest): TranslateResult {
|
override fun translate(request: TranslateRequest): TranslateResult {
|
||||||
@@ -247,7 +272,7 @@ class TranslationJobWorkerTest {
|
|||||||
|
|
||||||
private fun failingProvider(): TranslationProvider {
|
private fun failingProvider(): TranslationProvider {
|
||||||
return object : TranslationProvider {
|
return object : TranslationProvider {
|
||||||
override val providerName: String = "papago"
|
override val providerId: LanguageProviderId = LanguageProviderId.PAPAGO
|
||||||
override val providerVersion: String = "nmt-v1"
|
override val providerVersion: String = "nmt-v1"
|
||||||
|
|
||||||
override fun translate(request: TranslateRequest): TranslateResult {
|
override fun translate(request: TranslateRequest): TranslateResult {
|
||||||
|
|||||||
+9
-1
@@ -19,9 +19,13 @@ import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslatio
|
|||||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
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.CreatorCommunity
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
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.LanguageTranslationTargetType
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
|
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.TranslationJob
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobRepository
|
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobRepository
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobScheduler
|
import kr.co.vividnext.sodalive.i18n.translation.TranslationJobScheduler
|
||||||
@@ -668,7 +672,11 @@ class CreatorCommunityTranslationServiceTest @Autowired constructor(
|
|||||||
): TranslationReadModelMaterializer {
|
): TranslationReadModelMaterializer {
|
||||||
return TranslationReadModelMaterializer(
|
return TranslationReadModelMaterializer(
|
||||||
sourceExtractor = sourceExtractor,
|
sourceExtractor = sourceExtractor,
|
||||||
translationMemoryRepository = memoryRepository,
|
translationCoordinator = TranslationCoordinator(
|
||||||
|
listOf(PapagoTranslationService("test-id", "test-secret")),
|
||||||
|
LanguageProviderProperties(listOf(PAPAGO), listOf(PAPAGO)),
|
||||||
|
memoryRepository
|
||||||
|
),
|
||||||
contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java),
|
contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java),
|
||||||
aiCharacterTranslationRepository = Mockito.mock(AiCharacterTranslationRepository::class.java),
|
aiCharacterTranslationRepository = Mockito.mock(AiCharacterTranslationRepository::class.java),
|
||||||
contentThemeTranslationRepository = Mockito.mock(ContentThemeTranslationRepository::class.java),
|
contentThemeTranslationRepository = Mockito.mock(ContentThemeTranslationRepository::class.java),
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ cloud:
|
|||||||
|
|
||||||
aws:
|
aws:
|
||||||
credentials:
|
credentials:
|
||||||
accessKey: ""
|
accessKey: "test-access-key"
|
||||||
secretKey: ""
|
secretKey: "test-secret-key"
|
||||||
s3:
|
s3:
|
||||||
contentBucket: ""
|
contentBucket: ""
|
||||||
bucket: ""
|
bucket: ""
|
||||||
|
|||||||
Reference in New Issue
Block a user