the problem
맨 zh는 가장 중요한 것에 침묵합니다
대부분의 언어는 태그만으로 결정됩니다. ko는 한국어,
de는 독일어 — 더 알 것이 없습니다. 중국어는 예외이고,
그것도 아주 큰 예외입니다. zh는 간체인지 번체인지
말하지 않는데, 양쪽 다 수억 명의 독자가 있습니다.
거의 모두가 쓰는 코드는 이렇게 틀립니다:
const base = tag.split('-')[0]; // 'zh-CN' → 'zh'
if (base === 'zh') return 'zh-TW'; // ...간체 독자가 번체를 받는다
그리고 그 뒤에 두 번째 함정이 있습니다. zh-CN을 거부해도
본토 브라우저는 이렇게 보냅니다:
Accept-Language: zh-CN,zh;q=0.9
방금 거부한 태그 바로 뒤의 맨 zh가 그것을 구제해서,
페이지는 여전히 번체로 돌아옵니다. 프로덕션에서 실제로 측정한
현상이고, 이 라이브러리는 그 두 사고에서 나왔습니다.
playground
헤더를 넣어 보세요
이 페이지에 실제로 로드된 라이브러리가 아래에서 그대로 돕니다. 결과뿐 아니라 왜 그렇게 됐는지를 태그별로 보여줍니다.
the rule
두 층 규칙
가드는 하나의 기본 언어에 대해 태그를 세 값으로 분류하고, 그 태그가 어디서 왔는지에 따라 다르게 적용합니다.
| 태그의 출처 | 맨 zh의 의미 |
|---|---|
| 선언된 단일 값 ?lang= · 쿠키 · 저장된 설정 |
수용 — 중국어를 달라고 했고 우리에겐 중국어가 있다 |
| 순위 목록의 한 항목 Accept-Language · navigator.languages |
거부 — 순위 목록에서는 모호한 항목이 정확한 항목을 구제한다 |
중국어 가드는 당신이 쓴 supported 목록에서 자동으로
유도됩니다. 번체만 있으면 번체 가드가, 간체만 있으면 간체 가드가
설치되고, 양쪽 다 서비스하거나 중국어가 없으면 보호할 대상이 없으므로
가드도 없습니다. 방문자에 대한 추측이 아니라 당신이 직접 쓴 목록을
읽는 것입니다.
usage
쓰는 법
@devslab/locale-match
import { createLocaleResolver } from '@devslab/locale-match';
const locales = createLocaleResolver({
supported: ['ko', 'en', 'ja', 'zh-HK', 'zh-TW', 'fr'],
fallback: 'ko',
});
// 서버 (Workers · Node · Request가 있는 어디든)
const { locale } = locales.resolve({
query: url.searchParams.get('lang'),
cookieHeader: request.headers.get('cookie'),
acceptLanguage: request.headers.get('accept-language'),
});
// 브라우저: ?lang= → localStorage → navigator.languages
const { locale } = locales.resolveInBrowser();
@devslab/locale-match-react
<LocaleProvider supported={LOCALES} fallback="ko" initial={serverLocale}>
<App />
</LocaleProvider>
const { locale, setLocale, supported, isFallback } = useLocale();
감지는 렌더 중이 아니라 마운트 직후 이펙트에서 실행됩니다.
서버가 그린 HTML과 첫 클라이언트 렌더가 어긋나면 하이드레이션
불일치이기 때문입니다. 서버에서 확정한 값을 initial로
넘기면 그 한 프레임도 없습니다.
@devslab/locale-match-vue · -nuxt
// Vue 3
app.use(createLocalePlugin({ supported: LOCALES, fallback: 'ko' }));
// Nuxt — SSR에서 확정하므로 언어 깜빡임이 없습니다
export default defineNuxtConfig({
modules: ['@devslab/locale-match-nuxt'],
localeMatch: { supported: LOCALES, fallback: 'ko' },
});
빌드 스텝 없이
<script src="https://unpkg.com/@devslab/locale-match/dist/index.global.js"></script>
<script>
const locales = LocaleMatch.createLocaleResolver({ supported: [...], fallback: 'ko' });
document.documentElement.lang = locales.resolveInBrowser().locale;
</script>
이 페이지가 바로 그렇게 동작합니다.
extending
다른 언어 가드 추가하기
중국어는 거의 모두가 겪는 케이스라 내장했습니다. 하지만 메커니즘은 중국어 전용이 아닙니다 — 문자로 독자가 갈리는 언어라면 무엇이든 같은 방식으로 쓸 수 있습니다.
import { defineScriptGuard } from '@devslab/locale-match';
const serbianLatin = defineScriptGuard({
language: 'sr', // 기본 서브태그, 소문자
supported: /^sr-(latn|latin)\b/, // 소문자화된 태그에 테스트됨
unsupported: /^sr-(cyrl|cyrillic)\b/,
});
지역까지 덮으세요 — 사람들은 sr-Cyrl이 아니라
sr-RS라고 씁니다. 맨 태그는 분류하지 마세요 —
두 층 규칙이 처리합니다. 패턴을 고정하세요. 자세한 이유와
체크리스트는 README에 있고, 충분히 조사된 가드를 추가하는 PR을
환영합니다.
why not something else
기존 라이브러리를 쓰면 안 되나요?
써도 됩니다. 이게 필요 없을 수도 있습니다.
| @formatjs/intl-localematcher | 표준 알고리즘을 제대로 구현합니다. lookup은 옆걸음을 하지 않아 중국어 버그를 피하지만, 동시에 pt-PT가 pt-BR 대신 영어를 받습니다. best fit은 문자를 가로질러 매칭할 수 있습니다. |
| i18next-browser-languagedetector | 감지 순서를 제공합니다. 다만 load: 'languageOnly'는 지역을 잘라내서 이 라이브러리가 막으려는 바로 그 버그를 다시 만듭니다. |
| next-intl · vue-i18n | 완전한 i18n 프레임워크입니다. 이미 쓰고 있다면 그대로 두고, 협상 단계에만 이걸 쓰세요. |
이 라이브러리가 더하는 것은 어느 쪽도 결정해 주지 않는 부분입니다: 당신이 어떤 문자를 서비스하는가, 그리고 반대쪽을 요청한 독자를 어떻게 할 것인가.