next-intl로 Next.js 앱 다국어 지원: App Router 완전 가이드

프론트엔드

Next.jsnext-intl다국어i18nApp Router

이 글은 누구를 위한 것인가

  • 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 메시지 형식의 복수형 처리는 언어별 규칙을 자동으로 적용한다.