목차
App Router는 Next.js에서 데이터 페칭을 바라보는 방식을 바꿨습니다. i18n을 바라보는 방식도 마찬가지입니다. 현재 나와 있는 대부분의 가이드는 아직도 번역을 클라이언트 측 관심사로 다루고 있습니다—런타임에 로드되는 JSON 파일 번들로 말입니다. Pages Router 시대에는 그것이 맞는 방식이었습니다. 2026년, React Server Components가 기본값이 된 지금은 더 나은 방법을 사용할 수 있습니다.
이 포스트에서는 RSC 네이티브 i18n을 위해 정착한 패턴들을 살펴봅니다: 미들웨어에서 locale을 감지하는 방법, 클라이언트 오버헤드 없이 server components에서 번역을 페칭하는 방법, client islands를 깔끔하게 hydrate하는 방법, 그리고 CDN 우선 전달이 방정식을 어떻게 바꾸는지에 대해 설명합니다.
RSC가 i18n 기본 원칙을 바꾸는 이유
Pages Router에서는 getServerSideProps 또는 getStaticProps가 서버 측에서 번역을 페칭하고 페이지 번들에 직렬화했습니다. 클라이언트는 사용자별로 변경되지 않는 번역 딕셔너리를 포함한 모든 것을 re-hydrate했습니다.
Server Components는 그 패턴을 제거합니다. RSC 출력에는 hydration 단계가 없습니다—HTML이 최종본입니다. 즉, server component에서 번역을 페칭하고 번역된 문자열을 HTML로 직접 렌더링하며, 번역 관련 JavaScript를 클라이언트에 전혀 전달하지 않을 수 있습니다. 딕셔너리 번들도, 런타임 조회도, hydration 불일치도 없습니다.
주의할 점: 번역이 필요한 client components(인터랙티브 폼, 동적 UI)는 여전히 클라이언트 측 접근이 필요합니다. 두 가지 관심사를 명시적으로 분리하면 패턴이 명확해집니다.
미들웨어: 요청이 앱에 도달하기 전 Locale 감지
Locale 라우팅은 미들웨어에서 처리합니다. 컴포넌트가 렌더링되기 전 엣지에서 실행되어야 하기 때문입니다.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
const SUPPORTED_LOCALES = ['en', 'de', 'fr', 'es'] as const
type Locale = typeof SUPPORTED_LOCALES[number]
const DEFAULT_LOCALE: Locale = 'en'
function detectLocale(request: NextRequest): Locale {
// 1. URL 경로 접두사 확인
const pathname = request.nextUrl.pathname
const pathLocale = pathname.split('/')[1] as Locale
if (SUPPORTED_LOCALES.includes(pathLocale)) {
return pathLocale
}
// 2. Accept-Language 헤더 확인
const acceptLanguage = request.headers.get('accept-language')
if (acceptLanguage) {
const preferred = acceptLanguage
.split(',')
.map(lang => lang.split(';')[0].trim().slice(0, 2) as Locale)
.find(lang => SUPPORTED_LOCALES.includes(lang))
if (preferred) return preferred
}
return DEFAULT_LOCALE
}
export function middleware(request: NextRequest) {
const locale = detectLocale(request)
const pathname = request.nextUrl.pathname
// 루트를 locale 접두사가 붙은 경로로 리다이렉트
if (!SUPPORTED_LOCALES.some(l => pathname.startsWith(`/${l}`))) {
return NextResponse.redirect(
new URL(`/${locale}${pathname}`, request.url)
)
}
// server components를 위해 locale을 헤더에 주입
const response = NextResponse.next()
response.headers.set('x-locale', locale)
return response
}
export const config = {
matcher: ['/((?!api|_next|_static|favicon.ico).*)'],
}
x-locale 헤더는 미들웨어와 server components 사이의 다리 역할을 합니다. 모든 요청마다 설정하므로 어떤 server component든 prop drilling 없이 읽을 수 있습니다.
App Router 구조: Layout Segment로서의 Locale
App Router의 일반적인 접근 방식은 locale별 라우트를 [locale] 세그먼트 아래에 묶는 것입니다:
app/
[locale]/
layout.tsx ← locale 컨텍스트 설정, 기본 번역 페칭
page.tsx ← 홈 페이지
blog/
[slug]/
page.tsx
dashboard/
page.tsx
api/
...
[locale]/layout.tsx는 전체 서브트리를 위한 번역을 부트스트랩하는 곳입니다:
// app/[locale]/layout.tsx
import { headers } from 'next/headers'
import { notFound } from 'next/navigation'
import { TranslationProvider } from '@/components/translation-provider'
import { fetchTranslations } from '@/lib/translations'
const SUPPORTED_LOCALES = ['en', 'de', 'fr', 'es']
interface LayoutProps {
children: React.ReactNode
params: { locale: string }
}
export default async function LocaleLayout({ children, params }: LayoutProps) {
const { locale } = await params
if (!SUPPORTED_LOCALES.includes(locale)) {
notFound()
}
// 레이아웃 레벨 문자열을 위한 기본 번역 페칭
const baseTranslations = await fetchTranslations(locale, 'common')
return (
<html lang={locale}>
<body>
<TranslationProvider locale={locale} messages={baseTranslations}>
{children}
</TranslationProvider>
</body>
</html>
)
}
Server Components: 엣지에서 번역 페칭
핵심 변화는 다음과 같습니다: 앱에 locale 파일을 번들로 포함하는 대신, 요청 시 CDN에서 번역을 페칭합니다. 이 함수는 다른 비동기 데이터 페칭과 동일하게 보입니다:
// lib/translations.ts
import { cache } from 'react'
type Messages = Record<string, string>
// React cache()는 단일 렌더 패스 내에서 페칭을 중복 제거합니다
export const fetchTranslations = cache(async (
locale: string,
namespace: string
): Promise<Messages> => {
const url = `https://cdn.better-i18n.com/v1/${locale}/${namespace}.json`
const res = await fetch(url, {
next: {
revalidate: 3600, // CDN 전달, 시간별 revalidate
tags: [`translations-${locale}-${namespace}`],
},
})
if (!res.ok) {
// 페이지를 깨뜨리는 대신 영어로 폴백
if (locale !== 'en') {
return fetchTranslations('en', namespace)
}
throw new Error(`Failed to fetch translations: ${res.status}`)
}
return res.json()
})
React의 cache() 래퍼는 같은 페이지에서 동일한 namespace를 요청하는 여러 server components가 렌더 패스당 하나의 네트워크 요청만 트리거하도록 합니다. Next.js의 fetch 캐싱과 결합하면 CDN 응답은 일반적으로 엣지 캐시에서 제공됩니다—번역 조회에 밀리초 미만의 지연 시간이 걸립니다.
이 패턴을 사용하는 server component:
// app/[locale]/blog/[slug]/page.tsx
import { fetchTranslations } from '@/lib/translations'
import { getPost } from '@/lib/posts'
interface PageProps {
params: { locale: string; slug: string }
}
export default async function BlogPost({ params }: PageProps) {
const { locale, slug } = await params
// 두 페칭이 병렬로 실행됩니다
const [t, post] = await Promise.all([
fetchTranslations(locale, 'blog'),
getPost(slug),
])
if (!post) notFound()
return (
<article>
<h1>{post.title}</h1>
<p className="meta">
{t['blog.readTime'].replace('{n}', String(post.readTimeMinutes))}
</p>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
<footer>
<a href={`/${locale}/blog`}>{t['blog.backToList']}</a>
</footer>
</article>
)
}
클라이언트 JavaScript가 없습니다. hydration도 없습니다. 번역 키 조회는 서버에서 이루어지고 결과는 직렬화된 HTML입니다.
타입 안전 번역 키
문자열 기반 키 조회(t['blog.readTime'])는 키가 변경되면 조용히 실패합니다. 기본 locale의 메시지 파일에서 타입을 생성합니다:
// types/translations.ts (생성된 파일, 수동 편집 금지)
// 생성 소스: en/blog.json
export type BlogNamespaceKey =
| 'blog.readTime'
| 'blog.backToList'
| 'blog.publishedOn'
| 'blog.updatedOn'
| 'blog.authorBy'
| 'blog.sharePost'
| 'blog.relatedPosts'
// 타입이 지정된 접근자
export function t(messages: Record<string, string>, key: BlogNamespaceKey): string {
const value = messages[key]
if (value === undefined) {
// 개발 환경에서는 누락된 키를 표시
if (process.env.NODE_ENV === 'development') {
console.warn(`Missing translation key: ${key}`)
}
return key
}
return value
}
번역 파일이 변경될 때마다 CI 파이프라인의 일부로 생성 단계가 실행됩니다. 그러면 TypeScript가 누락되거나 이름이 변경된 키를 프로덕션 런타임이 아닌 컴파일 시간에 잡아냅니다.
이것은 Better i18n 같은 플랫폼이 진정한 레버리지를 제공하는 영역 중 하나입니다—타입 생성이 자동화되고, 대시보드를 통해 새 키를 푸시하는 즉시 에디터에서 타입 출력이 업데이트됩니다.
Client Component Hydration: Translation Islands
모든 것을 server component로 만들 수는 없습니다. 폼, 모달, 인터랙티브 UI는 동적 문자열—에러 메시지, 유효성 검사 피드백, 비동기 상태 레이블—에 대해 클라이언트 측 번역 접근이 필요합니다.
패턴은 관련 메시지 서브셋을 직렬화하는 컨텍스트 provider입니다:
// components/translation-provider.tsx
'use client'
import { createContext, useContext } from 'react'
type Messages = Record<string, string>
const TranslationContext = createContext<{
messages: Messages
locale: string
} | null>(null)
export function TranslationProvider({
children,
messages,
locale,
}: {
children: React.ReactNode
messages: Messages
locale: string
}) {
return (
<TranslationContext.Provider value={{ messages, locale }}>
{children}
</TranslationContext.Provider>
)
}
export function useTranslations(namespace?: string) {
const ctx = useContext(TranslationContext)
if (!ctx) throw new Error('useTranslations must be used inside TranslationProvider')
return {
t: (key: string) => {
const fullKey = namespace ? `${namespace}.${key}` : key
return ctx.messages[fullKey] ?? fullKey
},
locale: ctx.locale,
}
}
레이아웃 server component가 메시지를 페칭한 후 provider에 전달합니다. Client components는 추가 페칭을 트리거하지 않고 useTranslations()를 호출합니다:
// components/contact-form.tsx
'use client'
import { useTranslations } from '@/components/translation-provider'
import { useState } from 'react'
export function ContactForm() {
const { t } = useTranslations('forms')
const [error, setError] = useState<string | null>(null)
async function handleSubmit(formData: FormData) {
const result = await submitContact(formData)
if (!result.ok) {
setError(t('contact.submitError'))
}
}
return (
<form action={handleSubmit}>
<label>{t('contact.nameLabel')}</label>
<input name="name" required />
<label>{t('contact.emailLabel')}</label>
<input name="email" type="email" required />
<button type="submit">{t('contact.submitButton')}</button>
{error && <p role="alert">{error}</p>}
</form>
)
}
핵심 제약: client component가 실제로 필요한 namespace만 전달합니다. 전체 번역 딕셔너리를 클라이언트 번들에 직렬화하면 목적이 무색해집니다.
정적 라우트: generateStaticParams
정적으로 생성되는 페이지(마케팅 페이지, 블로그 포스트, 문서)의 경우 generateStaticParams를 사용하여 빌드 시간에 모든 locale을 사전 렌더링합니다:
// app/[locale]/page.tsx
export async function generateStaticParams() {
const locales = ['en', 'de', 'fr', 'es']
return locales.map(locale => ({ locale }))
}
export async function generateMetadata({ params }: { params: { locale: string } }) {
const { locale } = await params
const t = await fetchTranslations(locale, 'home')
return {
title: t['home.meta.title'],
description: t['home.meta.description'],
alternates: {
languages: {
'en': '/en',
'de': '/de',
'fr': '/fr',
'es': '/es',
},
},
}
}
CDN으로 전달되는 번역의 경우, 빌드는 빌드 시간에 CDN에서 메시지 파일을 페칭하여 정적 HTML에 포함시킵니다. 런타임에 CDN은 정적 파일을 제공합니다—번역 조회 시간은 사실상 0입니다.
next-intl 및 next-i18next와의 비교
next-intl은 App Router와 호환되는 가장 성숙한 라이브러리입니다. 위에서 설명한 미들웨어와 컨텍스트 보일러플레이트를 많이 처리해 줍니다. 트레이드오프는 locale 파일이 리포지토리에 있다고 가정한다는 점입니다(/messages/en.json, /messages/de.json). 새 번역마다 코드 배포가 필요합니다.
next-i18next는 Pages Router용으로 만들어졌습니다. App Router 프로젝트에서는 기껏해야 호환성 심입니다. 새 프로젝트에서는 피하는 것을 권장합니다.
CDN 우선 접근 방식(위에서 설명한 방식)은 번역 콘텐츠를 코드 배포에서 분리합니다. 프랑스어 번역을 업데이트하면 몇 초 내에 CDN에 반영됩니다—재배포도, PR도, CI 대기도 필요 없습니다. 번역 팀이 엔지니어링 팀과 독립적으로 운영됩니다.
파일 기반 접근 방식의 운영 오버헤드 주장("더 단순하다")은 20개의 locale과 전담 현지화 팀이 있을 때 금방 한계를 드러냅니다. 번역 PR을 머지하는 것은 노이즈이지 가치가 아닙니다.
2026년에 다르게 할 것들
- 스트리밍 번역: CDN 페칭이 완료되는 동안 번역이 많은 컴포넌트 주위에
<Suspense>사용—콜드 CDN 캐시로 인한 지연이 발생하는 엣지 케이스에 유용합니다. - 컴포넌트별 namespace 세분화: 레이아웃 레벨 페칭 대신 각 컴포넌트가 필요한 namespace만 페칭합니다. 좁은 번역 요구 사항을 가진 페이지에서 페이로드 크기를 줄입니다.
- 복수형 및 서식 규칙: 라이브러리 추상화에 의존하는 대신
Intl.PluralRules와Intl.NumberFormat을 직접 사용합니다. 브라우저 API는 이제 지원되는 모든 환경에서 신뢰할 수 있습니다.
시작하기
위의 패턴은 CDN으로 전달되는 모든 번역 소스와 함께 작동합니다. 인프라 설정을 건너뛰고 타입 안전 키, 자동화된 CDN 전달, 번역가 친화적인 에디터를 바로 사용하고 싶다면 Better i18n의 Next.js 통합에서 이 스택을 다룹니다—미들웨어 헬퍼, 타입 키 생성, 그리고 번역 팀이 코드를 건드리지 않고 사용할 수 있는 대시보드까지 제공합니다.
전체 API 레퍼런스와 SDK 통합 가이드는 개발자 문서를 참고하세요.