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
✅ 권장사항
- 전체 문자열 번역: UI의 모든 문자열을 번역 파일로 관리
- 컨텍스트 제공: 번역자가 이해할 수 있도록 주석 추가
- 복수형 처리: count 변수를 활용한 복수형 규칙
- RTL 지원: 논리적 CSS 속성 사용
- 날짜/숫자 형식: Intl API로 자동 현지화
- 언어 감지: Accept-Language 헤더로 자동 감지
- 폴백 언어: 번역이 없을 때 기본 언어로 폴백
⚠️ 피해야 할 것
- 하드코딩된 문자열: 모든 텍스트를 번역 파일로
- 문자열 연결: 변수 치환 사용
- UI에서 직접 번역: 컴포넌트 분리
- 이미지 내 텍스트: 텍스트는 HTML로
- 고정된 날짜 형식: Intl.DateTimeFormat 사용
- LTR 가정: RTL 언어 고려
- 번역 누락: 모든 언어에 동일한 키 제공
국제화 체크리스트
// 국제화 자가 진단 체크리스트
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에서 실행
로컬 실행
# 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관련 패턴
- Theming - 문화적 색상 고려
- Accessibility - 다국어 접근성
- Form Validation - 다국어 에러 메시지
Last updated on