개발자
류준열
next.js에 다국어 적용
i18n을 이용해서 next.js로 만든 프로젝트에 언어 변환 기능을 추가했다.
처음에는 react-i18next의 useTranslation 훅을 이용하여 다국어 기능을 구현했다.
그런데 이렇게 훅을 통해 언어 상태를 관리하는 경우 새로고침이나 링크 공유시에 언어가 초기화 되는 이슈가 있었다.
조금 더 찾아보니 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에는 유저가 사용하는 브라우저의 언어가 담겨있다.
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
로 리다이렉트 되는 것을 확인 할 수 있다.
각 언어별 json 파일을 추가한다.
json파일은 chatGPT 이용하면 쉽게 만들 수 있다.
만들어진 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 파일과 일치한다.
번역함수를 각 페이지의 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 파일에 등록되지 않은 단어를 넣게 되면 타입에러가 발생하기 때문에 번역이 누락된 텍스트를 배포하는 일을 방지할 수 있다.
후기
모든 페이지가 'use client'
로 작성되어 있던 상황에서는 Server side에서 실행되는 함수를 호출 할 수 없어서 전체 페이지에 Server side 로직을 추가했다.
header에서 버튼에 next/Link
를 달고 언어 전환을 했을 때 언어 전환이 되지 않는 컴포넌트들이 있었다. 그 컴포넌트들이 리렌더링 되지 않는 이유를 찾는데 시간이 좀 걸렸는데 layout.tsx의 children에 속해있지 않기 때문이었다. 이를 해결하기 위해 기존 /app/layout.tsx
의 일부를 /app/[lang]/layout.tsx
에 복사했다.
다국어 적용을 하면서 사소한 버그들을 만났고 이것들을 해결하기 위해 next의 라이프사이클을 더 깊게 공부해야 했다.
내가 사용하는 next.js라는 도구에 더 익숙해질 수 있었기 때문에 의미 있는 작업이었다.