Next.js 이론 14강

국제화(i18n)와 다국어 지원

Next.js App Router에서 다국어 웹사이트를 구축하는 방법을 학습합니다.

1. 국제화(i18n) 개요

국제화(Internationalization, i18n)는 웹사이트를 여러 언어와 지역에 맞게 제공하는 것입니다. 글로벌 이커머스에서 필수적인 기능입니다.

i18n vs L10n

i18n (Internationalization)
  • • 다국어 지원 구조 설계
  • • 번역 시스템 구축
  • • 언어 전환 기능
  • • RTL(오른쪽→왼쪽) 지원
L10n (Localization)
  • • 실제 번역 작업
  • • 날짜/통화 형식
  • • 문화적 맞춤화
  • • 지역별 콘텐츠

이커머스 i18n 요소

🌐 텍스트 번역

UI 텍스트, 상품명, 설명, 리뷰

💰 통화 형식

₩10,000 / $10.00 / €10,00

📅 날짜 형식

2024년 1월 15일 / Jan 15, 2024 / 15/01/2024

📏 단위

kg/lb, cm/inch, °C/°F

Next.js i18n 접근 방식

방식URL 예시특징
서브패스/ko/products, /en/productsSEO 우수, 권장
서브도메인ko.shop.com, en.shop.com지역별 서버 분리 가능
쿠키/헤더shop.com (동일)SEO 불리

서브패스 방식 권장 이유

  • SEO: 검색엔진이 언어별 페이지 인식
  • 단일 도메인: 인프라 관리 간편
  • Next.js 지원: App Router 기본 지원

2. Next.js App Router i18n 설정

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 설정

// 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>
  );
}

설정 포인트

  • [locale]: 동적 세그먼트로 언어 처리
  • 미들웨어: 언어 감지 및 리다이렉트
  • generateStaticParams: 정적 생성

3. 번역 파일 관리

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개 상품"

번역 관리 팁

  • 네임스페이스: common, product, cart 등으로 분류
  • 동적 import: 필요한 언어만 로드
  • 타입 안전: Dictionary 타입으로 자동완성

4. 컴포넌트에서 번역 사용

Server Component와 Client Component에서 번역을 사용하는 방법입니다.

Server 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>
  );
}

Client Component

'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"

5. 날짜와 통화 포맷

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,000

숫자 포맷

const 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

6. 언어 전환 UI

사용자가 언어를 전환할 수 있는 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>
  );
}

Link 컴포넌트

// 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);

7. 이커머스 i18n 예제

이커머스 상품 카드에 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>
  );
}

핵심 정리

next-intl로 App Router i18n 구현
JSON 파일로 번역 관리
useFormatter로 날짜/통화 포맷
[locale] 동적 라우트로 URL 구조화

i18n 베스트 프랙티스

  • • 번역 키는 의미 있는 이름 사용
  • • 네임스페이스로 번역 파일 분리
  • • 하드코딩된 텍스트 없이 모두 번역 처리
  • • SEO를 위해 각 언어별 메타데이터 설정