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 디렉토리를 기반으로 동작합니다.
- 폴더 및 파일 기반 라우팅
- 각 폴더가 URL 경로를 나타내며, 폴더 내 page.js가 해당 경로의 메인 컴포넌트를 렌더링
- e.g. app/products/page.js는 /products 경로를 처리
- 각 폴더가 URL 경로를 나타내며, 폴더 내 page.js가 해당 경로의 메인 컴포넌트를 렌더링
- 동적 라우팅
- 폴더명에 대괄호([ ])를 사용해 동적 경로를 정의
- e.g. app/products/[id]/page.js는 /products/123처럼 id 값에 따라 동작
- 폴더명에 대괄호([ ])를 사용해 동적 경로를 정의
- 중첩 레이아웃 지원
- layout.js 파일로 상위 경로의 공통 레이아웃을 정의하고, 하위 경로에서 이를 재사용 가능.
- e.g. app/products/layout.js는 모든 /products 하위 경로에 적용
- layout.js 파일로 상위 경로의 공통 레이아웃을 정의하고, 하위 경로에서 이를 재사용 가능.
- 로딩 및 에러 상태 처리
- loading.js와 error.js 파일로 로딩 중과 에러 상태를 페이지별로 관리 가능.
- e.g. app/products/loading.js는 /products 로드 중 표시
- loading.js와 error.js 파일로 로딩 중과 에러 상태를 페이지별로 관리 가능.
- API 경로 통합
- route.js 파일을 통해 API 경로를 정의할 수 있어, 페이지와 API를 동일 디렉토리에서 관리.
- e.g. app/api/products/route.js는 /api/products 경로 처리
- route.js 파일을 통해 API 경로를 정의할 수 있어, 페이지와 API를 동일 디렉토리에서 관리.
- 동적 세그먼트와 Internationalization(i18n)
- 동적 세그먼트(app/[locale])와 middleware.js를 결합해 언어별 URL 구조를 쉽게 구현 가능.
- e.g. app/[locale]/products/page.js는 /en/products, /ko/products를 처리
- 동적 세그먼트(app/[locale])와 middleware.js를 결합해 언어별 URL 구조를 쉽게 구현 가능.
중요한 부분만 요약하자면 아래와 같습니다.
- 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 훅을 사용해 필요한 네임스페이스에 접근합니다.
이렇게 모든 설정을 마치면,
이렇게 라우팅 언어마다 다른 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
'RUN' 카테고리의 다른 글
오픈그래프(og) 로컬에서 테스트하기 (0) | 2025.03.19 |
---|---|
Next.js Build Error: bcrypt 모듈 문제 해결하기 (0) | 2025.02.12 |
Next.js에서 다국어 지원하기 (1) google spreadsheet 연동편 (0) | 2025.01.22 |
프론트엔드에서 로그인/회원가입 기능 구현 (JWT를 곁들인) (1) | 2025.01.14 |