Skip to Content
PatternsInternationalization (i18n)

Internationalization (i18n)

다국어 지원과 현지화를 통해 글로벌 사용자에게 최적화된 경험을 제공하는 패턴입니다.

개요

국제화(i18n)는 다음과 같은 경우에 필요합니다:

  • 다국어 지원: 여러 언어로 콘텐츠 제공
  • 현지화: 지역별 날짜, 숫자, 통화 형식
  • RTL 지원: 아랍어, 히브리어 등 우측에서 좌측 언어
  • 타임존: 사용자 지역 시간대 표시
  • 문화적 적응: 색상, 아이콘 등 문화적 차이 고려

기본 패턴

1. next-intl을 활용한 다국어 지원

Next.js App Router에서 next-intl로 다국어를 구현합니다.

// i18n.ts import { getRequestConfig } from 'next-intl/server' export const locales = ['ko', 'en', 'ja', 'zh'] as const export type Locale = (typeof locales)[number] export default getRequestConfig(async ({ locale }) => ({ messages: (await import(`./messages/${locale}.json`)).default })) // messages/ko.json { "common": { "welcome": "환영합니다", "login": "로그인", "logout": "로그아웃", "submit": "제출", "cancel": "취소" }, "home": { "title": "홈 페이지", "description": "Vortex 디자인 시스템에 오신 것을 환영합니다" }, "validation": { "required": "필수 입력 항목입니다", "email": "올바른 이메일 형식이 아닙니다", "minLength": "최소 {min}자 이상 입력해주세요", "maxLength": "최대 {max}자까지 입력 가능합니다" } } // messages/en.json { "common": { "welcome": "Welcome", "login": "Login", "logout": "Logout", "submit": "Submit", "cancel": "Cancel" }, "home": { "title": "Home Page", "description": "Welcome to Vortex Design System" }, "validation": { "required": "This field is required", "email": "Invalid email format", "minLength": "Minimum {min} characters required", "maxLength": "Maximum {max} characters allowed" } }
// middleware.ts import createMiddleware from 'next-intl/middleware' import { locales } from './i18n' export default createMiddleware({ locales, defaultLocale: 'ko', localePrefix: 'as-needed' }) export const config = { matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'] } // 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} dir={locale === 'ar' ? 'rtl' : 'ltr'}> <body> <NextIntlClientProvider messages={messages}> {children} </NextIntlClientProvider> </body> </html> ) } // app/[locale]/page.tsx import { useTranslations } from 'next-intl' export default function HomePage() { const t = useTranslations('home') return ( <div> <h1>{t('title')}</h1> <p>{t('description')}</p> </div> ) } // 변수 치환 export function ValidationMessage() { const t = useTranslations('validation') return ( <p>{t('minLength', { min: 8 })}</p> // 출력: "최소 8자 이상 입력해주세요" ) } // 복수형 처리 // messages/ko.json { "items": { "count": "{count}개의 항목", "count_zero": "항목 없음", "count_one": "1개 항목", "count_other": "{count}개 항목" } } export function ItemCount({ count }: { count: number }) { const t = useTranslations('items') return <p>{t('count', { count })}</p> }

2. 언어 전환 컴포넌트

사용자가 언어를 쉽게 변경할 수 있도록 합니다.

// src/components/LanguageSwitcher.tsx "use client"; import { useLocale } from "next-intl"; import { useRouter, usePathname } from "next/navigation"; import { locales, type Locale } from "@/i18n"; const languages: Record<Locale, { name: string; flag: string }> = { ko: { name: "한국어", flag: "🇰🇷" }, en: { name: "English", flag: "🇺🇸" }, ja: { name: "日本語", flag: "🇯🇵" }, zh: { name: "中文", flag: "🇨🇳" }, }; export function LanguageSwitcher() { const locale = useLocale() as Locale; const router = useRouter(); const pathname = usePathname(); const handleLocaleChange = (newLocale: Locale) => { // 현재 경로에서 locale 부분만 변경 const segments = pathname.split("/"); segments[1] = newLocale; const newPathname = segments.join("/"); router.push(newPathname); }; return ( <div className="relative"> <button className="flex items-center gap-2 px-4 py-2 rounded-lg border" aria-label="언어 선택" > <span>{languages[locale].flag}</span> <span>{languages[locale].name}</span> </button> <div className="absolute mt-2 w-48 bg-white rounded-lg shadow-lg border"> {locales.map((loc) => ( <button key={loc} onClick={() => handleLocaleChange(loc)} className={`w-full flex items-center gap-2 px-4 py-2 hover:bg-gray-50 ${ locale === loc ? "bg-blue-50 text-blue-600" : "" }`} > <span>{languages[loc].flag}</span> <span>{languages[loc].name}</span> </button> ))} </div> </div> ); }

3. 날짜, 숫자, 통화 형식화

Intl API를 사용하여 지역별 형식을 자동으로 적용합니다.

// src/utils/formatters.ts import { useLocale } from "next-intl"; export function useFormatters() { const locale = useLocale(); // 날짜 형식화 const formatDate = (date: Date, options?: Intl.DateTimeFormatOptions) => { return new Intl.DateTimeFormat(locale, { year: "numeric", month: "long", day: "numeric", ...options, }).format(date); }; // 상대 시간 형식화 const formatRelativeTime = (date: Date) => { const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); const diff = date.getTime() - Date.now(); const seconds = Math.floor(diff / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (Math.abs(days) > 0) return rtf.format(days, "day"); if (Math.abs(hours) > 0) return rtf.format(hours, "hour"); if (Math.abs(minutes) > 0) return rtf.format(minutes, "minute"); return rtf.format(seconds, "second"); }; // 숫자 형식화 const formatNumber = (value: number, options?: Intl.NumberFormatOptions) => { return new Intl.NumberFormat(locale, options).format(value); }; // 통화 형식화 const formatCurrency = ( value: number, currency: string = "KRW", options?: Intl.NumberFormatOptions ) => { return new Intl.NumberFormat(locale, { style: "currency", currency, ...options, }).format(value); }; // 퍼센트 형식화 const formatPercent = (value: number, options?: Intl.NumberFormatOptions) => { return new Intl.NumberFormat(locale, { style: "percent", ...options, }).format(value); }; // 리스트 형식화 const formatList = ( items: string[], type: "conjunction" | "disjunction" = "conjunction" ) => { return new Intl.ListFormat(locale, { style: "long", type }).format(items); }; return { formatDate, formatRelativeTime, formatNumber, formatCurrency, formatPercent, formatList, }; } // 사용 예시 export function FormattedValues() { const { formatDate, formatCurrency, formatRelativeTime, formatList } = useFormatters(); return ( <div> <p>날짜: {formatDate(new Date())}</p> {/* ko: "2024년 1월 15일", en: "January 15, 2024" */} <p>가격: {formatCurrency(1234567)}</p> {/* ko: "₩1,234,567", en: "$1,234,567.00" */} <p>시간: {formatRelativeTime(new Date(Date.now() - 3600000))}</p> {/* ko: "1시간 전", en: "1 hour ago" */} <p>목록: {formatList(["사과", "바나나", "오렌지"])}</p> {/* ko: "사과, 바나나 및 오렌지", en: "사과, 바나나, and 오렌지" */} </div> ); }

고급 패턴

1. RTL (Right-to-Left) 지원

아랍어, 히브리어 등 RTL 언어를 지원합니다.

/* src/styles/rtl.css */ /* RTL 자동 전환 */ [dir="rtl"] { direction: rtl; text-align: right; } /* 논리적 속성 사용 (자동 RTL 대응) */ .container { /* margin-left 대신 margin-inline-start */ margin-inline-start: 1rem; /* margin-right 대신 margin-inline-end */ margin-inline-end: 1rem; /* padding-left, padding-right 대신 padding-inline */ padding-inline: 2rem; } /* border-radius도 논리적 속성으로 */ .card { border-start-start-radius: 0.5rem; /* top-left in LTR, top-right in RTL */ border-start-end-radius: 0.5rem; /* top-right in LTR, top-left in RTL */ border-end-start-radius: 0.5rem; /* bottom-left in LTR, bottom-right in RTL */ border-end-end-radius: 0.5rem; /* bottom-right in LTR, bottom-left in RTL */ } /* 플렉스박스 자동 반전 */ .flex-container { display: flex; /* flex-direction: row-reverse는 RTL에서 자동으로 반전되지 않음 */ /* 대신 direction 속성에 의존 */ } /* 아이콘 반전 (필요시) */ [dir="rtl"] .icon-arrow { transform: scaleX(-1); } /* 그림자 반전 */ [dir="ltr"] .shadow-left { box-shadow: -4px 0 8px rgba(0, 0, 0, 0.1); } [dir="rtl"] .shadow-left { box-shadow: 4px 0 8px rgba(0, 0, 0, 0.1); }
// src/components/RTLProvider.tsx 'use client' import { useEffect } from 'react' import { useLocale } from 'next-intl' const RTL_LOCALES = ['ar', 'he', 'fa', 'ur'] export function RTLProvider({ children }: { children: React.ReactNode }) { const locale = useLocale() const isRTL = RTL_LOCALES.includes(locale) useEffect(() => { document.documentElement.dir = isRTL ? 'rtl' : 'ltr' }, [isRTL]) return <>{children}</> } // Tailwind CSS RTL 플러그인 사용 // tailwind.config.js module.exports = { plugins: [ require('tailwindcss-rtl') ] } // 사용 예시 <div className="ltr:ml-4 rtl:mr-4"> Content </div>

2. 동적 번역 로딩 (코드 스플리팅)

필요한 언어 파일만 동적으로 로드합니다.

// src/utils/dynamic-translations.ts type TranslationModule = Record<string, any>; const translationCache = new Map<string, TranslationModule>(); export async function loadTranslations( locale: string, namespace: string ): Promise<TranslationModule> { const cacheKey = `${locale}-${namespace}`; if (translationCache.has(cacheKey)) { return translationCache.get(cacheKey)!; } try { const translations = await import(`@/messages/${locale}/${namespace}.json`); translationCache.set(cacheKey, translations.default); return translations.default; } catch (error) { console.error(`Failed to load translations for ${cacheKey}`, error); return {}; } } // src/hooks/useNamespaceTranslations.ts import { useState, useEffect } from "react"; import { useLocale } from "next-intl"; export function useNamespaceTranslations(namespace: string) { const locale = useLocale(); const [translations, setTranslations] = useState<Record<string, any>>({}); const [isLoading, setIsLoading] = useState(true); useEffect(() => { setIsLoading(true); loadTranslations(locale, namespace) .then(setTranslations) .finally(() => setIsLoading(false)); }, [locale, namespace]); const t = (key: string, variables?: Record<string, any>) => { let text = translations[key] || key; if (variables) { Object.entries(variables).forEach(([varKey, value]) => { text = text.replace(`{${varKey}}`, value); }); } return text; }; return { t, isLoading }; } // 사용 예시 export function AdminPanel() { const { t, isLoading } = useNamespaceTranslations("admin"); if (isLoading) { return <div>Loading translations...</div>; } return ( <div> <h1>{t("title")}</h1> <p>{t("description")}</p> </div> ); }

3. 서버 컴포넌트에서 번역

Next.js App Router 서버 컴포넌트에서 번역을 사용합니다.

// app/[locale]/products/page.tsx import { getTranslations } from "next-intl/server"; import { getProducts } from "@/lib/api"; export async function generateMetadata({ params: { locale }, }: { params: { locale: string }; }) { const t = await getTranslations({ locale, namespace: "products" }); return { title: t("meta.title"), description: t("meta.description"), }; } export default async function ProductsPage({ params: { locale }, }: { params: { locale: string }; }) { const t = await getTranslations({ locale, namespace: "products" }); const products = await getProducts(locale); return ( <div> <h1>{t("title")}</h1> <p>{t("description")}</p> <div className="grid grid-cols-3 gap-4"> {products.map((product) => ( <div key={product.id} className="p-4 border rounded"> <h3>{product.name[locale]}</h3> <p>{product.description[locale]}</p> <p> {t("price")}: {product.price} </p> </div> ))} </div> </div> ); }

4. 타입 안전한 번역

TypeScript로 번역 키의 타입 안전성을 보장합니다.

// src/types/i18n.ts type Messages = typeof import("@/messages/ko.json"); // 중첩된 객체의 모든 키를 "a.b.c" 형태로 변환 type NestedKeyOf<T> = T extends object ? { [K in keyof T & string]: T[K] extends object ? `${K}.${NestedKeyOf<T[K]>}` : K; }[keyof T & string] : never; export type TranslationKey = NestedKeyOf<Messages>; // src/hooks/useTypedTranslations.ts import { useTranslations as useNextIntlTranslations } from "next-intl"; import type { TranslationKey } from "@/types/i18n"; export function useTypedTranslations() { const t = useNextIntlTranslations(); return (key: TranslationKey, variables?: Record<string, any>) => { return t(key, variables); }; } // 사용 예시 (타입 체크) export function TypeSafeComponent() { const t = useTypedTranslations(); return ( <div> <h1>{t("home.title")}</h1> {/* ✅ 타입 안전 */} <p>{t("home.invalid.key")}</p> {/* ❌ 타입 에러 */} </div> ); } // messages/ko.json에 대한 타입 정의 자동 생성 // scripts/generate-i18n-types.ts import fs from "fs"; import path from "path"; function generateTypes() { const messagesPath = path.join(process.cwd(), "messages/ko.json"); const messages = JSON.parse(fs.readFileSync(messagesPath, "utf-8")); const typeDefinition = ` export type Messages = ${JSON.stringify(messages, null, 2)} export type TranslationKey = /* generated type */ `; fs.writeFileSync( path.join(process.cwd(), "src/types/i18n-generated.d.ts"), typeDefinition ); } generateTypes();

Best Practices

✅ 권장사항

  1. 전체 문자열 번역: UI의 모든 문자열을 번역 파일로 관리
  2. 컨텍스트 제공: 번역자가 이해할 수 있도록 주석 추가
  3. 복수형 처리: count 변수를 활용한 복수형 규칙
  4. RTL 지원: 논리적 CSS 속성 사용
  5. 날짜/숫자 형식: Intl API로 자동 현지화
  6. 언어 감지: Accept-Language 헤더로 자동 감지
  7. 폴백 언어: 번역이 없을 때 기본 언어로 폴백

⚠️ 피해야 할 것

  1. 하드코딩된 문자열: 모든 텍스트를 번역 파일로
  2. 문자열 연결: 변수 치환 사용
  3. UI에서 직접 번역: 컴포넌트 분리
  4. 이미지 내 텍스트: 텍스트는 HTML로
  5. 고정된 날짜 형식: Intl.DateTimeFormat 사용
  6. LTR 가정: RTL 언어 고려
  7. 번역 누락: 모든 언어에 동일한 키 제공

국제화 체크리스트

// 국제화 자가 진단 체크리스트 const i18nChecklist = { translation: [ "모든 UI 문자열이 번역 파일에 정의됨", "변수 치환으로 동적 텍스트 처리", "복수형 규칙 구현", "번역 키에 컨텍스트 제공", "폴백 언어 설정", ], formatting: [ "날짜를 Intl.DateTimeFormat으로 형식화", "숫자를 Intl.NumberFormat으로 형식화", "통화를 Intl.NumberFormat(currency)로 형식화", "타임존 고려", "상대 시간 형식화", ], rtl: [ "HTML dir 속성 설정", "논리적 CSS 속성 사용", "아이콘 반전 처리", "RTL 언어 테스트", ], ux: [ "언어 전환 UI 제공", "선택한 언어 저장", "브라우저 언어 자동 감지", "URL에 언어 코드 포함", ], };

제품별 국제화 전략

Foundation (기본 프레임워크)

// Foundation용 i18n 유틸리티 export const I18nUtils = { formatDate: (date: Date, locale: string) => { return new Intl.DateTimeFormat(locale).format(date); }, formatCurrency: (amount: number, locale: string, currency: string) => { return new Intl.NumberFormat(locale, { style: "currency", currency, }).format(amount); }, };

전체 코드 보기: Foundation i18n Utils

iCignal (분석 플랫폼)

// iCignal용 다국어 차트 레이블 export function LocalizedChart({ data }: { data: ChartData[] }) { const t = useTranslations("charts"); return ( <Chart data={data} labels={{ xAxis: t("xAxis"), yAxis: t("yAxis"), tooltip: t("tooltip"), }} /> ); }

전체 코드 보기: iCignal Localized Charts

Cals (예약 시스템)

// Cals용 다국어 날짜 선택기 export function LocalizedDatePicker() { const locale = useLocale(); const { formatDate } = useFormatters(); return ( <DatePicker locale={locale} formatDate={formatDate} placeholder={t("selectDate")} /> ); }

전체 코드 보기: Cals Localized DatePicker


테스트 및 실행

CodeSandbox에서 실행

Internationalization 예제 실행하기 

로컬 실행

# 1. next-intl 설치 pnpm add next-intl # 2. 번역 파일 생성 mkdir -p messages touch messages/ko.json messages/en.json # 3. 개발 서버 실행 pnpm dev # 4. 언어 테스트 open http://localhost:3000/ko open http://localhost:3000/en

관련 패턴

Last updated on