Internationalization

Next.js i18n: From DB, Metadata to SEO Optimization

Internationalization

Intro

프로젝트를 확장시켜나가면서 한국어와 영어를 혼용해서 편한대로 작업을 진행하고 있었는데, 자세한 deep dive 블로그 컨텐츠는 한국어, 타임라인과 같은 진입점은 영어로 작성한 결과물을 막상 직접 마주하니 사용자의 입장에서는 그 어느 쪽도 배려하지 않은 것을 깨달았다. 그렇다고 한 언어로 통일하려니, 이미 작성한 컨텐츠가 아까워서, 공통 메시지 개념으로 임시로 설정해두었던 next-intl 을 제대로 활용해보기로 했다.

단순 UI 에서의 표시 메시지를 넘어서, 문자 컨텐츠를 모두 포함하는 데이터베이스 수준에서의 번역 제공, 나아가 사이트의 메타데이터에도 i18n 을 적용하여 SEO 최적화까지, i18n 으로 이 프로젝트에서 다룰 수 있는 설계 범위를 의도적으로 최대한 커버해보았다.

Next.js 16+, App Router 기준으로 진행한다.

Configuration

본격적인 작업을 진행하기에 앞서, 화면에 표시될 환경을 구성하는 것이 먼저였다. next-intl 은 Next.js 어플리케이션에 적용할 수 있는 i18n 도구로, i18n 을 위한 경량 라이브러리이다. 기본적인 설정은 공식 문서를 참고했고, message.json 자동 완성 및 컴파일 타임 타입 검사 기능을 사용하기 위한 타입 생성 실험 기능을 도입했다.

Strategy

i18n 적용 전략 중에는 여러 가지가 존재한다. 대표적인 몇 가지를 살펴보자면 다음과 같다.

  • Domain Separation: example.co.kr, en.example.com 처럼 도메인 자체를 분리하여 서비스 하는 방법으로, 지역별 운영 전략, 캐싱 전략 등 별도의 localization 팀을 운영하며 지역별로 좀 더 차별화된 서비스를 제공할 수 있는 대규모 서비스에 적합한 방법이다.
  • Local Storage / Client Side Global State: 사용자의 환경 내부적으로 언어 설정을 관리하는 방법으로, 추가적인 routing 설정 없이 비교적 간단하게 적용할 수 있다.
  • Cookie: 브라우저 메모리로 언어 설정을 관리하여 SSR 친화적인 환경을 구성할 수 있으며, 언어 설정을 브라우저 수준에서 유지할 수 있다.
  • Locale Prefix: /ko/about, /en/about 처럼 URL 에 언어 코드를 포함하는 전략으로, URL 분리에 따라 추가적인 설정이 많이 필요하지만 SEO 측면에서 유리하다.

도메인 분리 전략은 팀 단위로 localization 운영을 하지 않는 이상 그 이점을 챙기기가 어렵다고 판단하여, (돈도 없고), 이를 제외한 나머지 전략들을 비교해보았다.

비교 항목Global StateCookieLocale-Prefix
핵심 메커니즘앱 메모리 내 상태 관리HTTP 헤더 및 브라우저 저장소URL 경로(/ko, /en)
구현 복잡도낮음 (상태 변경)중간 (서버/클라이언트 통신)높음 (모든 페이지 구조 변경)
데이터 정합성새로고침 시 유지 안 됨브라우저 세션 동안 유지URL에 명시되어 공유/북마크 가능
사용자 경험 (UX)전환 시 즉각 반영 (새로고침 없음)새로고침 없이 서버 데이터 갱신 가능서버 네비게이션을 통해 전환(새로고침처럼 보여짐)
SEO 최적화불가능 (검색 엔진 인덱싱 어려움)제한적 (URL이 하나라 언어별 수집 불가)매우 우수 (언어별 고유 URL 존재)

본 프로젝트에서는 단일 Next.js 환경, 서버 사이드 렌더링 기반 구조, 그리고 SEO 최적화 전략 시험 및 도입을 고려해 Locale-Prefix 방식을 선택했다.

Locale Prefix

언어 코드를 포함하여 전체 URL 을 다시 구성하는 것은 결코 쉽지 않다. 사용자가 언어 코드가 없는 URL 로 접근 했을 경우나, 어플리케이션 내부에서의 URL 이동에도 어떻게 언어 코드를 삽입할 것인가를 생각해보면, i18n 도입을 다시 고민하게 만들 정도로 비용이 크다. 예를 들면:

  • sitemap 과 metadata 모두 locale 기준으로 재작성 및 적용
  • 내부 링크 추가 시마다 locale 누락 여부 확인
  • 내부 링크에 대한 locale 검사 및 예외 처리 등

하지만 이 비용을 감수해야만 언어별 URL, 메타데이터, sitemap 을 일관되게 관리할 수 있다. next-intl 은 이런 작업을 간단한 설정 몇 가지로 대체할 수 있게 해준다. 이는 locale-prefix 방식을 선택하는 데에 큰 몫을 하기도 했다.

i18n/routing.ts
1import { defineRouting } from "next-intl/routing";
2
3export const routing = defineRouting({
4  // A list of all locales that are supported
5  locales: ["ko", "en"],
6
7  // Used when no locale matches
8  defaultLocale: "ko",
9});
10

제일 먼저, 지원할 언어 코드를 지정했다. 여기서 지정된 routing 은 single source of truth 로, 이후 모든 언어 지원 범위의 기준으로 삼는다.

i18n/navigation.ts
1import { createNavigation } from "next-intl/navigation";
2
3import { routing } from "./routing";
4
5// Lightweight wrappers around Next.js' navigation
6// APIs that consider the routing configuration
7export const { Link, redirect, usePathname, useRouter, getPathname } =
8  createNavigation(routing);
9

앞서 언급했던 언어 코드 삽입 문제를 전부 처리해주는 파일이다. usePathname 은 현재 URL 에서 언어 코드를 제외한 pathname 을 가져오고, Link 는 언어 코드 없는 href 에 언어 코드를 넣어 navigation 을 진행해준다. 다만, 기존 Next.js 의 default import 경로를 next/* 를 모두 이 파일로 수정해줘야 한다.

i18n/request.ts
1import { type AbstractIntlMessages, hasLocale } from "next-intl";
2import { getRequestConfig } from "next-intl/server";
3
4import { routing } from "./routing";
5
6export default getRequestConfig(async ({ requestLocale }) => {
7  const requested = await requestLocale;
8  const locale = hasLocale(routing.locales, requested)
9    ? requested
10    : routing.defaultLocale; // guard against invalid locale prefixes
11
12  return {
13    locale,
14    messages: (await import(`../messages/${locale}.json`))
15      .default as AbstractIntlMessages
16  };
17});
18

다음으로는 서버의 언어 설정과 클라이언트 언어 설정을 동기화시켜주는 파일을 작성해준다.

next.config.ts
1import type { NextConfig } from "next";
2import createNextIntlPlugin from "next-intl/plugin";
3
4const nextConfig: NextConfig = {};
5
6const withNextIntl = createNextIntlPlugin();
7
8export default withNextIntl(nextConfig);
9

Next.js 의 설정을 플러그인 어댑터로 감싸주면, 설정된 모든 경로들에 대해 locale prefix 가 적용된다.

proxy.ts
1// Next.js ~15: middleware.ts
2// Next.js 16+: proxy.ts
3import createMiddleware from "next-intl/middleware";
4
5import { routing } from "./i18n/routing";
6
7export default createMiddleware(routing);
8
9export const config = {
10  matcher: [
11    // Match all path names except for
12    // - ... if they start with `/api`, `/trpc`, `/_next`, or `/_vercel`
13    // - ... the ones containing a dot (e.g. `favicon.ico`)
14    "/((?!api|trpc|_next|_vercel|.*\\..*).*)",
15  ],
16};
17

다음으로, 정적 자산(static assets) 요청 경로 및 api 요청 경로들을 제외해주는 middleware 를 설정해주면 기본적인 설정은 거의 완료된 셈이다. 유의해야 할 점은 파일명으로, Next.js 15 까지는 middleware.ts, 16 이상부터는 proxy.ts 로 지정해야한다.

app/[locale]/layout.tsx
1import { hasLocale, NextIntlClientProvider } from "next-intl";
2import { setRequestLocale } from "next-intl/server";
3
4import { routing } from "@/i18n/routing";
5
6export function generateStaticParams() {
7  return routing.locales.map((locale) => ({ locale }));
8}
9
10export default async function RootLayout({
11  children,
12  params,
13}: Readonly<{
14  children: React.ReactNode;
15  params: Promise<{ locale: string }>;
16}>) {
17  const { locale } = await params;
18
19  if (!hasLocale(routing.locales, locale)) {
20    // Fallback to 404 page
21    notFound();
22  }
23
24  // Enable static rendering
25  setRequestLocale(locale);
26
27  return (
28    <html
29      lang={locale}
30      // ... other properties
31    >
32      // Suspense is required, the provider fetches messages from server
33      <React.Suspense fallback={null}>
34        <NextIntlClientProvider>
35        // ... other providers
36          {children}
37        </NextIntlClientProvider>
38      </React.Suspense>
39    </html>
40  );
41}
42

마지막으로, locale prefix 라는 이름처럼, app 폴더 내의 모든 파일들을 [locale] 하위로 옮겨주고, generateStaticParams 로 routing.ts 에서 설정한 언어 코드를 파라미터로 생성해주면, i18n 을 위한 기본적인 설정은 완료된다.

이 과정에서 모든 언어 코드 관련 정보를 routing.ts 를 참조하게끔 설정하여 언어 코드 추가 또는 변경 작업을 간소화할 수 있게 설계했다. 이 프로젝트에서는 locale 과 마찬가지로, formatting 형식을 같은 파일에서 정의하여 관련 설정의 핵심 축으로 삼고 있다.

TypeScript Augmentation

추가적으로, next-intl 에서는 타입 지원 선언 파일 생성을 지원한다. 이는 json 파일에 명시된 메시지들을 IDE 에서 자동 완성 등으로 사용하거나, 컴파일 시 타입 검사를 지원하여 개발 편의성을 높여줄 수 있는 기능이지만, TypeScript 에서 공식으로 지원하는 기능이 아니라는 문제가 있어, 별도의 설정이 필요하다.

tsconfig.json
1{
2  // ...
3  "compilerOptions": {
4    // ...
5    "allowArbitraryExtension": true
6  }
7}
8

먼저, tsconfig.json 에 JSON 타입을 허용하도록 한다.

next.config.ts
1// ...
2const withNextIntl = createNextIntlPlugin({
3  experimental: {
4    // Provide the path to the messages that you're using in `AppConfig`
5    createMessageDeclaration: "./messages/ko.json"
6  },
7  // ...
8});
9
10// ...
11

Next.js 설정 파일에서, 플러그인의 실험 기능인 createMessageDeclaration 을 활성화한다. 이 설정을 활성화하면, next dev 또는 next build, next typegen 을 실행했을 때, messages 디렉토리에 타입 선언 파일이 생성된다.

diff
1messages/ko.json
2++ messages/ko.d.json.ts
3

이 파일은 하나의 기준 locale(JSON)을 source of truth 로 삼아, 모든 언어가 동일한 메시지 구조를 가진다는 전제를 생성되어 이후 messages 타입 참조에 사용되고, CI 환경에서도 자동으로 생성되므로 .gitignore 에 포함해 충돌을 방지해주는 것이 좋다.

global.d.ts
1import { routing } from "./i18n/routing";
2import messages from "./messages/ko.json";
3
4declare module "next-intl" {
5  interface AppConfig {
6    Locale: (typeof routing.locales)[number];
7    Messages: typeof messages;
8  }
9}
10

json 파일을 기반으로 생성된 type declaration 파일과 Locale 정보를 next-intl 모듈에 연결하는 과정으로, IDE 의 자동 완성 기능 및 타입 검사를 가능하게 해주는 최종 설정이다.

next-intl 의 MessageKeys.d.ts 를 보면 useTranslations 에서 문자열과 .을 을 통해 어떻게 타입 추론을 가능하게 하는지(Template Literal Type) 확인해볼 수 있다.

VSCode Integration

VSCode Extension 중, i18n-ally(lokalise.i18n-ally) 는 next-intl 에서 설정된 메시지들을 IDE 수준에서 미리 보거나, 해당 메시지의 localization 에 대한 의견을 남기며 공유할 수도 있는 유용한 도구이다. 코멘트와 수정 요청 및 수정 기록까지 yml 형태로 남겨 공유할 수 있기 때문에 팀 단위로 i18n 을 관리해야 하는 상황이라면 반드시 도입하는 것을 추천한다.

ICU Messages

next-intl 에서는 key/translation 형태를 갖는 json 파일을 기준으로 표현할 메시지를 분류할 수 있으며, ICU 메시지 문법을 차용하여 다양한 문화별 언어 표현을 지원한다. 이는 "사람(들)을" 처럼 표현을 병기하지 않고, 더 구체적인 상황에 따라 UI 에 제공할 메시지를 세분화할 수 있는 옵션을 제공한다.

한국어에서 성별이나 인칭에 따라 형태가 바뀌는 부분은 떠올리기 쉽지 않기 때문에, "은(는)" 과 "이(가)" 처럼 가능한 형태를 모두 표기한 병기 형식이 문맥을 해치지 않아 간과하고 넘어가기 쉽지만, 스페인어와 같이 그 문맥에 영향이 갈 수 있는 문화권에서는 이렇게 세분화된 메시지 형식이 큰 도움이 될 수 있다.

여기에서 구체적인 예시와 사용법을 확인할 수 있다.

Message Structure

클라이언트 측에서의 렌더링을 위한 NextIntlClientProvider 는 기본적으로 서버 측에서 필요한 props 를 전달 받는다. 이 props에 따라 서버로부터 직렬화된 정보 클라이언트 렌더링에 적합한 React Context 형태로 복원하는 역할을 맡는다.

제일 가까운 Provider 를 참조하는 특성 상, props 를 지정한 provider 설정으로 메시지를 분리 제공할 수 있지만, 메시지를 물리적으로 분리하면 앞서 설정한 type augmentation 이 작동하지 않게 된다. 이를 다시 적용하기 위해서는 나눠진 모든 메시지들에 대해 모두 d.ts 파일로의 변환 과정을 거쳐 global.d.ts 에서 spread 연산자로 다시 합쳐야 하는데, 이는 복잡도가 크게 상승한다.

대신, next-intl 에서는 namespace 패턴을 지원한다.

ko.json
1{
2  "/": { ... },
3  "/blog": { ... },
4  ...,
5  "ui": { ... }
6}
7

본 프로젝트에서는 여러 페이지들에 걸쳐 필요한 공통 UI에 표시될 메시지와, 각 페이지 경로마다 한정적으로 표시될 메시지들을 구분하여 구성했다. 이렇게 namespace 기반으로 메시지를 구조화하면 아래와 같이 클라이언트에 전송할 메시지 번들 크기를 최적화할 수 있다.

layout.tsx
1import { NextIntlClientProvider } from "next-intl";
2import { getMessages } from "next-intl/server";
3
4export default async function Layout({
5  children
6}: {
7  children: React.ReactNode;
8}) {
9  // Load all messages on server memory
10  const allMessages = await getMessages();
11  // Select messages to be sent to client
12  const blogMessages = {
13    ...allMessages.ui,
14    ...allMessages["/blog"],
15  };
16
17  return (
18    <NextIntlClientProvider
19      // TypeScript requires full messages structure
20      messages={blogMessages as any}
21    >
22      {children}
23    </NextIntlClientProvider>
24  )
25}
26

Provider 에서는 앞서 global.d.ts 에 등록한, 모든 메시지 타입을 요구하기 때문에, 부분적으로 메시지를 전송하는 것에 대해서 blogMessages as any 로 우회해줄 필요가 있다. 이 부분은 useTranslations 에서의 namespace 제한을 통해 별도의 검사 없이 타입 안정성을 유지할 수 있다.

서버에서는 messages 들을 local storage 에서 접근하기 때문에 내용을 모두 불러오는 것에 큰 부하가 없지만, 클라이언트 측에서는 네트워크를 통해야 하기 때문에 부하가 걸릴 수 있다. 본 프로젝트에서는 적용하지 않았지만, 다뤄야할 메시지 크기가 성능을 고려해야 할 정도로 큰 상황이라면 provider 가 가질 message 를 나눠서 전송하는 방법으로 번들 크기를 조절할 수 있다.

Static Metadata i18n

메시지와 locale prefix 를 활용하는 이 방법은 메타데이터를 각 locale 에 맞게 제공할 수 있다는 추가적인 이점이 있다.

layout.tsx
1import { getTranslations } from "next-intl/server";
2//...
3
4export async function generateMetadata(): Promise<Metadata> {
5  // get messages with set locale
6  const t = await getTranslations("/.metadata");
7
8  const metadata: Metadata = {
9    title: { template: t("title.template"), default: t("title.default") },
10    description: t("description"),
11    // ... rest of metadata
12  }
13
14  return metadata;
15}
16

getTranslations 는 useTranslations 에 대응되는 서버 유틸리티로, 설정된 locale 과 주어진 namespace 에 맞는 메시지를 가져올 수 있어 static 경로들에 대해서 메타데이터를 언어 설정에 맞게 제공할 수 있다.

Localetitledescription
ko블로그블로그 설명
enBlogBlog description

이처럼, 정적으로 생성된 페이지들에 대한 메타데이터는 JSON 파일로 대응할 수 있다.

Database Translation

정적으로 생성된 페이지들에 대해서는 JSON 파일로 메시지와 메타데이터에 i18n 을 적용할 수 있었지만, 이 프로젝트는 데이터베이스를 기반으로 한 dynamic routing 도 사용하고 있다. 각 페이지에 대한 메타데이터도 데이터베이스를 기반으로 생성되는 구조이기 때문에, 동적으로 생성된 페이지들에도 i18n 을 적용하려면 schema 를 재설계할 필요가 있었다.

기존 데이터베이스는 title, description, content, word_count 등 언어 설정에 따라 영향을 받는 column 을 다수 포함하고 있었기 때문에 단순히 번역 column 을 추가하는 대신 locale 에 따라 내용을 가져올 수 있도록 translation 관계 테이블을 구성했다.

본 프로젝트에서는 제목 부분에 라틴 계열, 알파벳만 지원하는 폰트를 사용하고 있어 레이아웃의 일관성을 고려해 title 은 번역 대상에서 제외했으며, 개인으로 운영되는 특성상 번역본의 등록/수정 일시는 크게 구분할 필요가 없어 관계 테이블로 분리하지 않았다. locale 항목은 enum 으로 routing.ts 에서의 언어 코드에 맞춰 별도의 데이터 타입을 선언하여 사용한다.

Database Package

데이터베이스 접근에 사용되는 Prisma DB 패키지도 이에 맞춰 업데이트를 진행했다. 제일 먼저, 새로 추가된 Locale enum 타입을 사용할 수 있도록 types.ts 파일을 분리했다.

types.ts
1// Re-export types from generated Prisma client
2export type { Locale } from "@/generated/prisma/enums";
3

또한, locale 에 따라 가져올 수 있는 데이터가 나눠졌기 때문에 locale 이 없는 데이터 요청에 대한 fallback 설정을 추가했다.

constants.ts
1import { Locale } from "./types";
2
3export const DEFAULT_LOCALE: Locale = "ko";
4

이 부분은 DEFAULT_LOCALE 값을 ENV, 환경변수로 넘겨받아 설정하게 할 수도 있다. 실제 데이터를 요청하는 query 에서도 안전 장치를 추가 했다.

post.ts
1export async function all(
2  locale: Locale = DEFAULT_LOCALE,
3): Promise<PostWithoutContent[]> {
4  const posts = await prisma.post.findMany({
5    select: {
6      // ... existing columns
7      translations: {
8        where: { locale },
9        select: {
10          locale: true,
11          description: true,
12          word_count: true,
13        },
14      },
15      // ...
16    },
17    orderBy: { created_at: "desc" },
18  });
19
20  // Translation type for list views (without content)
21  type ListTranslation = {
22    locale: Locale;
23    description: string | null;
24    word_count: number;
25  };
26
27  // Handle fallback for missing translations
28  return Promise.all(
29    posts.map(async (post) => {
30      let translation: ListTranslation | undefined = post.translations[0];
31
32      // Fallback to default locale if translation missing
33      if (!translation && locale !== DEFAULT_LOCALE) {
34        const fallbackTranslation =
35          await prisma.post_translation.findUnique({
36            where: {
37              post_id_locale: { post_id: post.id, locale: DEFAULT_LOCALE },
38            },
39            select: {
40              locale: true,
41              description: true,
42              word_count: true,
43            },
44          });
45        translation = fallbackTranslation ?? undefined;
46      }
47
48      return {
49        // ...
50        translation: translation ?? {
51          locale,
52          description: null,
53          word_count: 0,
54        },
55      };
56    }),
57  );
58}
59

Cache Layer

Next.js 캐시 레이어로 분리한 부분에서도 locale 에 fallback 을 설정했다.

lib/cache/post/select.ts
1import { routing } from "@/i18n/routing";
2
3const DEFAULT_LOCALE: Locale = routing.defaultLocale;
4
5export async function all(locale: Locale = DEFAULT_LOCALE) {
6  "use cache";
7  cacheLife("weeks");
8  cacheTag("posts", `posts-${locale}`);
9  return await post.select.all(locale);
10}
11

이 부분은 Next.js 어플리케이션 범위 내이므로, routing.ts 파일을 참조하여 일관성을 유지한다. locale 분류에 따라 추가적으로 cache 를 조절할 수 있도록 캐시 태그를 확장하는 작업을 추가로 진행했다.

이로써 언어 지원 추가나 수정 작업이 필요할 때, routing.ts 와 정적 메시지인 JSON, 그리고 데이터베이스의 Locale enum, 이 세 가지로 모든 범위를 커버할 수 있는 환경을 완성했다.

NOTE: 정적 메시지 JSON 역시 fallback 을 설정할 수는 있으나, getRequestConfig 에서 제공되는 getMessageFallback 은 동기 처리만 지원하여 필요한 부분만 불러올 수 없어 fallback 으로 설정하려면 모든 메시지를 두 번 불러오게 된다. 어차피 꼭 필요한 메시지들이니, 지원할 언어 JSON 을 완성한 후에 언어 코드를 추가하는 것을 추천한다.

SEO Optimization

동적으로 생성되어 표현될 데이터 소스들은 준비를 마쳤다. 그럼 이 데이터는 어떻게 표현해야 할까?

기존에도 이미 데이터베이스 기반으로 메타데이터와 컨텐츠를 표시하고 있었다. 동적으로 생성되는 블로그 페이지의 slug 역할은 이미 글의 id 값을 기준으로 처리되고 있고, 글의 제목과 요약 설명을 메타데이터로 활용할 것을 전제로 schema 를 구성해두었기 때문에, i18n 을 적용한다고 해서 핵심 로직에 문제가 생기는 상황은 아니었다.

blog/[id]/layout.tsx
1// Generate static params for all blog posts
2export async function generateStaticParams() {
3  const posts = await cache.post.select.all();
4  return posts.map((post) => ({
5    id: post.id.toString(),
6  }));
7}
8
9// Generate metadata for each slug
10export async function generateMetadata({
11  params,
12}: {
13  params: Promise<{ id: string }>;
14}): Promise<Metadata> {
15  const locale = await getLocale();
16  const { id } = await params;
17
18  const posts = await cache.post.select.all(locale);
19  const post = posts.find((post) => post.id === Number(id));
20
21  if (!post) {
22    return {
23      title: "Project Not Found",
24      description: "The requested blog post could not be found.",
25    };
26  }
27
28  const { title, category, image, tags, created_at, updated_at } = post;
29  const { description } = post.translation;
30
31  return {
32    title,
33    description: description || `Blog post: ${title}`,
34    keywords: tags,
35
36    openGraph: {
37      type: "article",
38      title,
39      description: description || `Blog post: ${title}`,
40      tags,
41
42      publishedTime: created_at.toISOString(),
43      modifiedTime: updated_at.toISOString(),
44      section: category,
45    },
46
47    twitter: {
48      card: "summary_large_image",
49      title,
50      description: description || `Blog post: ${title}`,
51    },
52  };
53}
54

그런데, locale 에 따라 가져오는 translation 컨텐츠가 나눠짐에 따라, 같은 URL 주소에 대해 여러 개의 메타데이터가 생성되는 결과를 확인해볼 수 있다. 이에 더해 사이트 전체 URL 주소에 locale-prefix 가 적용되면서, locale 이 없는 URL 주소는 실제로는 없는 주소가 되어버린 상황이었다. 그렇다고 메타데이터에 단순히 locale 을 붙인 주소를 명시하면, 같은 내용을 다루고 언어만 다른게 아닌, 별도의 페이지로 취급된다.

여기서 검색 엔진을 위해 다뤄야 하는 요소 두 가지가 등장한다.

  • Alternates: 여러 형태의 URL 이 서로 같은 내용임을 명시하는 항목
  • Canonical: 여러 URL 중 검색 로봇이 기준으로 삼을 URL 주소

이 두 요소는 모든 메타데이터의 URL 구성에 필요하다. 이를 설정하지 않게 되면, 검색 엔진이 언어만 다른 동일한 콘텐츠를 서로 다른 페이지로 인식하게 되고, 검색 결과에서 동일한 페이지 간 우선순위 경쟁(cannibalization)이 발생할 수 있다. 이러한 혼란을 방지하기 위해, Alternates와 Canonical을 통해 관계를 명시적으로 정의해주는 편이 더 안전하고 정확하다.

Alternates Generation

구글 현지화된 페이지 알리기에서 언어 설정과 관련된 대체 페이지 설정에 대한 구글의 자세한 정책과 그 적용 방법을 확인할 수 있는데, 모든 페이지에 대해 대응되는 언어 페이지를 지정해주고, x-default 로 대표되는 기본 언어 설정까지를 다루고 있다. 이에 따라 URL 주소 생성을 프로젝트 전반에 적용되어야 하는 cross-cutting concern 으로 분류하고, 공통 유틸리티로 추상화하였다.

utils/server/metadata.ts
1import "server-only";
2
3import { getLocale } from "next-intl/server";
4
5import { routing } from "@/i18n/routing";
6
7export const BASE_URL = "your_base_url"; // can set with process.env.NEXT_PUBLIC_BASE_URL
8
9/**
10 * Generate language alternates for a given path
11 * @param path - The path without locale (e.g., "/blog", "/blog/1", "/playground")
12 * @returns Language alternates object for Next.js metadata
13 */
14export function getLanguageAlternates(path: string = "") {
15  // Remove leading slash for consistency
16  const normalizedPath = path.startsWith("/") ? path : `/${path}`;
17
18  const getUrl = (locale: string) =>
19    new URL(
20      `/${locale}${normalizedPath === "/" ? "" : normalizedPath}`,
21      BASE_URL,
22    ).toString();
23
24  return {
25    ...Object.fromEntries(
26      routing.locales.map((locale) => [locale, getUrl(locale)]),
27    ),
28    "x-default": getUrl(routing.defaultLocale),
29  };
30}
31
32/**
33 * Generate canonical URL for a given path and locale
34 * @param locale - The current locale
35 * @param path - The path without locale (e.g., "/blog", "/blog/1")
36 * @returns Canonical URL string
37 */
38export async function getCanonicalUrl(path: string = "") {
39  const locale = await getLocale();
40  const normalizedPath = path.startsWith("/") ? path.slice(1) : path;
41
42  return new URL(
43    `/${locale}${normalizedPath === "/" ? "" : normalizedPath}`,
44    BASE_URL,
45  ).toString();
46}
47

이 작업은 다음과 같은 점들을 고려하여 진행됐다:

  • server-only: 메타데이터는 HTML 의 head 에 포함되는 부분으로, 서버 측에서만 사용되는 모듈임을 명시
  • routing.ts: 여기서 또한 해당 파일에 선언된 locale 을 기준 삼아 주소 생성
  • new URL(): URL api 를 통한 주소 표준화 및 일관성 확보
  • getLocale: 사용자가 사용 중인 locale 을 포함한 주소를 canonical 표준으로 지정
  • x-default: 본 프로젝트에서 지원하는 언어권 외에서, 기본으로 표시할 기준 설정

URL Application

앞서 살펴봤던 blog/[id]/layout.tsx 에서, 몇 부분만 추가하면 alternates 와 canonical 을 지정할 수 있다.

diff
1++  import {
2++    getCanonicalUrl,
3++    getLanguageAlternates,
4++  } from "@/utils/server/metadata";
5
6export async function generateMetadata({
7  params,
8}: {
9  params: Promise<{ id: string }>;
10}): Promise<Metadata> {
11  // ...
12  const { title, category, image, tags, created_at, updated_at } = post;
13  const { description } = post.translation;
14++  const path = `/blog/${id}`;
15++  const canonicalUrl = await getCanonicalUrl(path);
16
17  return {
18++      alternates: {
19++        canonical: canonicalUrl,
20++        languages: getLanguageAlternates(path),
21++      },
22
23    title,
24    description: description || `Blog post: ${title}`,
25    keywords: tags,
26
27    openGraph: {
28      type: "article",
29      title,
30      description: description || `Blog post: ${title}`,
31++      url: canonicalUrl,
32      tags,
33      // ...
34    },
35
36    // ...
37  };
38}
39

기존에 사용하던 내부 URL 주소를 기반으로 언어 코드 처리를 할 수 있도록 metadata.ts 유틸리티에서 이미 설정해뒀기 때문에, Link 에 사용하던 내부 링크 주소(href)로 필요한 주소를 모두 처리할 수 있다. alternates 를 하나로 묶지 않고 canonical 을 굳이 나눈 이유는, openGraph 에서 공유될 주소와 사용자가 사용 중인 언어 컨텍스트의 연속성을 유지하기 위함이다.

Sitemap Generation

이제 마지막으로, 검색 엔진이 i18n 으로 분리된 주소 구조를 올바르게 이해할 수 있도록, 참조할 수 있는 index 를 만들어줄 차례이다.

본 프로젝트에서는 next-sitemap 을 시범 삼아 도입한 상태였다. 사이트맵의 구조와 생성 기준을 살펴보는 목적에서 가볍게 도입했지만, 지금까지 설정한 동적 페이지들을 반영하기 위해서는 추가적인 설정이 필요했다. 그런데, 이는 정적/동적 페이지들의 사이트맵 생성 시점이 달라지고, CI 프로세스로의 통합 필요 등 통제 범위가 늘어나는 부담이 있었다.

따라서 해당 라이브러리 대신, Next.js 에 내장된 sitemap.ts 를 활용하는 방향으로 전환했다. sitemap.ts 는 xml 파일을 생성하는 대신, /sitemap.xml 요청 시점에 동적으로 xml 을 생성해 응답하는 방식을 사용한다.

app/sitemap.ts
1// ...
2
3type SitemapEntry = MetadataRoute.Sitemap[number];
4
5/**
6 * Create a sitemap entry with language alternates
7 */
8function createEntry(
9  path: string,
10  locale: string,
11  lastModified?: Date,
12  options?: Partial<SitemapEntry>,
13): SitemapEntry {
14  const normalizedPath = path.startsWith("/") ? path : `/${path}`;
15  const fullPath = `/${locale}${normalizedPath === "/" ? "" : normalizedPath}`;
16
17  return {
18    url: new URL(fullPath, BASE_URL).toString(),
19    lastModified: lastModified ?? new Date(),
20    changeFrequency: "weekly",
21    priority: 0.6,
22    alternates: {
23      languages: getLanguageAlternates(path),
24    },
25    ...options,
26  };
27}
28
29// Helper to create entries for all locales
30function createEntriesForAllLocales(
31  path: string,
32  lastModified?: Date,
33  options?: Partial<SitemapEntry>,
34): SitemapEntry[] {
35  return routing.locales.map((locale) =>
36    createEntry(path, locale, lastModified, options),
37  );
38}
39

여기서의 핵심은, 유틸리티에서와 마찬가지로 new URL api 로 주소를 표준화한 부분과, 언어별로 생성된 모든 주소에 대해 alternates 등록을 자동화한 부분이다. 모든 언어별 주소를 sitemap에 함께 등록함으로써, 구글 반응형 언어 크롤링 정책을 만족할 수 있는 양방향성(bidirectional)을 확보하는 점은 놓치지 말아야 한다.

app/sitemap.ts
1// ...
2export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
3  // ...
4
5  // Blog posts
6  const posts = await cache.post.select.all(); // Uses default locale
7  const blogEntries = posts.flatMap((post) =>
8    createEntriesForAllLocales(`/blog/${post.id}`, post.updated_at),
9  );
10
11  // ...
12
13  return [
14    // ...
15    ...blogEntries,
16    // ...
17  ]
18}
19

이후, 데이터베이스의 수정 일시를 lastModified 날짜에 맞게 넘겨주면, 동적으로 생성된 페이지들에 대한 작업의 자동화까지 완료된다.

Trade Offs

지금까지 살펴본 Locale Prefix 기반 i18n 은, 처음 비교했던 Global State 나 Cookie 방식에 비해 언어 전환 시에 컴포넌트를 전부 다시 렌더링한다는 단점이 발생한다. 이는 Next.js 의 구조 상, [locale] 폴더 아래로 모든 페이지들이 위치하게 되기 때문이다.

다만 언어 전환 자체가 자주 발생할 만한 기능은 아니고, 지금의 목표는 i18n 시스템의 도입과 전반적인 설계에 있다. 해당 부분에 대한 성능 개선이나 사용자 경험 최적화는 추후 다뤄볼 예정이다.

Closing

i18n은 개인 프로젝트에서는 “이렇게까지 해야 할까?” 라는 고민이 먼저 드는 주제다. 단순히 UI 에 표시할 메시지 수준에서 끝나지 않고 생각보다 고려할 것도 많으며 유의할 점도 많은 부분이다. 그래서 도입을 계속 미뤄왔는데, 이번 기회를 빌미로 i18n의 영향 받는 부분들 중 확인 차 간단하게 설정했던 부분들을 모두 정리했다.

이번 작업의 핵심은 한 가지 원칙을 끝까지 지킨 것이다: routing.ts를 single source of truth로 삼아, 프로젝트 전반의 모든 언어 경계(링크·메시지·DB·메타데이터·사이트맵)를 일관되게 다루자는 것이다. 덕분에 설계 및 운영 관점에서의 복잡도를 많이 줄일 수 있었고, 언어 관련 규칙을 한 곳에서 바꿔도 시스템 전반에 효과가 파급되도록 만들었다.

i18n은 단순히 문자열을 번역하는 일을 넘어, URL 설계·데이터 모델·메타데이터·운영 파이프라인까지 건드리는 전역적인 설계 문제다. routing.ts를 기준으로 single source of truth 로 사용한 전략은 그 복잡함을 통제 가능한 수준으로 낮춰주었고, 새 언어를 추가할 때의 불확실성을 크게 줄여주었다.

이번 작업은 ‘완벽한 최적화’보다도 ‘일관성 있는 설계’와 ‘운영에서의 예측 가능성’을 우선시한 선택이었다.

Return to List
DateFebruary 04, 2026
Tags
NextJSSEOSSRi18ninternationalizationmetadatasystem designtranslation
Share article
FacebookFacebook
XX
LinkedInLinkedIn
포스트 테이블 스키마 다이어그램
i18n-ally VSCode Extension