이 글은 누구를 위한 것인가
- Next.js App Router에서 다국어를 구현하려는 팀
- 서버 컴포넌트와 클라이언트 컴포넌트 양쪽에서 번역을 써야 하는 개발자
- 번역 키의 타입 안전성을 원하는 팀
들어가며
Next.js App Router와 next-intl 조합은 서버 컴포넌트에서 번역, 미들웨어 로케일 감지, 타입 안전한 번역 키를 모두 제공한다. next-i18next의 Pages Router 방식에서 완전히 벗어난 현대적 접근법이다.
이 글은 bluefoxdev.kr의 Next.js 다국어 가이드 를 참고하여 작성했습니다.
1. next-intl 아키텍처
[next-intl App Router 구조]
파일 구조:
app/
[locale]/ ← 로케일 파라미터
layout.tsx ← NextIntlClientProvider 감싸기
page.tsx
products/
page.tsx
messages/
ko.json ← 한국어 번역
en.json ← 영어 번역
middleware.ts ← 로케일 감지 + 리다이렉트
i18n.ts ← next-intl 설정
[라우팅 예시]
/ko/products ← 한국어
/en/products ← 영어
/products → /ko/products 리다이렉트 (기본 로케일)
[번역 함수]
서버 컴포넌트: import { getTranslations } from 'next-intl/server'
클라이언트: import { useTranslations } from 'next-intl'
날짜/숫자: import { useFormatter } from 'next-intl'
[타입 안전성]
messages/ko.json → TypeScript 타입 자동 생성
t('product.price') → 키가 존재하지 않으면 컴파일 에러
2. next-intl 완전 구현
// middleware.ts
import createMiddleware from 'next-intl/middleware';
export default createMiddleware({
locales: ['ko', 'en', 'ja'],
defaultLocale: 'ko',
// 로케일 감지 우선순위
localeDetection: true, // Accept-Language 헤더 감지
// /ko/... → /... (기본 로케일 접두사 제거)
localePrefix: 'as-needed',
});
export const config = {
matcher: ['/((?!api|_next|.*\\..*).*)'],
};
// messages/ko.json
{
"common": {
"loading": "로딩 중...",
"error": "오류가 발생했습니다",
"save": "저장",
"cancel": "취소",
"confirm": "확인"
},
"product": {
"title": "상품 상세",
"price": "가격: {price}",
"stock": "{count, plural, =0 {품절} one {재고 {count}개} other {재고 {count}개}}",
"addToCart": "장바구니 추가",
"description": "상품 설명"
},
"navigation": {
"home": "홈",
"products": "상품",
"cart": "장바구니 ({count})",
"account": "계정"
}
}
// messages/en.json
{
"common": {
"loading": "Loading...",
"error": "An error occurred",
"save": "Save",
"cancel": "Cancel",
"confirm": "Confirm"
},
"product": {
"title": "Product Detail",
"price": "Price: {price}",
"stock": "{count, plural, =0 {Out of stock} one {{count} in stock} other {{count} in stock}}",
"addToCart": "Add to Cart",
"description": "Product Description"
},
"navigation": {
"home": "Home",
"products": "Products",
"cart": "Cart ({count})",
"account": "Account"
}
}
// i18n.ts - next-intl 설정
import { getRequestConfig } from 'next-intl/server';
import { notFound } from 'next/navigation';
const locales = ['ko', 'en', 'ja'];
export default getRequestConfig(async ({ locale }) => {
if (!locales.includes(locale as string)) notFound();
return {
messages: (await import(`../messages/${locale}.json`)).default,
timeZone: locale === 'ko' ? 'Asia/Seoul' : 'UTC',
now: new Date(),
};
});
// app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
export default async function LocaleLayout({
children,
params: { locale },
}: {
children: React.ReactNode;
params: { locale: string };
}) {
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
// 동적 메타데이터 다국어 처리
export async function generateMetadata({ params: { locale } }: { params: { locale: string } }) {
const t = await getTranslations({ locale, namespace: 'metadata' });
return {
title: t('siteTitle'),
description: t('siteDescription'),
alternates: {
canonical: `/${locale}`,
languages: {
'ko': '/ko',
'en': '/en',
'ja': '/ja',
},
},
};
}
// app/[locale]/products/[id]/page.tsx - 서버 컴포넌트
import { getTranslations } from 'next-intl/server';
import { getFormatter } from 'next-intl/server';
export default async function ProductPage({
params: { locale, id },
}: {
params: { locale: string; id: string };
}) {
// 서버 컴포넌트에서 번역
const t = await getTranslations('product');
const format = await getFormatter();
const product = await fetchProduct(id);
return (
<div>
<h1>{t('title')}</h1>
{/* 가격 포맷 (locale 인식) */}
<p>{t('price', {
price: format.number(product.price, {
style: 'currency',
currency: locale === 'ko' ? 'KRW' : 'USD',
}),
})}</p>
{/* 복수형 처리 */}
<p>{t('stock', { count: product.stock })}</p>
{/* 날짜 포맷 */}
<p>{format.dateTime(product.updatedAt, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}</p>
{/* 클라이언트 컴포넌트에 서버 번역 전달 */}
<AddToCartButton productId={id} />
</div>
);
}
// 클라이언트 컴포넌트 - use client
'use client';
import { useTranslations } from 'next-intl';
function AddToCartButton({ productId }: { productId: string }) {
const t = useTranslations('product');
const [count, setCount] = useState(0);
const tNav = useTranslations('navigation');
return (
<button onClick={() => setCount(c => c + 1)}>
{t('addToCart')} ({tNav('cart', { count })})
</button>
);
}
// 타입 안전 번역 설정
// global.d.ts
// import type messages from './messages/ko.json';
// declare global {
// type IntlMessages = typeof messages;
// }
// → t('product.price') 같이 존재하지 않는 키면 컴파일 에러
async function fetchProduct(id: string) {
return { price: 49900, stock: 5, updatedAt: new Date() };
}
// 언어 전환 컴포넌트
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, usePathname } from 'next/navigation';
export function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const locales = [
{ code: 'ko', label: '한국어' },
{ code: 'en', label: 'English' },
{ code: 'ja', label: '日本語' },
];
function switchLocale(newLocale: string) {
// 현재 경로의 로케일만 교체
const segments = pathname.split('/');
segments[1] = newLocale;
router.push(segments.join('/'));
}
return (
<select value={locale} onChange={e => switchLocale(e.target.value)}>
{locales.map(l => (
<option key={l.code} value={l.code}>{l.label}</option>
))}
</select>
);
}
마무리
next-intl의 App Router 지원은 서버 컴포넌트에서 getTranslations()을, 클라이언트 컴포넌트에서 useTranslations()을 사용하는 명확한 패턴을 제공한다. 타입 안전 번역 키 설정은 messages/ko.json 타입을 전역으로 등록하는 간단한 설정으로 활성화되며, 오탈자로 인한 번역 누락을 빌드 시점에 잡을 수 있다. ICU 메시지 형식의 복수형 처리는 언어별 규칙을 자동으로 적용한다.