Next.js App Router에서 다국어 웹사이트를 구축하는 방법을 학습합니다.
국제화(Internationalization, i18n)는 웹사이트를 여러 언어와 지역에 맞게 제공하는 것입니다. 글로벌 이커머스에서 필수적인 기능입니다.
🌐 텍스트 번역
UI 텍스트, 상품명, 설명, 리뷰
💰 통화 형식
₩10,000 / $10.00 / €10,00
📅 날짜 형식
2024년 1월 15일 / Jan 15, 2024 / 15/01/2024
📏 단위
kg/lb, cm/inch, °C/°F
| 방식 | URL 예시 | 특징 |
|---|---|---|
| 서브패스 | /ko/products, /en/products | SEO 우수, 권장 |
| 서브도메인 | ko.shop.com, en.shop.com | 지역별 서버 분리 가능 |
| 쿠키/헤더 | shop.com (동일) | SEO 불리 |
서브패스 방식 권장 이유
Next.js App Router에서 서브패스 방식으로 다국어를 구현합니다. [locale] 동적 세그먼트를 사용하여 언어별 라우팅을 처리합니다.
app/
├── [locale]/ # 동적 언어 세그먼트
│ ├── layout.tsx # 언어별 레이아웃
│ ├── page.tsx # 홈페이지
│ ├── products/
│ │ ├── page.tsx # 상품 목록
│ │ └── [id]/
│ │ └── page.tsx # 상품 상세
│ └── cart/
│ └── page.tsx # 장바구니
├── i18n/
│ ├── config.ts # i18n 설정
│ ├── dictionaries.ts # 번역 로더
│ └── middleware.ts # 언어 감지
└── dictionaries/
├── ko.json # 한국어 번역
├── en.json # 영어 번역
└── ja.json # 일본어 번역// i18n/config.ts
export const i18n = {
defaultLocale: 'ko',
locales: ['ko', 'en', 'ja'],
} as const;
export type Locale = (typeof i18n)['locales'][number];
// 언어 이름 매핑
export const localeNames: Record<Locale, string> = {
ko: '한국어',
en: 'English',
ja: '日本語',
};
// 언어별 설정
export const localeConfig: Record<Locale, {
currency: string;
dateFormat: string;
}> = {
ko: { currency: 'KRW', dateFormat: 'yyyy년 MM월 dd일' },
en: { currency: 'USD', dateFormat: 'MMM dd, yyyy' },
ja: { currency: 'JPY', dateFormat: 'yyyy年MM月dd日' },
};// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { i18n } from './i18n/config';
function getLocale(request: NextRequest): string {
// 1. URL에서 언어 확인
const pathname = request.nextUrl.pathname;
const pathnameLocale = i18n.locales.find(
locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (pathnameLocale) return pathnameLocale;
// 2. 쿠키에서 언어 확인
const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value;
if (cookieLocale && i18n.locales.includes(cookieLocale as any)) {
return cookieLocale;
}
// 3. Accept-Language 헤더에서 언어 감지
const acceptLanguage = request.headers.get('Accept-Language');
if (acceptLanguage) {
const browserLocale = acceptLanguage.split(',')[0].split('-')[0];
if (i18n.locales.includes(browserLocale as any)) {
return browserLocale;
}
}
return i18n.defaultLocale;
}
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// 정적 파일, API 제외
if (
pathname.startsWith('/_next') ||
pathname.startsWith('/api') ||
pathname.includes('.')
) {
return;
}
// 이미 locale이 있는지 확인
const pathnameHasLocale = i18n.locales.some(
locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (pathnameHasLocale) return;
// locale 추가하여 리다이렉트
const locale = getLocale(request);
return NextResponse.redirect(
new URL(`/${locale}${pathname}`, request.url)
);
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};// app/[locale]/layout.tsx
import { i18n, Locale } from '@/i18n/config';
export async function generateStaticParams() {
return i18n.locales.map((locale) => ({ locale }));
}
interface Props {
children: React.ReactNode;
params: { locale: Locale };
}
export default function LocaleLayout({ children, params }: Props) {
return (
<html lang={params.locale}>
<body>
{children}
</body>
</html>
);
}설정 포인트
JSON 파일로 번역을 관리하고, 타입 안전하게 사용합니다. 네임스페이스로 번역을 분류하여 관리 효율을 높입니다.
// dictionaries/ko.json
{
"common": {
"home": "홈",
"products": "상품",
"cart": "장바구니",
"login": "로그인",
"logout": "로그아웃",
"search": "검색",
"loading": "로딩 중...",
"error": "오류가 발생했습니다"
},
"product": {
"addToCart": "장바구니 담기",
"outOfStock": "품절",
"price": "가격",
"quantity": "수량",
"description": "상품 설명",
"reviews": "리뷰",
"relatedProducts": "관련 상품"
},
"cart": {
"title": "장바구니",
"empty": "장바구니가 비어있습니다",
"total": "총 금액",
"checkout": "결제하기",
"continueShopping": "쇼핑 계속하기"
},
"auth": {
"email": "이메일",
"password": "비밀번호",
"loginButton": "로그인",
"registerButton": "회원가입",
"forgotPassword": "비밀번호 찾기"
}
}
// dictionaries/en.json
{
"common": {
"home": "Home",
"products": "Products",
"cart": "Cart",
"login": "Login",
"logout": "Logout",
"search": "Search",
"loading": "Loading...",
"error": "An error occurred"
},
"product": {
"addToCart": "Add to Cart",
"outOfStock": "Out of Stock",
"price": "Price",
"quantity": "Quantity",
"description": "Description",
"reviews": "Reviews",
"relatedProducts": "Related Products"
},
"cart": {
"title": "Shopping Cart",
"empty": "Your cart is empty",
"total": "Total",
"checkout": "Checkout",
"continueShopping": "Continue Shopping"
},
"auth": {
"email": "Email",
"password": "Password",
"loginButton": "Sign In",
"registerButton": "Sign Up",
"forgotPassword": "Forgot Password"
}
}// i18n/dictionaries.ts
import { Locale } from './config';
const dictionaries = {
ko: () => import('@/dictionaries/ko.json').then((m) => m.default),
en: () => import('@/dictionaries/en.json').then((m) => m.default),
ja: () => import('@/dictionaries/ja.json').then((m) => m.default),
};
export const getDictionary = async (locale: Locale) => {
return dictionaries[locale]();
};
// 타입 정의
export type Dictionary = Awaited<ReturnType<typeof getDictionary>>;// app/[locale]/products/page.tsx
import { getDictionary } from '@/i18n/dictionaries';
import { Locale } from '@/i18n/config';
interface Props {
params: { locale: Locale };
}
export default async function ProductsPage({ params }: Props) {
const dict = await getDictionary(params.locale);
return (
<div>
<h1>{dict.common.products}</h1>
<div className="grid grid-cols-4 gap-4">
{products.map((product) => (
<div key={product.id}>
<h3>{product.name}</h3>
<p>{dict.product.price}: {product.price}</p>
<button>{dict.product.addToCart}</button>
</div>
))}
</div>
</div>
);
}// 번역 파일
{
"cart": {
"itemCount": "{{count}}개 상품",
"totalPrice": "총 {{price}}원"
}
}
// 유틸리티 함수
export function t(
template: string,
values: Record<string, string | number>
): string {
return template.replace(/\{\{(\w+)\}\}/g, (_, key) =>
String(values[key] ?? '')
);
}
// 사용
const message = t(dict.cart.itemCount, { count: 3 });
// 결과: "3개 상품"번역 관리 팁
Server Component와 Client Component에서 번역을 사용하는 방법입니다.
// app/[locale]/page.tsx
import { getTranslations } from 'next-intl/server';
export default async function HomePage() {
const t = await getTranslations('common');
return (
<div>
<h1>{t('home')}</h1>
<nav>
<a href="/products">{t('products')}</a>
<a href="/cart">{t('cart')}</a>
</nav>
</div>
);
}'use client';
import { useTranslations } from 'next-intl';
export function AddToCartButton() {
const t = useTranslations('product');
return (
<button>
{t('addToCart')}
</button>
);
}// 번역 파일
// "price": "{price}원"
// "reviews": "{count}개의 리뷰"
// 사용
const t = useTranslations('product');
<p>{t('price', { price: 29000 })}</p>
// 출력: "29000원"
<p>{t('reviews', { count: 42 })}</p>
// 출력: "42개의 리뷰"// en.json
{
"cart": {
"items": "{count, plural, =0 {No items} =1 {1 item} other {# items}}"
}
}
// 사용
t('items', { count: 0 }) // "No items"
t('items', { count: 1 }) // "1 item"
t('items', { count: 5 }) // "5 items"next-intl의 포맷팅 기능으로 날짜와 통화를 로케일에 맞게 표시합니다.
import { useFormatter } from 'next-intl';
function OrderDate({ date }: { date: Date }) {
const format = useFormatter();
return (
<div>
{/* 기본 */}
<p>{format.dateTime(date)}</p>
{/* ko: 2024. 1. 15. */}
{/* en: 1/15/2024 */}
{/* 커스텀 */}
<p>{format.dateTime(date, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}</p>
{/* ko: 2024년 1월 15일 */}
{/* en: January 15, 2024 */}
{/* 상대 시간 */}
<p>{format.relativeTime(date)}</p>
{/* ko: 3일 전 */}
{/* en: 3 days ago */}
</div>
);
}import { useFormatter } from 'next-intl';
function Price({ amount }: { amount: number }) {
const format = useFormatter();
return (
<span>
{format.number(amount, {
style: 'currency',
currency: 'KRW', // 또는 'USD', 'JPY'
})}
</span>
);
}
// 로케일별 출력:
// ko + KRW: ₩29,000
// en + USD: $29.00
// ja + JPY: ¥29,000const format = useFormatter();
// 천 단위 구분
format.number(1234567)
// ko: 1,234,567
// en: 1,234,567
// 퍼센트
format.number(0.25, { style: 'percent' })
// 25%
// 소수점
format.number(3.14159, { maximumFractionDigits: 2 })
// 3.14사용자가 언어를 전환할 수 있는 UI를 구현합니다.
'use client';
import { useLocale } from 'next-intl';
import { useRouter, usePathname } from 'next/navigation';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
const locales = [
{ code: 'ko', label: '한국어' },
{ code: 'en', label: 'English' },
{ code: 'ja', label: '日本語' },
];
export function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const handleChange = (newLocale: string) => {
// /ko/products → /en/products
const newPath = pathname.replace(`/${locale}`, `/${newLocale}`);
router.push(newPath);
};
return (
<Select value={locale} onValueChange={handleChange}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{locales.map((loc) => (
<SelectItem key={loc.code} value={loc.code}>
{loc.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}// next-intl의 Link 사용
import { Link } from '@/i18n/routing';
// 현재 로케일 유지하며 이동
<Link href="/products">상품 목록</Link>
// ko 로케일이면 → /ko/products
// en 로케일이면 → /en/products
// 특정 로케일로 이동
<Link href="/products" locale="en">
View in English
</Link>// i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
import { createNavigation } from 'next-intl/navigation';
export const routing = defineRouting({
locales: ['ko', 'en', 'ja'],
defaultLocale: 'ko',
});
// 네비게이션 헬퍼 생성
export const { Link, redirect, usePathname, useRouter } =
createNavigation(routing);이커머스 상품 카드에 i18n을 적용한 예제입니다.
'use client';
import { useTranslations, useFormatter } from 'next-intl';
interface ProductCardProps {
product: {
name: string;
price: number;
reviewCount: number;
isNew: boolean;
};
}
export function ProductCard({ product }: ProductCardProps) {
const t = useTranslations('product');
const format = useFormatter();
return (
<div className="border rounded-lg p-4">
{product.isNew && (
<span className="bg-blue-500 text-white px-2 py-1 text-xs">
{t('new')}
</span>
)}
<h3>{product.name}</h3>
<p className="text-lg font-bold">
{format.number(product.price, {
style: 'currency',
currency: 'KRW',
})}
</p>
<p className="text-sm text-gray-500">
{t('reviews', { count: product.reviewCount })}
</p>
<button className="w-full mt-4 bg-primary text-white py-2">
{t('addToCart')}
</button>
</div>
);
}i18n 베스트 프랙티스