fe-i18n
SKILL.md
FE Internationalization (i18n)
$ARGUMENTS를 분석하여 다국어 설정을 구성하거나 번역을 적용한다.
분석 절차
- 현재 상태 파악: 프로젝트의 i18n 설정을 확인한다
- 요구사항 파악: 지원할 언어, 기본 언어, 라우팅 전략을 확인한다
- 설정/구현: next-intl 기반으로 다국어를 설정하거나 번역을 적용한다
- 검증: 각 로케일에서 정상 동작하는지 확인한다
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,
}))
);
}
번역 키 관리 규칙
- 네임스페이스: 페이지/기능별로 분리 (
nav,auth,home,blog) - 공통 키:
common네임스페이스에 모아서 재사용 - 네이밍: camelCase, 구체적인 이름 (
submitButton>btn1) - 구조화: 2단계까지만 중첩 (깊은 중첩 지양)
- 변수: ICU MessageFormat 사용 (
{count},{name}) - 누락 방지: 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 {}
}
실행 규칙
setup인자 시 next-intl 초기 설정을 전체 진행한다- 파일 경로가 전달되면 해당 파일에 번역을 적용한다
add-locale [locale]인자 시 새 언어를 추가한다- 번역 파일 생성 시 기존 키 구조를 분석하여 일관성을 유지한다
- 하드코딩된 한국어 문자열을 탐지하여 번역 키로 추출을 제안한다
- 언어 전환 시 URL 구조와 SEO를 함께 고려한다
Weekly Installs
2
Repository
ingpdw/pdw-fe-dev-toolFirst Seen
Feb 7, 2026
Security Audits
Installed on
mcpjam2
openhands2
replit2
junie2
windsurf2
zencoder2