포스팅 목차
- i18n이란? 다국어 지원이 왜 필요한가?
- Next.js App Router에서 i18n 라이브러리 비교
- next-intl 설치 및 기본 세팅 (App Router 기준)
- 메시지 파일 구조 및 관리 전략
- 서버 컴포넌트 vs 클라이언트 컴포넌트에서 번역 사용법
- 공부하면서 생겼던 의문점
- 참고 자료
1. i18n이란? 다국어 지원이 왜 필요한가?
i18n(Internationalization) 은 소프트웨어를 다양한 언어와 지역에 맞게 조정할 수 있도록 설계하는 것을 의미합니다. 이름이 i18n인 이유는 Internationalization의 첫 글자 i와 마지막 글자 n 사이에 18개의 글자가 있기 때문입니다.
서비스가 글로벌 사용자를 대상으로 한다면 다국어 지원은 선택이 아닌 필수입니다. 한국어만 지원하는 서비스는 한국 사용자만 쓸 수 있지만, 영어/일본어/중국어를 추가하면 훨씬 넓은 사용자층을 확보할 수 있습니다.
i18n 구현 시 고려해야 할 것들
- 텍스트 번역 (언어별 메시지 파일 관리)
- URL 구조 (
/ko/about,/en/about등 locale prefix) - 날짜/숫자/통화 포맷 (나라마다 다름)
- 기본 언어 감지 (브라우저 언어 설정 기반)
2. Next.js App Router에서 i18n 라이브러리 비교
Next.js App Router 환경에서 자주 사용되는 i18n 라이브러리를 비교하면 아래와 같습니다.
| 구분 | next-intl | i18next (react-i18next) | next-i18next |
|---|---|---|---|
| App Router 지원 | O 공식 지원 | ⚠️ 별도 설정 필요 | X Pages Router 전용 |
| 서버 컴포넌트 지원 | O | ⚠️ 제한적 | X |
| 번들 사이즈 | 작음 | 큼 | 중간 |
| TypeScript 지원 | O 타입 자동 추론 | ⚠️ 수동 설정 필요 | ⚠️ |
| 러닝 커브 | 낮음 | 높음 | 중간 |
Next.js 15 App Router 환경에서는 next-intl이 가장 잘 맞습니다. 서버 컴포넌트를 기본으로 지원하고, TypeScript 타입 자동 추론까지 제공하기 때문입니다.
💡 i18next는 React 생태계에서 가장 많이 쓰이는 라이브러리지만, App Router의 서버 컴포넌트와 함께 쓰려면 설정이 복잡합니다. 새 프로젝트라면 next-intl을 추천합니다.
3. next-intl 설치 및 기본 세팅 (App Router 기준)
설치
npm install next-intl
최종 폴더 구조
세팅이 완료되면 아래와 같은 구조가 됩니다.
your-project/
├── messages/
│ ├── ko.json ← 한국어 메시지
│ └── en.json ← 영어 메시지
├── src/
│ ├── app/
│ │ └── [locale]/ ← locale 동적 라우팅 폴더
│ │ ├── layout.tsx
│ │ └── page.tsx
│ └── i18n/
│ ├── routing.ts ← locale 라우팅 설정
│ └── request.ts ← 요청별 locale 설정
├── middleware.ts ← locale 감지 및 리다이렉트
└── next.config.ts
Step 1 — routing.ts 설정
지원할 locale 목록과 기본 locale을 정의합니다.
// src/i18n/routing.ts
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
// 지원할 언어 목록
locales: ["ko", "en"],
// 기본 언어 (브라우저 언어가 목록에 없을 때 사용)
defaultLocale: "ko",
});
Step 2 — middleware.ts 설정
미들웨어에서 요청 URL을 분석해서 locale을 감지하고, 적절한 경로로 리다이렉트합니다.
// middleware.ts (프로젝트 루트)
import createMiddleware from "next-intl/middleware";
import { routing } from "./src/i18n/routing";
export default createMiddleware(routing);
export const config = {
// 미들웨어가 실행될 경로 패턴
// _next, api, 정적 파일 등은 제외
matcher: [
"/",
"/(ko|en)/:path*",
"/((?!_next|_vercel|.*\\..*).*)",
],
};
미들웨어가 하는 일은 아래와 같습니다.
/about접근 시 → 브라우저 언어 감지 →/ko/about또는/en/about으로 자동 리다이렉트/ko/about접근 시 → 그대로 통과
Step 3 — request.ts 설정
서버 컴포넌트에서 현재 요청의 locale에 맞는 메시지를 불러오는 설정입니다.
// src/i18n/request.ts
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";
export default getRequestConfig(async ({ requestLocale }) => {
// 현재 요청의 locale 확인
let locale = await requestLocale;
// 유효하지 않은 locale이면 기본값으로 대체
if (!locale || !routing.locales.includes(locale as "ko" | "en")) {
locale = routing.defaultLocale;
}
return {
locale,
// 해당 locale의 메시지 파일 동적 import
messages: (await import(`../../messages/${locale}.json`)).default,
};
});
Step 4 — next.config.ts 설정
// next.config.ts
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin(
// request.ts 경로 지정
"./src/i18n/request.ts"
);
const nextConfig = {};
export default withNextIntl(nextConfig);
Step 5 — app/[locale] 폴더 구조 설정
App Router에서 locale을 URL에 포함시키려면 [locale] 동적 라우팅 폴더를 사용합니다.
// src/app/[locale]/layout.tsx
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { routing } from "@/i18n/routing";
import { notFound } from "next/navigation";
interface LayoutProps {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}
export default async function LocaleLayout({ children, params }: LayoutProps) {
const { locale } = await params;
// 유효하지 않은 locale이면 404
if (!routing.locales.includes(locale as "ko" | "en")) {
notFound();
}
// 서버에서 메시지 로드
const messages = await getMessages();
return (
<html lang={locale}>
<body>
{/* 클라이언트 컴포넌트에서도 번역을 사용할 수 있도록 Provider로 감싸기 */}
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
4. 메시지 파일 구조 및 관리 전략
기본 메시지 파일 구조
메시지 파일은 JSON 형식으로 작성합니다. 키는 네임스페이스.키 형태로 계층적으로 구성하는 것이 좋습니다.
// messages/ko.json
{
"common": {
"confirm": "확인",
"cancel": "취소",
"save": "저장",
"delete": "삭제",
"loading": "로딩 중..."
},
"nav": {
"home": "홈",
"about": "소개",
"contact": "문의"
},
"home": {
"title": "안녕하세요",
"description": "next-intl로 만든 다국어 지원 서비스입니다.",
"greeting": "{name}님, 환영합니다!"
},
"error": {
"notFound": "페이지를 찾을 수 없습니다",
"serverError": "서버 오류가 발생했습니다"
}
}
// messages/en.json
{
"common": {
"confirm": "Confirm",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"loading": "Loading..."
},
"nav": {
"home": "Home",
"about": "About",
"contact": "Contact"
},
"home": {
"title": "Hello",
"description": "A multilingual service built with next-intl.",
"greeting": "Welcome, {name}!"
},
"error": {
"notFound": "Page not found",
"serverError": "A server error occurred"
}
}
TypeScript 타입 자동 추론 설정
next-intl은 메시지 파일을 기반으로 번역 키에 대한 TypeScript 타입을 자동으로 추론해줍니다. 존재하지 않는 키를 사용하면 컴파일 에러가 발생해서 오타를 사전에 방지할 수 있습니다.
// src/global.d.ts (또는 types/next-intl.d.ts)
import ko from "../messages/ko.json";
declare module "next-intl" {
interface AppConfig {
// 한국어 메시지를 기준으로 타입 자동 추론
Messages: typeof ko;
}
}
이 설정을 하면 IDE에서 번역 키 자동완성이 동작합니다.
메시지 파일 관리 전략
프로젝트가 커질수록 메시지 파일이 거대해지는 문제가 생깁니다. 아래 전략을 조합해서 관리하는 것을 권장합니다.
전략 1 — 네임스페이스로 도메인별 분리
messages/
├── ko/
│ ├── common.json ← 공통 버튼, 에러 메시지
│ ├── home.json ← 홈 페이지
│ ├── auth.json ← 로그인/회원가입
│ └── mypage.json ← 마이페이지
└── en/
├── common.json
├── home.json
├── auth.json
└── mypage.json
전략 2 — 번역 키 네이밍 컨벤션 통일
{
// 일관된 네이밍 — 페이지.섹션.키
"auth": {
"login": {
"title": "로그인",
"emailLabel": "이메일",
"passwordLabel": "비밀번호",
"submitButton": "로그인하기",
"errorMessage": "이메일 또는 비밀번호를 확인해주세요"
},
"signup": {
"title": "회원가입"
}
}
}
5. 서버 컴포넌트 vs 클라이언트 컴포넌트에서 번역 사용법
next-intl의 가장 큰 장점은 서버 컴포넌트와 클라이언트 컴포넌트 모두에서 번역을 사용할 수 있다는 점입니다.
서버 컴포넌트에서 사용 — getTranslations
서버 컴포넌트에서는 getTranslations를 사용합니다. (async 함수)
// src/app/[locale]/page.tsx (서버 컴포넌트)
import { getTranslations } from "next-intl/server";
export default async function HomePage() {
// 네임스페이스 지정으로 해당 섹션의 번역만 가져옴
const t = await getTranslations("home");
return (
<main>
<h1>{t("title")}</h1>
<p>{t("description")}</p>
{/* 변수 보간 */}
<p>{t("greeting", { name: "Ryn" })}</p>
</main>
);
}
클라이언트 컴포넌트에서 사용 — useTranslations
클라이언트 컴포넌트에서는 useTranslations 훅을 사용합니다.
// src/components/Header.tsx (클라이언트 컴포넌트)
"use client";
import { useTranslations } from "next-intl";
export const Header = () => {
// 네임스페이스 없이 전체 메시지에 접근
const t = useTranslations("nav");
return (
<nav>
<a href="/">{t("home")}</a>
<a href="/about">{t("about")}</a>
<a href="/contact">{t("contact")}</a>
</nav>
);
};
언어 전환 버튼 구현
// src/components/LocaleSwitcher.tsx
"use client";
import { useLocale } from "next-intl";
import { useRouter, usePathname } from "next/navigation";
export const LocaleSwitcher = () => {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const handleChange = (nextLocale: string) => {
// 현재 경로에서 locale만 교체
const newPath = pathname.replace(`/${locale}`, `/${nextLocale}`);
router.push(newPath);
};
return (
<div>
<button
onClick={() => handleChange("ko")}
style={{ fontWeight: locale === "ko" ? "bold" : "normal" }}
>
한국어
</button>
<button
onClick={() => handleChange("en")}
style={{ fontWeight: locale === "en" ? "bold" : "normal" }}
>
English
</button>
</div>
);
};
generateStaticParams로 정적 페이지 생성
SSG(Static Site Generation)를 사용하는 경우, 각 locale에 대한 정적 페이지를 미리 생성합니다.
// src/app/[locale]/page.tsx
import { routing } from "@/i18n/routing";
// 빌드 시 모든 locale에 대한 페이지를 정적으로 생성
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
6. 공부하면서 생겼던 의문점
Q. URL에 locale을 꼭 포함해야 하나요? (/ko/about 형식)
꼭 그렇지는 않습니다. next-intl은 두 가지 방식을 지원합니다.
| 방식 | URL 예시 | 특징 |
|---|---|---|
| prefix (기본값) | /ko/about, /en/about |
SEO에 유리, 명확한 URL |
| prefix 없음 (기본 locale) | /about (ko), /en/about (en) |
기본 언어는 prefix 생략 가능 |
기본 locale에서 prefix를 생략하고 싶다면 routing.ts에서 설정합니다.
export const routing = defineRouting({
locales: ["ko", "en"],
defaultLocale: "ko",
// 기본 locale(/ko)의 prefix 생략
localePrefix: "as-needed",
});
Q. next-intl과 i18next 중 어떤 걸 써야 하나요?
| 상황 | 추천 라이브러리 |
|---|---|
| Next.js App Router 신규 프로젝트 | next-intl |
| Next.js Pages Router 기존 프로젝트 | next-i18next |
| React (Next.js 아닌) 프로젝트 | react-i18next |
| 복잡한 번역 기능 필요 (복수형, 컨텍스트 등) | i18next |
Next.js 15 App Router 기반 신규 프로젝트라면 next-intl이 가장 자연스럽고 설정이 간단합니다.
Q. 번역 파일이 너무 커지면 어떻게 관리하나요?
번역 파일이 커지면 도메인별로 분리하고, 페이지 단위로 필요한 번역만 불러오는 것이 좋습니다.
// 특정 네임스페이스만 불러오기
const t = await getTranslations("auth.login");
// 여러 네임스페이스 동시에 불러오기
const [tCommon, tAuth] = await Promise.all([
getTranslations("common"),
getTranslations("auth"),
]);
또한 번역 작업이 많아지면 Crowdin, Lokalise 같은 번역 관리 플랫폼과 연동하는 것도 고려해볼 수 있습니다.
Q. 날짜, 숫자, 통화는 어떻게 포맷하나요?
next-intl은 useFormatter 훅으로 로케일에 맞는 포맷을 제공합니다.
"use client";
import { useFormatter } from "next-intl";
export const FormattedValues = () => {
const format = useFormatter();
return (
<div>
{/* 날짜 포맷 */}
<p>{format.dateTime(new Date(), { dateStyle: "long" })}</p>
{/* ko: 2024년 3월 9일 / en: March 9, 2024 */}
{/* 숫자 포맷 */}
<p>{format.number(1234567)}</p>
{/* ko: 1,234,567 / en: 1,234,567 */}
{/* 통화 포맷 */}
<p>{format.number(50000, { style: "currency", currency: "KRW" })}</p>
{/* ko: ₩50,000 / en: KRW 50,000 */}
</div>
);
};
useFormatter에서 currency: "KRW"를 명시하는 이유가 궁금할 수 있습니다.
같은 KRW라도 locale에 따라 표시 형식이 달라집니다.
format.number(50000, { style: "currency", currency: "KRW" })
// locale: "ko" → ₩50,000
// locale: "en" → KRW 50,000
// locale: "zh" → ₩50,000
반대로 같은 locale: "ko"라도 currency가 다르면 다른 통화가 표시됩니다.
// locale: "ko" 기준
format.number(50000, { style: "currency", currency: "KRW" }) // → ₩50,000
format.number(50000, { style: "currency", currency: "USD" }) // → US$50,000
format.number(50000, { style: "currency", currency: "JPY" }) // → JP¥50,000
💡
locale= 어떤 나라 형식으로 보여줄지 /currency= 실제로 어떤 돈인지currency를 명시하지 않으면Intl이 어떤 통화인지 알 수 없어서 에러가 발생합니다.
그렇다면 next-intl은 "ko"가 한국인지 어떻게 알고 자동으로 ₩를 보여주는 걸까요?
next-intl이 내부적으로 자바스크립트 내장 API인 Intl.NumberFormat 에 locale을 넘겨주면, 브라우저/Node.js가 알아서 그 나라 규칙대로 포맷해줍니다.
// next-intl이 내부적으로 이렇게 동작합니다
new Intl.NumberFormat("ko", { style: "currency", currency: "KRW" }).format(50000)
// → ₩50,000
new Intl.NumberFormat("en", { style: "currency", currency: "KRW" }).format(50000)
// → KRW 50,000
"ko"→ 한국 규칙 적용 → 한국에서는 원화를₩로 표시 →₩50,000"en"→ 영어권 규칙 적용 → 영어권에서 원화는 낯선 통화라 →KRW 50,000
이 규칙은 CLDR(Common Locale Data Repository) 이라는 국제 표준 데이터베이스에 전 세계 나라별 포맷 규칙이 정의되어 있고, Intl이 그걸 참조하는 방식입니다.
💡 next-intl이 locale을 Intl에 넘기면, Intl이 CLDR 표준 규칙대로 자동으로 포맷해줍니다. 개발자가 직접 나라별 규칙을 정의할 필요가 없습니다.
Q. next-intl과 함께 쓰면 유용한 도구들이 있나요?
1. i18n Ally (VSCode 확장)
실무에서 가장 많이 쓰는 필수 도구입니다.
- 번역 키 위에 실제 번역된 텍스트를 인라인으로 보여줍니다
- 번역 안 된 키 하이라이트 → 어떤 언어가 빠졌는지 바로 확인 가능합니다
- 번역 파일 직접 편집 UI를 제공합니다
t("home.title") ← 코드 옆에 "안녕하세요" 이렇게 바로 보여줍니다
2. Sherlock (VSCode 확장)
i18n Ally와 비슷하지만 next-intl 공식 문서에서도 추천하는 도구입니다.
- 번역 키 자동완성
- 누락된 번역 감지
- next-intl에 최적화되어 있습니다
3. Crowdin / Lokalise (번역 관리 플랫폼)
번역가나 기획자와 협업할 때 쓰는 플랫폼입니다.
- JSON 파일을 업로드하면 번역가가 웹에서 직접 번역할 수 있습니다
- 번역 완료 시 자동으로 PR을 생성해줍니다
- 규모 있는 글로벌 서비스에서 많이 사용합니다
상황별 추천 조합
| 상황 | 추천 도구 |
|---|---|
| 개인/소규모 프로젝트 | i18n Ally VSCode 확장 |
| next-intl 특화 | Sherlock VSCode 확장 |
| 번역가와 협업 필요 | Crowdin 또는 Lokalise |
| 대규모 프로젝트 | Crowdin + i18n Ally 조합 |
7. 참고 자료
- next-intl 공식 문서: https://next-intl.dev/docs/getting-started/app-router
- next-intl GitHub: https://github.com/amannn/next-intl
- next-intl TypeScript 설정: https://next-intl.dev/docs/workflows/typescript
- i18next 공식 문서: https://www.i18next.com
실무에서 직접 적용하면서 추가로 공부한 내용과,
다음 프로젝트에서도 유용하게 활용하기 위해 정리해봤습니다!
저는 Next.js App Router 첫 프로젝트에서 next-intl을 몰라 i18next를 사용했었는데요.
`routing.ts`와 `middleware.ts` 설정 없이
라우팅이 필요한 곳마다 직접 `ko`인지 `en`인지 locale을 하나하나 설정해주는 대참사를 겪었습니다 😭
여러분은 이 포스팅을 참고하셔서 저 같은 시행착오 없이 더 좋은 코드로 구현하시길 바랍니다!

댓글