next-intl을 사용한 국제화해보기!

김인태·2일 전
0
post-thumbnail

🔥 개요

평화롭게 일하고 있던 오후 갑자기 이사님께 연락이 왔다.

Q. "지금 회사 홈페이지에 영어로된 페이지가 필요해요"
A. "기한은 어느정도 될까요?"
Q. "원래 오늘까지였던거, 제가 내일까지 미뤘어요"
A. "아 .. 네 .. 찾아볼게요... "

언젠가는 해야되지만, 지금 우선순위는 아니라고 말씀하셔서 머리속에 잊혀져있던 국제화 갑자기 말씀을 들어서 당황스러웠지만, 그렇게 어려운 작업은 아니라고 생각했고 금방하겠지~~ 라고 생각했습니다.
그래서 일단 들어만봤던, i18n! 한 번 찾아봤습니다.

🐷 i18n? 국제화?

i18n = Internationalization
즉 i18n은 국제화랑 같은말이면서, 줄임말입니다.(별다줄이네요..)
i + (nternationalizatio의 18글자) + n = i18n
i18n은 다국어 지원을 위한 소프트웨어 설계입니다

🏃‍♂️ Try!

첫 번째 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 공식홈페이지도 같이 참고했습니다!

1. 설치

npm install next-intl

2. 폴더구조

공식 홈페이지에는 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

3. request.ts, routing.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,
  };
});

4. src/middleware.ts

 
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*",
 ],
};

5. layout 세팅

/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 입니다.

6. 컴포넌트 만들기!

실습으로 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을 사용해서 국제화를 할 수 있다!

locale 바꾸기?

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

profile
새로운 걸 배우는 것을 좋아하는 프론트엔드 개발자입니다!

0개의 댓글