j-devlog
EN
~/nav

// categories

// tags

[개발]

Next.js 블로그에 i18n 붙이기 — next-intl v4 실전 가이드

·10분 읽기·

이 블로그는 원래 한국어 전용 블로그를 현지화 엔지니어로서 당연히 i18n을 추가해야 되지 않을까?라는 생각으로 작업을 시작했다. 기존 URL을 건드리지 않으면서 /en/ 버전을 추가하는 작업을 시작했다.

Next.js App Router에서 다국어를 구현하는 방법은 여러 가지지만, 생태계 표준으로 자리잡은 next-intl을 선택했다. 이 글은 그 과정에서 설계한 구조와 마주친 에러들을 기록한 것이다.


설계 원칙 — 기존 URL을 건드리지 않는다#

i18n을 추가할 때 가장 먼저 부딪히는 결정이 URL 구조다.

방식한국어영어
prefix-always/ko/blog/foo/en/blog/foo
as-needed/blog/foo/en/blog/foo

prefix-always는 구현이 단순하지만, 기존에 구글에 색인된 /blog/foo URL이 전부 /ko/blog/foo로 바뀐다. SEO 관점에서 치명적이다.

as-needed는 기본 로케일(한국어)은 prefix 없이 그대로 두고, 영어만 /en/을 붙인다. next-intl v4에서 이 옵션을 직접 지원한다.

// i18n/routing.ts
import { defineRouting } from 'next-intl/routing'

export const routing = defineRouting({
  locales: ['ko', 'en'] as const,
  defaultLocale: 'ko',
  localePrefix: 'as-needed',
})

next-intl v4 기본 설정#

라우팅 설정 외에 두 파일이 더 필요하다.

i18n/request.ts — 요청마다 어떤 언어 메시지를 로드할지 결정한다.

import { getRequestConfig } from 'next-intl/server'
import { routing } from './routing'

export default getRequestConfig(async ({ requestLocale }) => {
  let locale = await requestLocale
  if (!locale || !(routing.locales as readonly string[]).includes(locale)) {
    locale = routing.defaultLocale
  }
  return {
    locale,
    messages: (await import(`../messages/${locale}.json`)).default,
  }
})

i18n/navigation.ts — next-intl의 Link, useRouter 등을 locale-aware 버전으로 교체한다. 기존 next/link를 이걸로 바꿔야 /en/ prefix가 자동으로 붙는다.

import { createNavigation } from 'next-intl/navigation'
import { routing } from './routing'

export const { Link, redirect, usePathname, useRouter, getPathname } =
  createNavigation(routing)

디렉토리 구조 변경#

next-intl v4에서 App Router와 통합하려면 app/ 하위 페이지들을 app/[locale]/로 이동해야 한다. 루트 app/layout.tsx는 passthrough 역할만 한다.

app/
  layout.tsx          ← passthrough only (children as never)
  [locale]/
    layout.tsx        ← setRequestLocale(locale) 여기서 한 번
    page.tsx
    blog/[slug]/page.tsx
    category/[name]/page.tsx
    tag/[name]/page.tsx
    tags/page.tsx
    about/page.tsx

루트 레이아웃의 as never 캐스트는 TypeScript를 만족시키기 위한 것이다.

// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return children as never
}

setRequestLocale — 반드시 넣어야 하는 한 줄#

[locale]/layout.tsx에서 이 한 줄이 없으면 하위 서버 컴포넌트에서 getTranslations()를 호출할 때 오류가 난다.

// app/[locale]/layout.tsx
import { setRequestLocale } from 'next-intl/server'

export default async function LocaleLayout({ children, params }) {
  const { locale } = await params
  setRequestLocale(locale)   // ← 이게 없으면 하위 전체가 깨진다
  // ...
}

next-intl은 요청 컨텍스트에서 locale을 읽는데, 이 함수가 그 컨텍스트를 초기화하는 역할을 한다. 한 번만 호출하면 하위 모든 서버 컴포넌트에서 getTranslations()를 locale prop 없이 바로 쓸 수 있다.


번역 방식 3가지#

이 블로그에서 번역이 필요한 텍스트는 크게 세 종류였고, 각각 다른 방식으로 처리했다.

1. UI 텍스트 — messages/*.json#

네비게이션, 버튼, 레이블 같은 UI 문자열은 messages/ko.jsonmessages/en.json으로 관리한다.

// messages/en.json (일부)
{
  "nav": { "home": "/home", "tags": "/tags", "about": "/about" },
  "post": { "readTime": "{minutes} min read" },
  "blog": {
    "prevPost": "Prev",
    "nextPost": "Next",
    "relatedPosts": "Related Posts"
  }
}

서버 컴포넌트에서는 getTranslations, 클라이언트 컴포넌트에서는 useTranslations를 쓴다.

// 서버 컴포넌트
const t = await getTranslations('post')
return <span>{t('readTime', { minutes: post.readTime })}</span>

// 클라이언트 컴포넌트
const t = useTranslations('blog')
return <button>{t('prevPost')}</button>

2. 태그 — translateTag() 함수#

태그는 URL 슬러그이기도 해서 messages/ 방식으로 관리하기 어렵다. 태그 URL은 항상 한국어 slug(/tag/인증)로 유지하되, 표시 텍스트만 번역한다.

// lib/tagTranslations.ts
const TAG_MAP: Record<string, string> = {
  '머신러닝': 'Machine Learning',
  '미적분': 'Calculus',
  '선형대수': 'Linear Algebra',
  '인증': 'Authentication',
  // ... 80여 개
}

export function translateTag(tag: string, locale: string): string {
  if (locale !== 'en') return tag
  return TAG_MAP[tag] ?? tag  // 번역 없으면 원문 그대로
}

태그가 표시되는 모든 곳 — PostCard, 블로그 상세 헤더, 태그 목록 페이지 — 에서 이 함수를 통해 렌더링한다.

실무에서는?
처음에 블로그 상세 페이지에서 태그를 {tag} 그대로 렌더링해서 EN 로케일에서도 한국어가 나오는 버그가 있었다. translateTag() 호출이 누락된 곳을 하나씩 확인해야 한다.

3. 포스트 콘텐츠 — slug.en.mdx#

UI 텍스트와 달리 포스트 본문은 분량이 방대하다. messages/ 파일에 담을 수 없고, MDX 파일 안에 조건부 렌더링을 넣는 방식도 지저분하다.

선택한 방법은 파일 병렬 전략이다. 영문 버전이 필요한 포스트는 같은 디렉토리에 .en.mdx 파일을 추가한다.

content/posts/
  oauth-oidc.mdx        ← 한국어 원본
  oauth-oidc.en.mdx     ← 영어 번역본
  calculus-1.mdx
  calculus-1.en.mdx

lib/mdx.ts에서 이를 처리하는 로직은 간단하다.

function resolvePostPath(slug: string, locale?: string): string {
  if (locale === 'en') {
    const enPath = path.join(postsDirectory, `${slug}.en.mdx`)
    if (fs.existsSync(enPath)) return enPath  // EN 파일 있으면 사용
  }
  return path.join(postsDirectory, `${slug}.mdx`)  // 없으면 한국어 fallback
}

export function getAllPosts(locale?: string): PostMeta[] {
  return fs.readdirSync(postsDirectory)
    .filter((name) => name.endsWith('.mdx') && !name.endsWith('.en.mdx'))
    // ↑ .en.mdx는 base 목록에서 제외 — 중복 노출 방지
    .map((fileName) => {
      const slug = fileName.replace(/\.mdx$/, '')
      const localizedPath = resolvePostPath(slug, locale)
      const { data, content } = matter(fs.readFileSync(localizedPath, 'utf8'))

      // 썸네일은 항상 한국어 base 파일에서 추출
      // (EN 파일에 이미지 없어도 썸네일이 깨지지 않는다)
      const basePath = path.join(postsDirectory, fileName)
      const baseContent = localizedPath !== basePath
        ? matter(fs.readFileSync(basePath, 'utf8')).content
        : content
      const thumbnail = extractFirstImage(baseContent)

      return { slug, title: data.title, .../* 나머지 frontmatter */ }
    })
}

.en.mdx 파일에서는 title, excerpt만 번역하고, tags, date, category, readTime, thumbnail은 그대로 둔다. 썸네일 이미지 경로는 어차피 base 파일에서 읽기 때문에 EN 파일에 이미지가 없어도 문제없다.


트러블슈팅 모음#

generateStaticParams에서 locale 없이 호출해야 한다#

// ❌ 잘못된 예 — locale별로 중복 slug가 생길 수 있다
export async function generateStaticParams() {
  return routing.locales.flatMap((locale) =>
    getAllPosts(locale).map((post) => ({ locale, slug: post.slug }))
  )
}

// ✅ 올바른 예 — base slugs를 locale 없이 가져오고, locale만 조합
export async function generateStaticParams() {
  return routing.locales.flatMap((locale) =>
    getAllPosts().map((post) => ({ locale, slug: post.slug }))
  )
}

getAllPosts(locale)을 쓰면 EN locale에서는 EN frontmatter 기준으로 읽히는데, slug 자체는 동일하므로 base로 읽는 것이 맞다.

zsh에서 대괄호 경로 git add#

app/[locale]/page.tsx 같은 경로를 그냥 git add하면 zsh glob으로 해석되어 오류가 난다.

# ❌
git add app/[locale]/blog/[slug]/page.tsx

# ✅
git add "app/[locale]/blog/[slug]/page.tsx"

locale-aware 링크가 필요한 컴포넌트에서 next/link를 그대로 쓰면 /en/ prefix가 붙지 않는다.

// ❌ — EN 로케일에서 /en/ prefix 누락
import Link from 'next/link'

// ✅ — locale 자동 처리
import { Link } from '@/i18n/navigation'

단, MDX 내부의 <a> 태그나 외부 링크는 next/link 사용이 맞다.


결과#

이 구조로 한국어 포스트 38개에 영문 번역본을 추가했다. /en/blog/oauth-oidc 같은 URL로 영문 버전에 접근할 수 있고, 영문 파일이 없는 포스트는 한국어 원본으로 fallback된다.

한국어영어
//en
포스트/blog/slug/en/blog/slug
태그/tag/인증/en/tag/인증
태그 표시#인증#Authentication

태그 URL은 한국어 slug 그대로지만, 표시 텍스트만 번역되는 구조다. SEO URL 일관성과 번역 사이의 타협점이다.


마무리#

next-intl v4의 localePrefix: 'as-needed'는 기존 URL을 건드리지 않고 다국어를 추가하는 가장 깔끔한 방법이다. 핵심은 세 가지다.

  • setRequestLocale[locale]/layout.tsx에서 반드시
  • Link 교체 — next/link@/i18n/navigationLink
  • 포스트 콘텐츠는 slug.en.mdx 파일 전략으로

작은 블로그에서 i18n을 직접 붙여보면서 "번역"이라는 게 UI 텍스트, 태그, 본문 콘텐츠 각각 다른 전략이 필요하다는 걸 알게 됐다. 하나의 솔루션으로 다 해결하려 하면 오히려 복잡해진다.

// 관련 포스트