Next.js 애플리케이션에 다국어 지원을 추가하면서 겪은 경험을 공유하고자 합니다. 처음에는 널리 사용되는 i18n 라이브러리들의 문서를 따라 구현했지만, Cloudflare에서 빌드가 계속 실패하는 문제에 직면했습니다. 이러한 어려움을 겪은 후, 직접 다국어 지원(i18n)을 구현하기로 결정했고, 이 글에서는 그 과정을 상세히 다루려고 합니다.
외부 라이브러리들은 편리하지만, Cloudflare와 같은 플랫폼에 배포할 때 예상치 못한 문제를 일으킬 수 있습니다. 제 경우에는 호환성 문제로 인해 빌드가 반복적으로 실패했습니다. 커스텀 솔루션을 구축함으로써 이러한 문제들을 해결할 수 있었고, i18n 프로세스를 완전히 제어할 수 있게 되었습니다.
i18n/
├── translations-provider.tsx
├── messages/
│ ├── cn.ts
│ ├── en.ts
│ ├── ja.ts
│ ├── ko.ts
│ └── types.ts
├── internal/
│ ├── client-translations-provider.tsx
│ ├── interpolate.ts
│ └── load-translations.ts
├── get-server-current-language.ts
├── get-server-translations.ts
├── get-preferred-language.ts
├── generate-static-params.ts
├── index.ts
├── supported-languages.ts
├── use-current-language.ts
└── use-translations.ts
이 구조는 번역 파일, 언어 유틸리티, 컨텍스트 프로바이더 등 다국어 지원에 필요한 모든 요소를 체계적으로 구성합니다.
이제 각 단계를 자세히 살펴보겠습니다...
// i18n/supported-languages.ts
export const DEFAULT_LANGUAGE = "en";
export const LANGUAGE_LIST = [
{ title: "English", locale: "en" },
{ title: "한국어", locale: "ko" },
{ title: "简体中文", locale: "cn" },
{ title: "日本語", locale: "ja" },
];
export const SUPPORTED_LANGUAGES = LANGUAGE_LIST.map(
(language) => language.locale
);
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
이 코드는 애플리케이션에서 지원하는 언어를 정의합니다. LANGUAGE_LIST
는 UI에서 표시할 언어 선택기를 위한 정보를 포함하고, SUPPORTED_LANGUAGES
는 내부적으로 사용할 언어 코드 배열입니다. TypeScript의 타입 시스템을 활용하여 SupportedLanguage
타입을 정의함으로써 지원되는 언어 코드만 사용되도록 타입 안정성을 확보합니다.
// i18n/messages/types.ts
export type Messages = {
header: {
home: string;
blog: string;
};
home: {
title: string;
description: string;
};
// 다른 네임스페이스들...
};
// i18n/messages/ko.ts
import { Messages } from "./types";
const ko: Messages = {
header: {
home: "홈",
blog: "블로그",
},
home: {
title: "NotionPresso와 함께하는\n한 잔의 커피",
description: "노션으로 만드는 나만의 블로그",
},
};
export default ko;
번역 메시지는 네임스페이스로 구분된 객체 구조를 가집니다. Messages
타입을 정의하여 모든 언어 파일이 동일한 구조를 따르도록 강제하며, 누락된 번역을 TypeScript가 컴파일 타임에 감지할 수 있게 합니다.
// i18n/internal/load-translations.ts
export async function loadTranslations(lang: string): Promise<Messages> {
const isSupported = SUPPORTED_LANGUAGES.includes(lang as SupportedLanguage);
const languageToLoad = isSupported ? lang : "en";
try {
const messages = await import(`../messages/${languageToLoad}`);
return messages.default;
} catch (error) {
console.error(`Failed to load messages for language: ${languageToLoad}`, error);
if (languageToLoad !== "en") {
const messages = await import("../messages/en");
return messages.default;
}
throw new Error("Failed to load default language messages.");
}
}
이 함수는 동적 임포트를 사용하여 언어 파일을 로드합니다. 주요 특징:
// i18n/internal/client-translations-provider.tsx
"use client";
import React, { createContext } from "react";
import { Messages } from "../messages/types";
interface TranslationsContextType {
messages: Messages;
lang: string;
}
export const TranslationsContext = createContext<TranslationsContextType>({
messages: {} as Messages,
lang: "",
});
interface TranslationsProviderProps {
messages: Messages;
lang: string;
children: React.ReactNode;
}
export const ClientTranslationsProvider: React.FC<TranslationsProviderProps> = ({
messages,
lang,
children,
}) => {
return (
<TranslationsContext.Provider value={{ messages, lang }}>
{children}
</TranslationsContext.Provider>
);
};
번역 컨텍스트는 React의 Context API를 활용하여 애플리케이션 전체에 번역 데이터를 제공합니다. "use client"
지시문을 사용하여 클라이언트 컴포넌트임을 명시하고, Next.js의 서버 컴포넌트 시스템과 호환되도록 구현되었습니다.
// i18n/internal/interpolate.ts
export function interpolate(
template: string,
variables: { [key: string]: string | number }
): string {
return template.replace(/\{(\w+)\}/g, (match, key) => {
return key in variables ? String(variables[key]) : match;
});
}
보간 유틸리티는 번역 문자열 내의 변수를 실제 값으로 대체합니다. 예를 들어:
// i18n/use-translations.ts
"use client";
import { useContext } from "react";
import { TranslationsContext } from "./internal/client-translations-provider";
import { interpolate } from "./internal/interpolate";
import { Messages } from "./messages/types";
type NestedKeyOf<TObj extends object> = {
[Key in keyof TObj & (string | number)]: TObj[Key] extends object
? `${Key}` | `${Key}.${NestedKeyOf<TObj[Key]>}`
: `${Key}`;
}[keyof TObj & (string | number)];
export function useTranslations<Namespace extends keyof Messages>(
namespace: Namespace
) {
const { messages } = useContext(TranslationsContext);
function t<
Key extends NestedKeyOf<Messages[Namespace]>,
Variables extends { [key: string]: any } = {}
>(key: Key, variables?: Variables): string {
const fullKey = `${namespace}.${key}`;
const keys = fullKey.split(".");
let message: any = messages;
for (const k of keys) {
if (message && k in message) {
message = message[k];
} else {
console.warn(`Translation for key "${fullKey}" not found.`);
return fullKey;
}
}
if (typeof message === "string") {
return variables ? interpolate(message, variables) : message;
} else {
console.warn(`Translation for key "${fullKey}" is not a string.`);
return fullKey;
}
}
return t;
}
useTranslations
훅의 주요 특징:
사용 예시:
function MyComponent() {
const t = useTranslations("home");
return (
<div>
<h1>{t("title")}</h1>
<p>{t("welcome", { name: "홍길동" })}</p>
</div>
);
}
// i18n/use-current-language.ts
"use client";
import { useContext } from "react";
import { TranslationsContext } from "./internal/client-translations-provider";
export function useCurrentLanguage(): string {
const { lang } = useContext(TranslationsContext);
return lang;
}
이 훅은 현재 설정된 언어 코드를 반환합니다. 언어 선택기 구현이나 조건부 렌더링에 유용하게 사용됩니다.
사용 예시:
function LanguageSwitch() {
const currentLang = useCurrentLanguage();
return (
<select value={currentLang}>
{LANGUAGE_LIST.map(lang => (
<option key={lang.locale} value={lang.locale}>
{lang.title}
</option>
))}
</select>
);
}
이러한 훅과 유틸리티의 조합으로 타입 안전하고 사용하기 쉬운 번역 시스템을 구축할 수 있습니다. 다음 섹션에서는 언어 감지와 리디렉션 구현에 대해 설명하겠습니다.
// i18n/get-preferred-language.ts
import { NextRequest } from "next/server";
import { DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES } from "./supported-languages";
export function getPreferredLanguage(request: NextRequest): string {
const acceptLanguage = request.headers.get("accept-language");
if (!acceptLanguage) return DEFAULT_LANGUAGE;
const langs = acceptLanguage.split(",").map((lang) => lang.split(";")[0]);
for (const lang of langs) {
const shortLang = lang.slice(0, 2).toLowerCase();
if (SUPPORTED_LANGUAGES.includes(shortLang)) {
return shortLang;
}
}
return DEFAULT_LANGUAGE;
}
이 함수는 브라우저의 Accept-Language 헤더를 분석하여 사용자의 선호 언어를 감지합니다:
// i18n/get-server-current-language.ts
import { SUPPORTED_LANGUAGES, DEFAULT_LANGUAGE } from "./supported-languages";
import { staticGenerationAsyncStorage } from "next/dist/client/components/static-generation-async-storage.external";
export function getServerCurrentLanguage(): string {
let lang = DEFAULT_LANGUAGE;
const store = staticGenerationAsyncStorage.getStore();
const pathname = store?.urlPathname;
if (pathname) {
const segments = pathname.split("/");
if (segments.length > 1 && segments[1]) {
const potentialLang = segments[1];
if (SUPPORTED_LANGUAGES.includes(potentialLang as any)) {
lang = potentialLang;
}
}
}
return lang;
}
이 함수는 URL 경로에서 언어 코드를 추출합니다:
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { SUPPORTED_LANGUAGES } from "@/i18n/supported-languages";
import { getPreferredLanguage } from "@/i18n/get-preferred-language";
export function middleware(request: NextRequest): NextResponse {
const { pathname } = request.nextUrl;
const response = NextResponse.next();
// 이미 언어 코드가 있는 경로인지 확인
if (
SUPPORTED_LANGUAGES.some(
(lang) => pathname.startsWith(`/${lang}/`) || pathname === `/${lang}`
)
) {
return response;
}
// 선호 언어로 리디렉션
const lang = getPreferredLanguage(request);
const newUrl = new URL(`/${lang}${pathname}`, request.url);
return NextResponse.redirect(newUrl);
}
export const config = {
matcher: [
// 정적 파일과 API 라우트 제외
"/((?!api|_next/static|_next/image|favicon\\.ico|.*\\.(?:png|jpg|jpeg|gif|webp|svg|ico|css|js)).*)",
],
};
미들웨어의 주요 기능:
1. 모든 요청을 인터셉트하여 URL 구조 검사
2. 이미 언어 코드가 포함된 경로는 그대로 통과
3. 언어 코드가 없는 경로는 사용자의 선호 언어로 리디렉션
4. 정적 파일과 API 라우트는 처리하지 않음
// i18n/generate-static-params.ts
import { SUPPORTED_LANGUAGES } from "./supported-languages";
export const generateStaticParams = () => {
return SUPPORTED_LANGUAGES.map((lang) => ({ lang }));
};
이 함수는 Next.js의 정적 사이트 생성(SSG)에서 사용됩니다:
사용 예시:
// app/[lang]/page.tsx
import { generateStaticParams } from "@/i18n/generate-static-params";
export { generateStaticParams };
export default function Page({ params: { lang } }) {
return <div>Current language: {lang}</div>;
}
이러한 구현을 통해 다음과 같은 URL 구조가 가능해집니다:
/en/about
- 영어 버전/ko/about
- 한국어 버전/ja/about
- 일본어 버전브라우저 언어 설정이 한국어인 사용자가 /about
으로 접근하면 자동으로 /ko/about
으로 리디렉션됩니다.
// app/[lang]/layout.tsx
import { ReactNode } from "react";
import TranslationsProvider from "@/i18n/translations-provider";
import { getServerCurrentLanguage } from "@/i18n/get-server-current-language";
interface RootLayoutProps {
children: ReactNode;
params: { lang: string };
}
export default function RootLayout({ children, params }: RootLayoutProps) {
const lang = getServerCurrentLanguage(); // URL에서 언어 감지
return (
<html lang={lang}>
<body>
<TranslationsProvider lang={lang}>
{children}
</TranslationsProvider>
</body>
</html>
);
}
// components/Header.tsx
"use client";
import { useTranslations } from "@/i18n/use-translations";
import { useCurrentLanguage } from "@/i18n/use-current-language";
import { LANGUAGE_LIST } from "@/i18n/supported-languages";
export default function Header() {
const t = useTranslations("header");
const currentLang = useCurrentLanguage();
return (
<header>
<nav>
<a href={`/${currentLang}`}>{t("home")}</a>
<a href={`/${currentLang}/blog`}>{t("blog")}</a>
</nav>
{/* 언어 선택기 */}
<select
value={currentLang}
onChange={(e) => {
const newLang = e.target.value;
window.location.pathname = window.location.pathname
.replace(`/${currentLang}`, `/${newLang}`);
}}
>
{LANGUAGE_LIST.map(({ locale, title }) => (
<option key={locale} value={locale}>
{title}
</option>
))}
</select>
</header>
);
}
// components/Welcome.tsx
"use client";
import { useTranslations } from "@/i18n/use-translations";
interface WelcomeProps {
username: string;
notificationCount: number;
}
export default function Welcome({ username, notificationCount }: WelcomeProps) {
const t = useTranslations("welcome");
return (
<div>
<h1>{t("greeting", { name: username })}</h1>
<p>{t("notifications", { count: notificationCount })}</p>
</div>
);
}
관련 번역 파일:
// i18n/messages/ko.ts
{
welcome: {
greeting: "{name}님, 환영합니다!",
notifications: "읽지 않은 알림이 {count}개 있습니다.",
}
}
// ❌ 잘못된 사용
export default function ServerComponent() {
const t = useTranslations("common"); // 에러: 서버 컴포넌트에서 훅 사용 불가
return <div>{t("hello")}</div>;
}
// ✅ 올바른 사용
"use client";
export default function ClientComponent() {
const t = useTranslations("common");
return <div>{t("hello")}</div>;
}
// pages/[lang]/posts/[id].tsx
import { generateStaticParams } from "@/i18n/generate-static-params";
import { SUPPORTED_LANGUAGES } from "@/i18n/supported-languages";
// 모든 언어와 게시물 ID 조합에 대해 정적 페이지 생성
export async function generateStaticPaths() {
const postIds = ["1", "2", "3"]; // 실제로는 DB에서 가져올 것
const paths = SUPPORTED_LANGUAGES.flatMap(lang =>
postIds.map(id => ({
params: { lang, id }
}))
);
return { paths, fallback: false };
}
// app/[lang]/head.tsx
import { useTranslations } from "@/i18n/use-translations";
export default function Head() {
const t = useTranslations("meta");
return (
<>
<title>{t("title")}</title>
<meta name="description" content={t("description")} />
<meta property="og:title" content={t("ogTitle")} />
<meta property="og:description" content={t("ogDescription")} />
</>
);
}
// i18n/loadPageTranslations.ts
export async function loadPageTranslations(lang: string, page: string) {
try {
const messages = await import(`../messages/${lang}/${page}`);
return messages.default;
} catch (error) {
const fallback = await import(`../messages/en/${page}`);
return fallback.default;
}
}
이러한 구현을 통해 타입 안전하고 유지보수가 용이한 다국어 지원 시스템을 구축할 수 있습니다.
외부 라이브러리 없이 Next.js에서 다국어 지원을 구현하면서 얻은 주요 이점들을 정리해보겠습니다:
이 글에서 설명한 다국어 지원 시스템의 실제 구현 사례는 NotionPresso에서 확인하실 수 있습니다. NotionPresso는 노션으로 자신만의 블로그를 쉽게 만들 수 있는 오픈소스 템플릿을 제공합니다. 더 자세한 구현 내용과 문서는 NotionPresso Docs에서 확인하실 수 있습니다.
nextjs 국제화기능을 어떻게 적용할지 이것저것 찾아보고있었는데, 너무 좋은글 감사합니다ㅠㅜㅠ
정리도 잘되어있어서 이해도 잘 되네요👍🏻