복수 언어를 수동으로 관리하면 누락, 중복, 불일치가 잦습니다. 이 글은 React 프로젝트에서 번역 키 추출, 병합, 검증, 배포까지 자동화하는 실무 워크플로를 단계별로 구축하는 방법을 다룹니다. i18next + react-i18next를 예제로 사용하며, Node.js 스크립트로 키 관리 파이프라인을 만듭니다.
1. 목표와 구조
목표는 다음과 같습니다: (1) 코드에서 번역 키를 자동 추출, (2) 언어별 JSON 자동 병합/정렬, (3) 미번역/불일치 검증으로 PR 차단, (4) 런타임은 지연 로딩으로 성능 유지. 구조는 src/locales/{lng}/{ns}.json 형태의 네임스페이스 기반으로 권장합니다.
2. 키 전략과 네임스페이스
네임스페이스를 화면/도메인 단위로 나눕니다: common, home, product 등. 키는 짧고 의미 있게 작성합니다. 예: t('product:title'), t('product:price', { value }). 변수는 i18next 기본 인터폴레이션 {{var}}를 사용합니다.
3. React 통합(i18next + react-i18next)
i18next-http-backend로 번역 파일을 지연 로딩하고, Suspense로 초기 로딩 상태를 처리합니다.
// src/i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import HttpBackend from 'i18next-http-backend';
i18n
.use(HttpBackend)
.use(initReactI18next)
.init({
fallbackLng: 'en',
supportedLngs: ['en', 'ko', 'ja'],
ns: ['common', 'home', 'product'],
defaultNS: 'common',
interpolation: { escapeValue: false },
react: { useSuspense: true },
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
});
export default i18n;
// src/main.tsx
import './i18n';
import React, { Suspense } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')!).render(
<Suspense fallback={<div>Loading…</div>} >
<App />
</Suspense>
);
// src/components/ProductTitle.tsx
import React from 'react';
import { useTranslation } from 'react-i18next';
export default function ProductTitle({ name }: { name: string }) {
const { t } = useTranslation('product');
return <h1>{t('title', { name })}</h1>; // key: product:title
}
4. 키 자동 추출 스크립트(Node + Babel)
코드에서 t('ns:key') 패턴을 찾아 키를 수집합니다. 단순 패턴에서 시작해 점진 확장합니다.
// scripts/extract-i18n.js
/* eslint-disable no-console */
const fg = require('fast-glob');
const fs = require('fs');
const path = require('path');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const SRC_GLOB = ['src/**/*.{ts,tsx,js,jsx}'];
function collectKeysFromFile(file) {
const code = fs.readFileSync(file, 'utf8');
const ast = parser.parse(code, {
sourceType: 'module',
plugins: ['typescript', 'jsx', 'classProperties']
});
const found = [];
traverse(ast, {
CallExpression(path) {
const callee = path.node.callee;
const isT = (n) => (n.type === 'Identifier' && n.name === 't') ||
(n.type === 'MemberExpression' && n.property?.name === 't');
if (!isT(callee)) return;
const arg = path.node.arguments[0];
if (arg && arg.type === 'StringLiteral' && arg.value.includes(':')) {
found.push(arg.value); // ns:key
}
},
});
return found;
}
(async function main() {
const files = await fg(SRC_GLOB, { dot: false });
const keys = new Set();
files.forEach((f) => collectKeysFromFile(f).forEach((k) => keys.add(k)));
// ns 별로 그룹화
const grouped = {};
for (const full of keys) {
const [ns, key] = full.split(':');
if (!grouped[ns]) grouped[ns] = [];
grouped[ns].push(key);
}
// 정렬
Object.keys(grouped).forEach((ns) => grouped[ns].sort());
const outDir = path.join(process.cwd(), 'tmp');
fs.mkdirSync(outDir, { recursive: true });
const outFile = path.join(outDir, 'extracted.json');
fs.writeFileSync(outFile, JSON.stringify(grouped, null, 2));
console.log(`extracted → ${outFile}`);
})();
5. 번역 파일 병합/정렬
추출 결과를 기반 언어(en)에 병합하고, 다른 언어에 비어 있는 문자열을 생성합니다. 기존 번역은 유지하고 새 키만 추가합니다.
// scripts/merge-i18n.js
/* eslint-disable no-console */
const fs = require('fs');
const path = require('path');
const LOCALES_DIR = path.join(process.cwd(), 'public', 'locales');
const BASE = 'en';
const TARGETS = ['ko', 'ja'];
function readJSON(file) {
return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
}
function writeJSON(file, obj) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(obj, null, 2) + '\n');
}
function sortObjectByKey(obj) {
return Object.keys(obj).sort().reduce((acc, k) => { acc[k] = obj[k]; return acc; }, {});
}
(function main() {
const extracted = JSON.parse(fs.readFileSync(path.join('tmp', 'extracted.json'), 'utf8'));
for (const ns of Object.keys(extracted)) {
const keys = extracted[ns];
// base(en)
const baseFile = path.join(LOCALES_DIR, BASE, `${ns}.json`);
const baseObj = readJSON(baseFile);
keys.forEach((k) => { if (!(k in baseObj)) baseObj[k] = ''; });
const baseSorted = sortObjectByKey(baseObj);
writeJSON(baseFile, baseSorted);
// targets
for (const lng of TARGETS) {
const file = path.join(LOCALES_DIR, lng, `${ns}.json`);
const obj = readJSON(file);
keys.forEach((k) => { if (!(k in obj)) obj[k] = ''; });
// 선택: 사용 안 하는 키를 유지할지 삭제할지 팀 규칙으로 결정
const sorted = sortObjectByKey(obj);
writeJSON(file, sorted);
}
}
console.log('merge complete');
})();
6. 검증: 미번역/플레이스홀더 불일치
PR에서 누락된 키, 사용 안 하는 키, 플레이스홀더 {{var}} 불일치를 검출해 실패시키면 품질이 안정됩니다.
// scripts/check-i18n.js
/* eslint-disable no-console */
const fs = require('fs');
const path = require('path');
const LOCALES_DIR = path.join(process.cwd(), 'public', 'locales');
const BASE = 'en';
const TARGETS = ['ko', 'ja'];
const NS = fs.existsSync(path.join('tmp', 'extracted.json'))
? Object.keys(JSON.parse(fs.readFileSync(path.join('tmp', 'extracted.json'), 'utf8')))
: [];
function read(file) { return JSON.parse(fs.readFileSync(file, 'utf8')); }
function getPlaceholders(str) {
const set = new Set();
const re = /{{\s*([\w.]+)\s*}}/g;
let m; while ((m = re.exec(str))) set.add(m[1]);
return set;
}
let hasError = false;
for (const ns of NS) {
const baseFile = path.join(LOCALES_DIR, BASE, `${ns}.json`);
const base = fs.existsSync(baseFile) ? read(baseFile) : {};
for (const lng of TARGETS) {
const file = path.join(LOCALES_DIR, lng, `${ns}.json`);
const obj = fs.existsSync(file) ? read(file) : {};
// 미번역 체크
for (const k of Object.keys(base)) {
if (!(k in obj) || obj[k] === '') {
hasError = true;
console.error(`[missing] ${lng}/${ns}.${k}`);
}
}
// 플레이스홀더 일치 체크
for (const k of Object.keys(base)) {
const a = getPlaceholders(base[k] || '');
const b = getPlaceholders((obj[k] || ''));
const diff = [...new Set([...a, ...b])].filter((x) => !(a.has(x) && b.has(x)));
if (diff.length) {
hasError = true;
console.error(`[placeholder-mismatch] ${lng}/${ns}.${k} → ${diff.join(', ')}`);
}
}
// 고아(orphan) 키 경고 (선택)
for (const k of Object.keys(obj)) {
if (!(k in base)) {
console.warn(`[orphan] ${lng}/${ns}.${k}`);
}
}
}
}
if (hasError) {
console.error('i18n check failed');
process.exit(1);
} else {
console.log('i18n check passed');
}
7. npm 스크립트와 CI 연동
로컬과 CI에서 동일하게 동작하도록 스크립트를 연결합니다. PR에서 누락/불일치가 있으면 실패합니다.
// package.json (일부)
{
"scripts": {
"i18n:extract": "node scripts/extract-i18n.js",
"i18n:merge": "node scripts/merge-i18n.js",
"i18n:check": "node scripts/check-i18n.js",
"i18n": "npm run i18n:extract && npm run i18n:merge && npm run i18n:check"
}
}
# .github/workflows/i18n-check.yml
name: i18n
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm run i18n:extract
- run: npm run i18n:merge
- run: npm run i18n:check
8. 번역 협업(스프레드시트/플랫폼 연동)
초기에는 CSV/Google Sheets로 내보내기/가져오기만으로도 충분합니다. 성장하면 Lokalise, Crowdin, POEditor API로 자동 동기화하세요.
// scripts/export-csv.js (간단 내보내기)
const fs = require('fs');
const path = require('path');
const LOCALES_DIR = path.join('public', 'locales');
const BASE = 'en';
function toCSVRow(arr) { return '"' + arr.map((v) => String(v).replace(/"/g, '""')).join('","') + '"\n'; }
const nsFiles = fs.readdirSync(path.join(LOCALES_DIR, BASE)).filter(f => f.endsWith('.json'));
let csv = toCSVRow(['namespace', 'key', 'en', 'ko', 'ja']);
for (const nsFile of nsFiles) {
const ns = nsFile.replace('.json', '');
const base = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, BASE, nsFile), 'utf8'));
const ko = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, 'ko', nsFile), 'utf8'));
const ja = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, 'ja', nsFile), 'utf8'));
for (const k of Object.keys(base)) {
csv += toCSVRow([ns, k, base[k] || '', ko[k] || '', ja[k] || '']);
}
}
fs.writeFileSync('tmp/i18n.csv', csv);
console.log('exported tmp/i18n.csv');
9. 성능 최적화(런타임)
번역 파일은 언어/네임스페이스별로 분할되어 HTTP 캐시가 잘 동작합니다. 백엔드 로더(HttpBackend)로 필요한 시점에만 로드하고, supportedLngs를 최소화합니다. Intl 관련 형식(날짜/숫자)은 브라우저 Intl을 사용하거나, 필요한 경우 polyfill을 조건부 로드하세요.
10. 품질 가드와 팁
(1) ESLint로 하드코딩 문자열 금지: i18next/no-literal-string 규칙을 도입하세요. (2) 기본 언어(en)를 소스 오브 트루스로 유지하고 리뷰 시 키 추가/삭제를 확인합니다. (3) 키 네이밍은 변경 비용이 크므로 초기에 가이드를 정합니다. (4) 스냅샷 테스트에서 t 모킹으로 UI 리그레션을 최소화하세요.
11. 트러블슈팅
번역이 적용되지 않으면 네임스페이스 로드 경로(loadPath), supportedLngs, 파일 이름(ns.json) 오탈자를 먼저 확인합니다. 플레이스홀더가 그대로 보이면 escapeValue, 템플릿 {{var}} 오타를 점검하세요. 지연 로딩 시 깜빡임이 보이면 Suspense fallback을 시각적으로 자연스럽게 조정합니다.
12. 마무리
키 추출 → 병합 → 검증 → 배포로 이어지는 자동화를 구축하면 언어가 늘어나도 비용이 선형적으로 증가하지 않습니다. 위 스크립트들을 프로젝트에 맞게 커스터마이즈해 팀의 번역 생산성을 극대화해 보세요.
'React' 카테고리의 다른 글
| React에서 REST API 에러 처리 및 재시도 로직 구현하기 (0) | 2026.06.24 |
|---|---|
| React 앱에서 서버 로그 데이터를 실시간 모니터링하기 (0) | 2026.06.23 |
| React 앱에서 사용자 입력 데이터 자동 저장(Auto Save) 기능 구현하기 (0) | 2026.06.22 |
| React에서 브라우저 풀스크린 API 제어하기 (1) | 2026.06.22 |
| React 앱에서 지연 로딩(Lazy Loading) 비디오 구현하기 (0) | 2026.06.19 |