AI로 국제화(i18n) 작업 줄이기: next-intl 메시지 구조, 초벌 번역 검수, 키 검사 자동화
번역은 AI 초벌과 사람 검수로, 키·플레이스홀더 어긋남은 스크립트로 잡는 흐름

핵심 요약
- next-intl은 로케일별 JSON에 키를 중첩해 두고 useTranslations로 네임스페이스와 키를 참조한다.
- 복수형 분류는 언어마다 달라 한국어 기준 메시지만으로는 영어·러시아어 분기 누락이 드러나지 않는다.
- AI 초벌 번역 뒤 키 집합과 ICU 플레이스홀더 이름을 스크립트로 검사하면 사람이 놓치는 구조 오류를 잡을 수 있다.
- 통화·날짜는 Intl.NumberFormat·DateTimeFormat에 맡기고 직접 조합하지 않는다.
문자열을 컴포넌트에 직접 쓰면 언어를 추가할 때마다 코드를 고쳐야 한다. 메시지를 로케일별 JSON으로 분리하면 코드는 키만 참조하고, 번역은 파일 단위로 교체·검수할 수 있다. AI는 초벌 번역을 빠르게 만들어 주지만 플레이스홀더 이름을 바꾸거나 키를 빠뜨리는 실수를 하고, 사람이 눈으로 대조하면 놓치기 쉽다. 이 글은 next-intl을 기준으로 메시지 구조, AI 초벌 번역 검수, 키 검사 자동화를 정리한다.
next-intl 메시지 구조
next-intl은 로케일별 JSON 파일(예: messages/ko.json)에 키와 번역을 두고, 키를 객체로 중첩해 네임스페이스를 만든다. 컴포넌트에서는 useTranslations에 네임스페이스를 넘기고 반환된 t 함수에 키를 넘긴다. 동적 값은 ICU 형식의 중괄호 인자로 넣으며, 인자 이름은 영문자·숫자·밑줄만 쓸 수 있다(하이픈 불가).
{
"Cart": {
"title": "장바구니",
"itemCount": "{count, plural, other {#개 상품}}",
"checkout": "{total} 결제하기"
},
"Auth": {
"errorRequired": "{field}을(를) 입력하세요."
}
}import { useTranslations } from 'next-intl';
export function CartSummary({ count, total }: { count: number; total: string }) {
const t = useTranslations('Cart');
return (
<section>
<h2>{t('title')}</h2>
<p>{t('itemCount', { count })}</p>
<button>{t('checkout', { total })}</button>
</section>
);
}키는 화면 위치가 아니라 의미와 역할로 짓는다(title, checkout, errorRequired). 기준 로케일 하나를 단일 출처로 정하고, 새 문자열은 기준 로케일에 먼저 추가한 뒤 다른 로케일이 같은 키 집합을 유지하게 한다.
복수형은 언어마다 분류가 다르다
next-intl의 plural 인자는 Intl.PluralRules로 형태를 고른다. 언어별로 어떤 분류가 있는지 Node.js 24.15에서 직접 확인했다(2026-10-09).
$ node -e "for (const loc of ['ko','en','ar','ru']) { const pr = new Intl.PluralRules(loc); console.log(loc, pr.resolvedOptions().pluralCategories.join(','), '| 0,1,2,5,21 →', [0,1,2,5,21].map(n => pr.select(n)).join(',')) }"
ko categories: other | 0,1,2,5,21 → other,other,other,other,other
en categories: one,other | 0,1,2,5,21 → other,one,other,other,other
ar categories: zero,one,two,few,many,other | 0,1,2,5,21 → zero,one,two,few,many
ru categories: one,few,many,other | 0,1,2,5,21 → many,one,few,many,one한국어는 other 하나뿐이라 한국어를 기준으로 작성한 메시지에는 복수형 분기가 없어도 문제가 드러나지 않는다. 그런데 영어는 one이 필요하고, 러시아어는 21에서 다시 one이 된다. AI에 번역을 맡길 때 "대상 언어의 복수 분류를 모두 채워라"를 요청에 넣어야 하는 이유다. 통화와 날짜도 직접 조합하지 말고 Intl.NumberFormat, Intl.DateTimeFormat에 맡긴다.
$ node -e "console.log(new Intl.NumberFormat('ko-KR',{style:'currency',currency:'KRW'}).format(12900), '|', new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR'}).format(12900.5)); console.log(new Intl.DateTimeFormat('ko-KR',{dateStyle:'long',timeZone:'Asia/Seoul'}).format(new Date('2026-10-09T00:00:00Z')), '|', new Intl.DateTimeFormat('en-US',{dateStyle:'long',timeZone:'Asia/Seoul'}).format(new Date('2026-10-09T00:00:00Z')))"
₩12,900 | 12.900,50 €
2026년 10월 9일 | October 9, 2026AI 초벌 번역과 검수 순서
- 기준 로케일 메시지를 확정한다.
- 키 목록과 함께 화면 맥락, 용어집, 금지 표현, 대상 언어의 복수 분류를 주고 초벌 번역을 요청한다. 플레이스홀더 이름은 바꾸지 말라고 명시한다.
- 아래 검사 스크립트로 키 집합과 플레이스홀더 이름을 확인한다.
- 원어민 검수자가 실제 화면에서 표현을 확인한다. 결제·인증·오류 메시지는 반드시 사람 검수를 거친다.
- 검사와 검수를 통과한 파일만 배포 파이프라인에 넣는다.
키·플레이스홀더 검사 스크립트
로케일 파일 사이에 키가 어긋나면 화면에 번역 대신 키나 기본값이 나오고, 플레이스홀더 이름이 바뀌면 값이 들어가지 않는다. 아래는 기준 로케일과 다른 로케일의 키 집합, ICU 인자 이름을 비교하는 스크립트다. 예시로 영어 파일에 AI 초벌 번역에서 흔한 실수 두 가지(키 누락, {total}을 {amount}로 바꿈)를 넣었다.
{
"Cart": {
"title": "Cart",
"itemCount": "{count, plural, one {# item} other {# items}}",
"checkout": "Pay {amount}"
},
"Auth": {}
}// 사용법: node check-messages.mjs messages ko
// 기준 로케일(ko)과 다른 로케일 파일의 키 집합, ICU 플레이스홀더 이름이 같은지 확인한다.
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
const [dir, base] = process.argv.slice(2);
const flatten = (obj, prefix = '') => Object.entries(obj).flatMap(([k, v]) =>
typeof v === 'object' && v !== null ? flatten(v, `${prefix}${k}.`) : [[`${prefix}${k}`, String(v)]]);
// {name} 또는 {count, plural, ...}의 인자 이름만 뽑는다. plural 분기 안의 #은 인자가 아니다.
const args = (msg) => new Set([...msg.matchAll(/\{\s*([A-Za-z0-9_]+)\s*(?:[,}])/g)].map((m) => m[1]));
const load = (loc) => new Map(flatten(JSON.parse(readFileSync(join(dir, `${loc}.json`), 'utf8'))));
const ref = load(base);
let failed = 0;
for (const file of readdirSync(dir).filter((f) => f.endsWith('.json') && f !== `${base}.json`)) {
const loc = file.replace('.json', '');
const cur = load(loc);
const problems = [];
for (const key of ref.keys()) if (!cur.has(key)) problems.push(`누락 키: ${key}`);
for (const key of cur.keys()) if (!ref.has(key)) problems.push(`기준에 없는 키: ${key}`);
for (const [key, msg] of cur) {
if (!ref.has(key)) continue;
const a = [...args(ref.get(key))].sort().join(',');
const b = [...args(msg)].sort().join(',');
if (a !== b) problems.push(`플레이스홀더 불일치: ${key} (${base}: {${a}} / ${loc}: {${b}})`);
}
if (problems.length) failed++;
console.log(`${problems.length ? 'FAIL' : 'PASS'} ${loc} (키 ${cur.size}/${ref.size})`);
problems.forEach((p) => console.log(` - ${p}`));
}
process.exitCode = failed ? 1 : 0;$ node check-messages.mjs messages ko
FAIL en (키 3/4)
- 누락 키: Auth.errorRequired
- 플레이스홀더 불일치: Cart.checkout (ko: {total} / en: {amount})
exit=1Pay {amount}는 문장만 보면 자연스러운 번역이라 사람 검수에서도 놓치기 쉽다. 하지만 코드는 total을 넘기므로 화면에는 금액이 빠진다. 이런 구조적 오류는 기계가 잡고, 사람은 표현과 맥락에 집중하도록 나누는 것이 핵심이다. 이 스크립트를 CI에 넣어 실패하면 병합을 막는다.
배포 후 확인
- 독일어처럼 문자열이 길어지는 언어에서 버튼·탭이 넘치지 않는지 확인한다.
- 매출·가입에 영향을 주는 핵심 화면부터 언어별로 실제 흐름을 걸어 본다.
- 번역 품질 이슈는 언어 태그를 붙여 이슈 트래커로 관리한다.
서버 액션의 오류 메시지까지 다국어로 다뤄야 한다면 서버 액션 에러 핸들링을, 접근성 점검과 함께 진행한다면 WCAG 2.2 접근성 점검을 참고한다.
초보자가 자주 실수하는 포인트
- 키를 화면 위치 기준으로 지어 같은 의미의 문구가 중복된다.
- AI 번역이 플레이스홀더 이름을 바꾼 것을 놓친다.
- 한국어 기준 메시지에 복수형 분기를 두지 않아 다른 언어에서 문장이 어색해진다.
- 통화 기호와 숫자를 문자열로 직접 조합한다.
체크리스트
- 기준 로케일을 정하고 키 네이밍 규칙을 문서로 남겼다.
- 번역 요청에 용어집, 맥락, 복수 분류, 플레이스홀더 유지 지시를 넣었다.
- 키·플레이스홀더 검사가 CI에서 실행된다.
- 결제·인증·오류 문구는 원어민 검수를 거쳤다.
- 긴 언어에서 레이아웃 넘침을 확인했다.
자주 묻는 질문
AI 번역을 그대로 배포해도 되나요?
결제, 인증, 법적 고지, 오류 메시지처럼 오해가 치명적인 문구는 원어민 검수를 거쳐야 합니다. 키와 플레이스홀더 같은 구조 오류는 스크립트로 먼저 걸러 검수자가 표현에 집중하게 합니다.
ICU 인자 이름에 하이픈을 써도 되나요?
next-intl 문서는 인자 이름에 영문자·숫자·밑줄만 쓸 수 있고 하이픈은 지원하지 않는다고 안내합니다. userName이나 user_name처럼 짓습니다.
참고 자료 · 검증 기준
위 자료와 내용을 대조한 날짜: . 도구·서비스 정책은 이후 바뀔 수 있으므로 적용 전 공식 문서를 다시 확인하세요.
이 글은 위 참고 자료를 바탕으로 정리했으며, 내용은 운영 과정에서 순차적으로 보완될 수 있습니다.