본문 바로가기

React

React에서 복수 언어 번역 관리 자동화 툴 구축하기

복수 언어를 수동으로 관리하면 누락, 중복, 불일치가 잦습니다. 이 글은 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. 마무리

키 추출 → 병합 → 검증 → 배포로 이어지는 자동화를 구축하면 언어가 늘어나도 비용이 선형적으로 증가하지 않습니다. 위 스크립트들을 프로젝트에 맞게 커스터마이즈해 팀의 번역 생산성을 극대화해 보세요.