개발자
류준열

next.js에 다국어 적용(i18n)

i18n을 이용해서 next.js로 만든 프로젝트에 언어 변환 기능을 추가했다.
처음에는 react-i18next의 useTranslation 훅을 이용하여 다국어 기능을 구현했다.

그런데 이렇게 훅을 통해 언어 상태를 관리하는 경우 새로고침이나 링크 공유시에 언어가 초기화 되는 이슈가 있었다.

링크 공유시 초기화 gif

조금 더 찾아보니 next.js 공식문서에 i18n 다국어 처리하는 방식을 소개하는 이 있었다.

Client Side가 아닌 Server Side에서 언어를 선택하고 /{lang}/...로 리다이렉트 시키는 방식이다. (ex: /ko/home,/en/home)

이렇게 하면 유저가 경험하는 Client Side에서는 언어 변환으로 인한 리렌더링 등의 어떤 효과도 발생하지 않는다.

i18n.config.ts 작성

export const i18n = {
  defaultLocale: "ko",
  locales: ["ko", "ja", "en"],
} as const;

export type Locale = (typeof i18n)["locales"][number];

디렉터리 구조 변경

url이 언어에 따라 /ko/home,/en/home 등으로 이루어지도록 디렉토리 구조를 /app/[lang]/... 로 변경한다.

디렉토리 구조

middleware.ts 작업

request header에는 유저가 사용하는 브라우저의 언어가 담겨있다. 언어설정탭

header accept-language

middleware.ts에서 request header의 Accept-Language를 기반으로 locale을 선택하는 함수인 getLocale을 middleware.ts 내에 작성한다.

// middleware.ts

import { NextRequest, NextResponse } from "next/server";
import { i18n } from "../i18n.config";
import { match as matchLocale } from "@formatjs/intl-localematcher";
import Negotiator from "negotiator";


function getLocale(request: NextRequest): string | undefined {
  const negotiatorHeaders: Record<string, string> = {};
  request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));

  // @ts-ignore locales are readonly
  const locales: string[] = i18n.locales;
  const languages = new Negotiator({ headers: negotiatorHeaders }).languages();

  const locale = matchLocale(languages, locales, i18n.defaultLocale);
  return locale;
}

getLocale의 반환값인 locale과 일치하는 페이지로 리다이렉트 시키는 기능을 추가한다. 위의 getLocale 까지 합치면 다음과 같다.

// middleware.ts

import { NextRequest, NextResponse } from "next/server";
import { i18n } from "../i18n.config";
import { match as matchLocale } from "@formatjs/intl-localematcher";
import Negotiator from "negotiator";

function getLocale(request: NextRequest): string | undefined {
  const negotiatorHeaders: Record<string, string> = {};
  request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));

  // @ts-ignore locales are readonly
  const locales: string[] = i18n.locales;
  const languages = new Negotiator({ headers: negotiatorHeaders }).languages();

  const locale = matchLocale(languages, locales, i18n.defaultLocale);
  return locale;
}

export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;

  const pathnameIsMissingLocale = i18n.locales.every(
    (locale: string) => !pathname.startsWith(`/${locale}`) && pathname !== `/${locale}`,
  );

  if (pathnameIsMissingLocale) {
    const locale = getLocale(request);

    return NextResponse.redirect(
      new URL(`/${locale}${pathname.startsWith("/") ? pathname : `/${pathname}`}`, request.url),
    );
  }
  return response;
}

export const config = {
  // Matcher ignoring `/_next/` and `/api/ and /assets`
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico|assets).*)"],
};

/tasks/history로 진입했을 때 /en/tasks/history로 리다이렉트 되는 것을 확인 할 수 있다. locale 하위로 리다이렉트

각 언어별 json 파일을 추가한다.

json파일은 chatGPT 이용하면 쉽게 만들 수 있다.
chatGPT 이용 만들어진 json의 예시는 다음과 같다. json파일들

위 json 파일들을 이용하는 번역함수를 작성한다. 이 함수는 Server Side에서 실행되어야 한다.

	import "server-only";
	import type { Locale } from "../../i18n.config";

	const translation = {
		ko: () => import("@/utils/i18n/locales/ko/page.json").then((module) => module.default),
		ja: () => import("@/utils/i18n/locales/ja/page.json").then((module) => module.default),
		en: () => import("@/utils/i18n/locales/en/page.json").then((module) => module.default),
	};

	export const getTranslation = async (locale: Locale) => {
		return translation[locale]();
	};

	export type TranslateType = Awaited<ReturnType<typeof getTranslation>>;

리턴되는 값의 타입인 TranslateType번역본을 담고 있는 json 파일과 일치한다.

Translate Type

번역함수를 각 페이지의 server side에서 실행시킨다.

/app/[lang]/page.tsx는 다음과 같이 클라이언트 컴포넌트를 children으로 갖고 있다.
Server side에서 언어 번역함수를 호출하여 반환된 값을 클라이언트 컴포넌트에 props로 넣어준다.

// /app/[lang]/page.tsx

import { TranslateType, getTranslation } from "@/lib/translation";
import { Locale } from "@/../i18n.config";
import DashboardPage from "./DashboardPage";

interface IndexPageProps {
  params: {
    lang: Locale; // 'ko'|'en'|'ja'
  };
}
export default async function IndexPage({ params: { lang } }: IndexPageProps) {
  const translate: TranslateType = await getTranslation(lang);
  return (
    <>
      <DashboardPage translate={translate} />
    </>
  );
}

클라이언트 컴포넌트에서는 props로 받은 transition을 다음과 같이 이용한다.

// DashboardPage.tsx

            <TimeText>
              {translate["기준 시간"]}: {formattedDate}
            </TimeText>

만약에 번역본 json 파일에 등록되지 않은 단어를 넣게 되면 타입에러가 발생하기 때문에 번역이 누락된 텍스트를 배포하는 일을 방지할 수 있다. translate 타입에러

후기

모든 페이지가 'use client' 로 작성되어 있던 상황에서는 Server side에서 실행되는 함수를 호출 할 수 없어서 전체 페이지에 Server side 로직을 추가했다.

header에서 버튼에 next/Link를 달고 언어 전환을 했을 때 언어 전환이 되지 않는 컴포넌트들이 있었다. 그 컴포넌트들이 리렌더링 되지 않는 이유를 찾는데 시간이 좀 걸렸는데 layout.tsx의 children에 속해있지 않기 때문이었다. 이를 해결하기 위해 기존 /app/layout.tsx의 일부를 /app/[lang]/layout.tsx 에 복사했다.

다국어 적용을 하면서 사소한 버그들을 만났고 이것들을 해결하기 위해 next의 라이프사이클을 더 깊게 공부해야 했다.

내가 사용하는 next.js라는 도구에 더 익숙해질 수 있었기 때문에 의미 있는 작업이었다.