국제화(i18n) — react-intl과 i18next로 다국어 지원하기
"1 items found" — 영어로 이것은 문법 오류입니다. "1 item found"여야 합니다. 한국어에서는 이런 구분이 필요없죠. 다국어를 지원한다는 것은 단순한 번역 이상의 문제입니다.
국제화(Internationalization, i18n)는 텍스트 번역뿐만 아니라 날짜 포맷, 숫자 포맷, 복수형, 텍스트 방향(RTL)까지 포함합니다. React 앱에서 i18n을 구현하는 핵심 방법을 react-i18next를 중심으로 정리합니다.
react-i18next 설정
npm install react-i18next i18next i18next-browser-languagedetector i18next-http-backend
// i18n.js
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import HttpBackend from 'i18next-http-backend';
i18n
.use(HttpBackend) // 번역 파일을 HTTP로 로드
.use(LanguageDetector) // 브라우저 언어 감지
.use(initReactI18next) // React 바인딩
.init({
fallbackLng: 'ko', // 기본 언어
supportedLngs: ['ko', 'en', 'ja'],
ns: ['common', 'auth', 'dashboard'], // 네임스페이스
defaultNS: 'common',
interpolation: {
escapeValue: false, // React가 이미 XSS를 방지
},
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
detection: {
order: ['localStorage', 'querystring', 'navigator'],
caches: ['localStorage'],
},
});
export default i18n;
// main.jsx
import './i18n'; // 앱 시작 전에 초기화
import App from './App';
ReactDOM.createRoot(document.getElementById('root')).render(
<Suspense fallback={<LoadingScreen />}>
<App />
</Suspense>
);
번역 파일 구조
public/
└── locales/
├── ko/
│ ├── common.json
│ ├── auth.json
│ └── dashboard.json
├── en/
│ ├── common.json
│ ├── auth.json
│ └── dashboard.json
└── ja/
├── common.json
└── ...
// locales/ko/common.json
{
"greeting": "안녕하세요, {{name}}님!",
"nav": {
"home": "홈",
"about": "소개",
"settings": "설정"
},
"items_found": "{{count}}개의 결과를 찾았습니다"
}
// locales/en/common.json
{
"greeting": "Hello, {{name}}!",
"nav": {
"home": "Home",
"about": "About",
"settings": "Settings"
},
"items_found_one": "{{count}} item found",
"items_found_other": "{{count}} items found"
}
기본 사용법
useTranslation Hook
import { useTranslation } from 'react-i18next';
function Header() {
const { t, i18n } = useTranslation();
return (
<header>
<h1>{t('greeting', { name: '홍길동' })}</h1>
<nav>
<a href="/">{t('nav.home')}</a>
<a href="/about">{t('nav.about')}</a>
<a href="/settings">{t('nav.settings')}</a>
</nav>
</header>
);
}
네임스페이스 지정
// auth 네임스페이스 사용
function LoginPage() {
const { t } = useTranslation('auth');
return (
<form>
<label>{t('email')}</label>
<input type="email" />
<label>{t('password')}</label>
<input type="password" />
<button>{t('login')}</button>
</form>
);
}
// 여러 네임스페이스 사용
function Dashboard() {
const { t } = useTranslation(['dashboard', 'common']);
return (
<div>
<h1>{t('dashboard:title')}</h1>
<p>{t('common:greeting', { name: 'User' })}</p>
</div>
);
}
복수형 (Pluralization)
// en/common.json
{
"message_one": "You have {{count}} message",
"message_other": "You have {{count}} messages",
"message_zero": "You have no messages"
}
// ko/common.json
{
"message": "{{count}}개의 메시지가 있습니다"
}
function Inbox({ messageCount }) {
const { t } = useTranslation();
return <p>{t('message', { count: messageCount })}</p>;
// 영어: "You have 1 message" / "You have 5 messages"
// 한국어: "1개의 메시지가 있습니다" / "5개의 메시지가 있습니다"
}
i18next는 언어별 복수형 규칙을 자동으로 적용합니다. 한국어는 복수형 구분이 없으므로 하나의 키만 필요합니다.
날짜와 숫자 포매팅
Intl API 활용
function FormattedDate({ date, lng }) {
const { i18n } = useTranslation();
const locale = lng || i18n.language;
const formatted = new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(new Date(date));
return <time dateTime={date}>{formatted}</time>;
// ko: 2026년 3월 19일
// en: March 19, 2026
// ja: 2026年3月19日
}
function FormattedNumber({ value }) {
const { i18n } = useTranslation();
const formatted = new Intl.NumberFormat(i18n.language).format(value);
return <span>{formatted}</span>;
// ko: 1,234,567
// en: 1,234,567
// de: 1.234.567
}
function FormattedCurrency({ value, currency = 'KRW' }) {
const { i18n } = useTranslation();
const formatted = new Intl.NumberFormat(i18n.language, {
style: 'currency',
currency,
}).format(value);
return <span>{formatted}</span>;
// ko + KRW: ₩1,234,567
// en + USD: $1,234,567.00
}
Trans 컴포넌트 — JSX 삽입
번역 문자열 안에 React 컴포넌트를 삽입해야 할 때 사용합니다.
{
"terms": "<0>이용약관</0>에 동의합니다",
"welcome": "환영합니다, <bold>{{name}}</bold>님!"
}
import { Trans } from 'react-i18next';
function TermsAgreement() {
return (
<p>
<Trans i18nKey="terms">
<a href="/terms">이용약관</a>에 동의합니다
</Trans>
</p>
);
}
function Welcome({ name }) {
return (
<p>
<Trans i18nKey="welcome" values={{ name }} components={{ bold: <strong /> }}>
환영합니다, <strong>{'{{name}}'}</strong>님!
</Trans>
</p>
);
}
언어 전환
function LanguageSwitcher() {
const { i18n } = useTranslation();
const languages = [
{ code: 'ko', label: '한국어' },
{ code: 'en', label: 'English' },
{ code: 'ja', label: '日本語' },
];
return (
<select
value={i18n.language}
onChange={(e) => i18n.changeLanguage(e.target.value)}
>
{languages.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.label}
</option>
))}
</select>
);
}
changeLanguage를 호출하면 i18next가 해당 언어의 번역을 로드하고, useTranslation을 사용하는 모든 컴포넌트가 자동으로 리렌더링됩니다.
RTL (Right-to-Left) 지원
function useDirection() {
const { i18n } = useTranslation();
const rtlLanguages = ['ar', 'he', 'fa'];
return rtlLanguages.includes(i18n.language) ? 'rtl' : 'ltr';
}
function App() {
const dir = useDirection();
const { i18n } = useTranslation();
useEffect(() => {
document.documentElement.dir = dir;
document.documentElement.lang = i18n.language;
}, [dir, i18n.language]);
return <div>{/* ... */}</div>;
}
CSS에서 RTL 대응
/* 논리적 속성 사용 — RTL 자동 대응 */
.card {
margin-inline-start: 16px; /* LTR: margin-left, RTL: margin-right */
padding-inline-end: 8px; /* LTR: padding-right, RTL: padding-left */
text-align: start; /* LTR: left, RTL: right */
}
/* 대신 사용하지 말 것 */
.card-old {
margin-left: 16px; /* RTL에서 잘못된 방향 */
text-align: left; /* RTL에서 잘못된 방향 */
}
CSS Logical Properties를 사용하면 dir 속성에 따라 자동으로 방향이 전환됩니다.
언어 감지
// i18next-browser-languagedetector 설정
detection: {
// 감지 순서
order: [
'localStorage', // 1. 사용자가 직접 선택한 언어
'querystring', // 2. URL ?lng=en
'navigator', // 3. 브라우저 설정
'htmlTag', // 4. <html lang="ko">
],
lookupQuerystring: 'lng',
lookupLocalStorage: 'i18nextLng',
caches: ['localStorage'],
}
번역 관리 팁
키 네이밍 규칙
{
"auth.login.title": "로그인",
"auth.login.submit": "로그인하기",
"auth.login.error.invalid": "이메일 또는 비밀번호가 올바르지 않습니다",
"common.button.save": "저장",
"common.button.cancel": "취소"
}
기능.컨텍스트.요소 형식으로 체계적으로 관리합니다.
TypeScript 타입 안전성
// i18next.d.ts
import 'i18next';
import common from '../public/locales/ko/common.json';
import auth from '../public/locales/ko/auth.json';
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'common';
resources: {
common: typeof common;
auth: typeof auth;
};
}
}
이렇게 설정하면 t('존재하지않는키')에 TypeScript 에러가 발생합니다.
정리
국제화는 "번역 + 로컬라이제이션"이며, 텍스트 번역은 그 일부일 뿐입니다.
- react-i18next: 네임스페이스, lazy loading, Suspense 통합을 지원하는 성숙한 라이브러리
- ** 복수형 **: 언어마다 규칙이 다르므로 i18next의 자동 처리에 맡기는 것이 안전합니다
- ** 날짜/숫자 **:
IntlAPI로 언어에 맞게 포매팅합니다 - RTL: CSS Logical Properties로 방향 전환을 자동화합니다
- ** 언어 감지 **: 사용자 선택 > URL > 브라우저 설정 순서로 우선순위를 둡니다
- TypeScript: 번역 키의 타입 안전성을 확보하면 오타로 인한 버그를 방지합니다
주의할 점
번역 문자열 안에 HTML을 하드코딩
번역 파일에 <b>굵은 텍스트</b> 같은 HTML을 넣으면 XSS 위험이 있고 번역가가 태그를 실수로 깨뜨릴 수 있습니다. i18next의 Trans 컴포넌트를 사용하여 React 컴포넌트로 처리하는 것이 안전합니다.
나중에 i18n을 추가하면 비용이 급증
모든 하드코딩된 문자열을 번역 키로 교체하고, 레이아웃을 다양한 언어 길이에 맞게 조정해야 합니다. 다국어 지원 가능성이 있다면 프로젝트 초기부터 i18n 구조를 잡아두어야 합니다.
i18n을 나중에 추가하는 것은 매우 어렵습니다. 다국어 지원이 필요할 가능성이 있다면, 프로젝트 초기부터 i18n 구조를 잡아두는 것이 필수입니다.