AWS 번역 공급자 추가 #469

Merged
klaus merged 12 commits from test into main 2026-11-10 05:25:25 +00:00
4 changed files with 573 additions and 0 deletions
Showing only changes of commit f3a558f659 - Show all commits
@@ -0,0 +1,341 @@
# 언어 감지·번역 공급자 공통화 구현 계획
| 항목 | 내용 |
|---|---|
| 작성일 | 2026-11-09 |
| 상태 | 구현·검증·독립 리뷰·리소스 정리 완료 |
| 요구사항 | [prd.md](prd.md), LP-001~010 및 확정 구현 계약 |
| 활성 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/추출 라이브러리/로그가 함께 제거됐으며 필요한 증거는 이 문서에 보존했다.
작업 임시 노트도 문서에 이관 후 삭제한다. 다른 작업의 파일·프로세스·캐시에는 조치하지 않았다.
@@ -0,0 +1,81 @@
# 언어 감지·번역 공급자 공통화 PRD
| 항목 | 내용 |
|---|---|
| 작성일 | 2026-11-09 |
| 상태 | 구현·기능 범위 검증·독립 리뷰 완료 |
| 승인 범위 | 감지·번역 별도 인터페이스, 공통 공급자 선택·실패 전환, 기능별 순서 설정 |
| 계획 | [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 | 빈/중복/알 수 없는 공급자 순서 설정은 초기화 단계에서 명확히 거부한다. 새 구현체 추가 시 기존 선택 코드나 공급자 enum 수정이 필요 없다. |
| LP-009 | 기존 파파고 캐시·번역 데이터 및 분할 번역 동작과 감지 캐시 동시성 승자 반환을 보존한다. |
| LP-010 | 추가 방법과 기능별 순서 설정 예시, 실제 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로 보완하고 그 한계를 기록한다.
## 확정 구현 계약
- 기존 `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의 공급자를 영구 고정하는 새 모델은 만들지 않는다.
@@ -0,0 +1,106 @@
# 언어 감지·번역 공급자 확장 가이드
## 현재 실행 구조
운영 구현체는 `PapagoLanguageDetectionProvider`와 `PapagoTranslationService`뿐이다.
AWS 감지·번역 adapter, 해당 SDK 및 전용 인증 설정은 추가하지 않았다. 지금 `aws`를 순서에 넣으면 시작에 실패한다.
감지와 번역은 별도 capability다. 감지만 또는 번역만 제공하는 공급자도 등록할 수 있다.
두 capability에서 같은 `providerName`을 쓰는 것은 가능하지만, 한 capability 안의 이름 중복은 시작 시 거부한다.
| 경계 | 계약 |
|---|---|
| `LanguageDetectionProvider` | `providerName: String`, `detect(query: String): String?` |
| `LanguageDetectionCacheService` | `detectWithCache(query: String): DetectedLanguage?` |
| `DetectedLanguage` | `detectedLanguage: String`, `providerName: String` |
| `TranslationProvider` | `providerName: String`, `providerVersion: String`, `translate(TranslateRequest): TranslateResult` |
| `TranslationCoordinator` | `findCached(sourceHash, sourceLanguage, targetLanguage): TranslationMemory?`, `translate(TranslateRequest): ProviderTranslationResult` |
| `ProviderTranslationResult` | `result: TranslateResult`, `providerName: String`, `providerVersion: String` |
인터페이스와 DTO는 `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/`에 있다.
감지 coordinator는 기존 `content/LanguageDetectionCacheService.kt`이며 별도 vendor가 아니다.
`TranslationCoordinator`도 `TranslationProvider`를 구현하지 않는다.
## 새 공급자 추가 순서
1. 필요한 capability 인터페이스를 구현하고 Spring `@Component`/`@Service` 또는 `@Bean`으로 등록한다.
`providerName`은 capability 내 유일하고 재배포 후에도 안정적인 값이어야 한다.
2. 공급자의 HTTP/SDK 및 인증은 adapter 안에 둔다. 외부 응답 형식과 빈 결과를 검증하고,
원문·인증정보·응답 본문·예외 cause를 로그로 출력하지 않는다. 번역은 입력별 결과를 같은 순서로 반환한다.
3. 구현한 capability의 설정 순서에만 이름을 넣고 재시작/재배포한다. `@Primary`, selector 분기, enum 등록은 필요 없다.
4. 아래 테스트 경계로 순서·실패 전환·실제 provider 저장·무호출 캐시 재사용·materializer 표시를 검증한다.
콘텐츠·댓글·커뮤니티 호출자, worker, materializer, scheduler에 공급자 분기를 추가하지 않는다.
기존 두 coordinator는 주입된 구현체 목록과 설정만 사용한다. 공급자마다 다른 분할·요청 크기 제한은 adapter 책임이다.
Papago의 5,000 UTF-16 단위 분할과 서로게이트 쌍 보존은 `PapagoTranslationService`에 남아 있다.
ko/en/ja 서비스 정책은 `TranslationLanguages`에 있으며 공급자를 추가해도 자동 확장하지 않는다.
## 기능별 독립 설정
`LanguageProviderProperties`는 component scan으로 등록되며 생성자 `@Value`로 문자열을 주입받는다.
별도 `@EnableConfigurationProperties` 등록은 필요 없다. 쉼표로 나누고 각 이름의 앞뒤 공백을 제거한다.
기본값은 두 기능 모두 `papago`다.
```yaml
sodalive:
language-providers:
detection-order: ${LANGUAGE_PROVIDERS_DETECTION_ORDER:papago}
translation-order: ${LANGUAGE_PROVIDERS_TRANSLATION_ORDER:papago}
```
다음은 **secondary 구현체를 두 capability에 등록한 뒤에만** 적용할 수 있는 예시다.
현재 `secondary` 구현체는 테스트에만 있다.
```bash
export LANGUAGE_PROVIDERS_DETECTION_ORDER=secondary,papago
export LANGUAGE_PROVIDERS_TRANSLATION_ORDER=papago,secondary
```
감지만 순서를 바꾸어도 번역 순서는 바뀌지 않는다. 반대로 번역만 바꿀 수도 있다.
Spring property를 직접 지정할 때는 `sodalive.language-providers.detection-order`와
`sodalive.language-providers.translation-order`를 각각 설정한다.
빈 값·빈 항목·중복 이름·미등록 이름·capability 안의 중복 bean 이름표(`providerName`)는 초기화에서 거부한다.
공급자가 번역에만 등록됐다면 그 이름을 감지 순서에 넣을 수 없다.
실행 중 설정 변경은 지원하지 않는다. AWS 구현체 등록 전 `papago,aws`는 사용할 수 없다.
## 캐시와 오류 계약
감지는 정규화/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` | 실제 `@Value` binding, 두 test bean, 기능별 독립 순서, 기본값과 잘못된 설정의 시작 실패 |
| `LanguageProviderPropertiesTest` | 순서 검증 및 선택되지 않은 bean을 포함한 capability 내 중복 이름 거부 |
| `LanguageDetectionProviderTest` / `TranslationCoordinatorTest` | 전체 요청 폴백, 첫 성공 중단, cache-first, 안전한 전체 실패, DB 오류 전파 |
| `PapagoLanguageDetectionProviderTest` / `PapagoTranslationServiceTest` | 제어된 HTTP form/header/response와 기존 17개 chunk 회귀 |
| `LanguageProviderCacheEndToEndTest` | 실제 H2의 secondary 감지/번역 귀속, providerVersion, 두 번째 요청 무호출, worker와 materializer의 실제 표시 |
H2 버전은 MySQL `FOR UPDATE SKIP LOCKED`를 지원하지 않으므로 새 H2 테스트는 claim 조회 한 곳만
test spy로 대체한다. 나머지 repository 저장·별도 트랜잭션 재조회·materialization은 실제 H2다.
이 테스트가 운영 MySQL claim 동시성을 검증한다는 뜻은 아니다. 기존 감지 cache 경합·revision 테스트는 별도로 유지한다.
```bash
./gradlew --no-daemon --offline test --tests 'kr.co.vividnext.sodalive.i18n.translation.*' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest'
```
전체 실행 명령, 결과와 미검증 범위는 [plan-task.md](plan-task.md)의 실행 증거를 따른다.
@@ -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 동시성을 검증했다고 주장하지 않는다. 이 한계는 이번 범위의 승인과 구분한다.