fe-i18n

📁 ingpdw/pdw-fe-dev-tool 📅 6 days ago
1
总安装量
1
周安装量
#52540
全站排名
安装命令
npx skills add https://github.com/ingpdw/pdw-fe-dev-tool --skill fe-i18n

Agent 安装分布

mcpjam 1
claude-code 1
replit 1
junie 1
zencoder 1

Skill 文档

FE Internationalization (i18n)

$ARGUMENTS를 분석하여 다국어 설정을 구성하거나 번역을 적용한다.

분석 절차

  1. 현재 상태 파악: 프로젝트의 i18n 설정을 확인한다
  2. 요구사항 파악: 지원할 언어, 기본 언어, 라우팅 전략을 확인한다
  3. 설정/구현: next-intl 기반으로 다국어를 설정하거나 번역을 적용한다
  4. 검증: 각 로케일에서 정상 동작하는지 확인한다

next-intl 초기 설정

1. 패키지 설치

pnpm add next-intl

2. 프로젝트 구조

src/
├── app/
│   └── [locale]/              # 로케일별 라우팅
│       ├── layout.tsx
│       ├── page.tsx
│       └── about/page.tsx
├── i18n/
│   ├── request.ts             # 서버 사이드 i18n 설정
│   └── routing.ts             # 라우팅 설정
├── messages/                  # 번역 파일
│   ├── ko.json
│   └── en.json
└── middleware.ts               # 로케일 감지 & 리다이렉트

3. 라우팅 설정

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

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

4. 미들웨어

// 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. 서버 사이드 설정

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

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

const withNextIntl = createNextIntlPlugin();

const nextConfig = {};

export default withNextIntl(nextConfig);

7. Root Layout

// 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>
  );
}

번역 파일 구조

네임스페이스 기반 구조

// 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}자 이상이어야 합니다"
  }
}
// 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에서 사용

// 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에서 사용

"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 문법)

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>

날짜/숫자 포맷

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>
  );
}

언어 전환 컴포넌트

"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 };

네비게이션 링크

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

// 로케일이 자동으로 URL에 포함됨
<Link href="/about">소개</Link>
// ko → /ko/about
// en → /en/about
// 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);

SEO + i18n 연동

로케일별 Metadata

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

// 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 타입으로 번역 키 검증

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를 함께 고려한다