본문 바로가기
(Frontend) 프론트엔드

(next-intl) Next.js 15 App Router 다국어(i18n) 적용하기 - next-intl 설치부터 서버/클라이언트 컴포넌트 적용까지, TypeScript

by 공부가싫다가도좋아 2026. 3. 28.
반응형

포스팅 목차

  1. i18n이란? 다국어 지원이 왜 필요한가?
  2. Next.js App Router에서 i18n 라이브러리 비교
  3. next-intl 설치 및 기본 세팅 (App Router 기준)
  4. 메시지 파일 구조 및 관리 전략
  5. 서버 컴포넌트 vs 클라이언트 컴포넌트에서 번역 사용법
  6. 공부하면서 생겼던 의문점
  7. 참고 자료

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.js App Router 첫 프로젝트에서 next-intl을 몰라 i18next를 사용했었는데요. 
`routing.ts`와 `middleware.ts` 설정 없이 
라우팅이 필요한 곳마다 직접 `ko`인지 `en`인지 locale을 하나하나 설정해주는 대참사를 겪었습니다 😭

여러분은 이 포스팅을 참고하셔서 저 같은 시행착오 없이 더 좋은 코드로 구현하시길 바랍니다!

반응형

댓글