fe-i18n

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

FE Internationalization (i18n)

前端国际化(i18n)

$ARGUMENTS
를 분석하여 다국어 설정을 구성하거나 번역을 적용한다.
分析
$ARGUMENTS
以配置多语言设置或应用翻译。

분석 절차

分析流程

  1. 현재 상태 파악: 프로젝트의 i18n 설정을 확인한다
  2. 요구사항 파악: 지원할 언어, 기본 언어, 라우팅 전략을 확인한다
  3. 설정/구현: next-intl 기반으로 다국어를 설정하거나 번역을 적용한다
  4. 검증: 각 로케일에서 정상 동작하는지 확인한다
  1. 掌握当前状态:确认项目的i18n设置
  2. 明确需求:确认要支持的语言、默认语言、路由策略
  3. 配置/实现:基于next-intl设置多语言或应用翻译
  4. 验证:确认各本地化环境下的正常运行

next-intl 초기 설정

next-intl 初始配置

1. 패키지 설치

1. 安装包

bash
pnpm add next-intl
bash
pnpm add next-intl

2. 프로젝트 구조

2. 项目结构

src/
├── app/
│   └── [locale]/              # 로케일별 라우팅
│       ├── layout.tsx
│       ├── page.tsx
│       └── about/page.tsx
├── i18n/
│   ├── request.ts             # 서버 사이드 i18n 설정
│   └── routing.ts             # 라우팅 설정
├── messages/                  # 번역 파일
│   ├── ko.json
│   └── en.json
└── middleware.ts               # 로케일 감지 & 리다이렉트
src/
├── app/
│   └── [locale]/              # 本地化路由
│       ├── layout.tsx
│       ├── page.tsx
│       └── about/page.tsx
├── i18n/
│   ├── request.ts             # 服务端i18n配置
│   └── routing.ts             # 路由配置
├── messages/                  # 翻译文件
│   ├── ko.json
│   └── en.json
└── middleware.ts               # 本地化检测与重定向

3. 라우팅 설정

3. 路由配置

typescript
// src/i18n/routing.ts
import { defineRouting } from "next-intl/routing";

export const routing = defineRouting({
  locales: ["ko", "en"],
  defaultLocale: "ko",
});
typescript
// src/i18n/routing.ts
import { defineRouting } from "next-intl/routing";

export const routing = defineRouting({
  locales: ["ko", "en"],
  defaultLocale: "ko",
});

4. 미들웨어

4. 中间件

typescript
// src/middleware.ts
import createMiddleware from "next-intl/middleware";
import { routing } from "@/i18n/routing";

export default createMiddleware(routing);

export const config = {
  matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
};
typescript
// src/middleware.ts
import createMiddleware from "next-intl/middleware";
import { routing } from "@/i18n/routing";

export default createMiddleware(routing);

export const config = {
  matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
};

5. 서버 사이드 설정

5. 服务端配置

typescript
// src/i18n/request.ts
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";

export default getRequestConfig(async ({ requestLocale }) => {
  let locale = await requestLocale;

  if (!locale || !routing.locales.includes(locale as "ko" | "en")) {
    locale = routing.defaultLocale;
  }

  return {
    locale,
    messages: (await import(`../messages/${locale}.json`)).default,
  };
});
typescript
// src/i18n/request.ts
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";

export default getRequestConfig(async ({ requestLocale }) => {
  let locale = await requestLocale;

  if (!locale || !routing.locales.includes(locale as "ko" | "en")) {
    locale = routing.defaultLocale;
  }

  return {
    locale,
    messages: (await import(`../messages/${locale}.json`)).default,
  };
});

6. next.config

6. next.config

typescript
// next.config.ts
import createNextIntlPlugin from "next-intl/plugin";

const withNextIntl = createNextIntlPlugin();

const nextConfig = {};

export default withNextIntl(nextConfig);
typescript
// next.config.ts
import createNextIntlPlugin from "next-intl/plugin";

const withNextIntl = createNextIntlPlugin();

const nextConfig = {};

export default withNextIntl(nextConfig);

7. Root Layout

7. 根布局

tsx
// src/app/[locale]/layout.tsx
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing";

interface LayoutProps {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
}

export default async function LocaleLayout({ children, params }: LayoutProps) {
  const { locale } = await params;

  if (!routing.locales.includes(locale as "ko" | "en")) {
    notFound();
  }

  const messages = await getMessages();

  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}
tsx
// src/app/[locale]/layout.tsx
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing";

interface LayoutProps {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
}

export default async function LocaleLayout({ children, params }: LayoutProps) {
  const { locale } = await params;

  if (!routing.locales.includes(locale as "ko" | "en")) {
    notFound();
  }

  const messages = await getMessages();

  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

번역 파일 구조

翻译文件结构

네임스페이스 기반 구조

基于命名空间的结构

json
// messages/ko.json
{
  "common": {
    "submit": "제출",
    "cancel": "취소",
    "save": "저장",
    "delete": "삭제",
    "loading": "로딩 중...",
    "error": "오류가 발생했습니다",
    "confirm": "확인"
  },
  "nav": {
    "home": "홈",
    "about": "소개",
    "blog": "블로그",
    "contact": "문의"
  },
  "auth": {
    "login": "로그인",
    "logout": "로그아웃",
    "signup": "회원가입",
    "email": "이메일",
    "password": "비밀번호",
    "forgotPassword": "비밀번호 찾기"
  },
  "home": {
    "title": "환영합니다",
    "description": "최고의 서비스를 제공합니다",
    "cta": "시작하기"
  },
  "blog": {
    "title": "블로그",
    "readMore": "더 읽기",
    "publishedAt": "{date}에 게시됨",
    "readingTime": "읽는 시간 {minutes}분"
  },
  "validation": {
    "required": "{field}을(를) 입력해주세요",
    "email": "유효한 이메일을 입력해주세요",
    "minLength": "{field}은(는) 최소 {min}자 이상이어야 합니다"
  }
}
json
// messages/en.json
{
  "common": {
    "submit": "Submit",
    "cancel": "Cancel",
    "save": "Save",
    "delete": "Delete",
    "loading": "Loading...",
    "error": "An error occurred",
    "confirm": "Confirm"
  },
  "nav": {
    "home": "Home",
    "about": "About",
    "blog": "Blog",
    "contact": "Contact"
  },
  "auth": {
    "login": "Log in",
    "logout": "Log out",
    "signup": "Sign up",
    "email": "Email",
    "password": "Password",
    "forgotPassword": "Forgot password"
  },
  "home": {
    "title": "Welcome",
    "description": "We provide the best service",
    "cta": "Get Started"
  },
  "blog": {
    "title": "Blog",
    "readMore": "Read more",
    "publishedAt": "Published on {date}",
    "readingTime": "{minutes} min read"
  },
  "validation": {
    "required": "Please enter {field}",
    "email": "Please enter a valid email",
    "minLength": "{field} must be at least {min} characters"
  }
}
json
// messages/ko.json
{
  "common": {
    "submit": "제출",
    "cancel": "취소",
    "save": "저장",
    "delete": "삭제",
    "loading": "로딩 중...",
    "error": "오류가 발생했습니다",
    "confirm": "확인"
  },
  "nav": {
    "home": "홈",
    "about": "소개",
    "blog": "블로그",
    "contact": "문의"
  },
  "auth": {
    "login": "로그인",
    "logout": "로그아웃",
    "signup": "회원가입",
    "email": "이메일",
    "password": "비밀번호",
    "forgotPassword": "비밀번호 찾기"
  },
  "home": {
    "title": "환영합니다",
    "description": "최고의 서비스를 제공합니다",
    "cta": "시작하기"
  },
  "blog": {
    "title": "블로그",
    "readMore": "더 읽기",
    "publishedAt": "{date}에 게시됨",
    "readingTime": "읽는 시간 {minutes}분"
  },
  "validation": {
    "required": "{field}을(를) 입력해주세요",
    "email": "유효한 이메일을 입력해주세요",
    "minLength": "{field}은(는) 최소 {min}자 이상이어야 합니다"
  }
}
json
// messages/en.json
{
  "common": {
    "submit": "Submit",
    "cancel": "Cancel",
    "save": "Save",
    "delete": "Delete",
    "loading": "Loading...",
    "error": "An error occurred",
    "confirm": "Confirm"
  },
  "nav": {
    "home": "Home",
    "about": "About",
    "blog": "Blog",
    "contact": "Contact"
  },
  "auth": {
    "login": "Log in",
    "logout": "Log out",
    "signup": "Sign up",
    "email": "Email",
    "password": "Password",
    "forgotPassword": "Forgot password"
  },
  "home": {
    "title": "Welcome",
    "description": "We provide the best service",
    "cta": "Get Started"
  },
  "blog": {
    "title": "Blog",
    "readMore": "Read more",
    "publishedAt": "Published on {date}",
    "readingTime": "{minutes} min read"
  },
  "validation": {
    "required": "Please enter {field}",
    "email": "Please enter a valid email",
    "minLength": "{field} must be at least {min} characters"
  }
}

번역 사용 패턴

翻译使用模式

Server Component에서 사용

在Server Component中使用

tsx
// src/app/[locale]/page.tsx
import { useTranslations } from "next-intl";

export default function HomePage() {
  const t = useTranslations("home");

  return (
    <main>
      <h1>{t("title")}</h1>
      <p>{t("description")}</p>
      <Button>{t("cta")}</Button>
    </main>
  );
}
tsx
// src/app/[locale]/page.tsx
import { useTranslations } from "next-intl";

export default function HomePage() {
  const t = useTranslations("home");

  return (
    <main>
      <h1>{t("title")}</h1>
      <p>{t("description")}</p>
      <Button>{t("cta")}</Button>
    </main>
  );
}

Client Component에서 사용

在Client Component中使用

tsx
"use client";

import { useTranslations } from "next-intl";

function LoginForm() {
  const t = useTranslations("auth");
  const tCommon = useTranslations("common");

  return (
    <form>
      <Input placeholder={t("email")} />
      <Input placeholder={t("password")} type="password" />
      <Button type="submit">{t("login")}</Button>
      <Button variant="outline">{tCommon("cancel")}</Button>
    </form>
  );
}
tsx
"use client";

import { useTranslations } from "next-intl";

function LoginForm() {
  const t = useTranslations("auth");
  const tCommon = useTranslations("common");

  return (
    <form>
      <Input placeholder={t("email")} />
      <Input placeholder={t("password")} type="password" />
      <Button type="submit">{t("login")}</Button>
      <Button variant="outline">{tCommon("cancel")}</Button>
    </form>
  );
}

변수 삽입 (ICU 문법)

变量插入(ICU语法)

tsx
const t = useTranslations("blog");

// 단순 변수
<p>{t("publishedAt", { date: "2024-01-15" })}</p>
// → "2024-01-15에 게시됨" (ko)
// → "Published on 2024-01-15" (en)

// 복수형 (messages에 정의)
// "items": "{count, plural, =0 {항목 없음} one {1개 항목} other {{count}개 항목}}"
<p>{t("items", { count: 5 })}</p>
// → "5개 항목"

// 리치 텍스트
// "terms": "<link>이용약관</link>에 동의합니다"
<p>
  {t.rich("terms", {
    link: (chunks) => <a href="/terms">{chunks}</a>,
  })}
</p>
tsx
const t = useTranslations("blog");

// 简单变量
<p>{t("publishedAt", { date: "2024-01-15" })}</p>
// → "2024-01-15에 게시됨"(韩语)
// → "Published on 2024-01-15"(英语)

// 复数形式(在messages中定义)
// "items": "{count, plural, =0 {항목 없음} one {1개 항목} other {{count}개 항목}}"
<p>{t("items", { count: 5 })}</p>
// → "5개 항목"

// 富文本
// "terms": "<link>이용약관</link>에 동의합니다"
<p>
  {t.rich("terms", {
    link: (chunks) => <a href="/terms">{chunks}</a>,
  })}
</p>

날짜/숫자 포맷

日期/数字格式化

tsx
import { useFormatter } from "next-intl";

function PriceDisplay({ price, date }: { price: number; date: Date }) {
  const format = useFormatter();

  return (
    <div>
      <p>
        {format.number(price, { style: "currency", currency: "KRW" })}
      </p>
      {/* → "₩10,000" (ko) / "$10,000" (en, currency에 따라) */}

      <p>
        {format.dateTime(date, { year: "numeric", month: "long", day: "numeric" })}
      </p>
      {/* → "2024년 1월 15일" (ko) / "January 15, 2024" (en) */}

      <p>
        {format.relativeTime(date)}
      </p>
      {/* → "3일 전" (ko) / "3 days ago" (en) */}
    </div>
  );
}
tsx
import { useFormatter } from "next-intl";

function PriceDisplay({ price, date }: { price: number; date: Date }) {
  const format = useFormatter();

  return (
    <div>
      <p>
        {format.number(price, { style: "currency", currency: "KRW" })}
      </p>
      {/* → "₩10,000"(韩语)/ "$10,000"(英语,根据货币类型) */}

      <p>
        {format.dateTime(date, { year: "numeric", month: "long", day: "numeric" })}
      </p>
      {/* → "2024년 1월 15일"(韩语)/ "January 15, 2024"(英语) */}

      <p>
        {format.relativeTime(date)}
      </p>
      {/* → "3일 전"(韩语)/ "3 days ago"(英语) */}
    </div>
  );
}

언어 전환 컴포넌트

语言切换组件

tsx
"use client";

import { useLocale } from "next-intl";
import { useRouter, usePathname } from "@/i18n/routing";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";

const localeLabels: Record<string, string> = {
  ko: "한국어",
  en: "English",
};

function LocaleSwitcher() {
  const locale = useLocale();
  const router = useRouter();
  const pathname = usePathname();

  function handleChange(newLocale: string) {
    router.replace(pathname, { locale: newLocale });
  }

  return (
    <Select value={locale} onValueChange={handleChange}>
      <SelectTrigger className="w-[120px]">
        <SelectValue />
      </SelectTrigger>
      <SelectContent>
        {Object.entries(localeLabels).map(([value, label]) => (
          <SelectItem key={value} value={value}>
            {label}
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  );
}

export { LocaleSwitcher };
tsx
"use client";

import { useLocale } from "next-intl";
import { useRouter, usePathname } from "@/i18n/routing";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";

const localeLabels: Record<string, string> = {
  ko: "한국어",
  en: "English",
};

function LocaleSwitcher() {
  const locale = useLocale();
  const router = useRouter();
  const pathname = usePathname();

  function handleChange(newLocale: string) {
    router.replace(pathname, { locale: newLocale });
  }

  return (
    <Select value={locale} onValueChange={handleChange}>
      <SelectTrigger className="w-[120px]">
        <SelectValue />
      </SelectTrigger>
      <SelectContent>
        {Object.entries(localeLabels).map(([value, label]) => (
          <SelectItem key={value} value={value}>
            {label}
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  );
}

export { LocaleSwitcher };

네비게이션 링크

导航链接

tsx
import { Link } from "@/i18n/routing";

// 로케일이 자동으로 URL에 포함됨
<Link href="/about">소개</Link>
// ko → /ko/about
// en → /en/about
typescript
// src/i18n/routing.ts — navigation 포함
import { defineRouting } from "next-intl/routing";
import { createNavigation } from "next-intl/navigation";

export const routing = defineRouting({
  locales: ["ko", "en"],
  defaultLocale: "ko",
});

export const { Link, redirect, usePathname, useRouter } =
  createNavigation(routing);
tsx
import { Link } from "@/i18n/routing";

// 本地化会自动包含在URL中
<Link href="/about">소개</Link>
// 韩语 → /ko/about
// 英语 → /en/about
typescript
// src/i18n/routing.ts — 包含导航
import { defineRouting } from "next-intl/routing";
import { createNavigation } from "next-intl/navigation";

export const routing = defineRouting({
  locales: ["ko", "en"],
  defaultLocale: "ko",
});

export const { Link, redirect, usePathname, useRouter } =
  createNavigation(routing);

SEO + i18n 연동

SEO + i18n 联动

로케일별 Metadata

本地化元数据

tsx
// src/app/[locale]/layout.tsx
import { getTranslations } from "next-intl/server";

export async function generateMetadata({ params }: LayoutProps) {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: "metadata" });

  return {
    title: t("title"),
    description: t("description"),
    alternates: {
      canonical: `https://example.com/${locale}`,
      languages: {
        ko: "https://example.com/ko",
        en: "https://example.com/en",
      },
    },
  };
}
tsx
// src/app/[locale]/layout.tsx
import { getTranslations } from "next-intl/server";

export async function generateMetadata({ params }: LayoutProps) {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: "metadata" });

  return {
    title: t("title"),
    description: t("description"),
    alternates: {
      canonical: `https://example.com/${locale}`,
      languages: {
        ko: "https://example.com/ko",
        en: "https://example.com/en",
      },
    },
  };
}

로케일별 Sitemap

本地化站点地图

typescript
// src/app/sitemap.ts
import type { MetadataRoute } from "next";

const locales = ["ko", "en"];

export default function sitemap(): MetadataRoute.Sitemap {
  const pages = ["", "/about", "/blog"];

  return pages.flatMap((page) =>
    locales.map((locale) => ({
      url: `https://example.com/${locale}${page}`,
      lastModified: new Date(),
      changeFrequency: "weekly" as const,
      priority: page === "" ? 1 : 0.8,
    }))
  );
}
typescript
// src/app/sitemap.ts
import type { MetadataRoute } from "next";

const locales = ["ko", "en"];

export default function sitemap(): MetadataRoute.Sitemap {
  const pages = ["", "/about", "/blog"];

  return pages.flatMap((page) =>
    locales.map((locale) => ({
      url: `https://example.com/${locale}${page}`,
      lastModified: new Date(),
      changeFrequency: "weekly" as const,
      priority: page === "" ? 1 : 0.8,
    }))
  );
}

번역 키 관리 규칙

翻译键管理规则

  1. 네임스페이스: 페이지/기능별로 분리 (
    nav
    ,
    auth
    ,
    home
    ,
    blog
    )
  2. 공통 키:
    common
    네임스페이스에 모아서 재사용
  3. 네이밍: camelCase, 구체적인 이름 (
    submitButton
    >
    btn1
    )
  4. 구조화: 2단계까지만 중첩 (깊은 중첩 지양)
  5. 변수: ICU MessageFormat 사용 (
    {count}
    ,
    {name}
    )
  6. 누락 방지: TypeScript 타입으로 번역 키 검증
  1. 命名空间:按页面/功能拆分(
    nav
    ,
    auth
    ,
    home
    ,
    blog
  2. 公共键:集中在
    common
    命名空间中复用
  3. 命名规范:使用camelCase,名称具体(
    submitButton
    >
    btn1
  4. 结构限制:最多嵌套2层(避免深层嵌套)
  5. 变量处理:使用ICU MessageFormat(
    {count}
    ,
    {name}
  6. 避免遗漏:通过TypeScript类型验证翻译键

TypeScript 타입 안전성

TypeScript类型安全性

typescript
// src/types/i18n.d.ts
import ko from "@/messages/ko.json";

type Messages = typeof ko;

declare module "next-intl" {
  interface IntlMessages extends Messages {}
}
typescript
// src/types/i18n.d.ts
import ko from "@/messages/ko.json";

type Messages = typeof ko;

declare module "next-intl" {
  interface IntlMessages extends Messages {}
}

실행 규칙

执行规则

  1. setup
    인자 시 next-intl 초기 설정을 전체 진행한다
  2. 파일 경로가 전달되면 해당 파일에 번역을 적용한다
  3. add-locale [locale]
    인자 시 새 언어를 추가한다
  4. 번역 파일 생성 시 기존 키 구조를 분석하여 일관성을 유지한다
  5. 하드코딩된 한국어 문자열을 탐지하여 번역 키로 추출을 제안한다
  6. 언어 전환 시 URL 구조와 SEO를 함께 고려한다
  1. 当传入
    setup
    参数时,完整执行next-intl初始配置
  2. 若传入文件路径,则在该文件中应用翻译
  3. 当传入
    add-locale [locale]
    参数时,添加新语言
  4. 创建翻译文件时,分析现有键结构以保持一致性
  5. 检测硬编码的韩语字符串,建议将其提取为翻译键
  6. 切换语言时,同时考虑URL结构和SEO