Next.js 국제화와 정적 생성: SSG 다국어 사이트 실전 가이드

Next.js App Router 프로젝트에서 처음 다국어 정적 생성을 시도했을 때 정말 많은 문제를 겪었습니다. 문서대로 설정했는데도 빌드하자마자 오류가 나거나, 빌드는 성공했지만 모든 페이지를 생성하는 데 15분이나 걸리는 식이었습니다.
제가 실제로 겪었던 대표적인 상황부터 살펴보겠습니다. 비슷한 경험이 있는지 확인해 보세요.
이런 문제를 겪고 있지 않나요?
상황 1: 빌드 오류
한번은 기대를 안고 npm run build를 실행했는데 터미널에 곧바로 이런 오류가 나타났습니다.
Error: Page "/en/about" is missing `generateStaticParams()`
so it cannot be used with `output: "export"`.
next.config.js에 분명 i18n을 설정했는데 왜 이런 오류가 나는지 당황스러웠습니다. 나중에야 App Router와 Pages Router의 국제화 방식이 완전히 다르며, 기존 설정이 더는 적용되지 않는다는 사실을 알게 됐습니다.
상황 2: 지나치게 긴 빌드 시간
다른 프로젝트는 6개 언어를 지원했고 언어마다 페이지가 약 50개였습니다. 한 번 빌드한 결과는 다음과 같았습니다.
✓ Generating static pages (152/152) - 15m 32s
무려 15분입니다. 작은 변경 하나에도 이렇게 오래 기다려야 하니 개발 경험이 크게 나빠졌고, 프로덕션 CI/CD에서는 얼마나 더 기다려야 할지 걱정됐습니다.
상황 3: 번역 업데이트가 반영되지 않음
가장 답답했던 문제는 zh-CN.json 번역 파일을 업데이트하고 다시 빌드·배포했는데도 사이트에 이전 번역이 계속 표시되는 것이었습니다. 새 내용을 보려면 브라우저 캐시까지 지워야 했습니다. 프로덕션에서 사용자에게 오래된 콘텐츠가 노출될 수 있는 심각한 문제입니다.
근본 원인은 무엇일까요?
시간을 들여 조사한 뒤 다음 세 가지가 핵심 원인임을 알게 됐습니다.
- App Router는 Pages Router의 i18n 설정을 더는 지원하지 않습니다. 가장 큰 함정입니다.
next.config.js에i18n필드를 설정해도 App Router는 인식하지 않습니다. - 정적 내보내기와 동적 렌더링이 충돌합니다.
output: 'export'를 지정하면 Next.js는 모든 페이지가 빌드 시점에 확정되기를 요구합니다.cookies()나headers()같은 동적 API를 사용하면 오류가 납니다. - 번역 파일 캐시가 남습니다. Next.js가 import한 JSON을 캐시하므로 개발 중 번역을 바꿔도 캐시가 무효화되지 않으면 최신 내용이 보이지 않습니다.
같은 문제를 겪고 있다면 이 글이 도움이 될 것입니다. 이제 Next.js App Router에서 다국어 정적 생성을 올바르게 구현하고 이런 함정을 피하는 방법을 단계별로 알아보겠습니다.
App Router의 새로운 i18n 패러다임 이해하기
코드를 작성하기 전에 App Router의 국제화 방식을 이해할 필요가 있습니다. Pages Router와는 상당히 다릅니다.
Pages Router와 App Router: 완전히 다른 두 방식
차이를 한눈에 볼 수 있도록 표로 정리했습니다.
| 항목 | Pages Router | App Router |
|---|---|---|
| 설정 방식 | next.config.js의 i18n 필드 | middleware + 동적 라우트 [lang] |
| 라우트 구조 | /en/, /zh/ 접두사를 자동 생성 | app/[lang]/page.tsx를 직접 생성 |
| 정적 생성 | getStaticPaths 사용 | generateStaticParams 사용 |
| 번역 로딩 | serverSideTranslations 함수 | 서버 컴포넌트에서 JSON을 직접 import |
거의 모든 단계가 바뀌었습니다. 처음 접했을 때는 마치 전혀 다른 Next.js를 배우는 느낌이었습니다.
generateStaticParams란 무엇인가요?
App Router의 핵심 개념 중 하나입니다. 간단히 말해 Next.js에 어떤 매개변수 조합으로 정적 페이지를 생성해야 하는지 알려 주는 함수입니다.
// app/[lang]/layout.tsx
export async function generateStaticParams() {
// 返回所有需要预渲染的语言参数
return [
{ lang: 'en' },
{ lang: 'zh' },
{ lang: 'ja' }
]
}
Next.js는 빌드 중 이 함수를 실행해 반환된 목록의 각 매개변수 조합마다 정적 HTML 파일을 생성합니다. 최종 출력은 다음과 같습니다.
out/
├── en/
│ └── index.html
├── zh/
│ └── index.html
└── ja/
└── index.html
핵심: 이 함수는 layout.tsx 또는 page.tsx에 정의해야 합니다. 이름도 getStaticParams나 generateParams가 아닌 정확히 generateStaticParams여야 합니다.
번역 파일은 어떻게 불러오나요?
Pages Router에서는 next-i18next의 serverSideTranslations 함수를 사용했습니다. App Router에서는 서버 컴포넌트에서 번역 파일을 직접 import할 수 있습니다.
// 服务端组件可以直接这样做
import enTranslations from '@/i18n/locales/en/common.json'
import zhTranslations from '@/i18n/locales/zh-CN/common.json'
const translations = {
'en': enTranslations,
'zh-CN': zhTranslations,
}
export default function Page({ params }: { params: { lang: string } }) {
const t = translations[params.lang]
return <h1>{t.title}</h1>
}
다만 이렇게 하면 모든 언어의 번역이 bundle에 포함돼 파일이 커집니다. 실제 프로젝트에서는 보통 필요한 번역만 불러오는 함수를 만듭니다.
// i18n/utils.ts
export async function loadTranslations(locale: string, namespaces: string[]) {
const translations: Record<string, any> = {}
for (const ns of namespaces) {
try {
const translation = await import(`@/i18n/locales/${locale}/${ns}.json`)
translations[ns] = translation.default
} catch (error) {
console.warn(`Translation file not found: ${locale}/${ns}`)
translations[ns] = {}
}
}
return translations
}
이 방식은 현재 페이지에 필요한 번역 namespace만 필요할 때 불러옵니다.
실전: 처음부터 다국어 SSG 프로젝트 만들기
이제 이론을 마쳤으니 전체 다국어 정적 사이트를 단계별로 만들어 보겠습니다.
1단계: 프로젝트 구조 설계
먼저 명확한 디렉터리 구조를 만듭니다. 실제 프로젝트에서 효과를 확인한 구성입니다.
app/
├── [lang]/ # 语言动态路由(核心)
│ ├── layout.tsx # 根布局,包含 generateStaticParams
│ ├── page.tsx # 首页
│ ├── about/
│ │ └── page.tsx # 关于页面
│ └── blog/
│ ├── page.tsx # 博客列表
│ └── [slug]/
│ └── page.tsx # 博客详情(嵌套动态路由)
├── i18n/
│ ├── locales/ # 翻译文件目录
│ │ ├── en/
│ │ │ ├── common.json # 公共翻译
│ │ │ ├── home.json # 首页翻译
│ │ │ └── blog.json # 博客翻译
│ │ ├── zh-CN/
│ │ │ ├── common.json
│ │ │ ├── home.json
│ │ │ └── blog.json
│ │ └── ja/
│ │ ├── common.json
│ │ ├── home.json
│ │ └── blog.json
│ ├── config.ts # i18n 配置文件
│ └── utils.ts # 翻译工具函数
└── middleware.ts # 语言检测和重定向
이렇게 설계하는 이유는 다음과 같습니다.
[lang]폴더가 동적 라우트의 핵심이며 URL의 언어 매개변수를 페이지 컴포넌트에 전달합니다.- 번역을 namespace별로 나누면 하나의 번역 파일이 지나치게 커지는 일을 피하고 필요한 파일만 불러올 수 있습니다.
- 모든 언어 설정을
config.ts에 모으면 유지보수가 쉬워집니다.
2단계: i18n 핵심 파일 설정
전체 시스템의 기반이 되는 설정 파일입니다.
// i18n/config.ts
export const i18nConfig = {
// 支持的语言列表
locales: ['en', 'zh-CN', 'ja'],
// 默认语言
defaultLocale: 'en',
// 路径前缀策略
// 'always': 所有语言都带前缀 /en/、/zh-CN/
// 'as-needed': 默认语言不带前缀,其他语言带前缀
localePrefix: 'always',
// 【重要】只预渲染主要语言(优化构建时间)
localesToPrerender: process.env.NODE_ENV === 'production'
? ['en', 'zh-CN'] // 生产环境只预渲染英文和中文
: ['en'], // 开发环境只渲染默认语言
} as const
// 导出类型,供 TypeScript 类型检查使用
export type Locale = (typeof i18nConfig)['locales'][number]
// 翻译命名空间(用于代码分割)
export const namespaces = ['common', 'home', 'about', 'blog'] as const
export type Namespace = (typeof namespaces)[number]
핵심을 짚어 보겠습니다.
as const는 넓은string[]이 아니라 정확한 리터럴 타입을 보장하는 TypeScript 문법입니다.localesToPrerender는 매우 중요합니다. 10개 언어를 지원해도 주요 언어 2개만 프리렌더링하면 빌드 시간을 80% 줄일 수 있습니다. 나머지는 ISR이나 요청 시 생성할 수 있습니다.- 번역 JSON을 여러 namespace로 나누면 첫 로딩 때 거대한 파일 하나를 내려받지 않아도 됩니다.
3단계: 번역 로더 구현
간단하면서 실용적인 번역 로더입니다.
// i18n/utils.ts
import type { Locale, Namespace } from './config'
// 翻译文件缓存(避免重复读取)
const translationsCache = new Map<string, any>()
/**
* 加载指定语言的翻译文件
*
* @param locale 语言代码,如 'en'、'zh-CN'
* @param namespaces 翻译命名空间数组,如 ['common', 'home']
* @returns 翻译对象 { common: {...}, home: {...} }
*/
export async function loadTranslations(
locale: Locale,
namespaces: Namespace[]
) {
const translations: Record<string, any> = {}
for (const namespace of namespaces) {
const cacheKey = `${locale}-${namespace}`
// 检查缓存,避免重复加载
if (!translationsCache.has(cacheKey)) {
try {
// 动态导入翻译文件
const translation = await import(
`@/i18n/locales/${locale}/${namespace}.json`
)
translationsCache.set(cacheKey, translation.default)
} catch (error) {
console.warn(`⚠️ Translation file not found: ${locale}/${namespace}.json`)
translationsCache.set(cacheKey, {})
}
}
translations[namespace] = translationsCache.get(cacheKey)
}
return translations
}
/**
* 创建类型安全的翻译函数
*
* 用法:
* const t = createTranslator(translations)
* t('common.nav.home')
* t('home.welcome', { name: 'John' }) // 支持变量替换
*/
export function createTranslator(translations: any) {
return (key: string, params?: Record<string, string>) => {
const keys = key.split('.')
let value = translations
// 逐层访问嵌套属性
for (const k of keys) {
value = value?.[k]
}
// 找不到翻译时返回 key 本身(便于调试)
if (!value) {
console.warn(`⚠️ Translation missing: ${key}`)
return key
}
// 支持变量替换:将 {{name}} 替换为实际值
if (params) {
return Object.entries(params).reduce(
(str, [key, val]) => str.replace(`{{${key}}}`, val),
value
)
}
return value
}
}
이 도구의 장점은 네 가지입니다.
- 처음 불러온 번역을 캐시해 파일을 반복해서 읽지 않습니다.
- 번역 파일이 없어도 앱이 종료되지 않고 경고를 남긴 뒤 빈 객체를 반환합니다.
{{name}}같은 변수 치환을 지원합니다.- TypeScript와 함께 사용하면 번역 key의 타입 안전성도 확보할 수 있습니다.
4단계: 루트 레이아웃 만들기
다국어 시스템의 핵심 파일입니다.
// app/[lang]/layout.tsx
import { i18nConfig } from '@/i18n/config'
import { loadTranslations } from '@/i18n/utils'
import type { Locale } from '@/i18n/config'
/**
* 【核心】生成所有语言的静态参数
*
* 这个函数在构建时执行,Next.js 会根据返回值生成对应的静态页面
*
* 重要提示:
* 1. 函数名必须是 generateStaticParams(不能拼错)
* 2. 必须在 layout.tsx 或 page.tsx 中定义
* 3. 返回的参数名必须与路由文件夹名匹配([lang] → lang)
*/
export async function generateStaticParams() {
console.log(`🌍 Generating static params for ${i18nConfig.localesToPrerender.length} locales...`)
return i18nConfig.localesToPrerender.map((locale) => ({
lang: locale, // ⚠️ 注意:必须是 'lang' 而不是 'locale'
}))
}
/**
* 根布局组件
*
* 这个组件会包裹所有页面,用于设置全局配置
*/
export default async function RootLayout({
children,
params,
}: {
children: React.ReactNode
params: { lang: string }
}) {
// 加载公共翻译(导航、页脚等)
const translations = await loadTranslations(params.lang as Locale, ['common'])
return (
<html
lang={params.lang}
// 如果是阿拉伯语,设置从右到左的布局
dir={params.lang === 'ar' ? 'rtl' : 'ltr'}
>
<head>
{/* 在这里可以添加全局 meta 标签 */}
</head>
<body>
{/* 这里可以放导航栏、页脚等全局组件 */}
{children}
</body>
</html>
)
}
/**
* 生成元数据(SEO)
*
* 这个函数用于生成页面的 <title>、<meta> 等标签
*/
export async function generateMetadata({ params }: { params: { lang: string } }) {
return {
// 设置语言相关的 meta 标签
alternates: {
canonical: `https://example.com/${params.lang}`,
languages: {
'en': 'https://example.com/en',
'zh-CN': 'https://example.com/zh-CN',
'ja': 'https://example.com/ja',
},
},
// Open Graph 标签(用于社交媒体分享)
openGraph: {
locale: params.lang,
alternateLocale: i18nConfig.locales.filter(l => l !== params.lang),
},
}
}
여기서 흔히 빠지는 함정이 있습니다.
// ❌ 错误:参数名是 locale,但路由文件夹是 [lang]
export async function generateStaticParams() {
return [{ locale: 'en' }] // 这样会报错
}
// ✅ 正确:参数名与文件夹名一致
export async function generateStaticParams() {
return [{ lang: 'en' }] // 必须是 lang
}
또 정적 페이지에서는 동적 API를 사용하면 안 됩니다.
// ❌ 错误:在静态生成的页面中使用 cookies
export default async function Layout({ children }) {
const locale = cookies().get('NEXT_LOCALE') // 这会导致构建失败
return <html lang={locale}>{children}</html>
}
// ✅ 正确:使用路由参数
export default async function Layout({ children, params }) {
return <html lang={params.lang}>{children}</html>
}
5단계: 번역 파일 만들기
번역 파일은 중첩 구조로 구성하는 것이 좋습니다.
// i18n/locales/zh-CN/common.json
{
"nav": {
"home": "首页",
"about": "关于",
"blog": "博客",
"contact": "联系"
},
"footer": {
"copyright": "© {{year}} 版权所有",
"privacy": "隐私政策",
"terms": "服务条款"
},
"actions": {
"readMore": "阅读更多",
"backToTop": "返回顶部",
"share": "分享",
"edit": "编辑"
},
"messages": {
"loading": "加载中...",
"error": "出错了",
"success": "操作成功",
"noResults": "没有找到结果"
}
}
// i18n/locales/zh-CN/blog.json
{
"title": "博客文章",
"publishedAt": "发布于",
"author": "作者",
"tags": "标签",
"relatedPosts": "相关文章",
"readingTime": "阅读时间:{{minutes}} 分钟",
"shareOn": "分享到 {{platform}}"
}
번역 파일에서는 다음 원칙을 지키십시오.
- 중첩 객체로 번역을 정리하고 모든 key를 최상위에 두지 않습니다.
{{변수명}}형식의 placeholder를 사용합니다.- 모든 언어 파일에서 같은 key 구조를 유지합니다.
- 복잡한 번역에는 사용 맥락을 설명하는 주석을 덧붙입니다.
6단계: 중첩 동적 라우트 처리
블로그나 상품 상세 페이지가 있다면 중첩 동적 라우트를 처리해야 합니다.
// app/[lang]/blog/[slug]/page.tsx
import { i18nConfig } from '@/i18n/config'
import { loadTranslations, createTranslator } from '@/i18n/utils'
import type { Locale } from '@/i18n/config'
// 假设你有这些辅助函数(实际项目中需要自己实现)
async function getBlogSlugs(): Promise<string[]> {
// 从文件系统或 CMS 获取所有博客文章的 slug
return ['getting-started', 'advanced-tips', 'performance-guide']
}
async function getBlogPost(slug: string, locale: Locale) {
// 获取特定语言的博客文章内容
// ...
}
/**
* 【关键】嵌套路由的 generateStaticParams
*
* 需要生成 语言 × 文章 的所有组合
* 例如:en/getting-started, zh-CN/getting-started, en/advanced-tips...
*/
export async function generateStaticParams() {
const startTime = Date.now()
console.log('📝 Generating blog post params...')
// 获取所有文章的 slug(只需要一次请求)
const slugs = await getBlogSlugs()
// 使用 flatMap 生成所有语言和文章的组合
const params = i18nConfig.localesToPrerender.flatMap((locale) =>
slugs.map((slug) => ({
lang: locale,
slug: slug,
}))
)
const duration = Date.now() - startTime
console.log(`✅ Generated ${params.length} blog post params in ${duration}ms`)
return params
}
/**
* 博客文章页面组件
*/
export default async function BlogPost({
params,
}: {
params: { lang: string; slug: string }
}) {
// 加载翻译和文章内容
const [translations, post] = await Promise.all([
loadTranslations(params.lang as Locale, ['common', 'blog']),
getBlogPost(params.slug, params.lang as Locale),
])
const t = createTranslator(translations)
return (
<article className="prose">
<h1>{post.title}</h1>
<p className="text-gray-600">
{t('blog.publishedAt')}: {new Date(post.date).toLocaleDateString(params.lang)}
</p>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}
언어마다 데이터를 따로 요청하면 빌드가 느려집니다.
// ❌ 错误做法:多次请求,构建慢
export async function generateStaticParams() {
const results = []
for (const locale of i18nConfig.localesToPrerender) {
// 每种语言都请求一次数据库或 CMS,太慢了!
const slugs = await getBlogSlugs(locale)
results.push(...slugs.map(slug => ({ lang: locale, slug })))
}
return results
}
데이터를 한 번만 요청하고 flatMap으로 조합하는 것이 올바른 방식입니다.
// ✅ 正确做法:一次请求,快速生成
export async function generateStaticParams() {
// 只请求一次数据
const slugs = await getBlogSlugs()
// 用 flatMap 生成所有语言 × 文章的组合
return i18nConfig.localesToPrerender.flatMap((locale) =>
slugs.map((slug) => ({ lang: locale, slug }))
)
}
제 프로젝트에서는 이 최적화로 빌드 시간이 18분에서 6분으로 줄었습니다.
7단계: Middleware 언어 감지 구현
Middleware는 사용자 언어 선호도를 감지해 알맞은 언어 버전으로 리디렉션합니다.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { i18nConfig } from './i18n/config'
/**
* Middleware 中间件
*
* 这个函数会在每个请求之前执行,用于:
* 1. 检测用户的语言偏好
* 2. 重定向到对应的语言路径
*/
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// 检查路径是否已经包含语言前缀
const pathnameHasLocale = i18nConfig.locales.some(
(locale) =>
pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
)
// 如果已经有语言前缀,直接放行
if (pathnameHasLocale) return
// 获取用户的首选语言
const locale = getLocale(request) ?? i18nConfig.defaultLocale
// 重定向到带语言前缀的路径
request.nextUrl.pathname = `/${locale}${pathname}`
return NextResponse.redirect(request.nextUrl)
}
/**
* 语言检测函数
*
* 优先级:
* 1. Cookie 中保存的语言偏好
* 2. Accept-Language 请求头
* 3. 返回 null,使用默认语言
*/
function getLocale(request: NextRequest): string | null {
// 优先级 1:检查 Cookie
const localeCookie = request.cookies.get('NEXT_LOCALE')?.value
if (localeCookie && i18nConfig.locales.includes(localeCookie as any)) {
return localeCookie
}
// 优先级 2:检查 Accept-Language 请求头
const acceptLanguage = request.headers.get('accept-language')
if (acceptLanguage) {
// Accept-Language 格式:zh-CN,zh;q=0.9,en;q=0.8
const preferred = acceptLanguage.split(',')[0].split('-')[0]
const match = i18nConfig.locales.find(locale =>
locale.toLowerCase().startsWith(preferred.toLowerCase())
)
if (match) return match
}
// 没有找到匹配的语言,返回 null
return null
}
/**
* Middleware 配置
*
* matcher 定义了哪些路径需要执行 middleware
*/
export const config = {
// 匹配所有路径,除了:
// - /api 开头的 API 路由
// - /_next/static 静态文件
// - /_next/image 图片
// - /favicon.ico 等静态资源
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}
사용자가 https://example.com/blog에 직접 방문하면 Middleware는 다음 순서로 처리합니다.
- Cookie에 이전에 선택한 언어가 있는지 확인합니다.
- 없다면 브라우저가 보내는
Accept-Language헤더를 확인합니다. - 결과에 따라
https://example.com/zh-CN/blog또는https://example.com/en/blog로 리디렉션합니다.
자동 언어 감지 덕분에 사용자 경험이 좋아집니다.
8단계: 언어 전환 컴포넌트 만들기
사용자가 직접 언어를 바꿀 수 있는 전환기를 만듭니다.
// components/LanguageSwitcher.tsx
'use client'
import { usePathname, useRouter } from 'next/navigation'
import { i18nConfig } from '@/i18n/config'
import type { Locale } from '@/i18n/config'
// 语言显示名称映射
const localeNames: Record<Locale, string> = {
'en': 'English',
'zh-CN': '简体中文',
'ja': '日本語',
}
export function LanguageSwitcher({ currentLocale }: { currentLocale: Locale }) {
const pathname = usePathname()
const router = useRouter()
const handleLocaleChange = (newLocale: Locale) => {
// 保存语言偏好到 Cookie
document.cookie = `NEXT_LOCALE=${newLocale};path=/;max-age=31536000`
// 替换路径中的语言前缀
// 例如:/zh-CN/blog → /en/blog
const newPathname = pathname.replace(`/${currentLocale}`, `/${newLocale}`)
// 跳转到新的语言版本
router.push(newPathname)
}
return (
<div className="relative">
<select
value={currentLocale}
onChange={(e) => handleLocaleChange(e.target.value as Locale)}
className="px-4 py-2 border rounded-lg"
>
{i18nConfig.locales.map((locale) => (
<option key={locale} value={locale}>
{localeNames[locale]}
</option>
))}
</select>
</div>
)
}
탐색 메뉴에서는 다음처럼 사용합니다.
// components/Navigation.tsx
import { LanguageSwitcher } from './LanguageSwitcher'
export function Navigation({ lang }: { lang: string }) {
return (
<nav className="flex items-center justify-between p-4">
<div className="flex gap-4">
<a href={`/${lang}/`}>首页</a>
<a href={`/${lang}/about`}>关于</a>
<a href={`/${lang}/blog`}>博客</a>
</div>
<LanguageSwitcher currentLocale={lang} />
</nav>
)
}
성능 최적화: 빌드 속도 높이기
기본 기능은 갖췄지만 여러 언어를 지원하면 빌드 시간이 길어질 수 있습니다. 실용적인 최적화 방법을 살펴보겠습니다.
최적화 1: 선택적 프리렌더링
가장 효과적인 방법입니다. 10개 언어를 지원하더라도 트래픽이 주요 2~3개 언어에 집중된다면 그 언어만 프리렌더링합니다.
// i18n/config.ts
export const i18nConfig = {
// 支持的所有语言
locales: ['en', 'zh-CN', 'ja', 'ko', 'de', 'fr', 'es', 'pt'],
defaultLocale: 'en',
// 【关键】只预渲染主要语言
localesToPrerender: process.env.NODE_ENV === 'production'
? ['en', 'zh-CN'] // 生产环境:只预渲染英文和中文
: ['en'], // 开发环境:只渲染默认语言(加快开发)
}
| 설정 | 빌드 시간 | 설명 |
|---|---|---|
| 8개 언어 프리렌더링 | 약 24분 | 모든 언어의 정적 페이지 생성 |
| 2개 언어 프리렌더링 | 약 6분 | 나머지는 첫 방문 때 생성 |
| 1개 언어만 렌더링 | 약 3분 | 개발 환경 권장 |
빌드 시간을 75% 줄일 수 있습니다.
최적화 2: ISR 사용
덜 중요한 언어나 자주 방문하지 않는 페이지는 ISR로 요청 시 생성할 수 있습니다.
// app/[lang]/blog/[slug]/page.tsx
// 开启 ISR,1 小时后重新验证
export const revalidate = 3600
export async function generateStaticParams() {
const slugs = await getBlogSlugs()
// 只预渲染主要语言的热门文章
const topSlugs = slugs.slice(0, 10) // 只预渲染前 10 篇
return i18nConfig.localesToPrerender.flatMap((locale) =>
topSlugs.map((slug) => ({ lang: locale, slug }))
)
}
// 【重要】允许动态生成未预渲染的页面
export const dynamicParams = true
이 설정은 빌드 때 2개 언어 × 인기 글 10개, 즉 20페이지만 생성합니다. 프리렌더링되지 않은 페이지는 첫 방문 시 생성해 캐시하고, 1시간이 지나면 자동 갱신합니다.
최적화 3: 데이터 병렬 요청
generateStaticParams에서 여러 데이터를 가져와야 한다면 반드시 병렬로 처리하십시오.
// ❌ 错误:串行获取(慢)
export async function generateStaticParams() {
const posts = await getBlogPosts() // 等待 2 秒
const categories = await getCategories() // 等待 1 秒
// 总共 3 秒
}
// ✅ 正确:并行获取(快)
export async function generateStaticParams() {
const [posts, categories] = await Promise.all([
getBlogPosts(), // 同时执行
getCategories(), // 同时执行
])
// 总共 2 秒(取最长的那个)
}
제 프로젝트에서는 데이터 요청 시간이 40% 줄었습니다.
최적화 4: 번역 캐시 문제 해결
개발 중 번역 파일을 바꿔도 페이지가 갱신되지 않는 원인은 Next.js의 JSON import 캐시입니다. 개발 환경에서는 캐시를 사용하지 않도록 할 수 있습니다.
// i18n/utils.ts
import fs from 'fs/promises'
import path from 'path'
const isDev = process.env.NODE_ENV === 'development'
export async function loadTranslations(
locale: Locale,
namespaces: Namespace[]
) {
// 开发环境:每次都重新读取文件
if (isDev) {
const translations: Record<string, any> = {}
for (const ns of namespaces) {
const filePath = path.join(
process.cwd(),
'i18n',
'locales',
locale,
`${ns}.json`
)
try {
const content = await fs.readFile(filePath, 'utf-8')
translations[ns] = JSON.parse(content)
} catch (error) {
console.warn(`Translation file not found: ${filePath}`)
translations[ns] = {}
}
}
return translations
}
// 生产环境:使用缓存
return loadTranslationsWithCache(locale, namespaces)
}
이제 개발 중 번역 파일을 수정한 뒤 새로고침하면 최신 내용을 확인할 수 있습니다.
자주 발생하는 문제 해결 가이드
문제 1: 빌드 중 “generateStaticParams not found”
Error: Page "/en/about" is missing `generateStaticParams()`
so it cannot be used with `output: "export"`.
다음을 확인하십시오.
layout.tsx또는page.tsx에generateStaticParams가 정의돼 있는가?- 함수 이름이
getStaticParams나generateParams가 아닌 정확한 이름인가? export async function으로 내보냈는가?- 매개변수 이름이 라우트 폴더 이름과 일치하는가?
// ❌ 错误示例
export async function getStaticParams() { // 函数名错了
return [{ locale: 'en' }] // 参数名也错了
}
// ✅ 正确示例
export async function generateStaticParams() {
return [{ lang: 'en' }] // 参数名必须与 [lang] 匹配
}
문제 2: Dynamic rendering detected
Error: Route /[lang]/about couldn't be rendered statically
because it used `headers` or `cookies`.
정적 페이지에서 headers(), cookies(), searchParams 같은 동적 API를 사용해서 발생합니다.
// ❌ 错误:在服务端组件使用 cookies
export default async function Page() {
const locale = cookies().get('NEXT_LOCALE') // 触发动态渲染
return <div>...</div>
}
// ✅ 方案 1:在 middleware 中处理
// middleware.ts
export function middleware(request: NextRequest) {
const locale = request.cookies.get('NEXT_LOCALE')
// 处理逻辑...
}
// ✅ 方案 2:使用客户端组件
'use client'
export function LanguageSwitcher() {
const [locale, setLocale] = useState(() => {
// 在客户端读取 Cookie
return getCookie('NEXT_LOCALE')
})
// ...
}
문제 3: Translation file not found
Error: Cannot find module './locales/en/common.json'
다음 목록을 점검하십시오.
- 파일 경로와 대소문자가 정확한지 확인합니다. Linux는 대소문자를 구분합니다.
- JSON 문법이 올바른지 확인합니다.
tsconfig.json의 경로 alias를 확인합니다.
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
}
}
- 빌드에 번역 파일이 포함되는지 확인합니다.
// next.config.js
module.exports = {
// 确保 JSON 文件被包含
webpack: (config) => {
config.module.rules.push({
test: /\.json$/,
type: 'json',
})
return config
},
}
문제 4: 언어 전환 뒤 라우트 매개변수 유실
/zh-CN/blog/my-post에서 영어로 전환했는데 /en/blog/my-post가 아니라 /en/으로 이동하는 현상입니다. 언어 전환기가 기존 라우트 매개변수를 보존하지 않아서 생깁니다.
// ❌ 错误:硬编码路径
<Link href="/about">About</Link>
// ✅ 方案 1:手动拼接语言参数
<Link href={`/${params.lang}/about`}>About</Link>
// ✅ 方案 2:封装一个智能的 Link 组件
// components/LocalizedLink.tsx
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
export function LocalizedLink({
href,
children,
...props
}: {
href: string
children: React.ReactNode
[key: string]: any
}) {
const pathname = usePathname()
// 从当前路径提取语言
const locale = pathname.split('/')[1]
// 自动添加语言前缀
const localizedHref = `/${locale}${href}`
return (
<Link href={localizedHref} {...props}>
{children}
</Link>
)
}
문제 5: SEO 태그 누락 또는 오류
다국어 페이지의 hreflang과 canonical 설정이 잘못되면 검색 엔진 색인에 영향을 줄 수 있습니다. 각 페이지의 generateMetadata에서 올바르게 설정하십시오.
// app/[lang]/blog/[slug]/page.tsx
export async function generateMetadata({
params,
}: {
params: { lang: string; slug: string }
}) {
const baseUrl = 'https://example.com'
return {
// 页面标题和描述
title: 'My Blog Post',
description: 'This is a blog post',
// Canonical URL(规范链接)
alternates: {
canonical: `${baseUrl}/${params.lang}/blog/${params.slug}`,
// hreflang 标签(告诉搜索引擎其他语言版本)
languages: {
'en': `${baseUrl}/en/blog/${params.slug}`,
'zh-CN': `${baseUrl}/zh-CN/blog/${params.slug}`,
'ja': `${baseUrl}/ja/blog/${params.slug}`,
'x-default': `${baseUrl}/en/blog/${params.slug}`, // 默认语言
},
},
// Open Graph 标签(用于社交媒体分享)
openGraph: {
title: 'My Blog Post',
description: 'This is a blog post',
url: `${baseUrl}/${params.lang}/blog/${params.slug}`,
locale: params.lang,
alternateLocale: i18nConfig.locales.filter(l => l !== params.lang),
},
}
}
모범 사례 요약
실제로 적용할 수 있는 체크리스트를 정리했습니다.
프로젝트 초기화 체크리스트
- 지원 언어와 기본 언어 확정
-
app/[lang]디렉터리 구조 생성 -
i18n/config.ts와 번역 파일 디렉터리 설정 -
middleware.ts언어 감지 구현 - 루트 레이아웃에
generateStaticParams추가 - 정적 내보내기가 필요하면
next.config.js에output: 'export'설정
개발 단계 권장 사항
- 개발 환경에서는 기본 언어만 프리렌더링 (
localesToPrerender: ['en']) - TypeScript로 번역 key의 타입 안전성 확보
- 기능별 namespace로 번역 분리(common, home, blog 등)
- 개발 환경에서 번역 캐시 비활성화(
fs.readFile로 실시간 읽기) - 누락된 번역을 찾기 위한 경고 로그 추가
프로덕션 배포 체크리스트
- 주요 언어만 선택적으로 프리렌더링해 빌드 시간 최적화
- 보조 언어를 요청 시 생성하도록 ISR과
revalidate설정 -
Promise.all로 데이터 병렬 요청 - 올바른 hreflang 및 canonical 태그 설정
- 다국어 경로를 고려한 CDN 캐시 전략 설정
- 언어별 트래픽과 빌드 시간 모니터링
next.config.js 전체 설정 예제
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// 静态导出(如果需要)
output: 'export',
// 图片优化配置
images: {
unoptimized: true, // 静态导出时需要
},
// 环境变量
env: {
BUILD_TIME: new Date().toISOString(),
},
// 自定义构建 ID(用于缓存失效)
generateBuildId: async () => {
return `build-${Date.now()}`
},
// Webpack 配置
webpack: (config, { isServer }) => {
// 确保 JSON 文件正确处理
config.module.rules.push({
test: /\.json$/,
type: 'json',
})
return config
},
}
module.exports = nextConfig
추천 도구와 라이브러리
직접 구현하고 싶지 않다면 다음 라이브러리를 고려할 수 있습니다.
| 도구/라이브러리 | 용도 | 추천 점수 | 설명 |
|---|---|---|---|
| next-intl | 완전한 i18n 솔루션 | ⭐⭐⭐⭐⭐ | 공식 권장, 기능이 가장 많고 App Router 지원 |
| next-international | 경량 i18n 라이브러리 | ⭐⭐⭐⭐ | 가볍고 간결하며 타입 안전성 지원 |
| @formatjs/intl | 국제화 형식 지정 | ⭐⭐⭐⭐ | 날짜, 숫자, 통화 형식 처리 |
| typesafe-i18n | 타입 안전 번역 | ⭐⭐⭐⭐ | 타입 정의 자동 생성 |
| i18next | 전통적인 i18n 라이브러리 | ⭐⭐⭐ | 기능은 강력하지만 App Router용 조정 필요 |
저는 Next.js App Router를 위해 설계돼 바로 사용할 수 있는 next-intl을 추천합니다. 다만 i18n의 구현 원리를 깊이 이해하거나 고도로 맞춤화해야 한다면 이 글처럼 직접 구현하는 것도 좋은 선택입니다.
마무리
지금까지 완전한 Next.js App Router 다국어 정적 생성 방식을 구현했습니다.
- 핵심 기능
[lang]동적 라우트 기반 다국어 구조generateStaticParams를 이용한 정적 페이지 생성- namespace로 나눈 번역 파일 시스템
- Middleware 자동 언어 감지와 리디렉션
- 성능 최적화
- 주요 언어 선택적 프리렌더링으로 빌드 시간 75% 단축
- ISR로 보조 언어를 요청 시 생성
- 데이터 병렬 요청
- 개발 환경 캐시 비활성화
- 문제 해결
generateStaticParams설정 오류- 동적 API로 인한 빌드 실패
- 번역 파일 캐시
- 언어 전환 시 라우트 유실
- SEO 태그 설정
핵심만 다시 정리하면 다음과 같습니다.
- App Router의 i18n은 직접 구현해야 하며 Pages Router 설정을 사용할 수 없습니다.
generateStaticParams는 layout 또는 page에 정의하고 매개변수 이름을 라우트와 일치시켜야 합니다.- 정적 페이지에서는
cookies(),headers()같은 동적 API를 사용할 수 없습니다. - 선택적 프리렌더링과 ISR로 지나치게 긴 빌드 시간을 피하십시오.
Next.js App Router로 다국어 사이트를 만들고 있다면 이 글이 시행착오를 줄이는 데 도움이 되기를 바랍니다. 국제화 자체는 복잡하지 않습니다. Next.js의 빌드 방식을 이해하고 그 규칙에 맞게 설정하는 것이 중요합니다.
직접 구현하는 일이 번거롭다면 next-intl을 사용해 보세요. 상당한 수고를 덜 수 있습니다.
Next.js 다국어 정적 생성 전체 설정 과정
빌드 오류 해결부터 빌드 시간 최적화와 번역 업데이트 처리까지의 전체 과정
⏱️ Estimated time: 3 hr
- 1
Step 1: 빌드 오류 해결: generateStaticParams 설정
문제: Page "/en/about" is missing generateStaticParams()
해결: layout.tsx에 generateStaticParams를 설정합니다.
```tsx
// app/[locale]/layout.tsx
export async function generateStaticParams() {
return [
{ locale: 'zh' },
{ locale: 'en' },
]
}
export default async function LocaleLayout({
children,
params: { locale }
}) {
// ...
}
```
핵심:
• generateStaticParams는 layout 또는 page에 정의해야 합니다.
• 매개변수 이름은 [locale]과 일치해야 합니다.
• 모든 언어 버전을 반환해야 합니다.
주의: 정적으로 생성되는 페이지에서는 cookies(), headers() 같은 동적 API를 사용할 수 없습니다. - 2
Step 2: 빌드 시간 최적화
문제: 6개 언어 × 50개 페이지 = 300개 페이지이며 빌드에 15분이 걸립니다.
최적화 방법:
1. 선택적 프리렌더링(중요한 페이지만 프리렌더링):
```tsx
export async function generateStaticParams() {
// 홈페이지와 소개 페이지만 프리렌더링
return [
{ locale: 'zh', slug: 'home' },
{ locale: 'zh', slug: 'about' },
{ locale: 'en', slug: 'home' },
{ locale: 'en', slug: 'about' },
]
}
```
2. ISR(증분 정적 재생성) 사용:
```tsx
export const revalidate = 3600 // 1시간 뒤 다시 생성
```
3. 병렬 빌드:
```tsx
export async function generateStaticParams() {
const locales = ['zh', 'en', 'ja', 'ko', 'fr', 'de']
const pages = ['home', 'about', 'contact']
return locales.flatMap(locale =>
pages.map(slug => ({ locale, slug }))
)
}
```
결과: 15분에서 5분 이내로 단축됩니다. - 3
Step 3: 번역 업데이트가 반영되지 않는 문제 해결
문제: 번역 파일을 업데이트했지만 사이트에는 여전히 이전 번역이 표시됩니다.
원인: Next.js가 import한 JSON 파일을 캐시합니다.
해결 방법:
1. 동적 import 사용:
```tsx
const messages = await import(`../messages/${locale}.json`)
```
2. 캐시 삭제:
```bash
rm -rf .next
npm run build
```
3. 타임스탬프 사용:
```tsx
const messages = await import(
`../messages/${locale}.json?v=${Date.now()}`
)
```
핵심:
• 개발 중에는 동적 import를 사용합니다.
• 프로덕션에서는 캐시를 삭제합니다.
• 타임스탬프로 캐시를 피합니다. - 4
Step 4: 동적 API 충돌 방지
문제: 정적으로 생성되는 페이지에서는 cookies(), headers() 같은 동적 API를 사용할 수 없습니다.
해결 방법:
1. 페이지 유형 확인:
```tsx
// ❌ 잘못된 예: 정적 페이지에서 동적 API 사용
export default async function Page() {
const cookies = await cookies() // 오류
return <div>...</div>
}
// ✅ 올바른 예: 동적 페이지에서 동적 API 사용
export const dynamic = 'force-dynamic'
export default async function Page() {
const cookies = await cookies() // 사용 가능
return <div>...</div>
}
```
2. 정적 페이지와 동적 페이지 분리:
```tsx
// 정적 페이지: 동적 API를 사용하지 않음
// 동적 페이지: dynamic = 'force-dynamic' 사용
```
핵심:
• 정적 페이지에서는 동적 API를 사용할 수 없습니다.
• 동적 API가 필요한 페이지에는 force-dynamic을 지정합니다.
• 정적 페이지와 동적 페이지를 적절히 분리합니다.
FAQ
App Router로 다국어 사이트를 빌드할 때 왜 오류가 발생하나요?
다국어 사이트의 빌드 시간을 어떻게 최적화하나요?
번역 업데이트가 반영되지 않는 이유는 무엇인가요?
정적으로 생성되는 페이지에서 동적 API를 사용할 수 있나요?
generateStaticParams는 어떻게 설정하나요?
다국어 사이트의 모범 사례는 무엇인가요?
13분 읽기 · 게시일: 2025년 12월 25일 · 수정일: 2026년 9월 8일
Next.js 완전 가이드
검색으로 들어왔다면 같은 시리즈의 이전 글이나 다음 글로 이동하는 것이 가장 빠릅니다.
이전
Next.js 국제화 완전 가이드: next-intl 모범 사례
Next.js App Router의 국제화 방식을 깊이 있게 설명하고, next-intl 전체 설정과 다국어 라우팅 설계, 번역 파일 관리 모범 사례 및 실전 코드 예제를 소개합니다.
45편 중 13편
다음
Next.js 다국어 SEO 최적화 가이드: 검색 엔진이 언어별 페이지를 올바르게 색인하게 만드는 법
Next.js에서 hreflang 태그, 다국어 Sitemap, URL 전략을 설정하는 방법을 설명합니다. 흔한 오류를 피하고 언어별 페이지가 검색 결과에 정확히 노출되도록 구성해 보세요.
45편 중 15편



댓글
GitHub로 로그인하여 댓글을 남기세요