feat: add internationalization message platform

This commit is contained in:
donghyeon-ka
2026-07-26 16:17:39 +09:00
parent b8c0444217
commit 668bf05b48
57 changed files with 1711 additions and 201 deletions
@@ -0,0 +1,96 @@
# VD-06: Intl과 typed local message catalog
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-i18n-message-formatting-contract`
- 재검토: 승인 locale·복수형 문법·번역 추출 workflow가 local catalog 범위를 넘을 때
## 배경
공통 셸, route surface, async/form 상태와 디자인 시스템 기본 문구가 JSX와
JavaScript에 분산돼 있었다. 날짜는 일부 application mapper에서 고정 locale로
가공되어 presentation이 locale을 바꿀 수 없었고, direction·누락 key·보간 실패
정책도 없었다. 반면 현재 skeleton에는 번역 관리 서비스, 실제 번역 승인 절차,
복잡한 ICU 문법이라는 제품 요구가 아직 없다. 이 단계에서 i18n vendor를 기본
번들에 넣으면 소비 프로젝트가 제거하거나 다시 감싸야 할 의존성만 늘어난다.
## 결정
1. 표준 `Intl.DateTimeFormat`, `NumberFormat`, `RelativeTimeFormat`,
`ListFormat`, `PluralRules`와 typed local catalog를 기본 엔진으로 사용한다.
2. `MessageKey`는 한국어 canonical catalog에서 도출하며 영어와 RTL smoke
catalog는 `satisfies Record<MessageKey, string>`으로 compile-time parity를
강제한다.
3. 보간이 필요한 key는 `MessageParameters`에 key별 parameter object를
선언한다. 잘못된 key, 누락·초과 parameter는 TypeScript negative fixture가
거절한다.
4. 기본 locale은 `ko-KR`, fallback locale도 `ko-KR`이다. 알려지지 않은 locale은
language fallback 후 `ko-KR`로 정규화한다. 알려지지 않은 key와 누락 보간은
raw key나 외부 값을 출력하지 않고 안전한 공통 fallback을 반환한다.
5. `en-XA`는 영어 문구를 확장·accent 처리하는 pseudo locale이고 `ar-EG`
RTL 동작 smoke locale이다. 이 두 locale은 실제 제품 번역 완료를 의미하지
않는다.
6. 날짜 formatter의 기본 timezone은 테스트와 SSR/브라우저 결과가 흔들리지
않도록 `UTC`다. 제품 timezone이 필요하면 호출자가 명시한다. invalid
date/number/timezone은 `—`를 반환하고 throw하지 않는다.
7. locale state는 React inbound concern이다. `LocaleProvider`가 copy,
formatter와 `<html lang/dir>`을 제공하며 application/domain은 미리 번역된
문자열 대신 의미 값과 timestamp를 반환한다.
8. backend `message`, raw HTML, stack과 내부 key를 catalog 입력으로 신뢰하지
않는다. transport/application failure kind를 등록된 사용자 message key로
매핑한 뒤 presentation이 해석한다.
9. key rename은 즉시 제거하지 않고 `MESSAGE_KEY_ALIASES`에 compatibility alias를
둔다. alias는 새 호출의 타입에 포함하지 않아 신규 코드는 canonical key만
사용한다.
10. extraction, ICU rich message, 번역 SaaS 또는 framework adapter가 필요해지면
`presentation/i18n` public API 뒤에서 교체한다. vendor type은 feature와
design-system public prop으로 노출하지 않는다.
## 실행 경계
```text
route/form/failure 의미 값
-> presentation message key
-> LocaleProvider
-> typed catalog / Intl formatter
-> text node와 accessible name
```
- canonical catalog: `src/presentation/i18n/catalog.ts`
- feature contribution: `src/features/*/contracts/*-message-catalog.js`
`src/features/installed-feature-messages.js`에서 조립
- key/보간/fallback/alias: `message-contract.ts`
- locale-safe value formatting: `formatters.ts`
- React composition과 document metadata: `locale-provider.tsx`
- public entry: `src/presentation/i18n/index.ts`
## 검증
- `check:i18n`은 catalog key와 placeholder parity, common UI의 한국어 literal,
backend message JSX 렌더링과 raw HTML 사용을 검사한다.
- `check:i18n:fixture`는 세 금지 사례를 실제로 거절해야 성공으로 인정된다.
- type negative fixture는 unknown key와 잘못된 parameter shape를 거절한다.
- unit test는 fallback, alias, pseudo 확장, direction과 timezone/number/relative/
list/plural/select의 결정성을 검증한다.
- component test는 document `lang/dir`, RTL Tabs와 direction-aware pagination,
Drawer semantics를 검증한다.
- Playwright는 320px pseudo reflow와 RTL compact shell/Drawer/focus restore를
Chromium, Firefox, WebKit project에서 실행한다.
## 한계와 재검토 조건
현재 catalog는 실제 번역 승인, ICU rich text, locale별 plural 문장 전체 조합,
메시지 추출/번역 메모리와 서버 locale negotiation을 제공하지 않는다. 다음 중
하나가 확인되면 별도 ADR로 엔진을 재평가한다.
- 세 개 이상의 실제 승인 locale과 번역 담당 workflow
- 복수형·성별·select가 한 문장 안에서 중첩되는 제품 copy
- server/client extraction, namespace lazy-loading 또는 번역 SaaS 연동
- SSR locale negotiation과 hydration 일치가 필요한 rendering mode
## Rollback
`ko-KR` catalog가 기존 기본 문구를 보존하므로 provider를 고정 locale adapter로
되돌려도 기본 UX를 유지한다. formatter/vendor 교체 시 public `message`,
`date`, `number`, `relativeTime`, `list`, `plural`, `select` 계약과 negative
fixture는 유지한다. alias는 migration window 종료 근거 없이 제거하지 않는다.