Files
sodalive-backend-spring-boot/docs/20260506_번역언어감지효율화구상.md

339 lines
25 KiB
Markdown

# 번역/언어감지 효율화 구상
## 배경
- 현재 구조는 도메인별 번역 테이블과 이벤트 기반 Papago 호출이 혼재되어 있다.
- 생성/수정 이벤트 번역과 조회 시 캐시 미스 번역이 동시에 존재해 동일 원문이 중복 번역될 수 있다.
- 번역 저장 로직은 `ContentTranslation`, `SeriesTranslation`, `AiCharacterTranslation`, `OriginalWorkTranslation`, `CategoryTranslation` 등으로 분산되어 있으며, 조회/upsert 패턴이 반복된다.
- Papago 호출은 텍스트와 대상 locale 단위로 순차 실행되어 대상 하나가 여러 HTTP 요청으로 확장된다.
## 목표
- Papago 호출 수와 비용을 줄인다.
- 조회 API에서 Papago 호출로 인한 응답 지연을 줄인다.
- 번역 저장/조회 로직의 중복을 줄이고, 향후 번역 provider 교체가 가능하도록 한다.
- 기존 API 응답 스키마는 유지하고, DB 변경은 단계적으로 적용한다.
## 구현 항목
- [x] `TranslationProvider` 인터페이스와 Papago provider 구현을 분리한다.
- [x] `translation_memory`, `translation_job`, `language_detection_result` 테이블을 추가한다.
- [x] 조회 fallback의 직접 Papago 호출을 제거하고 누락 번역 job 예약으로 전환한다.
- [x] 생성/수정 이벤트 번역을 `translation_job` 기반 워커 처리로 전환한다.
- [x] `TranslationMemory` 결과를 기존 JSON/scalar read model로 materialize한다.
- [ ] 목록 조회 병목 측정 후 필요한 도메인에만 hot column을 추가한다.
## 접근안 비교
### 접근안 A: 현재 구조 유지 + 중복 방지 보강
- 도메인별 번역 테이블은 유지한다.
- 번역 전 기존 번역 조회와 unique 제약을 강화한다.
- 조회 fallback에서 번역 생성 대신 번역 작업만 예약하도록 바꾼다.
장점:
- 변경 범위가 가장 작다.
- 기존 코드와 데이터 마이그레이션 위험이 낮다.
단점:
- 도메인별 중복 저장/로직 구조는 그대로 남는다.
- 원문이 같아도 도메인별로 재번역될 수 있다.
### 접근안 B: 원문 해시 기반 Translation Memory + 비동기 작업 큐 추가
- 원문 텍스트를 정규화하고 `source_hash`로 중복을 제거한다.
- `translation_memory`에 원문/언어쌍/번역 결과를 저장한다.
- 도메인 엔티티는 `translation_job`을 생성하고, 워커가 Papago를 호출해 결과를 저장한다.
- 조회 API는 Papago를 직접 호출하지 않고 기존 번역을 반환하거나 미번역 상태를 유지한다.
장점:
- 동일 원문 재번역을 줄일 수 있다.
- 조회 응답 지연을 안정적으로 제거할 수 있다.
- 기존 도메인별 번역 테이블을 당장 제거하지 않아도 단계 적용이 가능하다.
단점:
- 작업 큐, 상태 관리, 재시도 정책이 필요하다.
- 생성 직후 번역이 아직 없을 수 있으므로 UX/응답 정책을 정해야 한다.
### 접근안 C: 도메인별 번역 테이블을 공통 번역 저장소로 통합
- 모든 번역 결과를 `translation_entry` 같은 공통 테이블에 저장한다.
- `resource_type`, `resource_id`, `field_key`, `locale`, `translated_text` 또는 JSON payload로 도메인별 필드를 표현한다.
- 기존 도메인별 번역 테이블은 읽기 호환 기간 후 제거한다.
장점:
- 저장/조회/upsert 로직을 크게 단순화할 수 있다.
- 새 번역 대상 추가 비용이 줄어든다.
단점:
- 마이그레이션 위험이 가장 크다.
- 도메인별 payload 검증과 타입 안정성이 약해질 수 있다.
- 기존 조회 쿼리와 응답 조립 로직 영향 범위가 넓다.
## 권장 방향
- 최종 구현방식은 접근안 B를 중심으로 하되, 조회 성능을 위해 도메인별 read model을 유지하는 하이브리드 구조로 한다.
- 기존 번역 데이터를 배제할 수 있어도 모든 번역 결과를 정규화 row만으로 저장하는 방식은 채택하지 않는다.
- 이유는 Papago 호출 수 절감은 원문 해시 기반 `TranslationMemory`가 담당하고, API 조회 성능은 `entityId + locale` 단위 read model이 담당하는 분리가 가장 효율적이기 때문이다.
- 2차로 운영 안정화 후 도메인별 read model의 중복을 줄이되, 조회 경로가 복잡해지는 전면 통합은 실제 병목이 확인될 때만 진행한다.
## 최종 구현방식
### 설계 원칙
- 번역 원장은 정규화한다.
- 조회 결과는 도메인별로 materialize한다.
- Papago 호출은 사용자 요청 스레드에서 수행하지 않는다.
- JSON payload는 원장이 아니라 조회 최적화용 read model로만 사용한다.
### 저장 구조
#### `translation_memory`
- 동일 원문을 반복 번역하지 않기 위한 공통 번역 캐시이다.
- 원문은 의미가 바뀌지 않는 선에서 공백, 줄바꿈, Unicode 표현을 정규화한다.
- 주요 컬럼:
- `id`
- `source_hash`
- `source_text`
- `source_language`
- `target_language`
- `translated_text`
- `provider`
- `provider_version`
- `normalization_version`
- `created_at`
- `updated_at`
- unique 제약:
- `(source_hash, source_language, target_language, provider, normalization_version)`
- 역할:
- Papago 호출 수 절감의 핵심 원장이다.
- 도메인이 달라도 원문과 언어쌍이 같으면 같은 번역을 재사용한다.
#### `translation_job`
- 번역 실행 상태를 관리하는 durable job 테이블이다.
- 현재 `@Async` 이벤트만으로 처리하던 작업을 명시적인 상태 모델로 바꾼다.
- 주요 컬럼:
- `id`
- `resource_type`
- `resource_id`
- `field_key`
- `source_hash`
- `source_language`
- `target_language`
- `status`
- `retry_count`
- `last_error_message`
- `next_retry_at`
- `created_at`
- `updated_at`
- 중복 방지:
- 활성 상태 기준 `(resource_type, resource_id, field_key, target_language, source_hash)` 중복 생성을 막는다.
- 역할:
- 중복 작업 방지, 재시도, 실패 추적, 운영자 재처리를 담당한다.
#### `language_detection_result`
- 언어 감지 결과를 원본 엔티티에만 저장하지 않고 별도 캐시/이력으로 관리한다.
- 주요 컬럼:
- `id`
- `source_hash`
- `source_text_sample`
- `detected_language`
- `provider`
- `confidence`
- `normalization_version`
- `created_at`
- 역할:
- 짧은 문구나 반복 문구의 감지 호출을 줄인다.
- 원본 엔티티의 `languageCode`는 기존 호환 필드로 유지할 수 있다.
#### 도메인별 번역 read model
- `ContentTranslation`, `SeriesTranslation`, `AiCharacterTranslation`, `OriginalWorkTranslation`처럼 여러 필드가 묶인 응답은 JSON payload read model을 유지한다.
- `CategoryTranslation`, `ContentThemeTranslation`, `SeriesGenreTranslation`처럼 단일 문자열만 필요한 대상은 scalar column을 유지한다.
- JSON payload는 `translation_memory`를 조립한 결과를 저장하는 materialized cache로 본다.
- provider, retry, source hash 같은 실행/원장 메타데이터는 JSON payload에 넣지 않는다.
### JSON 유지 여부
- JSON payload 저장은 유지한다.
- 단, JSON을 번역의 최종 원장으로 보지 않고 API 응답을 빠르게 만들기 위한 read model로 격하한다.
- 현재 조회 패턴은 JSON 내부 검색이 아니라 `resourceId + locale`로 1-row 조인 후 payload를 읽는 방식이므로, 정규화 row를 매번 pivot하는 것보다 조회 경로가 단순하다.
- 목록 API에서 제목이나 이름 하나만 자주 읽는데 payload가 커지는 도메인은 `translated_title`, `translated_name` 같은 hot column을 선택적으로 추가한다.
- 단일 문자열 번역 대상은 JSON으로 바꾸지 않는다.
### 처리 흐름
- 생성/수정 시 번역 대상 필드를 세그먼트로 추출한다.
- 세그먼트별 `source_hash`를 계산한다.
- 언어 정보가 없으면 `language_detection_result`를 조회하고, 없을 때만 감지 작업을 수행한다.
- 대상 언어별로 `translation_memory`를 먼저 조회한다.
- cache hit이면 Papago를 호출하지 않고 read model materialize 단계로 넘어간다.
- cache miss이면 `translation_job``PENDING`으로 생성한다.
- 워커가 job을 가져와 Papago를 호출하고 `translation_memory`에 저장한다.
- 대상 resource의 모든 필드 번역이 준비되면 도메인별 read model JSON 또는 scalar row를 갱신한다.
### 조회 정책
- 조회 API에서는 Papago를 직접 호출하지 않는다.
- 번역 read model이 있으면 해당 번역을 반환한다.
- 번역 read model이 없으면 원문 또는 기존 fallback locale을 반환한다.
- 누락된 번역은 조회 요청에서 job만 예약할 수 있으며, 외부 API 응답을 기다리지 않는다.
### 최종 선택 요약
| 영역 | 최종 선택 | 이유 |
|---|---|---|
| Papago 중복 호출 방지 | `translation_memory` 정규화 | 동일 원문/언어쌍 재사용이 가능하다. |
| 번역 실행 | `translation_job` 비동기 큐 | 재시도와 실패 추적이 가능하고 조회 응답을 지연시키지 않는다. |
| 다중 필드 응답 저장 | JSON read model 유지 | `entityId + locale` 1-row 조회가 가능해 API 조립이 단순하다. |
| 단일 문자열 번역 저장 | scalar column 유지 | JSON보다 단순하고 불필요한 변환 비용이 없다. |
| 자주 읽는 일부 필드 | 선택적 hot column 추가 | 큰 JSON payload 전체 로딩 비용을 줄일 수 있다. |
| provider 확장 | `TranslationProvider` 인터페이스 | Papago 의존을 낮추고 향후 교체를 쉽게 한다. |
## 상세 정책
### 운영자 재처리
- 관리자/운영 API에서는 번역 상태를 확인하고 재번역을 요청할 수 있게 한다.
- 실패한 job은 원문, 대상 언어, provider, 실패 사유, 재시도 횟수를 함께 보여준다.
### 중복 방지 정책
- `TranslationMemory``(source_hash, source_language, target_language, provider, normalization_version)`에 unique 제약을 둔다.
- `TranslationJob`은 활성 상태 기준 `(resource_type, resource_id, field_key, target_language, source_hash)` 중복 생성을 막는다.
- 동일 작업이 동시에 들어오면 기존 `PENDING` 또는 `RUNNING` 작업을 재사용한다.
### 오류와 재시도
- Papago 실패 시 `TranslationJob.status = FAILED`와 실패 사유를 저장한다.
- 일시 오류는 지수 백오프로 재시도한다.
- 영구 오류나 빈 원문은 재시도하지 않는다.
- 재시도 횟수를 초과하면 운영자가 재시도할 수 있도록 별도 상태로 남긴다.
### 운영 안정화 보완 작업
- 현재 `TranslationJobWorker.claimNextJob()``PENDING` job을 조회한 뒤 `RUNNING`으로 변경한다.
- 다중 애플리케이션 인스턴스에서 같은 job을 동시에 조회할 수 있으므로 운영 반영 전 claim을 원자화한다.
- 권장 방식은 MySQL 기준 `SELECT ... FOR UPDATE SKIP LOCKED` 또는 `UPDATE ... WHERE status = 'PENDING' ... LIMIT 1` 기반 원자적 claim이다.
- job row를 여러 인스턴스가 나눠 처리하는 목적에는 `FOR UPDATE SKIP LOCKED` 또는 atomic update claim을 우선한다.
- `ShedLock`은 스케줄러 실행 자체의 중복을 막는 용도로는 적합하지만, job row 단위 분산 claim을 대체하지는 않는다.
- Papago 호출은 DB lock을 잡은 트랜잭션 밖에서 수행하고, claim/완료/실패 상태 변경만 짧은 트랜잭션으로 처리한다.
- `FAILED`로 즉시 종료하는 최소 구현에서 지수 백오프 기반 재시도 정책으로 보완한다.
- 재시도 정책은 `retry_count`, `next_retry_at`, `last_error_message`를 함께 갱신하고, 최대 재시도 초과 상태를 운영자가 확인할 수 있게 한다.
- worker 처리량과 부하를 운영 설정으로 제어할 수 있도록 `fixed-delay-ms`, tick당 처리 group 수, 최대 재시도 횟수, Papago 호출 rate limit을 설정화한다.
- tick당 처리 단위는 단순 job row 수가 아니라 `(resource_type, resource_id, target_language)` group으로 잡는다.
- 1차 운영 기준은 tick당 최대 5개 group 처리로 둔다.
- group 내부의 field job은 순차 처리하고, 같은 resource/locale의 모든 필드가 `translation_memory`에 준비된 뒤 read model을 materialize한다.
- 콘텐츠 기준으로는 1개 group이 `title`, `detail`, `tags` 3개 field job이므로 tick당 최대 15개 field job이 된다.
- 캐릭터처럼 필드가 많은 리소스도 group 5개 제한 안에서 처리해 Papago 호출 burst를 완화한다.
- 운영 관측을 위해 pending/running/failed/completed count, oldest pending age, 처리 성공/실패 수, Papago 호출 시간, materialize 실패 수를 로그 또는 metric으로 남긴다.
### 번역 job 실행 주기 조정 검토
- 현재 구현은 `sodalive.translation-job.fixed-delay-ms` 설정이 없으면 기본 `5000ms` fixed delay로 실행된다.
- 콘텐츠가 지속적으로 올라오는 서비스 형태가 아니므로 기본 주기를 10분(`600000ms`)으로 늘리는 방향을 검토한다.
- `fixedDelay`는 작업 종료 후 다음 실행까지의 지연 시간이므로 실제 실행 간격은 `처리 시간 + 10분`이 된다.
- 정확히 벽시계 기준 10분마다 실행해야 한다면 `cron + 스케줄 중복 방지 lock`을 검토하고, 처리 후 10분 쉬는 정책이면 `fixedDelay`를 사용한다.
- 10분 주기의 장점은 불필요한 DB polling과 Papago 호출 burst 가능성을 줄이고, 낮은 트래픽 환경에서 백그라운드 작업 부하를 완화하는 것이다.
- 10분 주기의 단점은 번역 read model 반영 지연이 최대 10분 이상으로 늘어날 수 있다는 점이다.
- 조회 정책이 원문 즉시 반환 + job 예약 방식이므로 API 응답 실패로 이어지지는 않지만, 사용자는 첫 조회 후 최대 다음 worker 실행까지 원문을 볼 수 있다.
- 기존 tick당 20 job row 처리 방식은 같은 resource/locale의 일부 필드만 처리하고 다음 tick으로 넘어갈 수 있다.
- 10분 주기에서는 부분 처리된 resource의 read model 반영이 다음 tick까지 지연될 수 있으므로 `(resource_type, resource_id, target_language)` group 단위 처리로 보완한다.
- 10분 주기를 적용하려면 tick당 처리 group 수를 운영 설정으로 조정하거나, pending backlog가 특정 기준을 넘을 때 수동/운영자 재처리 또는 임시 짧은 주기 전환이 가능해야 한다.
- 생성 직후 번역 노출이 중요한 리소스가 발견되면 해당 리소스만 별도 즉시 처리 정책을 두고, 일반 조회 fallback은 10분 주기를 유지한다.
- 1차 운영 기준은 `fixed-delay-ms = 600000`, tick당 최대 5개 group, 원문 fallback 허용, backlog/oldest pending age 모니터링으로 둔다.
### 단계별 적용
- 1단계: `TranslationProvider` 인터페이스를 만들고 기존 `PapagoTranslationService`를 provider 구현으로 감싼다.
- 2단계: `translation_memory`, `translation_job`, `language_detection_result` 테이블을 추가한다.
- 3단계: 조회 fallback의 직접 Papago 호출을 제거하고, 누락 번역 job 예약 방식으로 전환한다.
- 4단계: 생성/수정 이벤트 번역을 `translation_job` 기반 워커로 전환한다.
- 5단계: `TranslationMemory` 결과를 기존 JSON/scalar read model로 materialize한다.
- 6단계: 목록 조회에서 큰 JSON payload 로딩이 병목이면 hot column을 선택적으로 추가한다.
## 검증 관점
- 동일 원문을 여러 도메인에서 번역해도 Papago 호출이 한 번만 발생하는지 확인한다.
- 조회 API에서 Papago 호출이 발생하지 않는지 확인한다.
- 생성/수정 이벤트 후 번역 작업이 중복 생성되지 않는지 확인한다.
- Papago 장애 시 원 API 응답이 실패하지 않고 작업 상태만 실패로 남는지 확인한다.
- 기존 API 응답 스키마가 바뀌지 않는지 확인한다.
## 남은 결정사항
- 번역이 없는 조회 응답은 원문을 반환한다.
- 실시간 번역이 꼭 필요한 엔드포인트는 1차 구현 범위에 포함하지 않는다. 단, 생성 직후 번역 노출이 중요한 리소스가 확인되면 해당 리소스만 별도 즉시 처리 정책을 검토한다.
- JSON read model을 장기 유지하되, hot column이 필요한 목록 API를 측정으로 결정해야 한다.
- Papago 외 provider는 1차 구현에서는 인터페이스만 준비하고 실제 추가 provider는 범위에서 제외한다.
## 검증 기록
- 2026-05-06: 코드 탐색 결과를 바탕으로 현재 번역 저장 구조, Papago 호출 흐름, 외부 아키텍처 패턴을 비교해 설계 초안을 작성했다.
- 2026-05-06: 문서 내 미완성 placeholder, 상충되는 범위, 구현 전제 누락 여부를 점검했다.
- 2026-05-06: 기존 번역 데이터 보존 제약을 배제해도 `TranslationMemory + TranslationJob + JSON read model` 하이브리드가 최종 구현방식으로 적합한지 재평가하고 문서에 반영했다.
- 2026-05-06: TDD RED 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizerTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest'` 실행 시 `SourceTextNormalizer`, `TranslationJobRepository`, `TranslationJobScheduler`, `LanguageDetectionResultRepository`, `LanguageDetectionCacheService` 미구현으로 `compileTestKotlin` 실패를 확인했다.
- 2026-05-06: 구현 후 동일 targeted test 명령을 재실행해 `BUILD SUCCESSFUL`을 확인했다. 정규화/해시, 누락 번역 job 생성, 언어 감지 캐시 hit 시 provider 미호출 동작을 검증했다.
- 2026-05-06: 전체 회귀 검증으로 `./gradlew test``./gradlew build`를 실행해 모두 `BUILD SUCCESSFUL`을 확인했다. `build`에는 ktlint main/test/sourceSet check가 포함되어 스타일도 함께 검증됐다.
- 2026-05-06: `PapagoTranslationService|translationService\.translate|papagoTranslationService\.translate|TranslateRequest` 검색으로 직접 번역 호출이 `PapagoTranslationService`, `TranslationProvider`, `TranslationJobWorker`, DTO, 지원 언어 목록 참조에만 남아 있음을 확인했다. 조회 fallback과 `LanguageTranslationListener`에는 직접 Papago 번역 호출이 남아 있지 않다.
- 2026-05-06: MySQL unique 제약은 활성 상태 partial unique를 표현할 수 없으므로 완료 job이 있는 동일 key 재예약 시 중복 insert가 발생하지 않도록 repository 파생 쿼리 기반 회귀 테스트를 추가했다. RED는 중복 job 조회 메서드 미구현으로 `compileTestKotlin` 실패, GREEN은 동일 테스트 `BUILD SUCCESSFUL`로 확인했다.
- 2026-05-06: 최종 확인에서 Kotlin LSP 서버가 설정되어 있지 않아 `lsp_diagnostics``No LSP server configured for extension: .kt`로 실행할 수 없었다. 대체 검증으로 `./gradlew test`, `./gradlew build`, 신규 focused test `--rerun-tasks`를 실행했고 모두 `BUILD SUCCESSFUL`을 확인했다.
- 2026-05-06: 운영 안정화 보완 구현으로 `TranslationJobWorker` 기본 fixed delay를 10분(`600000ms`)으로 변경하고, MySQL `FOR UPDATE SKIP LOCKED` 기반 job id claim, 실패 시 `PENDING` 재전환 + `next_retry_at` backoff + 최대 재시도 후 `FAILED` 전환을 적용했다. RED는 `TranslationJobWorkerTest`에서 원자 claim 메서드 미구현으로 `compileTestKotlin` 실패, GREEN은 동일 focused test `BUILD SUCCESSFUL`로 확인했다.
- 2026-05-06: 문서에 반영한 group 단위 처리 정책을 구현했다. `TranslationJobWorker`는 tick당 최대 5개 `(resource_type, resource_id, target_language)` group을 처리하고, group 내부 pending field job을 `RUNNING`으로 claim한 뒤 모두 성공한 경우 한 번만 read model을 materialize한다. RED는 `findPendingJobIdsForGroupForUpdate`, `processNextGroup` 미구현으로 `TranslationJobWorkerTest``compileTestKotlin` 실패를 확인했고, GREEN은 동일 focused test `BUILD SUCCESSFUL`로 확인했다.
- 2026-05-06: group 처리 리뷰에서 materialize 실패 후 재시도 불가 가능성과 seed row 기반 claim의 group 분리 가능성을 확인했다. 보완 구현으로 단일 native query `findNextPendingGroupJobIdsForUpdate`에서 다음 pending group의 job id들을 `FOR UPDATE SKIP LOCKED`로 함께 claim하고, materialize 실패 시 group job들을 backoff 재시도 대상으로 되돌리도록 수정했다. RED는 새 group claim 메서드 미구현으로 `TranslationJobWorkerTest``compileTestKotlin` 실패를 확인했고, GREEN은 동일 focused test `BUILD SUCCESSFUL`로 확인했다.
- 2026-05-06: group 처리 전환 후 남은 이전 row 단위 claim 함수 사용처를 `rg`, AST 검색, explore/librarian 병렬 탐색으로 확인했다. production 경로가 `processNextGroup` + `findNextPendingGroupJobIdsForUpdate`로 수렴되어 `processNextJob`, `findFirstByStatusAndNextRetryAtLessThanEqualOrderByCreatedAtAsc`, `findNextPendingJobIdForUpdate`, `findPendingJobIdsForGroupForUpdate`를 제거했다.
## 2026-05-06 구현 DDL
운영 MySQL은 `spring.jpa.hibernate.ddl-auto=validate`이므로 아래 DDL을 선반영해야 한다. `created_at`, `updated_at``BaseEntity``createdAt`, `updatedAt`과 매핑된다.
```sql
CREATE TABLE translation_memory (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '번역 메모리 ID',
source_hash VARCHAR(64) NOT NULL COMMENT '정규화 원문 SHA-256 해시',
source_text TEXT NOT NULL COMMENT '정규화 원문 텍스트',
source_language VARCHAR(10) NOT NULL COMMENT '원문 언어 코드',
target_language VARCHAR(10) NOT NULL COMMENT '대상 언어 코드',
translated_text TEXT NOT NULL COMMENT '번역 결과 텍스트',
provider VARCHAR(50) NOT NULL COMMENT '번역 provider 이름',
provider_version VARCHAR(50) NOT NULL COMMENT '번역 provider 버전',
normalization_version VARCHAR(20) NOT NULL COMMENT '정규화 규칙 버전',
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
PRIMARY KEY (id),
UNIQUE KEY uk_translation_memory_source_target_provider (
source_hash,
source_language,
target_language,
provider,
normalization_version
),
KEY idx_translation_memory_source_hash (source_hash),
KEY idx_translation_memory_target_language (target_language)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE translation_job (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '번역 작업 ID',
resource_type VARCHAR(50) NOT NULL COMMENT '번역 대상 리소스 타입',
resource_id BIGINT NOT NULL COMMENT '번역 대상 리소스 ID',
field_key VARCHAR(80) NOT NULL COMMENT '번역 대상 필드 키',
source_hash VARCHAR(64) NOT NULL COMMENT '정규화 원문 SHA-256 해시',
source_text TEXT NOT NULL COMMENT '정규화 원문 텍스트',
source_language VARCHAR(10) NOT NULL COMMENT '원문 언어 코드',
target_language VARCHAR(10) NOT NULL COMMENT '대상 언어 코드',
status VARCHAR(20) NOT NULL COMMENT '번역 작업 상태',
retry_count INT NOT NULL COMMENT '재시도 횟수',
last_error_message TEXT DEFAULT NULL COMMENT '마지막 오류 메시지',
next_retry_at TIMESTAMP NOT NULL COMMENT '다음 재시도 가능 시각',
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
PRIMARY KEY (id),
UNIQUE KEY uk_translation_job_resource_field_target_hash (
resource_type,
resource_id,
field_key,
target_language,
source_hash
),
KEY idx_translation_job_status_retry (status, next_retry_at),
KEY idx_translation_job_resource (resource_type, resource_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE language_detection_result (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '언어 감지 결과 ID',
source_hash VARCHAR(64) NOT NULL COMMENT '정규화 원문 SHA-256 해시',
source_text_sample VARCHAR(500) NOT NULL COMMENT '정규화 원문 샘플 텍스트',
detected_language VARCHAR(10) NOT NULL COMMENT '감지된 언어 코드',
provider VARCHAR(50) NOT NULL COMMENT '언어 감지 provider 이름',
confidence DOUBLE DEFAULT NULL COMMENT '언어 감지 신뢰도',
normalization_version VARCHAR(20) NOT NULL COMMENT '정규화 규칙 버전',
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
PRIMARY KEY (id),
UNIQUE KEY uk_language_detection_result_hash_provider_version (
source_hash,
provider,
normalization_version
),
KEY idx_language_detection_result_source_hash (source_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```