외부 라이브러리 없이 Next.js에서 다국어 지원 구현하기

타락한스벨트전도사·2024년 11월 3일
16
post-thumbnail

들어가며

Next.js 애플리케이션에 다국어 지원을 추가하면서 겪은 경험을 공유하고자 합니다. 처음에는 널리 사용되는 i18n 라이브러리들의 문서를 따라 구현했지만, Cloudflare에서 빌드가 계속 실패하는 문제에 직면했습니다. 이러한 어려움을 겪은 후, 직접 다국어 지원(i18n)을 구현하기로 결정했고, 이 글에서는 그 과정을 상세히 다루려고 합니다.

왜 커스텀 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

이 구조는 번역 파일, 언어 유틸리티, 컨텍스트 프로바이더 등 다국어 지원에 필요한 모든 요소를 체계적으로 구성합니다.

구현 단계

  1. 지원 언어 정의
  2. 번역 메시지 생성
  3. 메시지 타입 정의
  4. 동적 번역 로드
  5. 번역 프로바이더 생성
  6. 번역 훅 생성
  7. 보간 유틸리티
  8. 현재 언어 감지
  9. 언어 리디렉션을 위한 미들웨어
  10. 동적 라우트용 정적 파라미터 생성
  11. i18n 내보내기 인덱싱

이제 각 단계를 자세히 살펴보겠습니다...

외부 라이브러리 없이 Next.js에서 다국어 지원 구현하기

핵심 구현 단계

1. 지원 언어 정의

// 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 타입을 정의함으로써 지원되는 언어 코드만 사용되도록 타입 안정성을 확보합니다.

2. 번역 메시지 관리

// 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가 컴파일 타임에 감지할 수 있게 합니다.

3. 동적 번역 로드

// 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.");
  }
}

이 함수는 동적 임포트를 사용하여 언어 파일을 로드합니다. 주요 특징:

  • 지원되지 않는 언어 코드가 입력되면 기본 언어(영어)로 폴백
  • 언어 파일 로드 실패 시 영어로 폴백하는 에러 처리
  • 영어 파일마저 로드 실패 시 명시적 에러 발생

4. 번역 컨텍스트 제공

// 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의 서버 컴포넌트 시스템과 호환되도록 구현되었습니다.

5. 번역 훅과 보간 유틸리티

5.1 보간 유틸리티 구현

// 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;
  });
}

보간 유틸리티는 번역 문자열 내의 변수를 실제 값으로 대체합니다. 예를 들어:

  • 템플릿: "안녕하세요, {name}님"
  • 변수: { name: "홍길동" }
  • 결과: "안녕하세요, 홍길동님"

5.2 번역 훅 구현

// 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 훅의 주요 특징:

  • 네임스페이스 기반 접근: 번역 키를 네임스페이스로 구분하여 관리
  • TypeScript 타입 안전성: 존재하지 않는 번역 키 사용 시 컴파일 에러 발생
  • 자동 완성 지원: IDE에서 사용 가능한 번역 키 자동 완성
  • 폴백 처리: 번역이 없는 경우 키 자체를 반환
  • 변수 보간: 동적 값을 번역 문자열에 삽입 가능

사용 예시:

function MyComponent() {
  const t = useTranslations("home");
  
  return (
    <div>
      <h1>{t("title")}</h1>
      <p>{t("welcome", { name: "홍길동" })}</p>
    </div>
  );
}

5.3 현재 언어 훅

// 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>
  );
}

이러한 훅과 유틸리티의 조합으로 타입 안전하고 사용하기 쉬운 번역 시스템을 구축할 수 있습니다. 다음 섹션에서는 언어 감지와 리디렉션 구현에 대해 설명하겠습니다.

6. 언어 감지 및 리디렉션

6.1 브라우저 선호 언어 감지

// 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 헤더를 분석하여 사용자의 선호 언어를 감지합니다:

  • 브라우저가 선호하는 언어 목록을 우선순위 순으로 확인
  • 지원하는 언어가 발견되면 해당 언어 코드 반환
  • 일치하는 언어가 없으면 기본 언어(영어) 반환

6.2 서버의 현재 언어 감지

// 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 경로에서 언어 코드를 추출합니다:

  • Next.js의 staticGenerationAsyncStorage를 사용하여 현재 URL 경로 접근
  • 경로의 첫 번째 세그먼트를 언어 코드로 간주
  • 유효한 언어 코드인 경우 해당 값 반환, 아니면 기본 언어 반환

6.3 미들웨어를 통한 언어 리디렉션

// 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 라우트는 처리하지 않음

6.4 동적 라우트를 위한 정적 파라미터 생성

// 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으로 리디렉션됩니다.

7. 실제 구현 예시와 주의사항

7.1 애플리케이션 레이아웃 구성

// 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>
  );
}

7.2 컴포넌트에서 번역 사용

// 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>
  );
}

7.3 변수가 포함된 번역 사용

// 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}개 있습니다.",
  }
}

7.4 주의사항과 해결방법

  1. 서버 컴포넌트와 클라이언트 컴포넌트 구분
// ❌ 잘못된 사용
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>;
}
  1. 동적 라우팅과 언어 코드 처리
// 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 };
}
  1. SEO 최적화
// 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")} />
    </>
  );
}

7.5 성능 최적화 팁

  1. 번역 파일 분할
    큰 규모의 애플리케이션에서는 번역을 페이지별로 분할하여 로드:
// 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에서 다국어 지원을 구현하면서 얻은 주요 이점들을 정리해보겠습니다:

  1. 배포 안정성
  • 외부 의존성이 줄어 빌드 실패 위험 감소
  • Cloudflare 등 엣지 환경에서도 안정적인 동작
  • 번들 사이즈 최적화 용이
  1. 타입 안전성
  • TypeScript의 타입 시스템을 활용한 번역 키 자동 완성
  • 누락된 번역 감지를 컴파일 타임에 수행
  • 런타임 에러 가능성 최소화
  1. 유연한 커스터마이징
  • 프로젝트 요구사항에 맞는 라우팅 전략 구현
  • 성능 최적화를 위한 번역 파일 분할 및 로딩 전략 수립
  • SEO 요구사항에 맞는 메타 데이터 처리

이 글에서 설명한 다국어 지원 시스템의 실제 구현 사례는 NotionPresso에서 확인하실 수 있습니다. NotionPresso는 노션으로 자신만의 블로그를 쉽게 만들 수 있는 오픈소스 템플릿을 제공합니다. 더 자세한 구현 내용과 문서는 NotionPresso Docs에서 확인하실 수 있습니다.

profile
스벨트쓰고요. 오픈소스 운영합니다

1개의 댓글

comment-user-thumbnail
2024년 11월 4일

nextjs 국제화기능을 어떻게 적용할지 이것저것 찾아보고있었는데, 너무 좋은글 감사합니다ㅠㅜㅠ
정리도 잘되어있어서 이해도 잘 되네요👍🏻

답글 달기