국제화 라우팅
App Router의 locale 세그먼트와 번역 사전을 구성하고 언어별 메타데이터·숫자·날짜 형식을 적용합니다.
국제화(internationalization, i18n)는 애플리케이션을 여러 언어와 지역의 규칙에 맞게 넓힐 수 있도록 설계하는 과정입니다. 문자열 번역뿐 아니라 URL, 날짜와 숫자의 형식, 검색 엔진 메타데이터, 번역이 빠졌을 때의 정책까지 함께 다룹니다. App Router에서는 [locale] 동적 세그먼트로 /ko/about, /en/about처럼 언어가 드러나는 URL을 만듭니다. Pages Router용 next.config.js의 i18n 설정과 next-i18next 예제는 App Router 프로젝트에 섞지 않습니다.
16장에서는 국제화 라우팅, PWA 설정, 서버리스 함수, WebSocket 통합 같은 고급 기능을 차례로 다룹니다. 이 글에서는 locale 세그먼트와 번역 사전을 구성하고, 언어별 메타데이터와 숫자, 날짜 형식을 적용하는 방법을 알아봅니다.
locale 경로 설계
먼저 지원하는 언어와 타입 가드를 한곳에 정의합니다. as const를 붙이면 locales의 타입이 string[]이 아니라 세 문자열만 담긴 읽기 전용 튜플이 되고, (typeof locales)[number]로 그 원소 타입인 'ko' | 'en' | 'ja'를 꺼낼 수 있습니다. isLocale의 반환 타입 value is Locale은 이 함수가 true를 돌려준 뒤에는 value를 Locale로 다뤄도 된다고 TypeScript에 알리는 타입 가드입니다. URL에서 읽은 문자열은 아무 값이나 될 수 있으므로, 뒤의 레이아웃과 페이지는 이 함수로 먼저 거른 뒤에 씁니다.
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] 아래에 둡니다. 폴더 이름의 [locale]은 동적 세그먼트이므로 /ko/about의 ko가 params.locale로 들어옵니다. 아래 구조에서는 [locale]/layout.tsx가 <html>을 그리는 루트 레이아웃이 되고, api는 언어와 상관없는 경로라서 [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|.*\\..*).*)',
],
};matcher는 API, _next, 메타 파일 등을 제외합니다. 예제의 점(.) 검사는 파일 확장자뿐 아니라 점이 들어간 일반 경로도 제외한다는 점에 주의합니다. 확장자가 없는 공개 파일을 추가한다면 그 경로도 matcher의 제외 목록에 명시합니다.
더 깊이: App Router가 언어를 고르지 않는 이유와 locale을 읽는 다른 방법
Pages Router에는 next.config의 i18n 설정으로 locale 경로를 만들어 주는 기능이 있지만, App Router에는 이런 내장 국제화 라우팅이 없습니다. 마이그레이션 안내는 App Router에서 이 기능이 더는 필요하지 않다고 설명하며, 실제로 useRouter의 locale, locales, defaultLocale 값도 빠졌습니다. 대신 Next.js의 국제화 안내는 이 글의 [locale]처럼 동적 세그먼트(공식 예제에서는 [lang])와 Proxy로 직접 구성하는 방법을 보여 주며, 경로는 /fr/products 같은 하위 경로나 my-site.fr/products 같은 도메인으로 나눌 수 있습니다.
같은 안내는 어느 언어로 보낼지를 브라우저의 언어 설정에 따라 고르라고 권합니다. 사용자가 브라우저의 선호 언어를 바꾸면 요청의 Accept-Language 헤더가 바뀌므로, Proxy에서 negotiator와 @formatjs/intl-localematcher 같은 라이브러리로 이 헤더와 지원 언어 목록, 기본 언어를 비교해 locale을 정할 수 있습니다. 이 글의 예제는 URL의 형태를 단순하게 유지하려고 언제나 /ko로 보내지만, 이 방식을 쓰면 같은 /로 들어와도 사용자마다 맞는 언어로 이동합니다.
깊은 하위 컴포넌트에서 현재 locale이 필요할 때 params를 단계마다 넘기지 않아도 되는 방법도 생겼습니다. Next.js 16.3에 추가된 next/root-params는 루트 레이아웃보다 앞에 있는 동적 세그먼트, 곧 이 글의 [locale] 같은 루트 매개변수(root parameter)를 읽는 함수를 제공하므로, 서버 컴포넌트와 서버 쪽 유틸리티 어디서든 그 값을 읽을 수 있습니다. 다만 클라이언트 컴포넌트, 서버 액션, Route Handler에서는 쓸 수 없고 16.3보다 앞선 버전에는 없으므로, 여러 Next.js 16 버전에서 동작해야 하는 코드라면 지금의 예제처럼 params를 넘기는 방식이 안전합니다.
locale 레이아웃 만들기
Next.js 16에서 동적 params는 Promise이므로 await한 뒤 쓰며, 지원하지 않는 언어는 notFound()로 처리합니다. 아래 레이아웃은 <html lang>에 현재 언어를 넣는 루트 레이아웃입니다. URL에서 꺼낸 locale은 아직 검증하지 않은 string이므로 isLocale을 통과하지 못하면 notFound()로 렌더링을 멈추고, 통과한 뒤에는 locale의 타입이 Locale로 좁혀집니다.
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 속성은 화면 낭독기와 검색 엔진이 문서의 언어를 판단하는 기준입니다.
서버 전용 번역 사전
작은 프로젝트는 언어별 JSON 파일을 서버에서 직접 불러오는 방식으로 시작할 수 있습니다. 한국어 사전은 다음과 같습니다.
{
"home": {
"title": "회원 게시판",
"welcome": "방문해 주셔서 감사합니다."
},
"about": {
"title": "소개"
},
"navigation": {
"home": "홈",
"about": "소개"
}
}사전은 화면 영역(home, about, navigation)마다 키를 묶은 중첩 객체이며, 코드에서는 dictionary.home.title처럼 키 경로로 문장을 꺼냅니다. 그래서 언어마다 바뀌는 것은 값뿐이고, 키 이름과 구조는 모든 언어에서 같아야 합니다. 영어 사전도 같은 키 구조로 만듭니다.
{
"home": {
"title": "Member Board",
"welcome": "Thank you for visiting."
},
"about": {
"title": "About"
},
"navigation": {
"home": "Home",
"about": "About"
}
}영어 사전에서 about.title과 navigation.about은 둘 다 About이지만 키를 따로 둡니다. 쓰이는 자리가 다르면 나중에 한 언어에서 한쪽 표현만 바꿔야 할 수 있기 때문입니다. 일본어 사전도 같은 키 구조입니다.
{
"home": {
"title": "会員掲示板",
"welcome": "ご訪問いただきありがとうございます。"
},
"about": {
"title": "紹介"
},
"navigation": {
"home": "ホーム",
"about": "紹介"
}
}허용한 locale만 정적 import 함수에 연결합니다. 아래 코드의 dictionaries 객체는 locale마다 JSON을 불러오는 함수를 값으로 가지며, import()가 함수 안에 있으므로 그 locale의 사전은 처음 필요해질 때 불러옵니다. satisfies Record<Locale, ...>는 세 locale의 키가 모두 있는지 검사하되 각 함수의 구체적인 반환 타입은 그대로 남깁니다. 그래서 getDictionary를 부른 쪽에서도 dictionary.home.title 같은 키가 타입 검사를 받습니다.
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 경로에 넣지 않습니다. 정적 매핑을 쓰면 번들러가 어떤 번역 파일이 있을 수 있는지 알 수 있고, 경로 조작도 막을 수 있습니다. 맨 위의 import 'server-only'는 이 모듈을 클라이언트 컴포넌트에서 잘못 가져오면 빌드 오류가 나게 해, 사전 전체가 브라우저 번들에 들어가는 것을 막습니다.
서버 컴포넌트에서 번역 사용
페이지는 검증된 locale로 사전을 가져옵니다. 레이아웃에서 이미 isLocale로 검사했지만, 페이지는 그 검사 결과를 넘겨받지 않고 자기 params를 따로 받습니다. 그래서 페이지에서도 다시 검사해야 locale이 Locale 타입으로 좁혀져 getDictionary에 넘길 수 있습니다.
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에서 모든 사전의 키 집합을 비교합니다.
언어 전환 링크
언어 전환은 현재 경로의 첫 세그먼트만 바꾸고 나머지 경로는 유지합니다. 현재 경로를 읽는 usePathname은 클라이언트 훅이므로 이 파일에는 'use client'가 필요합니다. replaceLocale은 경로를 /로 나눈 배열의 두 번째 칸을 바꿉니다. 경로가 /로 시작하므로 첫 칸은 빈 문자열이고, 예를 들어 /en/about은 ['', 'en', 'about']이 되어 segments[1]을 ja로 바꾸면 /ja/about이 됩니다.
'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>
);
}Next · Locale URL behavior
Proxy와 언어 전환의 URL 처리
원문의 Proxy는 지원 언어가 없는 경로 앞에 ko를 붙이고 URL의 query를 유지합니다. 언어 버튼은 usePathname의 첫 세그먼트만 바꾸므로 query·hash를 다시 붙이지 않습니다.
| 입력·동작 | 예제 결과 | 해석 |
|---|---|---|
| /about?q=1 요청 | /ko/about?q=1로 리디렉션 | 복제한 URL의 pathname만 변경 |
| /en/about 요청 | 그대로 통과 | 지원 언어 접두사가 이미 있음 |
| /fr/about 요청 | /ko/fr/about로 리디렉션 | fr을 언어로 판별하지 않고 일반 경로로 취급 |
| 언어 전환 버튼 | 첫 경로 조각을 선택한 언어로 교체 | usePathname만 사용해 query·hash는 이어 붙이지 않음 |
- /about?q=1 요청
- 예제 결과
- /ko/about?q=1로 리디렉션
- 해석
- 복제한 URL의 pathname만 변경
- /en/about 요청
- 예제 결과
- 그대로 통과
- 해석
- 지원 언어 접두사가 이미 있음
- /fr/about 요청
- 예제 결과
- /ko/fr/about로 리디렉션
- 해석
- fr을 언어로 판별하지 않고 일반 경로로 취급
- 언어 전환 버튼
- 예제 결과
- 첫 경로 조각을 선택한 언어로 교체
- 해석
- usePathname만 사용해 query·hash는 이어 붙이지 않음
이 Proxy는 브라우저의 선호 언어를 협상하지 않습니다. 지원 언어가 없으면 기본 언어 ko를 앞에 붙이는 정책입니다.
위 표는 예제의 Proxy와 언어 전환 버튼이 URL을 어떻게 처리하는지 정리합니다. /about?q=1을 요청하면 복제한 URL의 pathname만 바뀌어 /ko/about?q=1로 리디렉션되고, /en/about은 이미 지원 언어의 접두사가 있으므로 그대로 통과합니다. /fr/about은 fr을 언어로 판별하지 않고 일반 경로로 다루므로 /ko/fr/about으로 리디렉션됩니다. 언어 전환 버튼은 첫 경로 조각만 고른 언어로 바꾸며, usePathname만 쓰므로 query와 hash는 이어 붙이지 않습니다. 표의 설명처럼 이 Proxy는 브라우저의 선호 언어를 협상하지 않고, 지원 언어가 없으면 기본 언어 ko를 앞에 붙이는 정책입니다.
사전을 추가할 때는 세 언어의 home, about, navigation 하위 키를 비교하세요. 키 이름은 같게 두고 값만 번역합니다. 통화는 별개의 조건이므로, 영어 화면이라고 원화 금액이 달러로 환산되는 것은 아닙니다. 예제의 버튼은 KO, EN, JA를 보여 주지만, 실제 서비스에서는 한국어, English, 日本語처럼 사용자가 알아볼 수 있는 언어 이름을 쓸 수 있습니다. 고른 언어를 쿠키에 저장할 수도 있지만, 현재 페이지의 언어를 정하는 최종 기준은 URL의 locale입니다.
언어별 메타데이터
generateMetadata()도 Promise인 params를 기다리며, 대체 언어의 URL은 alternates.languages에 명시합니다. 레이아웃은 모든 하위 경로에 상속되므로, 경로별 canonical을 언어 레이아웃에 고정하지 않습니다. 실제 pathname을 아는 페이지에서 그 페이지와 번역 페이지의 URL을 함께 만듭니다. 다음은 페이지 컴포넌트에 함께 정의할 메타데이터 부분이며, 이것만으로 완전한 page.tsx가 되지는 않습니다.
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은 실제로 같은 내용의 번역 페이지가 있을 때만 등록합니다.
숫자와 날짜 지역화
번역 문자열만 바꾸고 숫자와 날짜 형식을 고정하면 사용자에게 어색한 화면이 됩니다. 브라우저와 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을 어떻게 처리할지 정책을 정합니다. 이 예제에서는 /fr/about을 앞의 Proxy가 먼저 /ko/fr/about으로 바꾼다는 점을 구분해야 합니다. 모든 번역 사전이 같은 키를 갖는지 확인하고, 서버 전용 사전 전체를 클라이언트 번들에 넘기지 않습니다. 경로를 전환할 때 현재 페이지와 검색 조건을 유지할지도 정책으로 정합니다. 문서의 lang, canonical, 언어별 대체 링크는 실제 URL과 일치시키고, 숫자, 통화, 날짜, 시간대도 locale 정책에 맞게 출력합니다.
정리
- App Router의 국제화는
[locale]동적 세그먼트 아래에 다국어 페이지를 두고, Proxy가 locale이 없는 요청을 기본 언어 경로로 보냅니다. [locale]/layout.tsx는params를await해 지원하지 않는 언어를notFound()로 처리하고,<html lang>을 설정합니다.- 번역 사전은 허용한 locale에만 연결한 정적 import로 서버에서 불러오며,
server-only로 클라이언트 번들에 들어가지 않게 합니다. - 언어별 canonical과
alternates.languages는 실제 경로를 아는 페이지의generateMetadata에서 만듭니다. - 숫자, 통화, 날짜는
IntlAPI로 출력하며, 통화와 시간대는 locale과 별개로 명시합니다.
연습 문제
1. URL을 직접 추적하기
/about?q=1 요청을 Proxy가 처리할 때와, /en/about?q=1#team 화면에서 JA 버튼을 누를 때의 결과를 각각 적어 보세요. 또 /fr/about이 Proxy를 지난 직후에는 어떤 경로가 되며, 이것만으로 그 페이지가 있다고 보장할 수 있을까요?
해설 보기
첫 요청은 URL을 복제해 pathname만 바꾸므로 /ko/about?q=1로 이동합니다. 언어 버튼은 usePathname()에서 받은 /en/about만 바꿔 /ja/about을 만들며, query와 hash를 더하는 코드가 없으므로 검색 조건과 #team은 이 링크에 남지 않습니다. 이를 유지하는 정책이라면 검색 조건은 useSearchParams() 등으로, hash는 클라이언트에서 따로 읽어 링크에 반영해야 합니다.
/fr/about은 /ko/fr/about으로 바뀝니다. 이는 fr을 지원 언어로 판정한 결과가 아니며, ko 아래에 fr/about 페이지가 있다는 보장도 없습니다. 지원하지 않는 언어를 오류로 다룰지 일반 경로로 다룰지 먼저 정한 뒤, Proxy와 페이지의 기대값을 맞춥니다.
2. Intl로 출력한 값
숫자와 날짜 예제의 price와 publishedAt은 각각 어떤 문자열이 될까요? timeZone을 'America/New_York'으로 바꾸면 날짜가 달라질까요?
해설 보기
price는 원화 통화 형식으로 ₩25,000이 됩니다. 2026-07-18T03:00:00Z는 서울 시간(UTC+9)으로 같은 날 정오이므로, publishedAt은 2026년 7월 18일이 됩니다.
뉴욕은 7월에 UTC-4인 서머타임이 적용되므로, 같은 순간이 7월 17일 오후 11시가 되어 날짜가 2026년 7월 17일로 바뀝니다. 시간대를 명시하지 않으면 서버와 브라우저의 기본 시간대에 따라 이렇게 날짜가 달라질 수 있습니다.
3. 번역 키가 빠졌을 때
ja.json에서 home.welcome 키를 빠뜨렸습니다. next build와 /ja 페이지는 어떻게 될까요? 이런 일을 미리 막으려면 어떻게 해야 할까요?
해설 보기
이 글의 getDictionary는 세 사전의 타입을 합친 유니언(union)을 돌려주므로, ja.json의 home에 welcome이 없으면 page.tsx의 dictionary.home.welcome에서 그 속성이 없다는 타입 오류가 납니다. next build는 타입 오류가 있으면 실패하므로 배포까지 가지 않습니다. 타입 검사를 끄고(typescript.ignoreBuildErrors) 배포했다면 dictionary.home.welcome이 undefined가 되어 해당 문단이 비어 보이고, home 객체 자체가 빠졌다면 읽을 때 오류가 날 수도 있습니다. 다만 이 검사는 코드에서 실제로 읽는 키만 확인하므로, 아직 쓰지 않는 키까지 맞추려면 기준 언어인 ko.json의 타입을 만들어 다른 사전이 같은 구조를 갖추게 하거나, CI에서 모든 사전의 키 집합을 비교해 빠진 키가 있으면 빌드나 테스트가 실패하게 합니다.
App Router의 국제화 구성은 Next.js 공식 문서의 Internationalization 안내에서 확인할 수 있습니다.
다음 글 PWA 설정에서는 웹 앱 매니페스트와 서비스 워커로 Next.js 애플리케이션을 설치할 수 있는 PWA로 만드는 방법을 알아봅니다.