Skip to Content
PatternsInternationalization (i18n)

Internationalization (i18n)

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


목차

  1. 개요
  2. Vite 환경 (icignal-vite-demo)
  3. Next.js 환경 (icignal-demo)
  4. 공통 패턴
  5. 고급 패턴
  6. Best Practices
  7. 체크리스트

개요

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

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

프레임워크별 접근 방식

구분Vite (icignal-vite-demo)Next.js (icignal-demo)
라이브러리외부 라이브러리 없음 (순수 React)외부 라이브러리 없음 (Next.js 기본)
언어 감지URL 경로 + React RouterURL 경로 + 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 ko

src/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.tsx

Next.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 ko

src/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

✅ 권장사항

  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에 언어 코드 포함", ], }

프레임워크별 요약

Next.js vs Vite 비교

기능Next.js (icignal-demo)Vite (icignal-vite-demo)
ProviderDictionaryProviderI18nProvider
HookuseDictionary(), 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/user

Vite (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

관련 패턴


참고 자료

Last updated on