[개발]
Next.js 블로그에 i18n 붙이기 — next-intl v4 실전 가이드
이 블로그는 원래 한국어 전용 블로그를 현지화 엔지니어로서 당연히 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.json과 messages/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"
next/link vs @/i18n/navigation의 Link#
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/navigation의Link- 포스트 콘텐츠는
slug.en.mdx파일 전략으로
작은 블로그에서 i18n을 직접 붙여보면서 "번역"이라는 게 UI 텍스트, 태그, 본문 콘텐츠 각각 다른 전략이 필요하다는 걸 알게 됐다. 하나의 솔루션으로 다 해결하려 하면 오히려 복잡해진다.
