평화롭게 일하고 있던 오후 갑자기 이사님께 연락이 왔다.
Q. "지금 회사 홈페이지에 영어로된 페이지가 필요해요"
A. "기한은 어느정도 될까요?"
Q. "원래 오늘까지였던거, 제가 내일까지 미뤘어요"
A. "아 .. 네 .. 찾아볼게요... "
언젠가는 해야되지만, 지금 우선순위는 아니라고 말씀하셔서 머리속에 잊혀져있던 국제화 갑자기 말씀을 들어서 당황스러웠지만, 그렇게 어려운 작업은 아니라고 생각했고 금방하겠지~~ 라고 생각했습니다.
그래서 일단 들어만봤던, i18n! 한 번 찾아봤습니다.
i18n = Internationalization
즉 i18n은 국제화랑 같은말이면서, 줄임말입니다.(별다줄이네요..)
i + (nternationalizatio의 18글자) + n = i18n
i18n은 다국어 지원을 위한 소프트웨어 설계입니다
첫 번째 Try.. Claude를 통해서하면 진짜 순식간에 해버릴 수 있겠다 생각했습니다.
현재 홈페이지는 next.js 15버전을 사용해서 만들어져있는데, 클로드에게 말해서 세팅을했습니다.
분명히 next.js 15버전을 사용한다고 말했습니다.
next.js에 내장되어있는 i18n을 사용하라고 추천받았습니다.
아무래도 랜딩페이지를 보여줘야 하다보니 복잡한 관리가 필요없다고 판단하에 내장 i18n을 사용하기로 결정했습니다.
바로 public/locales/en&ko/common.json 만들고,
미들웨어 세팅하고..하다보니까 이녀석.. 13버전에서 사용하던 i18n 세팅을 알려주고 있었고,
로컬에서 테스트하려고하니, 계속 하이드레이션에러를 뱉지않나..
해결된거같으면 메인이미지만 크게 나오지않나.. 그래서 이것은 오늘 하루안에 끝내야 하니까 이 에러 해결에 매달리는 것보다, 다 revert 해버리고 라이브러리를 사용해서 다시 만드는게 빠르겠다! 라고 판단했습니다.
그러다가 아주 잘 정리된 https://hyunki99.tistory.com/124 글을 찾게 되었습니다!
이 글 초반에 다국어 라이브러리를 정리해주신 부분이 있는데, 그 부분을 참고해서 (제일 큰 이유는 Next.js 최신 버전과 호환이 잘되는 것이 컸습니다) 저도 next-intl을 사용해서 국제화를 진행하게 되었습니다.
설치화 세팅과정은 https://next-intl.dev/docs/getting-started/app-router/with-i18n-routing 공식홈페이지도 같이 참고했습니다!
npm install next-intl
공식 홈페이지에는 navigation.ts 도 있습니다만, routing.ts 에서 다 정의하고 있기 때문에 굳이 만들 필요 없었습니다.
📦 messages/
├── 📜 en.json
└── 📜 ko.json
📦 src/
├── 📂 app/
│ ├── 📂 [locale]/
│ │ ├── 📜 layout.tsx
│ │ ├── 📜 not-found.tsx
│ │ └── 📜 page.tsx
│ ├── 📂 admin/
│ ├── 📜 globals.css
│ └── 📜 layout.tsx
├── 📂 i18n/
│ ├── 📜 request.ts
│ └── 📜 routing.ts
└── 📜 middleware.ts
└── 📜 next.config.ts
/src/i18n/routing.ts
저희 프로젝트는 일단 초기에 영어랑 한국어만 대응하기로 해서 en, ko만 썼습니다.
routing.ts는 next-intl의 라우팅 설정과 네비게이션 헬퍼들을 한 파일에서 정의하고 export하는 역할입니다.
URL 구조가 /ko/page, /en/page 형태가 되고, 기본 로케일인 한국어는 /page로도 접근 가능할 수 있습니다.
import { defineRouting } from "next-intl/routing";
import { createNavigation } from "next-intl/navigation";
export const routing = defineRouting({
locales: ["en", "ko"],
defaultLocale: "ko",
});
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);
🤓 각각의 역할
Link: → 현재 로케일 기준으로 /ko/about 또는 /en/about로 자동 변환
-> 국제화 적용하시면서 next/link, next/navigation에 있는 Link 태그 사용하면 /[locale]/about 이런식으로 이동해야하는데, /about 으로 이동해서 404에러가나고, middleware.ts 에서 로케일 기반 라우팅을 처리하면서 에러날 수 있기 때문에 기존의 Link 태그가 있는지 잘 체크 해봐야합니다!
redirect: 서버에서 리다이렉트 시 로케일 유지
usePathname: /about 반환 (로케일 제외한 순수 경로)
useRouter: 로케일을 고려한 클라이언트 라우팅
getPathname: 서버사이드에서 경로 추출
나머지들도 마찬가지로 경로를 사용하는 부분들은 locale을 고려한 hook들로 바꿔야합니다!
/src/i18n/request.ts
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";
export default getRequestConfig(async ({ requestLocale }) => {
// 이것은 일반적으로 '[locale]' 세그먼트에 해당합니다
// 각 HTTP 요청마다 어떤 언어를 사용할지 결정합니다.
let locale = await requestLocale;
// 유효한 locale이 사용되었는지 확인합니다
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// 유효하지 않은 로케일이 오면 ko로 폴백합니다.
if (!locale || !routing.locales.includes(locale as any)) {
locale = routing.defaultLocale;
}
// 결정된 로케일에 맞는 파일을 동적로드합니다.
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
};
});
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import createIntlMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
// next-intl 미들웨어를 생성합니다.
const handleI18nRouting = createIntlMiddleware(routing);
export default function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 1. /admin 경로에 대한 인증 로직을 먼저 처리합니다.
// 이 경로는 다국어 처리가 필요 없으므로 next-intl 미들웨어를 건너뜁니다.
if (pathname.startsWith("/admin")) {
// /admin/login 경로는 인증 체크 없이 통과시킵니다.
if (pathname === "/admin/login") {
return NextResponse.next();
}
// /admin/dashboard로 시작하는 모든 경로를 체크합니다.
if (pathname.startsWith("/admin/dashboard")) {
const token = request.cookies.get("admin_token")?.value;
// 토큰이 없으면 로그인 페이지로 리다이렉트합니다.
if (!token) {
const loginUrl = new URL("/admin/login", request.url);
return NextResponse.redirect(loginUrl);
}
// 토큰이 있으면 API 요청을 위해 헤더에 토큰을 추가합니다.
const requestHeaders = new Headers(request.headers);
requestHeaders.set("Authorization", token);
return NextResponse.next({
request: {
headers: requestHeaders,
},
});
}
// /admin 하위의 다른 경로들은 별도 처리 없이 통과시킵니다.
return NextResponse.next();
}
// 2. /admin 경로가 아닌 모든 요청은 next-intl 미들웨어로 처리합니다.
return handleI18nRouting(request);
}
export const config = {
// 미들웨어를 실행할 경로를 지정합니다.
// 아래 정규식은 api, _next/static, _next/image, favicon.ico를 제외한 모든 경로와 일치합니다.
// 이렇게 하면 불필요한 미들웨어 실행을 막아 성능을 최적화할 수 있습니다.
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico).*)",
"/admin/:path*",
],
};
/src/app/layout.tsx
import type { Metadata, Viewport } from "next";
import localFont from "next/font/local";
import "./globals.css";
import { Provider } from "jotai/react";
import ReactQueryProvider from "@/providers/ReactQueryProvider";
import {
DefaultMetadata,
DefaultViewport,
mainLd,
} from "@/constant/common/metadata";
const geistSans = localFont({
src: "/fonts/GeistVF.woff", // public/fonts/에서 가져오기
variable: "--font-geist-sans",
weight: "100 900",
});
const geistMono = localFont({
src: "/fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
weight: "100 900",
});
export const metadata: Metadata = DefaultMetadata;
export const viewport: Viewport = DefaultViewport;
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<head />
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-black text-white min-h-screen flex flex-col`}
>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(mainLd) }}
/>
<ReactQueryProvider>
<Provider>
{children}
<div id="portal"></div>
</Provider>
</ReactQueryProvider>
</body>
</html>
);
}
/src/app/[locale]/layout.tsx
import { getMessages } from "next-intl/server";
import { NextIntlClientProvider } from "next-intl";
import Header from "@/components/common/Header";
import Footer from "@/components/common/Footer";
export default async function LocaleLayout({
children,
}: {
children: React.ReactNode;
}) {
const messages = await getMessages();
return (
<NextIntlClientProvider messages={messages}>
<div className="mb-12">
<Header />
</div>
<main className="flex-grow">{children}</main>
<Footer />
</NextIntlClientProvider>
);
}
NextLntlClientProvider가 messages prop으로 받은 번역 데이터를 하위 컴포넌트들에게 전달합니다. 이 messages는 /messages/en.json & ko.json 입니다.
실습으로 not-found.tsx 를 만들어보겠습니다.
/messages/en.json
{
"NotFound": {
"title": "The page you are looking for does not exist",
"description": "The page may have been deleted or the address may have been changed",
"backToHome": "Back to Home",
"contactMessage": "If the problem persists, please contact",
"contactSuffix": ""
},
}
/messages/ko.json
{
"NotFound": {
"title": "찾으시는 페이지가 존재하지 않습니다",
"description": "페이지가 삭제되었거나 주소가 변경되었을 수 있습니다",
"backToHome": "메인으로 돌아가기",
"contactMessage": "문제가 계속되면",
"contactSuffix": "으로 연락해주세요"
},
}
/app/[locale]/not-found.tsx
import { Link } from "@/i18n/routing";
import { getTranslations } from "next-intl/server";
import Image from "next/image";
export default async function NotFound() {
const t = await getTranslations("NotFound");
return (
<main className="min-h-screen w-full flex flex-col items-center justify-center bg-black text-white p-4">
<div className="max-w-2xl w-full text-center space-y-8">
<Link href="/" className="inline-block mb-12">
<div className="relative w-48 h-auto mx-auto">
<Image
src="/images"
alt="image"
width={270}
height={130}
className="w-full h-auto object-contain"
priority
/>
</div>
</Link>
<h1 className="text-9xl font-bold text-[#DC3545]">404</h1>
<div className="space-y-4">
<h2 className="text-2xl font-bold">{t("title")}</h2>
<p className="text-gray-400">{t("description")}</p>
</div>
<div className="mt-8">
<Link
href="/"
className="inline-flex items-center px-6 py-3 bg-[#DC3545] text-white rounded-md hover:bg-[#c82333] transition-colors duration-200"
>
{t("backToHome")}
</Link>
</div>
</div>
<div className="mt-16 text-center text-gray-500 max-w-md">
<p>
{t("contactMessage")}{" "}
<a
href="mailto:contact@contact.com"
className="text-[#DC3545] hover:underline"
>
contact@contact.com
</a>
{t("contactSuffix")}
</p>
</div>
</main>
);
}
이 방법대로 따라하면 next-intl을 사용해서 국제화를 할 수 있다!
import { useLocale } from "next-intl";
import { useRouter, usePathname } from "next/navigation";
import { useState, useTransition } from "react";
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const [isHovered, setIsHovered] = useState(false);
const toggleLocale = () => {
const newLocale = locale === "ko" ? "en" : "ko";
const newPath = pathname.replace(`/${locale}`, `/${newLocale}`);
router.push(newPath);
};
const isKorean = locale === "ko";
useLocale을 사용해서 현재 Locale을 string 형태로 받을 수 있습니다!
string 형태로 받은 locale을 토글버튼 등을 활용해서 사용언어를 변경할 수도 있습니다!
[출처]
[next-intl 국제화 실습] https://hyunki99.tistory.com/124
[next-intl docs app router] https://next-intl.dev/docs/getting-started/app-router/with-i18n-routing