국제화 라우팅
App Router의 locale 세그먼트와 번역 사전을 구성하고 언어별 메타데이터·숫자·날짜 형식을 적용합니다.
국제화(Internationalization, i18n)는 애플리케이션을 여러 언어와 지역 규칙에 맞게 확장할 수 있도록 설계하는 과정입니다.
문자열 번역뿐 아니라 URL, 날짜·숫자 형식, 검색 엔진 메타데이터와 누락 번역 정책을 함께 다룹니다.
App Router에서는 [locale] 동적 세그먼트를 사용해 /ko/about, /en/about처럼 언어가 드러나는 URL을 구성합니다.
Pages Router용 next.config.js의 i18n 설정과 next-i18next 예제를 App Router 프로젝트에 섞지 않습니다.
아래 다이어그램은 locale 값이 라우팅, 번역 사전, 화면 형식과 SEO 계약에 전달되는 흐름을 보여줍니다.
locale 경로 설계
먼저 지원 언어와 타입 가드를 한곳에 정의합니다.
export const locales = ['ko', 'en', 'ja'] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = 'ko';
export function isLocale(value: string): value is Locale {
return locales.includes(value as Locale);
}라우트 구조는 모든 다국어 페이지를 [locale] 아래에 둡니다.
src/app/
├─ [locale]/
│ ├─ layout.tsx
│ ├─ page.tsx
│ └─ about/page.tsx
└─ api/
src/
└─ proxy.ts기본 언어 URL을 /로 유지할지 /ko로 보낼지는 서비스 정책입니다.
이 교재에서는 모든 언어 URL의 형태를 같게 유지하기 위해 /를 /ko로 이동합니다.
[locale]/layout.tsx를 루트 레이아웃으로 사용하려면 locale이 없는 요청을 페이지 렌더링 전에 Proxy에서 이동시킵니다.
import { NextResponse, type NextRequest } from 'next/server';
import { defaultLocale, locales } from '@/i18n/config';
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const hasLocale = locales.some(
(locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`),
);
if (hasLocale) {
return NextResponse.next();
}
const url = request.nextUrl.clone();
url.pathname = `/${defaultLocale}${pathname}`;
return NextResponse.redirect(url);
}
export const config = {
matcher: [
'/((?!api|_next|favicon.ico|robots.txt|sitemap.xml|manifest.webmanifest|.*\\..*).*)',
],
};API, _next 아래의 개발·데이터·정적 자산, 검색 엔진 메타 파일, 웹 앱 manifest, 확장자가 있는 public/ 자산은 locale 경로를 사용하지 않으므로 matcher에서 제외합니다.
확장자가 없는 공개 파일을 추가한다면 그 경로도 matcher의 제외 목록에 명시합니다.
locale 레이아웃 만들기
Next.js 16에서 동적 params는 Promise이므로 await한 뒤 사용합니다.
지원하지 않는 언어는 notFound()로 처리합니다.
import { notFound } from 'next/navigation';
import { isLocale, locales, type Locale } from '@/i18n/config';
interface LocaleLayoutProps {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
const { locale } = await params;
if (!isLocale(locale)) {
notFound();
}
return (
<html lang={locale}>
<body>{children}</body>
</html>
);
}generateStaticParams()는 지원 언어 경로를 빌드 시점에 만들 수 있게 합니다.
lang 속성은 화면 낭독기와 검색 엔진이 문서 언어를 판단하는 기준입니다.
아래 다이어그램은 URL 세그먼트 검증부터 레이아웃 렌더링까지의 순서를 정리합니다.
서버 전용 번역 사전
작은 프로젝트는 언어별 JSON 파일을 서버에서 직접 불러오는 방식으로 시작할 수 있습니다.
{
"home": {
"title": "회원 게시판",
"welcome": "방문해 주셔서 감사합니다."
},
"about": {
"title": "소개"
},
"navigation": {
"home": "홈",
"about": "소개"
}
}{
"home": {
"title": "Member Board",
"welcome": "Thank you for visiting."
},
"about": {
"title": "About"
},
"navigation": {
"home": "Home",
"about": "About"
}
}{
"home": {
"title": "会員掲示板",
"welcome": "ご訪問いただきありがとうございます。"
},
"about": {
"title": "紹介"
},
"navigation": {
"home": "ホーム",
"about": "紹介"
}
}허용된 locale만 정적 import 함수에 연결합니다.
import 'server-only';
import type { Locale } from './config';
const dictionaries = {
ko: () => import('@/dictionaries/ko.json').then((module) => module.default),
en: () => import('@/dictionaries/en.json').then((module) => module.default),
ja: () => import('@/dictionaries/ja.json').then((module) => module.default),
} satisfies Record<Locale, () => Promise<unknown>>;
export async function getDictionary(locale: Locale) {
return dictionaries[locale]();
}사용자 입력을 그대로 import 경로에 넣지 않습니다.
정적 매핑을 사용하면 번들러가 가능한 번역 파일을 알고 경로 조작도 막을 수 있습니다.
아래 다이어그램은 서버 전용 번역 사전과 클라이언트 번들의 경계를 보여줍니다.
서버 컴포넌트에서 번역 사용
페이지는 검증된 locale로 사전을 가져옵니다.
import { notFound } from 'next/navigation';
import { getDictionary } from '@/i18n/dictionaries';
import { isLocale } from '@/i18n/config';
interface HomePageProps {
params: Promise<{ locale: string }>;
}
export default async function HomePage({ params }: HomePageProps) {
const { locale } = await params;
if (!isLocale(locale)) {
notFound();
}
const dictionary = await getDictionary(locale);
return (
<main>
<h1>{dictionary.home.title}</h1>
<p>{dictionary.home.welcome}</p>
</main>
);
}번역 파일의 키 구조가 언어마다 다르면 런타임에 빈 문구가 생깁니다.
기준 언어의 타입을 만들거나 CI에서 모든 사전의 키 집합을 비교합니다.
언어 전환 링크
언어 전환은 현재 경로의 첫 세그먼트만 바꾸고 나머지 경로는 유지합니다.
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { locales, type Locale } from '@/i18n/config';
function replaceLocale(pathname: string, locale: Locale) {
const segments = pathname.split('/');
segments[1] = locale;
return segments.join('/') || `/${locale}`;
}
export function LocaleSwitcher() {
const pathname = usePathname();
return (
<nav aria-label="언어 선택">
{locales.map((locale) => (
<Link key={locale} href={replaceLocale(pathname, locale)} hrefLang={locale}>
{locale.toUpperCase()}
</Link>
))}
</nav>
);
}언어 선택 버튼은 번역된 이름만 쓰기보다 한국어, English, 日本語처럼 사용자가 알아볼 수 있는 표기를 제공합니다.
선택한 언어를 쿠키에 저장할 수 있지만 URL의 locale이 현재 페이지 언어의 최종 기준입니다.
언어별 메타데이터
generateMetadata()도 Promise인 params를 기다립니다.
대체 언어 URL은 alternates.languages에 명시합니다.
레이아웃은 모든 하위 경로에 상속되므로 경로별 canonical을 언어 레이아웃에 고정하지 않습니다.
실제 pathname을 아는 페이지에서 해당 페이지와 번역 페이지의 URL을 함께 만듭니다.
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getDictionary } from '@/i18n/dictionaries';
import { isLocale } from '@/i18n/config';
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
if (!isLocale(locale)) {
notFound();
}
const dictionary = await getDictionary(locale);
return {
title: dictionary.about.title,
alternates: {
canonical: `https://board.example/${locale}/about`,
languages: {
ko: 'https://board.example/ko/about',
en: 'https://board.example/en/about',
ja: 'https://board.example/ja/about',
},
},
};
}페이지별 canonical은 현재 언어와 현재 경로의 URL을 가리킵니다.
각 대체 URL은 실제로 같은 내용의 번역 페이지가 존재할 때만 등록합니다.
아래 다이어그램은 canonical과 언어별 대체 링크가 검색 엔진에 전달되는 구조를 보여줍니다.
숫자와 날짜 지역화
번역 문자열만 바꾸고 숫자와 날짜 형식을 고정하면 사용자에게 어색한 화면이 됩니다.
브라우저와 Node.js가 제공하는 Intl API를 사용합니다.
const price = new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW',
}).format(25000);
const publishedAt = new Intl.DateTimeFormat('ko-KR', {
dateStyle: 'long',
timeZone: 'Asia/Seoul',
}).format(new Date('2026-07-18T03:00:00Z'));locale과 통화는 같은 개념이 아닙니다.
영어 화면에서도 원화 가격을 표시할 수 있으므로 통화는 업무 데이터에서 별도로 받습니다.
서버와 브라우저의 기본 시간대가 다를 수 있으므로 출력 시간대를 명시합니다.
아래 다이어그램은 locale·통화·시간대가 각각 어떤 출력 규칙을 결정하는지 비교합니다.
국제화 점검 기준
지원하지 않는 locale은 404 또는 명시적인 기본 언어 이동으로 처리합니다.
모든 번역 사전이 같은 키를 갖는지 확인합니다.
서버 전용 사전 전체를 클라이언트 번들에 전달하지 않습니다.
경로 전환 시 현재 페이지와 검색 조건을 유지할지 정책을 정합니다.
문서의 lang, canonical과 언어별 대체 링크를 실제 URL과 일치시킵니다.
숫자·통화·날짜·시간대도 locale 정책에 맞게 출력합니다.
마지막 다이어그램으로 라우트 검증부터 번역·형식·SEO까지의 전체 흐름을 확인합니다.