Internationalization (i18n)
다국어 지원과 현지화를 통해 글로벌 사용자에게 최적화된 경험을 제공하는 패턴입니다.
목차
개요
국제화(i18n)는 다음과 같은 경우에 필요합니다:
- 다국어 지원: 여러 언어로 콘텐츠 제공
- 현지화: 지역별 날짜, 숫자, 통화 형식
- RTL 지원: 아랍어, 히브리어 등 우측에서 좌측 언어
- 타임존: 사용자 지역 시간대 표시
- 문화적 적응: 색상, 아이콘 등 문화적 차이 고려
프레임워크별 접근 방식
| 구분 | Vite (icignal-vite-demo) | Next.js (icignal-demo) |
|---|---|---|
| 라이브러리 | 외부 라이브러리 없음 (순수 React) | 외부 라이브러리 없음 (Next.js 기본) |
| 언어 감지 | URL 경로 + React Router | URL 경로 + Middleware |
| 서버 렌더링 | CSR 전용 | Server Component 지원 |
| 번역 로딩 | 클라이언트에서 동적 로드 | 서버에서 로드 후 클라이언트 전달 |
Vite 환경 (icignal-vite-demo)
Vite + React 환경에서 순수 React만으로 다국어를 구현하는 방법입니다.
Vite 프로젝트 구조
src/
├── i18n/
│ ├── config.ts # i18n 설정
│ ├── types.ts # Dictionary 타입 정의
│ ├── get-dictionary.ts # 번역 로드 함수
│ ├── index.ts # export
│ └── dictionaries/
│ ├── ko.json
│ └── en.json
├── providers/
│ └── i18n-provider.tsx # I18n Context Provider
└── pages/
└── ...Vite 설정 파일
src/i18n/config.ts
export const i18n = {
defaultLocale: "ko",
locales: ["ko", "en"],
} as const
export type Locale = (typeof i18n)["locales"][number]src/i18n/types.ts
import type ko from "./dictionaries/ko.json"
export type Dictionary = typeof kosrc/i18n/get-dictionary.ts
import type { Locale } from "./config"
const dictionaries = {
ko: () => import("./dictionaries/ko.json").then((module) => module.default),
en: () => import("./dictionaries/en.json").then((module) => module.default),
}
export const getDictionary = async (locale: Locale) =>
dictionaries[locale]?.() ?? dictionaries.ko()Vite I18n Provider
src/providers/i18n-provider.tsx
import {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from "react"
import { useLocation } from "@vortex/ui-icignal"
import { i18n, type Locale, type Dictionary } from "@/i18n"
import { getDictionary } from "@/i18n/get-dictionary"
interface I18nContextType {
locale: Locale
dictionary: Dictionary | null
setLocale: (locale: Locale) => void
}
const I18nContext = createContext<I18nContextType | null>(null)
export function I18nProvider({ children }: { children: ReactNode }) {
const location = useLocation()
const [locale, setLocale] = useState<Locale>(() => {
// URL에서 언어 추출: /ko/... 또는 /en/...
const pathLang = location.pathname.split("/")[1] as Locale
return i18n.locales.includes(pathLang) ? pathLang : i18n.defaultLocale
})
const [dictionary, setDictionary] = useState<Dictionary | null>(null)
useEffect(() => {
// URL 변경 시 언어 업데이트
const pathLang = location.pathname.split("/")[1] as Locale
if (i18n.locales.includes(pathLang) && pathLang !== locale) {
setLocale(pathLang)
}
}, [location.pathname, locale])
useEffect(() => {
// 언어 변경 시 dictionary 로드
getDictionary(locale).then(setDictionary)
}, [locale])
if (!dictionary) {
return <div>Loading...</div>
}
return (
<I18nContext.Provider value={{ locale, dictionary, setLocale }}>
{children}
</I18nContext.Provider>
)
}
export function useI18n() {
const context = useContext(I18nContext)
if (!context) {
throw new Error("useI18n must be used within I18nProvider")
}
return context
}
export function useDictionary(): Dictionary {
const { dictionary } = useI18n()
if (!dictionary) {
throw new Error("Dictionary not loaded")
}
return dictionary
}컴포넌트에서 사용
App 진입점
import { I18nProvider } from "@/providers/i18n-provider"
function App() {
return (
<I18nProvider>
<Router />
</I18nProvider>
)
}컴포넌트에서 번역 사용
import { useDictionary, useI18n } from "@/providers/i18n-provider"
export function UserList() {
const dict = useDictionary()
const { locale, setLocale } = useI18n()
return (
<div>
<h1>{dict.users.title}</h1>
<table>
<thead>
<tr>
<th>{dict.users.name}</th>
<th>{dict.users.email}</th>
<th>{dict.users.role}</th>
</tr>
</thead>
</table>
</div>
)
}Next.js 환경 (icignal-demo)
Next.js 15 App Router 환경에서 외부 라이브러리 없이 다국어를 구현하는 방법입니다.
Next.js 프로젝트 구조
src/
├── i18n/
│ ├── config.ts # i18n 설정 (locales, defaultLocale)
│ ├── types.ts # Dictionary 타입 정의
│ ├── get-dictionary.ts # 서버용 번역 로드 함수
│ ├── dictionary-provider.tsx # 클라이언트용 Context Provider
│ ├── index.ts # export
│ └── dictionaries/
│ ├── ko.json # 한국어 번역
│ └── en.json # 영어 번역
├── middleware.ts # locale 리다이렉트
└── app/
└── [lang]/ # 동적 locale 라우트
├── layout.tsx
└── page.tsxNext.js 설정 파일
src/i18n/config.ts - 지원 언어 정의
export const i18n = {
defaultLocale: "ko",
locales: ["ko", "en"],
} as const
export type Locale = (typeof i18n)["locales"][number]src/i18n/types.ts - 타입 자동 추론
import type ko from "./dictionaries/ko.json"
export type Dictionary = typeof kosrc/i18n/get-dictionary.ts - 서버용 Dictionary 로더
import type { Locale } from "./config"
const dictionaries = {
ko: () => import("./dictionaries/ko.json").then((module) => module.default),
en: () => import("./dictionaries/en.json").then((module) => module.default),
}
export const getDictionary = async (locale: Locale) =>
dictionaries[locale]?.() ?? dictionaries.ko()Next.js Dictionary Provider
src/i18n/dictionary-provider.tsx - 클라이언트용 Context
"use client"
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
type ReactNode,
} from "react"
import type { Dictionary } from "./types"
import type { Locale } from "./config"
import { i18n } from "./config"
interface LocaleContextValue {
locale: Locale
setLocale: (locale: Locale) => void
dictionary: Dictionary
}
const LocaleContext = createContext<LocaleContextValue | null>(null)
// 쿠키에서 locale 읽기
function getLocaleFromCookie(): Locale {
if (typeof document === "undefined") return i18n.defaultLocale
const match = document.cookie.match(/locale=([^;]+)/)
const locale = match?.[1] as Locale
return i18n.locales.includes(locale) ? locale : i18n.defaultLocale
}
// dictionary 동적 로드
async function loadDictionary(locale: Locale): Promise<Dictionary> {
const dictionaries = {
ko: () => import("./dictionaries/ko.json").then((m) => m.default),
en: () => import("./dictionaries/en.json").then((m) => m.default),
}
return dictionaries[locale]?.() ?? dictionaries.ko()
}
export function DictionaryProvider({
dictionary: initialDictionary,
children,
}: {
dictionary: Dictionary
children: ReactNode
}) {
const [locale, setLocaleState] = useState<Locale>(getLocaleFromCookie)
const [dictionary, setDictionary] = useState<Dictionary>(initialDictionary)
const setLocale = useCallback((newLocale: Locale) => {
document.cookie = `locale=${newLocale}; path=/; max-age=31536000`
setLocaleState(newLocale)
}, [])
useEffect(() => {
loadDictionary(locale).then(setDictionary)
}, [locale])
return (
<LocaleContext.Provider value={{ locale, setLocale, dictionary }}>
{children}
</LocaleContext.Provider>
)
}
export function useDictionary() {
const context = useContext(LocaleContext)
if (!context) {
throw new Error("useDictionary must be used within DictionaryProvider")
}
return context.dictionary
}
export function useLocale() {
const context = useContext(LocaleContext)
if (!context) {
throw new Error("useLocale must be used within DictionaryProvider")
}
return { locale: context.locale, setLocale: context.setLocale }
}Server Component에서 사용
app/[lang]/layout.tsx
import { i18n, type Locale, getDictionary } from "@/i18n"
import { DictionaryProvider } from "@/i18n/dictionary-provider"
export async function generateStaticParams() {
return i18n.locales.map((locale) => ({ lang: locale }))
}
export default async function RootLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ lang: Locale }>
}) {
const { lang } = await params
const dictionary = await getDictionary(lang)
return (
<html lang={lang}>
<body>
<DictionaryProvider dictionary={dictionary}>
{children}
</DictionaryProvider>
</body>
</html>
)
}Client Component에서 사용
"use client"
import { useDictionary, useLocale } from "@/i18n/dictionary-provider"
export function UserTable() {
const dict = useDictionary()
const { locale, setLocale } = useLocale()
return (
<div>
<h1>{dict.users.title}</h1>
<button onClick={() => setLocale(locale === "ko" ? "en" : "ko")}>
{locale === "ko" ? "English" : "한국어"}
</button>
</div>
)
}공통 패턴
번역 파일 구조
dictionaries/ko.json
{
"common": {
"loading": "로딩 중...",
"error": "오류",
"success": "성공",
"cancel": "취소",
"confirm": "확인",
"delete": "삭제",
"edit": "수정",
"save": "저장",
"close": "닫기",
"search": "검색",
"retry": "다시 시도"
},
"auth": {
"login": "로그인",
"logout": "로그아웃",
"username": "사용자 이름",
"password": "비밀번호",
"loginRequired": "로그인이 필요합니다",
"loginSuccess": "로그인되었습니다",
"loginFailed": "로그인에 실패했습니다"
},
"users": {
"title": "사용자 관리",
"list": "사용자 목록",
"detail": "사용자 상세",
"name": "이름",
"email": "이메일",
"role": "역할",
"actions": "작업",
"view": "보기",
"deleteConfirm": "정말 삭제하시겠습니까?",
"deleteSuccess": "사용자가 성공적으로 삭제되었습니다",
"deleteFailed": "사용자 삭제에 실패했습니다",
"loadFailed": "데이터 로드 실패",
"noPermission": "삭제 권한이 없습니다",
"notFound": "사용자를 찾을 수 없습니다"
},
"errors": {
"unknown": "알 수 없는 오류가 발생했습니다",
"network": "네트워크 오류: 서버에 연결할 수 없습니다",
"unauthorized": "인증 실패: 로그인이 필요합니다",
"forbidden": "접근 권한이 없습니다",
"notFound": "요청한 리소스를 찾을 수 없습니다",
"serverError": "서버 오류가 발생했습니다"
}
}타입 안전한 번역
JSON 파일에서 타입을 자동 추론하여 오타 방지와 자동완성을 제공합니다.
// types.ts
import type ko from "./dictionaries/ko.json"
export type Dictionary = typeof ko
// 사용 시 자동완성 지원
const dict: Dictionary = await getDictionary("ko")
dict.users.title // ✅ 자동완성
dict.users.invalid // ❌ 타입 에러날짜/숫자/통화 형식화
Intl API를 사용하여 지역별 형식을 자동으로 적용합니다.
export function useFormatters(locale: string) {
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
}).format(date)
}
const formatCurrency = (value: number, currency = "KRW") => {
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
}).format(value)
}
const formatRelativeTime = (date: Date) => {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" })
const diff = Math.floor((date.getTime() - Date.now()) / 1000)
const hours = Math.floor(diff / 3600)
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")
return rtf.format(Math.floor(diff / 60), "minute")
}
return { formatDate, formatCurrency, formatRelativeTime }
}
// 사용 예시
function PriceDisplay({ price }: { price: number }) {
const { locale } = useI18n()
const { formatCurrency } = useFormatters(locale)
return <span>{formatCurrency(price)}</span>
// ko: ₩1,234,567
// en: $1,234,567.00
}언어 전환 컴포넌트
import { useNavigate, useLocation } from "react-router-dom"
import { i18n, type Locale } from "@/i18n"
import { useI18n } from "@/providers/i18n-provider"
const languages: Record<Locale, { name: string; flag: string }> = {
ko: { name: "한국어", flag: "🇰🇷" },
en: { name: "English", flag: "🇺🇸" },
}
export function LanguageSwitcher() {
const { locale } = useI18n()
const navigate = useNavigate()
const location = useLocation()
const handleLocaleChange = (newLocale: Locale) => {
// URL 경로에서 언어 부분만 변경
const segments = location.pathname.split("/")
segments[1] = newLocale
navigate(segments.join("/"))
}
return (
<div className="flex gap-2">
{i18n.locales.map((loc) => (
<button
key={loc}
onClick={() => handleLocaleChange(loc)}
className={locale === loc ? "font-bold" : "opacity-60"}
>
{languages[loc].flag} {languages[loc].name}
</button>
))}
</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 "@/i18n/dictionary-provider"
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
import type { Locale } from "@/i18n/config"
type TranslationModule = Record<string, any>
const translationCache = new Map<string, TranslationModule>()
export async function loadTranslations(
locale: Locale,
namespace: string,
): Promise<TranslationModule> {
const cacheKey = `${locale}-${namespace}`
if (translationCache.has(cacheKey)) {
return translationCache.get(cacheKey)!
}
try {
const translations = await import(
`@/i18n/dictionaries/${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 "@/i18n/dictionary-provider"
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. 변수 치환 헬퍼
번역 문자열에 변수를 삽입하는 유틸리티입니다.
// src/utils/translate.ts
type Variables = Record<string, string | number>
export function interpolate(text: string, variables?: Variables): string {
if (!variables) return text
return Object.entries(variables).reduce((result, [key, value]) => {
return result.replace(new RegExp(`{${key}}`, "g"), String(value))
}, text)
}
// 사용 예시
// dictionaries/ko.json
// { "greeting": "안녕하세요, {name}님! {count}개의 메시지가 있습니다." }
const message = interpolate(dict.greeting, { name: "홍길동", count: 5 })
// 출력: "안녕하세요, 홍길동님! 5개의 메시지가 있습니다."4. 복수형 처리
한국어는 복수형이 단순하지만, 영어 등 다른 언어를 위한 복수형 처리입니다.
// src/utils/pluralize.ts
type PluralRules = {
zero?: string
one?: string
other: string
}
export function pluralize(
count: number,
rules: PluralRules,
locale: string = "ko",
): string {
const pluralRules = new Intl.PluralRules(locale)
const rule = pluralRules.select(count)
const text = rules[rule as keyof PluralRules] || rules.other
return text.replace("{count}", String(count))
}
// dictionaries/en.json
// {
// "items": {
// "zero": "No items",
// "one": "1 item",
// "other": "{count} items"
// }
// }
// 사용 예시
pluralize(0, dict.items, "en") // "No items"
pluralize(1, dict.items, "en") // "1 item"
pluralize(5, dict.items, "en") // "5 items"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에 언어 코드 포함",
],
}프레임워크별 요약
Next.js vs Vite 비교
| 기능 | Next.js (icignal-demo) | Vite (icignal-vite-demo) |
|---|---|---|
| Provider | DictionaryProvider | I18nProvider |
| Hook | useDictionary(), useLocale() | useDictionary(), useI18n() |
| 초기 로드 | 서버에서 로드 후 props 전달 | 클라이언트에서 동적 로드 |
| 언어 저장 | 쿠키 (locale=ko) | URL 경로 (/ko/...) |
| SSR 지원 | ✅ Server Component | ❌ CSR 전용 |
장단점
Next.js 방식
- ✅ Server Component에서 번역 파일이 클라이언트 번들에 포함되지 않음
- ✅ SEO 친화적 (각 locale별 정적 페이지 생성 가능)
- ✅ 초기 로딩 속도 빠름
- ⚠️ Client/Server Component 경계 관리 필요
Vite 방식
- ✅ 설정이 단순하고 직관적
- ✅ React Router와 자연스럽게 통합
- ✅ 모든 컴포넌트에서 동일한 방식으로 사용
- ⚠️ 초기 dictionary 로딩 시 로딩 상태 필요
테스트 및 실행
Next.js (icignal-demo) 실행
# 1. 프로젝트로 이동
cd examples/icignal-demo
# 2. 의존성 설치
pnpm install
# 3. 개발 서버 실행
pnpm dev
# 4. 언어별 테스트
open http://localhost:3004/ko/admin/user
open http://localhost:3004/en/admin/userVite (icignal-vite-demo) 실행
# 1. 프로젝트로 이동
cd examples/icignal-vite-demo
# 2. 의존성 설치
pnpm install
# 3. 개발 서버 실행
pnpm dev
# 4. 언어별 테스트
open http://localhost:5173/ko
open http://localhost:5173/en새 프로젝트에 적용하기
# 1. i18n 디렉토리 구조 생성
mkdir -p src/i18n/dictionaries
# 2. 설정 파일 생성
touch src/i18n/config.ts
touch src/i18n/types.ts
touch src/i18n/get-dictionary.ts
touch src/i18n/index.ts
# 3. 번역 파일 생성
touch src/i18n/dictionaries/ko.json
touch src/i18n/dictionaries/en.json
# 4. Provider 생성 (프레임워크에 따라 선택)
# Next.js: src/i18n/dictionary-provider.tsx
# Vite: src/providers/i18n-provider.tsx관련 패턴
- Theming - 문화적 색상 고려
- Accessibility - 다국어 접근성
- Form Validation - 다국어 에러 메시지
참고 자료
Last updated on