RUN

Next.js App Router에서 다국어 지원하기 (2) i18n 적용편 (next-intl)

ssoonn 2025. 2. 7. 15:21

 

 

Next.js에서 다국어 지원하기 (1) google spreadsheet 연동편

3월에 쓰려고 했던 이 글,,,지금 만나러 갑니다   Next.js에서 Google 스프레드시트와 i18n-next를 활용하여 다국어 지원을 구현하는 방법을 처음부터 차근차근 설명해드립니다.  0. 프로젝트 생성 G

stdmoii.tistory.com

 

앱라우터에서 i18n 적용하기

근데 이제 좀 많이 삽질한...

 

 

0. next-i18next는 페이지 라우터를 위한 것

이전 프로젝트에서는 당연히~ next-i18next를 사용했기에 아무 의심 없이 설치했는데요, 대차게 시간을 버리고 맙니다.

왜냐? next-i18next는 *앱라우터를 지원하지 않기 때문!

*Next.js App Router with i18next (Tutorial), ,*next-i18next

 

앱 라우터에서 다국어 지원을 원한다면 next-intl, react-i18next, intlayer를 사용할 수 있습니다.

이번 게시글에선 next-intl로 i18n을 적용해보고자 합니다.

 

Q. 그럼 님은 왜 next-i18next를 시도하셨나요?

A. 지금까지 한 모든 프로젝트가 page router이었음을 잊었습니다.... 기억력 이슈

 

1. 앱라우터가 뭔데요

앱라우터는 Next.js 13부터 새롭게 도입된 파일 기반 라우팅 시스템으로, app 디렉토리를 기반으로 동작합니다.

  1. 폴더 및 파일 기반 라우팅
    • 각 폴더가 URL 경로를 나타내며, 폴더 내 page.js가 해당 경로의 메인 컴포넌트를 렌더링
      • e.g. app/products/page.js는 /products 경로를 처리
  2. 동적 라우팅
    • 폴더명에 대괄호([ ])를 사용해 동적 경로를 정의
      • e.g. app/products/[id]/page.js는 /products/123처럼 id 값에 따라 동작
  3. 중첩 레이아웃 지원
    • layout.js 파일로 상위 경로의 공통 레이아웃을 정의하고, 하위 경로에서 이를 재사용 가능.
      • e.g. app/products/layout.js는 모든 /products 하위 경로에 적용
  4. 로딩 및 에러 상태 처리
    • loading.js와 error.js 파일로 로딩 중과 에러 상태를 페이지별로 관리 가능.
      • e.g. app/products/loading.js는 /products 로드 중 표시
  5. API 경로 통합
    • route.js 파일을 통해 API 경로를 정의할 수 있어, 페이지와 API를 동일 디렉토리에서 관리.
      • e.g. app/api/products/route.js는 /api/products 경로 처리
  6. 동적 세그먼트와 Internationalization(i18n)
    • 동적 세그먼트(app/[locale])와 middleware.js를 결합해 언어별 URL 구조를 쉽게 구현 가능.
      • e.g. app/[locale]/products/page.js는 /en/products, /ko/products를 처리

 

중요한 부분만 요약하자면 아래와 같습니다.

 

  • App Router는 중첩 레이아웃React Server Components(RSC)를 지원해 성능과 코드 재사용성이 뛰어나다.
  • i18n 관점에서 App Router는 middleware.js동적 세그먼트(app/[locale])로 언어별 URL과 데이터 관리를 더 유연하게 처리할 수 있다.
  • layout.js 파일을 활용해 로케일 데이터를 상위 레이아웃에서 정의하고 하위 페이지로 전달할 수 있다.

 

page router와 app router의 차이를 잘 정리한 글 ↓
[Next.js] App Router 경로 구성하기 (next13/Parallel Routes/Intercepting Routes)
Next.js 14: The Differences Between App Router and Page Router - Medium

 

2. next-intl

결과물의 디렉터리 살펴보기

├── public/
│   └── locales/          # 언어별 번역 파일 디렉토리
│       ├── en/           # 영어 번역
│       │   └── messages.json
│       └── ko/           # 한국어 번역
│           └── messages.json
└── src/
    ├── app/             # Next.js 15의 App Router 구조
    │   ├── [locale]/    # 동적 라우팅을 위한 디렉토리
    │   │   ├── layout.tsx
    │   │   └── page.tsx
    │   └── layout.tsx   # 루트 레이아웃
    ├── i18n/            # i18n 관련 설정
    │   └── request.ts   # next-intl 설정
    └── middleware.ts    # 라우팅 및 로케일 처리

 

1. 패키지 설치

npm install next-intl

 

 

2. 설정 파일 구성

2-1. next.config.ts

import createNextIntlPlugin from 'next-intl/plugin';

const withNextIntl = createNextIntlPlugin();

const nextConfig = {};

export default withNextIntl(nextConfig);

next-intl 플러그인을 Next.js 설정에 추가하여 i18n 기능을 활성화합니다.

2-2. next-intl.config.js

module.exports = {
  defaultLocale: "ko",
  locales: ["en", "ko"],
  messages: {
    en: "./public/locales/en/*.json",
    ko: "./public/locales/ko/*.json",
  },
};

 

i18n 설정을 정의하고, 기본 로케일과 지원되는 로케일을 설정합니다.

 

 

2-3. src/middleware.ts

import createMiddleware from 'next-intl/middleware';

export default createMiddleware({
  locales: ['en', 'ko'],        // 지원하는 언어 목록
  defaultLocale: 'ko',          // 기본 언어 설정
  localeDetection: true         // 브라우저 언어 자동 감지
});

export const config = {
  matcher: ['/((?!api|_next|.*\\..*).*)']  // 미들웨어를 적용할 경로 패턴
};

url 기반 로케일을 감지하기 위한 파일입니다. + 라우팅 제어

이 파일이 없을 경우, 수동으로 URL 파싱을 해야하며 언어 감지 로직을 직접 구현해야 함

 

2-3. src/i18n/request.ts

import { getRequestConfig } from 'next-intl/server';

export default getRequestConfig(async ({ locale }) => {
  const messages = (await import(`../../../public/locales/${locale}/messages.json`)).default;
  
  return {
    messages,
    locale,
    timeZone: "Asia/Seoul",
  };
});

 

request.ts는 각 로케일에 맞는 번역 파일을 동적으로 로드하고, 로케일별 시간대 설정을 가능하게 합니다.

또한 서버 컴포넌트에서 필요한 i18n 설정을 제공하고, ssr시 필요한 번역 데이터를 준비합니다.

 

cf. middleware.ts와 request.ts간 흐름

클라이언트 요청
→ middleware.ts (URL 체크 및 로케일 감지)
→ request.ts (해당 로케일의 설정 및 메시지 로드)
→ 페이지 렌더링

 

 

3. 페이지 구성

3-1. src/app/layout.tsx

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ko">
      <body>{children}</body>
    </html>
  );
}

루트 레이아웃을 사용한 이유는 아래와 같습니다.

  • HTML 문서 기본 구조 제공 (html 태그생성)
  • 모든 페이지에 공통으로 적용될 최상위 레이아웃 구성: 공통 적용 속성 정의
    • 기본 언어 설정, 공통 스타일 적용 등

3-2. src/app/[locale]/layout.tsx

import { notFound } from "next/navigation";
import { NextIntlClientProvider } from "next-intl";
import "@/app/[locale]/globals.css";

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

export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: { locale: string };
}) {
  const locale = params.locale;

  // 지원하지 않는 locale인 경우 404
  if (!locales.includes(locale)) {
    notFound();
  }

  let messages;
  try {
    messages = (await import(`../../../public/locales/${locale}/messages.json`))
      .default;
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
  } catch (error) {
    notFound();
  }

  return (
    <NextIntlClientProvider messages={messages} locale={locale}>
      <div>{children}</div>
    </NextIntlClientProvider>
  );
}

 

NextIntlClientProvider로 하위 컴포넌트들에게 번역 컨텍스트를 제공하고, 잘못된 접근 시 404 페이지를 표시합니다.

 

3-3. src/app/[locale]/page.tsx

"use client";

import Image from "next/image";
import React from "react";
import { useTranslations } from "next-intl";

export default function Home() {
  const t = useTranslations("test");
  const a = useTranslations("login");

  return (
    <div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
      <main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
        <Image
          className="dark:invert"
          src="/next.svg"
          alt="Next.js logo"
          width={180}
          height={38}
          priority
        />
        <ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
          <li className="mb-2">{t("test")}</li>
          <li>{t("yap")}</li> <li className="mb-2">{a("test")}</li>
          <li>{a("yap")}</li>{" "}
        </ol>
      </main>
    </div>
  );
}

 

useTranslation 훅을 사용해 필요한 네임스페이스에 접근합니다.

 

 

이렇게 모든 설정을 마치면,

en / ko

이렇게 라우팅 언어마다 다른 value가 출력되는 걸 볼 수 있습니다.

 

 

3. 고난 (극복 x 타협o)

제 추구미는 아래와 같습니다.

1. 1탄에서 설정했던 json 파일의 구조를 바꾸지 않을 것

2. 여러 페이지에서 자신의 json 파일만 참조할 것 (같은 키가 여러개 있을 수 있음)

 

하지만 이 두가지를 충족하는 방법은,, 2일을 삽질해도 찾을 수 없었습니다.



방법1. json 파일의 구조 변경

방법2. 각 json 파일에서 unique한 키를 사용하고, 페이지에서는 단순히 useTranslations()를 사용해 키에 직접 접근하기

 

이 중 제가 선택한 방법은 1번입니다. 왜냐? 키값 길어지는건 싫으니까...

 

1. 스프레드시트 구조 변경

 

2. 스크립트 수정

/* eslint-disable @typescript-eslint/no-require-imports */
const { GoogleSpreadsheet } = require("google-spreadsheet");
const fs = require("fs");
const path = require("path");

const SERVICE_ACCOUNT_PATH = path.join(__dirname, "googleSheet.json");

async function fetchDataAndSaveAsJson() {
  const serviceAccount = JSON.parse(
    fs.readFileSync(SERVICE_ACCOUNT_PATH, "utf-8")
  );
  const privateKey = serviceAccount.private_key;
  const clientEmail = serviceAccount.client_email;

  const SPREADSHEET_ID = "1FdG5KV2yqnoU9AS-0oshUxXQ-6_bQNEO6lu8JPskIGo";
  const doc = new GoogleSpreadsheet(SPREADSHEET_ID);

  await doc.useServiceAccountAuth({
    client_email: clientEmail,
    private_key: privateKey,
  });

  await doc.loadInfo();
  const sheet = doc.sheetsByIndex[0];
  const rows = await sheet.getRows();

  // 네임스페이스별로 데이터 그룹화
  const translations = {
    ko: {}, // 한국어 번역을 위한 객체
    en: {}, // 영어 번역을 위한 객체
  };

  rows.forEach((row) => {
    const namespace = row.namespace;
    const key = row.key;

    // 각 언어별 네임스페이스 초기화
    if (!translations.ko[namespace]) {
      translations.ko[namespace] = {};
    }
    if (!translations.en[namespace]) {
      translations.en[namespace] = {};
    }

    // 번역 데이터 추가
    translations.ko[namespace][key] = row.ko;
    translations.en[namespace][key] = row.eng;
  });

  // 각 언어별로 파일 생성
  const localesDir = path.join("public", "locales");

  // 폴더 생성
  ["ko", "en"].forEach((lang) => {
    const dir = path.join(localesDir, lang);
    if (!fs.existsSync(dir)) {
      fs.mkdirSync(dir, { recursive: true });
    }

    // messages.json 파일 생성
    fs.writeFileSync(
      path.join(dir, "messages.json"),
      JSON.stringify(translations[lang], null, 2),
      "utf-8"
    );
  });
}

fetchDataAndSaveAsJson().catch((err) => console.error(err));

 

 

 

 

 

cf 1. react-i18next

react-i18next를 사용하실 분들은 아래 아티클을 참고해 주세요.

기존 page router에서 로컬라이제이션을 해보신 분들은 이 방법이 더 익숙하실 겁니다.

 

Next.js 13/14 App Router with i18next (Tutorial)

A walkthrough for setting up the Next.js 13/14 App Router with internationalized routing and react-i18next.

i18nexus.com

 

 

[Next.js 14] App router 기반 Localization / Internationalization [i18next]

다국어를 지원하는 웹에 들어가보면 드롭다운으로 언어를 바꾸고, 그에 맞게 텍스트가 휙휙 바뀌는 것을 본 적이 있을 것이다.이번 포스팅에서는 i18next 모듈을 이용해서 앱 라우터 기반의 Next.js

hotsunchip.tistory.com

cf 2. intlayer

구글링 결과 직접 적용한 포스팅은 아직 확인하지 못했습니다.

공식 문서를 보고 츄라이츄라이~

해보신 분들은 후기를 남겨주시길 부탁드립니다.

 

Next.js 14와 App Router의 웹사이트를 번역하십시오 | Intlayer

Next.js 14 App Router 웹사이트를 다국어로 만드는 방법을 알아보세요. 국제화(i18n)하고 번역하려면 문서를 따르세요.

intlayer.org

 

 

 

 

앱라우터에 적용하는건 처음이기도 하고ㅎㅎ

코드도 그렇게 썩 마음에 드는 깔끔한 코드는 아닙니다만,,,

꽤나 오래 붙잡고 있었기에 아까워서 올려봅니다.

 

읽어주셔서 감사감사합니다

 

 

 

reference

https://medium.com/@f3ssoftware/internationalization-with-next-js-15-and-react-intl-6b67b5a2d28d

 

Internationalization with Next.js 15 and react-intl

There is two approaches that can be used with Next.js 15 and the library next-intl

medium.com

https://jihyundev.tistory.com/37?category=986297

 

다국어 프로젝트 시작해보기 (feat. Next.js, next-intl)

글로벌 서비스에 다국어 기능은 선택이 아닌 필수다. 그런데 단순히 한국어만을 고려한 어플리케이션과, 여러 개의 국가를 고려한 어플리케이션은 메세지를 화면에 띄워주는 구조부터 다르다.

jihyundev.tistory.com

https://hotsunchip.tistory.com/13

 

[Next.js 14] App router 기반 Localization / Internationalization [i18next]

다국어를 지원하는 웹에 들어가보면 드롭다운으로 언어를 바꾸고, 그에 맞게 텍스트가 휙휙 바뀌는 것을 본 적이 있을 것이다.이번 포스팅에서는 i18next 모듈을 이용해서 앱 라우터 기반의 Next.js

hotsunchip.tistory.com