Claude Code로 i18n 구현하기: Next.js 다국어 실전 가이드
Claude Code로 Next.js i18n을 구현하는 방법을 next-intl 설정, 번역 검증, 실전 사례와 함정까지 정리했습니다.
i18n은 번역 파일 추가가 아니라 구조 설계다
i18n은 internationalization, 즉 국제화를 뜻합니다. 실무에서는 단순히 JSON 파일을 여러 언어로 만드는 일이 아닙니다. URL 구조, locale 판정, SEO metadata, 날짜와 통화 형식, 복수형, 내부 링크, 번역 누락 검증까지 함께 설계해야 공개 가능한 품질이 됩니다. Claude Code는 여러 파일을 읽고 수정하며 명령까지 실행할 수 있어서 잘 맞지만, 목표가 흐릿하면 그럴듯한 번역 파일만 늘어나고 운영은 어려워집니다.
Masa가 콘텐츠 사이트를 운영하며 효과를 본 방식은 Claude Code에 “번역해줘”라고 말하지 않는 것입니다. 대신 “라우팅 기반, 메시지 파일, 페이지 이전, 검사 스크립트, 리뷰 관점까지 한 번에 만들어줘”라고 요청합니다. 기반이란 다국어 기능을 지탱하는 발판입니다. 이 발판이 안정적이면 영어, 독일어, 인도네시아어를 나중에 추가해도 변경 범위가 작고 리뷰가 쉬워집니다.
이 글은 Next.js App Router와 next-intl을 기준으로 설명합니다. 리뷰할 때는 Claude Code 공식 문서, next-intl routing setup, Next.js internationalization 가이드, MDN의 Intl 문서를 함께 확인하세요. 특히 최신 Next.js에서는 proxy.ts와 이전 middleware.ts의 차이를 확인해야 합니다.
flowchart LR
A[요구사항] --> B[라우팅]
B --> C[번역 JSON]
C --> D[페이지와 metadata]
D --> E[누락 검사]
E --> F[SEO와 공개 확인]
Next.js App Router와 next-intl 기본 구성
예시는 ja를 기본 언어로 두고 en, de를 추가합니다. Next.js 16 문서는 라우팅 가로채기 파일로 proxy.ts를 사용합니다. 오래된 프로젝트에는 같은 역할의 middleware.ts가 남아 있을 수 있으므로, Claude Code에 먼저 Next.js 버전을 확인하게 하세요.
src/
app/
[locale]/
layout.tsx
page.tsx
i18n/
navigation.ts
request.ts
routing.ts
messages/
ja.json
en.json
de.json
proxy.ts
routing.ts에 지원 언어와 경로를 모읍니다. 문자열 치환으로 /ja와 /en을 곳곳에 넣으면 나중에 URL 정책이 바뀔 때 위험합니다.
// src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['ja', 'en', 'de'],
defaultLocale: 'ja',
pathnames: {
'/': '/',
'/pricing': {
ja: '/pricing',
en: '/pricing',
de: '/preise',
},
'/docs': {
ja: '/docs',
en: '/docs',
de: '/dokumentation',
},
},
});
export type Locale = (typeof routing.locales)[number];
// src/i18n/navigation.ts
import { createNavigation } from 'next-intl/navigation';
import { routing } from './routing';
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);
요청 단위 설정은 request.ts에 둡니다. 지원하지 않는 locale이 들어오면 기본 언어로 되돌려, 존재하지 않는 JSON import를 막습니다.
// src/i18n/request.ts
import { hasLocale } from 'next-intl';
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale;
const locale = hasLocale(routing.locales, requested)
? requested
: routing.defaultLocale;
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});
// src/proxy.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)',
};
번역 키를 실제 페이지에 연결하기
번역 파일은 화면 단위로 나누는 편이 좋습니다. common에는 내비게이션, 언어 선택, 공통 버튼만 넣고, 페이지 전용 문구는 HomePage, PricingPage, DocsPage처럼 분리합니다. 이렇게 해야 Claude Code가 새 문구를 추가할 때도 위치가 분명하고, 리뷰어가 문맥을 잃지 않습니다.
{
"common": {
"language": {
"label": "표시 언어",
"ja": "日本語",
"en": "English",
"de": "Deutsch"
},
"nav": {
"docs": "문서",
"pricing": "요금"
}
},
"HomePage": {
"title": "팀 지식을 여러 언어로 전달하기",
"lead": "독자의 언어에 맞춰 {count}개의 글을 보여줍니다.",
"cta": "도입 상담하기"
}
}
// src/app/[locale]/page.tsx
import { getTranslations, setRequestLocale } from 'next-intl/server';
type Props = {
params: Promise<{ locale: string }>;
};
export default async function HomePage({ params }: Props) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations({ locale, namespace: 'HomePage' });
return (
<main>
<h1>{t('title')}</h1>
<p>{t('lead', { count: 42 })}</p>
<a href={`/${locale}/pricing`}>{t('cta')}</a>
</main>
);
}
언어 전환은 현재 경로를 유지한 채 locale만 바꾸는 방식이 안전합니다. 단순 문자열 치환은 /enquiry 같은 경로를 망가뜨릴 수 있습니다.
CI에서 번역 누락을 막기
Claude Code가 메시지 파일을 만들면 사람은 어색한 표현은 잘 보지만 키 누락은 놓치기 쉽습니다. 아래 스크립트는 기준 언어와 다른 언어의 키를 비교합니다.
// scripts/check-translations.mjs
import { readdir, readFile } from 'node:fs/promises';
const messagesDir = new URL('../src/messages/', import.meta.url);
const baseLocale = 'ja';
function flattenKeys(value, prefix = '') {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return [prefix];
}
return Object.entries(value).flatMap(([key, child]) => {
const nextPrefix = prefix ? `${prefix}.${key}` : key;
return flattenKeys(child, nextPrefix);
});
}
async function readMessages(locale) {
const file = new URL(`${locale}.json`, messagesDir);
return JSON.parse(await readFile(file, 'utf8'));
}
const files = await readdir(messagesDir);
const locales = files.filter((file) => file.endsWith('.json')).map((file) => file.replace(/\.json$/, ''));
const baseKeys = new Set(flattenKeys(await readMessages(baseLocale)));
let hasError = false;
for (const locale of locales.filter((item) => item !== baseLocale)) {
const targetKeys = new Set(flattenKeys(await readMessages(locale)));
const missing = [...baseKeys].filter((key) => !targetKeys.has(key));
const extra = [...targetKeys].filter((key) => !baseKeys.has(key));
if (missing.length || extra.length) {
hasError = true;
console.error(`\n${locale}.json has translation key drift`);
if (missing.length) console.error('Missing:', missing.join(', '));
if (extra.length) console.error('Extra:', extra.join(', '));
}
}
if (hasError) process.exit(1);
console.log(`Translation keys are aligned for ${locales.length} locales.`);
이 검사는 번역의 자연스러움까지 보장하지 않습니다. 하지만 버튼이 특정 언어에서 사라지는 문제는 CI에서 막을 수 있습니다. Claude Code에는 스크립트 추가 후 package.json의 lint:i18n과 CI 연결까지 요청하는 것이 좋습니다.
세 가지 실전 사용 사례
첫 번째는 SaaS 요금 페이지입니다. 가격, 세금, 결제 주기, 플랜 이름은 국가마다 표현이 달라집니다. 문자열을 이어 붙이지 말고 Intl.NumberFormat이나 next-intl formatter를 쓰게 해야 합니다.
두 번째는 문서 사이트입니다. middleware, proxy, locale, namespace 같은 용어는 모두 번역하지 않는 편이 더 검색 친화적입니다. 첫 등장에서는 “locale은 언어 코드”처럼 쉽게 설명하고, 이후에는 원어를 유지하는 방식이 읽기 좋습니다.
세 번째는 관리자 화면입니다. 여기서는 SEO보다 실수를 막는 문구가 중요합니다. “삭제”, “비활성화”, “초대 취소”는 짧게 번역하다가 의미가 바뀌면 위험합니다. 버튼, 확인 모달, toast, 감사 로그까지 같은 키 체계에 넣어야 합니다.
블로그나 미디어 사이트라면 본문뿐 아니라 title, description, canonical, alternate links, OGP 이미지도 확인해야 합니다. 이 규칙은 CLAUDE.md 베스트 프랙티스에 적어 두면 Claude Code가 반복 작업에서 덜 흔들립니다.
흔한 함정과 검증 결과
| 함정 | 문제 | 대응 |
|---|---|---|
| URL 설계를 미룸 | 공개 후 redirect가 복잡해짐 | prefix, subdomain, domain 방식을 먼저 결정 |
| 키 이름이 추상적 | title2 같은 키가 늘어남 | 화면명과 의미로 이름 짓기 |
| fallback에 의존 | 누락이 운영에서 숨겨짐 | CI에서 누락을 실패로 처리 |
| 날짜와 통화를 직접 조합 | 어순과 구분자가 깨짐 | Intl 또는 formatter 사용 |
| metadata 미번역 | 검색 결과가 기본 언어로 남음 | generateMetadata도 번역 |
검증용 메시지 파일에서 영어의 HomePage.cta를 삭제해 보니 위 스크립트가 누락 키를 정확히 보고했습니다. 다만 “도입 상담하기”가 모든 시장에서 자연스러운지는 판단하지 못합니다. 그래서 Claude Code와 CI는 기계적 누락을 잡고, 사람은 CTA, 가격, 법무 문구, 문화적 어색함을 검토하는 역할 분담이 현실적입니다. 다음 단계로는 Claude Code 리뷰 워크플로와 ClaudeCodeLab 상담을 함께 활용하세요.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.