다국어 번역, 제대로 관리하고 계신가요?

웹 애플리케이션을 운영하다 보면 화면마다 문구가 계속 추가되고 수정되죠. 이때 다국어 번역 파일을 일일이 관리하는 건 정말 번거롭습니다. 언어가 많아질수록 번역 키를 누락하거나 파일이 꼬이는 일도 생기고요.

그래서 저는 next-intl, i18next-parser 를 활용하여 자동화된 다국어 처리 구조를 만들었습니다. 이 글에서는 그 구조를 차근차근 소개해 드릴게요. 😊


1. 시스템 아키텍처: 전체 플로우 한눈에 보기

1-1. 번역 흐름 요약

  1. i18next-parser가 코드에서 번역 키를 자동으로 추출 → JSON 파일로 저장
  2. 번역 키 파일을 빌드 시점에 백엔드 API로 전송자동 번역 수행 (DeepL)
  3. 어드민에서 수동으로 번역 수정 가능 (자동 번역 보완)
  4. 프론트엔드에서 번역본을 API로 받아오고 캐싱next-intl에 맞게 변환
  5. 번역본이 변경되면 캐시 무효화 요청으로 최신 번역을 반영

1-2. 시퀀스 다이어그램


2. 번역 키 관리: i18next-parser로 자동화하기

2-1. 번역 키 자동 추출하기

i18next-parser는 코드에서 번역 키를 자동으로 추출해서 네임스페이스별 JSON 파일로 만들어줍니다. 설정 파일 예시는 이렇게 되어 있어요:

export default {
  keySeparator: ":",
  defaultValue: (_, namespace, key) => `${namespace}:${key}`,
  lexers: {
    tsx: [
      { lexer: "JsxLexer", functions: ["t", "t.rich"], namespaceFunctions: ["useTranslations"] },
      { lexer: "JsxLexer", functions: ["commonT"], namespaceFunctions: ["useCommonTranslations"] }
    ]
  },
  output: "public/translations/$NAMESPACE.json",
  input: ["apps/client/src/**/*.{js,jsx,ts,tsx}"],
};

2-2. 네임스페이스와 파일 구조

translations/
├── 000.공통.json
...
├── 006.게시판.json
...
  • 숫자 prefix: 어드민에서 번역 키 그룹의 노출 순서를 제어
  • 한글 네임스페이스: 어떤 기능에서 쓰이는 번역인지 쉽게 파악

2-3. 번역 키 규칙

{ordering}.{namespace}:{content}
  • ordering: 어드민에서 주제 별 노출될 순서를 관리하기 위함
  • namespace: 주제
  • content: 내용

2-4. 한글 키값을 사용하게 된 배경

처음에 영어로 번역 키를 쓸지 고민했었어요.
근데 이런 대화가 오갔죠:

이렇게 해서 한글 키값을 네임스페이스와 함께 사용하기로 했어요.
이 덕분에 기획자, 디자이너, 번역가도 키값만 봐도 무슨 의미인지 쉽게 알 수 있답니다. 😊

또한, 이렇게 한글로 만든 키 값 덕분에 번역 본 수정 시 원문을 바로 알 수 있어 수정에 용이해요.




3. 번역 키를 백엔드로 보내는 과정

빌드 시점에 번역 키를 백엔드로 보내서 번역본을 관리할 수 있어요.
이 과정을 담당하는 스크립트가 바로 export-translation-file.js입니다!

3-1. 번역 파일 예시

000.공통.json

{
  "검색": "000.공통:검색"
}

006.게시판.json

{
  "게시글": "006.게시판:게시글",
  "최신기사": "006.게시판:최신기사",
  "제목을 입력해 주세요": "006.게시판:제목을 입력해 주세요",
  "첨부파일 {num}개 ({capacity}KB)": "006.게시판:첨부파일 {num}개 ({capacity}KB)"
}

이런 식으로 번역 키를 추출해서 JSON 파일로 저장하고, 빌드 시에 이 파일들을 백엔드로 전송합니다.
어드민에서는 이 번역본을 불러와서 수동으로 번역을 수정할 수도 있어요.
자동 번역과 수동 수정이 자연스럽게 조화를 이루는 구조랍니다.

3-2. 스크립트 흐름 (export-translation-file.js)

const fs = require('fs');
const path = require('path');

const translationsDir = path.join(__dirname, '../../public/translations');

// 백엔드에 원하는 형태로 보내기 위한 전처리 과정
const getAllTranslationFiles = () => {
  const files = fs.readdirSync(translationsDir);

  const translations = files
    .filter((file) => file.endsWith('.json'))
    .reduce((acc, file) => {
      const filePath = path.join(translationsDir, file);
      const fileContents = fs.readFileSync(filePath, 'utf-8');
      const parsedData = JSON.parse(fileContents);

      const formattedTranslations = Object.entries(parsedData).map(
        ([_, value]) => ({
          name: value,
        }),
      );

      acc.push(...formattedTranslations);
      return acc;
    }, []);

  return translations;
};

// 번역 데이터 전송
const translations = getAllTranslationFiles();

fetch(`<api-base-url>/<end-point>`, {
  method: 'POST',
  headers: {
    'X-API-KEY': ENV.X_API_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(translations),
});

3-3. 이 스크립트가 하는 일:

  • 번역 키 파일을 모두 읽고,
  • 백엔드에 번역 키를 전송해 번역 키를 저장하고 써드파티 번역 API를 통해 번역본을 저장해요.

이렇게 하면 번역 키를 코드에서 작성하는 것만으로 백엔드와 어드민까지 자동으로 연결돼요!


4. 프론트엔드에서 번역본 불러오기: getRequestConfig

프론트엔드에서는 next-intl을 사용해 번역본을 적용합니다.
이때 서버에서 번역본을 받아오고, 캐시를 활용해 성능을 높여요.

import { getRequestConfig } from 'next-intl/server';
import { cookies } from 'next/headers';
import { notFound } from 'next/navigation';

import { COOKIE_KEYS } from '@/constants/cookie-keys';
import { languageApi } from '@/generated/static-apis/Language/Language.query';

// 번역 본을 받아서 next-intl가 이해할 수 있는 형태로 변환
const transformData = (data) => {
  const result = {};
  for (const { key, value } of data) {
    const parts = key.split(/[:.]/);
    let obj = result;
    for (let i = 0; i < parts.length - 1; i++) {
      obj = obj[parts[i]] = obj[parts[i]] || {};
    }
    obj[parts[parts.length - 1]] = value;
  }
  return result;
};

export default getRequestConfig(async () => {
  const cookieLocale = cookies().get(COOKIE_KEYS.LOCALE)?.value || 'ko';
  const [languageList, languageTransList] = await Promise.all([
    languageApi.languageList({ params: { cache: 'force-cache' } }),
    languageApi.languageTransList({ code: cookieLocale, params: { cache: 'force-cache' } }),
  ]);
  const messages = transformData(languageTransList);
  const locales = languageList.map(({ code }) => code);
  if (!locales.includes(cookieLocale)) notFound();
  return { messages, locale: cookieLocale };
});

4-1. 흐름 설명:

  • 쿠키에서 **사용자의 언어(locale)**를 가져오고,
  • 백엔드 API에서 번역 데이터를 받아와서,
  • next-intl이 사용하는 messages 형태로 변환합니다.
  • **캐시(cache)**를 사용해 반복 호출 시 성능도 챙겼어요.

5. 변수 치환 문제: {name}도 번역된다고요?

자동 번역 API를 사용하면 변수도 같이 번역돼버릴 수 있어요.

예시:

{name}님, 안녕하세요!

→ 포르투갈어로 번역 시:

Olá, {nome}!

해결 방법:

  1. 번역 전에 내용에 사용되는 변수를 숫자 변수로 치환
  2. 번역이 끝난 후 다시 원래 변수로 복원

이렇게 하면 변수는 안전하게 보호할 수 있어요!


6. 캐시 전략: 읽기 성능 최적화

  • 번역 데이터는 읽기(Read)가 많고, 쓰기(Write)는 적어요.
  • 그래서 프론트엔드에서는 영구 캐시를 사용합니다.
  • 번역본이 업데이트되면 캐시 무효화 API를 호출해 최신 데이터를 유지해요.
  • 나중에는 번역본을 S3옮겨서 정적 파일로 서빙하는 것도 고려 중입니다.

다국어 관리 스트레스 탈출하기

i18next-parser자동 번역 키 추출 → 누락 걱정 NO

  • 백엔드 자동 번역 + 어드민 수동 수정 → 품질 보장
  • 프론트엔드 캐시 + 무효화 → 성능과 신뢰성 확보
  • 네임스페이스 구조화 → 확장성과 가독성 향상

+ Recent posts