AWS 번역 공급자 추가 #469

Merged
klaus merged 12 commits from test into main 2026-11-10 05:25:25 +00:00
50 changed files with 3894 additions and 238 deletions
+6
View File
@@ -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`로 실행한다.
+111
View File
@@ -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 한계가 있다.
+14
View File
@@ -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
} }
} }
@@ -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
}
}
@@ -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)
}
@@ -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) }
}
}
@@ -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?)
@@ -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,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")
}
}
@@ -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
@@ -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"
}
} }
@@ -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
@@ -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) }
} }
} }
@@ -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()
@@ -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))
)
}
} }
@@ -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")
)
}
}
@@ -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"))
}
}
}
@@ -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)
}
}
@@ -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))
}
}
@@ -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()
}
}
@@ -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)
}
}
@@ -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 }
}
}
}
@@ -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)
}
}
@@ -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()
}
}
@@ -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() {
@@ -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)
}
}
@@ -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 {
@@ -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),
+2 -2
View File
@@ -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: ""