intl-layer
브라우저 Intl API를 위한 locale 결정·포맷터 레이어
설치
pnpm add @cbcruk/intl-layer
# React 바인딩을 쓸 때만:
pnpm add react왜
Intl.* 생성자의 locales 인자는 optional이라, 안 넘기면 런타임의 default locale을
silent하게 쓴다. 이게 실무에선 함정이다.
- SSR/CSR 불일치: 서버 Node default(
en-US)와 클라이언트navigator.language(ko-KR)가 달라 hydration mismatch + flash. - OS locale ≠ 선호 locale: 한국 거주자가 영어 UI를 원하면 OS는
ko-KR이지만 앱은en. - silent fallback: 미지원 locale도 에러 없이 가까운 것으로 fallback → 버그 늦게 발견.
- 우회 경로:
toLocaleString()은 Intl 인스턴스도 안 만들고 default로 포맷 → layer 우회.
계약: 이 라이브러리는 default locale을 절대 쓰지 않고 locale을 반드시 인자로 받는다.
환경 의존은 단일 결정 지점(resolveLocale / detect*)에만 둔다.
locale 결정 (단일 지점)
우선순위 urlSegment > cookie > Accept-Language > fallback. supported를 주면
language subtag까지 협상한다. supported가 없으면 각 후보를 Intl.getCanonicalLocales와
Intl.DateTimeFormat.supportedLocalesOf로 검증해, /products/…의 products처럼 locale이
아닌 값은 건너뛰고 통과한 첫 후보를 정규화된 태그(en-us → en-US)로 돌려준다.
import { resolveLocale } from '@cbcruk/intl-layer'
const locale = resolveLocale({
urlSegment: 'ko', // /ko/dashboard 의 첫 세그먼트
cookie: null,
acceptLanguage: navigator.language, // 또는 Accept-Language 헤더
supported: ['ko-KR', 'en-US', 'ja-JP'],
fallback: 'en-US',
})
// → 'ko-KR' (language 협상)사용법
Vanilla — I18nStore
의존성 없는 reactive store. EventTarget 기반 change 이벤트, 포매터 memoize,
locale 변경 시 캐시 무효화 + <html lang> 동기화.
import { createI18nStore, applyI18n, bindI18nSweep } from '@cbcruk/intl-layer'
const store = createI18nStore({ locale: 'ko-KR', timeZone: 'Asia/Seoul' })
store.date({ dateStyle: 'medium' }).format(new Date())
store.number({ style: 'currency', currency: 'KRW' }).format(12000)
// data attribute sweep (패턴 A) — SSR이 채운 텍스트를 클라이언트가 self-heal
applyI18n(store)
bindI18nSweep(store) // change마다 자동 재적용, 해제 함수 반환
store.setLocale('en-US') // 캐시 비움 + <html lang> 갱신 + change 발생Custom Element (패턴 B):
import { defineFormattedDate } from '@cbcruk/intl-layer'
defineFormattedDate(store) // <fmt-date value="2026-01-15" style-as="long">React — Context + memoize
import { LocaleProvider, useFormatters } from '@cbcruk/intl-layer'
// 서버에서 결정한 locale 주입 (Next.js App Router라면 [locale] segment → RootLayout)
function Root() {
return (
<LocaleProvider locale={resolveLocale(/* ... */)}>
<App />
</LocaleProvider>
)
}
function Price({ value }: { value: number }) {
const { currency } = useFormatters()
return <span>{currency('KRW').format(value)}</span>
}vanilla store를 React에 잇고 싶다면:
import { useI18nStore } from '@cbcruk/intl-layer'
const locale = useI18nStore(store) // change에 반응해 리렌더API
| 항목 | 설명 |
|---|---|
resolveLocale(sources) | locale 결정 단일 지점 (우선순위 + supported 협상, 없으면 후보 검증) |
maximizeLocale(input) | "ko" → "ko-Kore-KR" (likely subtags 보강) |
parseAcceptLanguage(header) | q-값 내림차순 태그 배열 |
detectLocaleMismatch(fmt, expected) | dev용 silent-fallback 탐지 (불일치 시 해석된 태그) |
I18nStore / createI18nStore | vanilla reactive store (date/number/relativeTime memoize) |
detectInitialLocale / detectTimeZone | 명시적 환경 탐지 헬퍼 |
applyI18n / bindI18nSweep | data attribute sweep (패턴 A). 해석 불가 값은 원문 유지, 요소별 예외 격리 |
defineFormattedDate | <fmt-date> custom element (패턴 B). 해석 불가 value는 원문 유지 |
LocaleProvider / useLocale / useFormatters | React context 바인딩 |
useI18nStore(store) | vanilla store ↔ React 연결 |
stableStringify(opts) | 키 순서 무관 캐시 키 직렬화 |
Gotchas
-
memo 키 옵션 순서.
JSON.stringify는 키 순서에 민감 →stableStringify로 정규화. (ReactuseMemo는 reference equality라 무관, vanilla 캐시는 직접 처리.) -
silent fallback 조기 탐지. dev에서
detectLocaleMismatch로 expected와 비교해 로깅. -
Intl은 formatting이지 계산이 아니다. 상대 시간은 차이를 직접 계산해relativeTime에 넘긴다. 단위 변환(°C, miles)은 하지 않는다. -
multi-tab 동기화는
storage이벤트로 거의 공짜. 한 탭에서localStorage.setItem('locale', tag), 다른 탭에서storage→store.setLocale. -
잘못된 값은 원문 유지.
data-fmt-date/<fmt-date value>가 날짜로,data-fmt-number가 숫자로 해석되지 않으면 요소를 건드리지 않아 SSR 텍스트가 남는다. 그 밖에 요소 하나에서 난 예외(예: 잘못된data-fmt-date-style)는reportError(없으면console.error)로 보고하고 나머지 요소는 계속 갱신한다. -
후보 검증은 런타임 locale 데이터에 의존.
supported없이resolveLocale을 쓰면Intl.DateTimeFormat.supportedLocalesOf로 후보를 거르므로, ICU 데이터가 일부만 있는 런타임(small-icu 빌드 등)에서는 실제 locale도 탈락해fallback이 될 수 있다. 허용 목록이 정해져 있다면supported를 넘기는 편이 확실하다.
컨벤션 / 가드레일
- consumer 코드에서
toLocaleString/new Intl.*직접 호출 금지 (ESLintno-restricted-syntax/no-restricted-properties로 PR 단계 차단). - 모든 포맷은 store 메소드 또는 라이브러리 API를 거친다.
- public API는 locale을 필수 인자로 받는다(default 없음).