junyeokk
Blog
React Ecosystem·2025. 11. 15

React i18next

웹 애플리케이션에 다국어 지원을 추가해야 하는 상황을 생각해보자. 가장 단순한 접근은 조건문으로 분기하는 것이다.

javascript
const greeting = language === 'ko' ? '안녕하세요' : 'Hello';

이 방식은 언어가 2~3개일 때도 금방 한계에 부딪힌다. 문자열이 수백 개가 되면 코드 전체에 조건문이 퍼져서 유지보수가 불가능해지고, 번역가에게 JSON 파일을 넘기는 것도 불가능하다. 번역 문자열을 코드에서 분리하고, 언어 전환·감지·로딩을 체계적으로 관리할 프레임워크가 필요하다.

i18next는 이 문제를 해결하는 국제화(i18n) 프레임워크다. React뿐 아니라 Node.js, Vue, Angular 등 어디서든 사용할 수 있는 코어 라이브러리이고, react-i18next는 이 코어를 React에 바인딩하는 레이어다. 핵심은 번역 리소스를 코드에서 완전히 분리하고, 런타임에 언어를 감지·전환하며, 복수형·보간·네임스페이스 같은 실무적 요구사항을 표준화된 방식으로 처리하는 것이다.


아키텍처 구조

i18next 생태계는 세 개의 레이어로 구성된다.

text
┌─────────────────────────────────┐
│       react-i18next             │  ← React 바인딩 (훅, HOC, 컴포넌트)
├─────────────────────────────────┤
│       i18next (코어)            │  ← 번역 엔진, 보간, 복수형, 포매팅
├─────────────────────────────────┤
│       플러그인들                 │  ← 백엔드, 언어감지, 캐시, 후처리
└─────────────────────────────────┘

코어(i18next): 번역 키를 받아서 현재 언어에 맞는 문자열을 반환하는 엔진이다. 보간(interpolation), 복수형(pluralization), 컨텍스트(context), 네임스페이스(namespace) 처리를 담당한다.

React 바인딩(react-i18next): useTranslation 훅, Trans 컴포넌트, I18nextProvider 등을 제공한다. 언어가 변경되면 자동으로 리렌더링을 트리거한다.

플러그인: i18next의 확장 포인트. 번역 파일을 어디서 불러올지(백엔드), 사용자 언어를 어떻게 감지할지(디텍터), 번역 결과를 어떻게 후처리할지(포스트프로세서) 등을 플러그인으로 교체할 수 있다.

이 구조의 장점은 코어가 환경에 의존하지 않는다는 것이다. 같은 번역 리소스와 설정을 서버(SSR)와 클라이언트에서 공유할 수 있고, React에서 Next.js로 마이그레이션해도 코어 설정은 그대로 유지된다.


초기화 설정

i18next 초기화는 보통 i18n.ts 파일 하나에서 한다. 여기서 언어, 리소스, 플러그인, 옵션을 모두 설정한다.

typescript
// src/i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';

i18n
  .use(Backend)                // 번역 파일 로딩
  .use(LanguageDetector)       // 사용자 언어 감지
  .use(initReactI18next)       // React 바인딩 연결
  .init({
    fallbackLng: 'en',
    debug: process.env.NODE_ENV === 'development',

    ns: ['common', 'auth', 'dashboard'],
    defaultNS: 'common',

    interpolation: {
      escapeValue: false,      // React가 이미 XSS 방지를 하므로 불필요
    },

    backend: {
      loadPath: '/locales/{{lng}}/{{ns}}.json',
    },

    detection: {
      order: ['querystring', 'localStorage', 'navigator'],
      caches: ['localStorage'],
    },
  });

export default i18n;

.use() 체이닝 순서는 상관없다. i18next가 내부적으로 플러그인 타입을 구분해서 적절한 위치에 등록한다.

initReactI18next는 사실 i18next 인스턴스를 react-i18next 내부의 전역 변수에 저장하는 아주 단순한 플러그인이다. 이걸 등록해야 useTranslation 훅이 어떤 i18next 인스턴스를 사용할지 알 수 있다.

앱의 진입점에서 이 파일을 import하면 초기화가 실행된다.

typescript
// src/main.tsx
import './i18n';  // 사이드이펙트 import — 이것만으로 초기화 완료
import { App } from './App';

주요 초기화 옵션

옵션설명기본값
lng강제 언어 설정. 설정하면 감지 건너뜀-
fallbackLng번역 키가 없을 때 대체 언어'dev'
ns로딩할 네임스페이스 배열['translation']
defaultNSt('key') 호출 시 기본 네임스페이스'translation'
supportedLngs지원 언어 목록 (목록 밖 언어는 fallback)false (제한 없음)
load'all', 'currentOnly', 'languageOnly''all'
interpolation.escapeValueHTML 이스케이프 여부true
react.useSuspenseSuspense 모드 사용 여부true
returnNull키가 없을 때 null 반환 여부false (v23+)
debug콘솔에 디버그 로그 출력false

lng을 직접 설정하면 LanguageDetector가 무시된다. 사용자 브라우저 설정에 맞추려면 lng을 생략하고 LanguageDetector에 맡기는 게 일반적이다.

load: 'languageOnly'en-US 대신 en만 로딩하게 한다. 지역 변형(en-US, en-GB)을 구분하지 않는다면 이 옵션으로 불필요한 리소스 요청을 줄일 수 있다.


언어 감지 (Language Detection)

i18next-browser-languagedetector는 여러 소스에서 사용자의 언어를 감지하는 플러그인이다. detection.order 배열 순서대로 감지를 시도하고, 첫 번째로 유효한 값을 사용한다.

typescript
detection: {
  // 감지 순서: 왼쪽부터 시도
  order: ['querystring', 'cookie', 'localStorage', 'sessionStorage', 'navigator', 'htmlTag', 'path', 'subdomain'],

  // 감지된 언어를 저장할 위치
  caches: ['localStorage', 'cookie'],

  // 쿼리스트링 파라미터 이름
  lookupQuerystring: 'lng',

  // localStorage 키 이름
  lookupLocalStorage: 'i18nextLng',

  // 쿠키 이름
  lookupCookie: 'i18next',

  // path에서 언어 위치 (0-indexed)
  lookupFromPathIndex: 0,

  // 캐시 만료 (쿠키)
  cookieMinutes: 10080, // 7일
}

각 감지 소스의 동작

querystring: URL의 ?lng=ko에서 감지. 테스트나 공유 링크에 유용하다.

localStorage / sessionStorage / cookie: 이전에 저장된 언어 선택값을 읽는다. caches에 지정된 저장소에 자동으로 쓰기도 한다.

navigator: navigator.languages 배열에서 브라우저 설정 언어를 읽는다. 사용자가 직접 선택한 적 없을 때의 기본 감지 소스다.

htmlTag: <html lang="ko">lang 속성에서 읽는다. SSR 환경에서 서버가 미리 설정해둔 언어를 감지할 때 유용하다.

path: URL 경로에서 감지. /ko/about 같은 경로 기반 라우팅에서 사용한다.

subdomain: ko.example.com에서 서브도메인 기반 감지.

일반적인 SPA에서는 ['localStorage', 'navigator'] 정도면 충분하다. localStorage에 이전 선택이 있으면 그걸 쓰고, 없으면 브라우저 설정을 따른다.

커스텀 디텍터 만들기

기본 제공 감지 소스가 부족하면 커스텀 디텍터를 만들 수 있다.

typescript
const customDetector = {
  name: 'userProfileDetector',

  lookup(options) {
    // 예: API에서 가져온 사용자 프로필의 언어 설정
    const userLang = window.__USER_PROFILE__?.language;
    return userLang || undefined; // undefined면 다음 디텍터로 넘어감
  },

  cacheUserLanguage(lng, options) {
    // 사용자가 언어를 변경했을 때 저장 로직
    // API 호출로 서버에도 반영할 수 있다
  },
};

const languageDetector = new LanguageDetector();
languageDetector.addDetector(customDetector);

i18n
  .use(languageDetector)
  .init({
    detection: {
      order: ['userProfileDetector', 'localStorage', 'navigator'],
    },
  });

lookupundefined를 반환하면 order의 다음 디텍터가 시도된다. 이 체이닝 구조 덕분에 우선순위 기반 감지가 자연스럽게 동작한다.


네임스페이스 (Namespace)

네임스페이스는 번역 리소스를 논리적 단위로 분리하는 메커니즘이다. 모든 번역을 하나의 거대한 JSON 파일에 넣으면 로딩 시간이 늘어나고, 키 이름 충돌이 발생하며, 번역가에게 넘길 때도 어떤 파일이 어떤 기능에 해당하는지 알기 어렵다.

text
/locales
  /ko
    common.json      ← 공통 (버튼, 네비게이션, 에러 메시지)
    auth.json         ← 인증 (로그인, 회원가입)
    dashboard.json    ← 대시보드 전용
    settings.json     ← 설정 페이지
  /en
    common.json
    auth.json
    dashboard.json
    settings.json
json
// /locales/ko/common.json
{
  "button": {
    "save": "저장",
    "cancel": "취소",
    "delete": "삭제"
  },
  "error": {
    "network": "네트워크 오류가 발생했습니다",
    "unauthorized": "로그인이 필요합니다"
  }
}

// /locales/ko/auth.json
{
  "login": {
    "title": "로그인",
    "emailPlaceholder": "이메일을 입력하세요",
    "passwordPlaceholder": "비밀번호를 입력하세요",
    "submit": "로그인",
    "forgotPassword": "비밀번호를 잊으셨나요?"
  },
  "register": {
    "title": "회원가입",
    "agree": "이용약관에 동의합니다"
  }
}

네임스페이스 사용

useTranslation에 네임스페이스를 지정해서 사용한다.

tsx
// 단일 네임스페이스
const { t } = useTranslation('auth');
t('login.title');  // "로그인"

// 복수 네임스페이스
const { t } = useTranslation(['dashboard', 'common']);
t('widget.title');            // dashboard의 키 (첫 번째가 기본)
t('button.save', { ns: 'common' });  // 명시적으로 common 지정

// 콜론 표기법 (어디서든 다른 네임스페이스 참조)
t('common:button.save');

배열로 여러 네임스페이스를 전달하면 첫 번째가 기본이 된다. 두 번째 이후의 네임스페이스에서 키를 가져오려면 { ns: 'common' } 옵션이나 'common:key' 콜론 표기법을 사용한다.

네임스페이스와 코드 스플리팅

네임스페이스의 진짜 가치는 지연 로딩과 결합될 때 나타난다. i18next-http-backend를 사용하면 컴포넌트가 렌더링될 때 해당 네임스페이스의 번역만 로딩한다.

typescript
// i18n 초기화에서 초기 로딩할 네임스페이스만 지정
i18n.init({
  ns: ['common'],         // 앱 시작 시 common만 로딩
  defaultNS: 'common',
  backend: {
    loadPath: '/locales/{{lng}}/{{ns}}.json',
  },
});
tsx
// DashboardPage.tsx — 이 컴포넌트가 렌더링될 때 dashboard.json 로딩
function DashboardPage() {
  const { t, ready } = useTranslation('dashboard');

  if (!ready) return <Skeleton />;  // Suspense 미사용 시 로딩 처리

  return <h1>{t('title')}</h1>;
}

React의 Suspense와 함께 사용하면 ready 체크 없이 더 깔끔하게 처리할 수 있다.

tsx
// Suspense 모드 (react.useSuspense: true가 기본값)
function DashboardPage() {
  const { t } = useTranslation('dashboard');
  return <h1>{t('title')}</h1>;  // 로딩 중이면 Suspense fallback 표시
}

// 상위에서 Suspense로 감싸기
<Suspense fallback={<Skeleton />}>
  <DashboardPage />
</Suspense>

라우트 기반 코드 스플리팅과 네임스페이스를 1:1로 매핑하면, 페이지 번들과 번역 리소스가 동시에 지연 로딩되어 초기 번들 크기를 최소화할 수 있다.


보간 (Interpolation)

보간은 번역 문자열에 동적 값을 삽입하는 기능이다. 단순 문자열 연결 대신 보간을 사용하면 번역가가 문장 구조를 자유롭게 조정할 수 있다.

json
{
  "greeting": "{{name}}님, 환영합니다!",
  "itemCount": "{{count}}개의 항목이 있습니다",
  "lastLogin": "마지막 접속: {{date, datetime}}"
}
tsx
t('greeting', { name: '준혁' });
// "준혁님, 환영합니다!"

t('itemCount', { count: 42 });
// "42개의 항목이 있습니다"

{{변수명}} 이중 중괄호 안에 변수명을 넣고, t() 호출 시 두 번째 인자로 값을 전달한다.

포매팅

보간에 포맷 함수를 연결할 수 있다. i18next v21부터는 Intl API 기반 포매팅이 내장되어 있다.

json
{
  "price": "가격: {{amount, number(style: currency; currency: KRW)}}",
  "date": "{{val, datetime(dateStyle: long)}}",
  "percent": "진행률: {{val, number(style: percent)}}"
}
tsx
t('price', { amount: 15000 });
// "가격: ₩15,000"

t('date', { val: new Date() });
// "2026년 2월 15일"

t('percent', { val: 0.85 });
// "진행률: 85%"

커스텀 포맷이 필요하면 interpolation.format 함수를 등록하거나, i18next의 services.formatter.add()로 named format을 추가한다.

typescript
i18n.init({
  interpolation: {
    escapeValue: false,
  },
});

// 커스텀 포맷 등록
i18n.services.formatter.add('relativeTime', (value, lng, options) => {
  const rtf = new Intl.RelativeTimeFormat(lng, { numeric: 'auto' });
  const diff = Math.round((value - Date.now()) / 86400000);
  return rtf.format(diff, 'day');
});
json
{
  "lastSeen": "{{date, relativeTime}}"
}

중첩 보간 (Nesting)

번역 문자열 안에서 다른 번역 키를 참조할 수 있다. $t(key) 구문을 사용한다.

json
{
  "appName": "포토부스",
  "welcome": "$t(appName)에 오신 것을 환영합니다!"
}
tsx
t('welcome');
// "포토부스에 오신 것을 환영합니다!"

공통으로 반복되는 브랜드명이나 용어를 한 곳에서 관리할 때 유용하다.


복수형 (Pluralization)

언어마다 복수형 규칙이 다르다. 영어는 단수/복수 2가지지만, 아랍어는 6가지, 한국어는 1가지(복수 구분 없음)다. i18next는 Intl.PluralRules를 기반으로 언어별 복수형 규칙을 자동 처리한다.

영어 복수형

json
{
  "item_one": "{{count}} item",
  "item_other": "{{count}} items"
}
tsx
t('item', { count: 1 });   // "1 item"
t('item', { count: 5 });   // "5 items"
t('item', { count: 0 });   // "0 items"

키 뒤에 _one, _other 접미사를 붙인다. count 값에 따라 i18next가 Intl.PluralRules로 어떤 키를 사용할지 결정한다.

복수형 카테고리 (CLDR)

언어마다 사용하는 복수형 카테고리가 다르다.

카테고리설명사용 언어 예시
zero0아랍어, 라트비아어
one단수영어, 독일어, 프랑스어
two쌍수아랍어, 히브리어
few소수러시아어, 폴란드어, 체코어
many다수러시아어, 폴란드어
other기타 (필수)모든 언어
json
// 러시아어 예시 (ru.json)
{
  "item_one": "{{count}} элемент",
  "item_few": "{{count}} элемента",
  "item_many": "{{count}} элементов",
  "item_other": "{{count}} элементов"
}

_other는 모든 언어에서 반드시 있어야 하는 필수 카테고리다. 해당 언어에서 사용하지 않는 카테고리 키는 무시된다.

한국어에서의 복수형

한국어는 복수 구분이 없어서 _other 하나면 충분하다.

json
{
  "item_other": "{{count}}개의 항목"
}

하지만 0개일 때 다른 메시지를 보여주고 싶다면? 복수형 카테고리 대신 별도 키로 처리하는 게 깔끔하다.

json
{
  "item_other": "{{count}}개의 항목이 있습니다",
  "itemEmpty": "항목이 없습니다"
}
tsx
count === 0 ? t('itemEmpty') : t('item', { count })

컨텍스트 (Context)

같은 단어라도 문맥에 따라 번역이 달라지는 경우가 있다. 영어의 "friend"는 성별에 따라 독일어에서 "Freund"(남)/"Freundin"(여)이 된다. 컨텍스트는 이런 상황을 처리한다.

json
{
  "friend_male": "남사친",
  "friend_female": "여사친",
  "greeting_formal": "안녕하십니까, {{name}}님",
  "greeting_casual": "안녕, {{name}}!"
}
tsx
t('friend', { context: 'male' });     // "남사친"
t('friend', { context: 'female' });   // "여사친"

t('greeting', { context: 'formal', name: '김철수' });
// "안녕하십니까, 김철수님"

키에 _컨텍스트값 접미사가 붙는 패턴이다. 복수형과 결합할 수도 있다: friend_male_one, friend_male_other.


Trans 컴포넌트

번역 문자열 안에 React 컴포넌트(링크, 볼드, 아이콘 등)를 넣어야 할 때 사용한다. t() 함수로는 JSX를 삽입할 수 없기 때문이다.

json
{
  "terms": "<0>이용약관</0>과 <1>개인정보처리방침</1>에 동의합니다",
  "bold": "총 <strong>{{count}}개</strong>의 결과"
}
tsx
import { Trans } from 'react-i18next';

<Trans i18nKey="terms">
  <Link to="/terms">이용약관</Link>과
  <Link to="/privacy">개인정보처리방침</Link>에 동의합니다
</Trans>

<0>, <1> 같은 숫자 태그는 Trans의 자식 컴포넌트 순서에 매핑된다. 번역가가 <0><1>의 위치를 바꾸면 실제 렌더링에서도 순서가 바뀐다.

components prop으로 명시적으로 매핑할 수도 있다.

tsx
<Trans
  i18nKey="bold"
  values={{ count: 42 }}
  components={{ strong: <strong /> }}
/>
// "총 <strong>42개</strong>의 결과" 렌더링

이 방식이 더 명확하고, 태그 이름을 자유롭게 정할 수 있어서 번역가가 이해하기도 쉽다.


useTranslation 훅

react-i18next에서 가장 많이 사용하는 API다.

tsx
function MyComponent() {
  const { t, i18n, ready } = useTranslation('auth');

  return (
    <div>
      <h1>{t('login.title')}</h1>
      <p>현재 언어: {i18n.language}</p>
      <button onClick={() => i18n.changeLanguage('en')}>
        English
      </button>
    </div>
  );
}
반환값설명
t번역 함수. 키를 받아서 현재 언어의 번역 문자열 반환
i18ni18next 인스턴스. changeLanguage, language 등 접근
ready번역 리소스 로딩 완료 여부 (Suspense 미사용 시)

언어 변경

tsx
const { i18n } = useTranslation();

// 언어 변경 — Promise 반환
await i18n.changeLanguage('en');

// 현재 언어 확인
console.log(i18n.language);          // 실제 사용 중인 언어 (resolved)
console.log(i18n.resolvedLanguage);  // fallback 체인 고려한 최종 언어

changeLanguage는 새 언어의 리소스를 로딩하고, 모든 useTranslation 훅을 사용하는 컴포넌트를 리렌더링한다. LanguageDetector의 caches 설정이 있으면 localStorage 등에도 자동 저장된다.

t 함수의 옵션들

tsx
// 기본값 (키가 없을 때)
t('missing.key', 'Default text');
t('missing.key', { defaultValue: 'Default text' });

// 네임스페이스 지정
t('key', { ns: 'auth' });

// 보간
t('greeting', { name: '준혁' });

// 복수형
t('item', { count: 5 });

// 컨텍스트
t('friend', { context: 'male' });

// 리턴 타입 지정
t('key', { returnObjects: true });  // 객체/배열 반환 가능

// 키 존재 확인
i18n.exists('some.key');  // boolean

returnObjects: true는 번역 리소스에서 객체나 배열을 통째로 가져올 때 사용한다. 리스트 형태의 번역(FAQ 목록 등)에서 유용하다.


번역 리소스 관리 전략

1. 번들 포함 (정적)

번역 파일을 빌드 시 번들에 포함하는 방식. 가장 단순하고 네트워크 요청이 없다.

typescript
import ko from './locales/ko/common.json';
import en from './locales/en/common.json';

i18n.init({
  resources: {
    ko: { common: ko },
    en: { common: en },
  },
});

번역 파일이 작고 언어 수가 적을 때 적합하다. 단점은 사용하지 않는 언어의 번역도 번들에 포함된다는 것.

2. HTTP 백엔드 (동적)

i18next-http-backend로 필요한 시점에 서버에서 가져오는 방식.

typescript
import Backend from 'i18next-http-backend';

i18n.use(Backend).init({
  backend: {
    loadPath: '/locales/{{lng}}/{{ns}}.json',
    // 또는 CDN
    // loadPath: 'https://cdn.example.com/i18n/{{lng}}/{{ns}}.json',
    requestOptions: {
      cache: 'no-store',  // 개발 중 캐시 방지
    },
  },
});

초기 번들 크기를 줄이고, 번역 업데이트 시 앱 재배포 없이 JSON 파일만 교체할 수 있다. 대부분의 프로덕션 앱에서 권장되는 방식이다.

3. 하이브리드

기본 언어는 번들에 포함하고, 나머지 언어는 동적 로딩하는 절충안.

typescript
import ko from './locales/ko/common.json';

i18n.use(Backend).init({
  partialBundledLanguages: true,
  resources: {
    ko: { common: ko },  // 한국어는 번들에 포함
  },
  backend: {
    loadPath: '/locales/{{lng}}/{{ns}}.json',  // 나머지는 동적 로딩
  },
});

partialBundledLanguages: true가 핵심이다. 이 옵션 없이 resourcesbackend를 동시에 사용하면 i18next가 번들 리소스만 사용하고 백엔드 로딩을 건너뛴다.


타입 안전성

TypeScript 환경에서 번역 키에 대한 자동완성과 타입 체크를 받을 수 있다.

typescript
// src/@types/i18next.d.ts
import 'i18next';
import common from '../locales/ko/common.json';
import auth from '../locales/ko/auth.json';

declare module 'i18next' {
  interface CustomTypeOptions {
    defaultNS: 'common';
    resources: {
      common: typeof common;
      auth: typeof auth;
    };
  }
}

이 선언 파일을 추가하면 t('button.save')에서 자동완성이 동작하고, t('buttton.save') 같은 오타를 컴파일 타임에 잡을 수 있다.

returnNullreturnEmptyString 설정에 따라 t() 함수의 반환 타입도 달라진다.

typescript
interface CustomTypeOptions {
  returnNull: false;         // t()가 null 반환하지 않음 → 반환 타입에서 null 제거
  returnEmptyString: false;  // 빈 문자열도 반환하지 않음
}

SSR / Next.js 통합

서버 사이드 렌더링에서는 서버와 클라이언트가 동일한 i18next 인스턴스 설정을 공유하면서도, 요청별로 다른 언어를 사용해야 한다.

Next.js App Router에서는 next-intl이나 next-i18next를 쓰는 게 일반적이지만, i18next 코어를 직접 사용하는 구조를 이해하면 어떤 래퍼를 쓰든 원리를 파악할 수 있다.

핵심 원칙은 서버에서는 요청마다 새로운 i18next 인스턴스를 생성해야 한다는 것이다. 싱글톤 인스턴스를 공유하면 동시 요청 간 언어가 섞인다.

typescript
// 서버 유틸
import { createInstance } from 'i18next';
import { initReactI18next } from 'react-i18next/initReactI18next';

export async function getServerTranslation(lng: string, ns: string) {
  const i18nInstance = createInstance();
  await i18nInstance
    .use(initReactI18next)
    .init({
      lng,
      ns,
      resources: await loadResources(lng, ns),
    });
  return {
    t: i18nInstance.getFixedT(lng, ns),
    i18n: i18nInstance,
  };
}

실무 팁

키 네이밍 컨벤션

json
// ❌ 나쁜 예 — 의미 불명확, 계층 없음
{
  "text1": "저장",
  "msg": "성공했습니다"
}

// ✅ 좋은 예 — 기능.컴포넌트.요소 계층
{
  "auth": {
    "login": {
      "title": "로그인",
      "submit": "로그인",
      "error": {
        "invalidEmail": "올바른 이메일을 입력하세요",
        "wrongPassword": "비밀번호가 일치하지 않습니다"
      }
    }
  }
}

점(.) 표기법으로 접근: t('auth.login.error.invalidEmail'). 깊이가 3단계를 넘으면 읽기 어려워지니 네임스페이스 분리를 고려하자.

번역 누락 처리

typescript
i18n.init({
  saveMissing: true,           // 누락된 키를 백엔드에 저장
  missingKeyHandler: (lngs, ns, key, fallbackValue) => {
    if (process.env.NODE_ENV === 'development') {
      console.warn(`[i18n] Missing: ${ns}:${key}`);
    }
    // 프로덕션에서는 로깅 서비스에 보고
  },
});

개발 중에 saveMissing: true를 활성화하면 코드에서 사용하는데 JSON에 없는 키를 자동으로 수집할 수 있다. Locize 같은 번역 관리 서비스와 연동하면 번역가에게 자동으로 전달되는 파이프라인을 구축할 수 있다.

언어 전환 UI 패턴

tsx
function LanguageSwitcher() {
  const { i18n } = useTranslation();

  const languages = [
    { code: 'ko', label: '한국어', flag: '🇰🇷' },
    { code: 'en', label: 'English', flag: '🇺🇸' },
    { code: 'ja', label: '日本語', flag: '🇯🇵' },
  ];

  return (
    <select
      value={i18n.language}
      onChange={(e) => i18n.changeLanguage(e.target.value)}
    >
      {languages.map(({ code, label, flag }) => (
        <option key={code} value={code}>
          {flag} {label}
        </option>
      ))}
    </select>
  );
}

언어 라벨은 항상 해당 언어의 자국어로 표시한다. "Korean"이 아니라 "한국어"로. 한국어를 읽을 수 없는 사용자가 언어를 전환하려는 상황을 생각하면 당연하다.

날짜/숫자 포매팅은 Intl에 맡기기

i18next의 보간 포매팅보다 Intl.DateTimeFormat, Intl.NumberFormat을 직접 사용하는 게 더 유연한 경우가 많다. i18next는 문자열 번역에 집중하고, 날짜/숫자 포매팅은 Intl API에 맡기는 분리가 깔끔하다.

tsx
const formatter = new Intl.DateTimeFormat(i18n.language, {
  dateStyle: 'long',
  timeStyle: 'short',
});

return <span>{formatter.format(new Date())}</span>;
// "2026년 2월 15일 오후 9:11" (ko)
// "February 15, 2026, 9:11 PM" (en)

왜 i18next인가

React 국제화 라이브러리는 크게 세 가지 선택지가 있다. react-intl(FormatJS)은 ICU MessageFormat 표준을 따르는 정통파다. 복수형, 성별, 선택 표현이 하나의 메시지 문법으로 통합되어 있어 번역가에게 익숙하지만, 학습 곡선이 있고 메시지 추출을 위한 빌드 단계가 필요하다. next-intl은 Next.js App Router에 최적화된 솔루션으로, 서버 컴포넌트에서의 번역 처리가 깔끔하지만 Next.js 밖에서는 쓸 수 없다. i18next는 프레임워크 독립적이라는 게 가장 큰 강점이다. React, Vue, Node.js 서버, CLI 도구까지 같은 번역 리소스와 설정을 공유할 수 있고, 플러그인 생태계가 방대해서 번역 파일 로딩, 언어 감지, 번역 관리 서비스 연동 등을 모듈 단위로 조합할 수 있다. 단점은 React에 특화된 기능(서버 컴포넌트 지원 등)이 래퍼 수준에서만 제공된다는 것이지만, 범용성과 생태계 규모를 고려하면 대부분의 프로젝트에서 안전한 선택이다.

정리

  • i18next는 프레임워크 독립적인 코어 + React 바인딩 구조로, 번역 리소스를 코드에서 완전히 분리하고 런타임에 언어를 감지·전환한다
  • 네임스페이스로 번역 파일을 분리하면 코드 스플리팅과 결합해서 필요한 번역만 지연 로딩할 수 있다
  • 보간, 복수형, 컨텍스트, Trans 컴포넌트로 실무에서 마주치는 대부분의 번역 패턴을 표준화된 방식으로 처리할 수 있다

관련 문서

  • Zustand persist - 클라이언트 상태 영속화 (언어 설정 저장과 유사한 패턴)
  • next-themes - 테마 전환과 유사한 전역 설정 패턴