Internationalization

김동현·2026년 3월 4일

next.js 공식문서 번역

목록 보기
26/79

Next.js를 사용하면 여러 언어를 지원하도록 콘텐츠의 라우팅과 렌더링을 구성할 수 있어요. 사이트를 다양한 로케일(locale)에 맞게 적응시킨다는 것은 번역된 콘텐츠(지역화, localization)를 제공하는 것과 국제화된 라우트(internationalized routes)를 설정하는 것을 모두 포함한답니다.

💡 강사의 부연 설명: '국제화(i18n)'와 '지역화(l10n)'의 차이를 아시나요? 면접에서 가끔 물어보기도 하는데요! 국제화는 애플리케이션이 여러 언어와 지역을 지원할 수 있도록 뼈대(인프라)를 설계하는 기술적인 작업(라우팅 등)을 말하고, 지역화는 그 뼈대 위에 실제로 한국어, 영어, 프랑스어 등 번역된 콘텐츠나 그 지역에 맞는 화폐 단위 등을 채워 넣는 작업을 말해요. Next.js는 이 두 가지를 모두 아주 우아하게 지원합니다.

용어 (Terminology)

  • 로케일 (Locale): 언어 및 형식 기본 설정의 집합을 나타내는 식별자예요. 일반적으로 사용자가 선호하는 언어와, 경우에 따라 지리적 지역(국가) 정보까지 포함하게 됩니다.
    • en-US: 미국에서 사용되는 영어
    • nl-NL: 네덜란드에서 사용되는 네덜란드어
    • nl: 특정 지역이 지정되지 않은 네덜란드어

라우팅 개요 (Routing Overview)

어떤 로케일을 사용할지 결정할 때는 브라우저에 설정된 사용자의 언어 기본 설정을 사용하는 것을 권장해요. 사용자가 기본 언어를 변경하면 애플리케이션으로 들어오는 Accept-Language 헤더의 내용이 수정됩니다.

예를 들어, 다음 라이브러리들을 사용하면 Headers에 담긴 정보, 여러분이 지원하고자 계획한 로케일 목록, 그리고 기본(default) 로케일을 기반으로 들어오는 Request(요청)를 확인하여 어떤 로케일을 선택할지 결정할 수 있어요.

//filename="proxy.js"
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'

let headers = { 'accept-language': 'en-US,en;q=0.5' }
let languages = new Negotiator({ headers }).languages()
let locales = ['en-US', 'nl-NL', 'nl']
let defaultLocale = 'en-US'

match(languages, locales, defaultLocale) // -> 'en-US'

라우팅은 서브 패스(sub-path, 예: /fr/products)를 사용하거나 도메인(domain, 예: my-site.fr/products)을 사용하여 국제화할 수 있습니다. 이렇게 파악한 로케일 정보를 바탕으로, 이제 Proxy(프록시) 내부에서 로케일에 따라 사용자를 리디렉션(redirect)할 수 있어요.

//filename="proxy.js"
import { NextResponse } from "next/server";

let locales = ['en-US', 'nl-NL', 'nl']

// 위에서 보여드린 예시와 유사한 방식이나 라이브러리를 사용하여 선호하는 로케일을 가져옵니다.
function getLocale(request) { ... }

export function proxy(request) {
  // pathname에 지원하는 로케일이 포함되어 있는지 확인합니다.
  const { pathname } = request.nextUrl
  const pathnameHasLocale = locales.some(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  )

  if (pathnameHasLocale) return

  // 로케일이 없다면 리디렉션합니다.
  const locale = getLocale(request)
  request.nextUrl.pathname = `/${locale}${pathname}`
  // 예: 들어오는 요청이 /products 라면
  // 이제 새로운 URL은 /en-US/products 가 됩니다.
  return NextResponse.redirect(request.nextUrl)
}

export const config = {
  matcher: [
    // 모든 내부 경로(_next)는 건너뜁니다.
    '/((?!_next).*)',
    // 선택 사항: 루트(/) URL에서만 실행하도록 할 수도 있습니다.
    // '/'
  ],
}

💡 강사의 실무 팁: 여기서 미들웨어(위 코드에서는 proxy.js 개념)를 사용해 리디렉션하는 방식이 매우 중요합니다. 실무에서 다국어 사이트를 운영하다 보면, 사용자가 즐겨찾기 해둔 /products URL로 바로 접속하는 경우가 많아요. 이때 서버 측에서 브라우저 언어를 캐치해서 알아서 /ko/products/en-US/products로 매끄럽게 보내주는 거죠. 이 경험이 사용자에게 아주 좋은 인상을 줍니다.

마지막으로, app/ 디렉토리 내부에 있는 모든 특수 파일들이 app/[lang] 폴더 아래에 중첩(nested)되도록 구성해야 해요. 이렇게 하면 Next.js 라우터가 라우트에서 다양한 로케일을 동적으로 처리할 수 있게 되고, lang 파라미터를 모든 레이아웃(layout)과 페이지(page)로 전달할 수 있게 됩니다. 예를 들어보죠:

//filename="app/[lang]/page.tsx" switcher
// 이제 현재 로케일에 접근할 수 있습니다.
// 예: /en-US/products 로 접속했다면 -> `lang` 파라미터는 "en-US"가 됩니다.
export default async function Page({ params }: PageProps<'/[lang]'>) {
  const { lang } = await params
  return ...
}
//filename="app/[lang]/page.js" switcher
// 이제 현재 로케일에 접근할 수 있습니다.
// 예: /en-US/products 로 접속했다면 -> `lang` 파라미터는 "en-US"가 됩니다.
export default async function Page({ params }) {
  const { lang } = await params
  return ...
}

알아두면 좋은 점 (Good to know): PagePropsLayoutProps는 라우트 파라미터에 대한 강력한 타입 지정을 제공하는 전역으로 사용 가능한 TypeScript 헬퍼(helper)입니다. 더 자세한 내용은 PagePropsLayoutProps를 참고해 보세요.

가장 최상위의 루트 레이아웃(root layout) 또한 이 새로운 폴더 안(예: app/[lang]/layout.js)에 중첩해서 위치시킬 수 있습니다.

지역화 (Localization)

사용자가 선호하는 로케일에 따라 화면에 표시되는 콘텐츠를 변경하는 작업, 즉 지역화(Localization)는 Next.js에만 국한된 특별한 기술이 아니에요. 아래에 설명된 패턴들은 어떤 웹 애플리케이션 프레임워크와도 똑같이 작동할 수 있는 일반적인 방식이랍니다.

우리 애플리케이션 내에서 영어와 네덜란드어 콘텐츠를 모두 지원하고 싶다고 가정해 볼게요. 이를 위해 우리는 두 개의 서로 다른 "사전(dictionaries)"을 유지 및 관리할 수 있습니다. 이 사전은 어떤 키(key)값을 그에 맞는 지역화된 문자열로 매핑해 주는 객체입니다. 예를 들어:

//filename="dictionaries/en.json"
{
  "products": {
    "cart": "Add to Cart"
  }
}
//filename="dictionaries/nl.json"
{
  "products": {
    "cart": "Toevoegen aan Winkelwagen"
  }
}

그러고 나서, 요청된 로케일에 맞는 번역본을 불러오기 위한 getDictionary 함수를 만들 수 있어요:

//filename="app/[lang]/dictionaries.ts" switcher
import 'server-only'

const dictionaries = {
  en: () => import('./dictionaries/en.json').then((module) => module.default),
  nl: () => import('./dictionaries/nl.json').then((module) => module.default),
}

export type Locale = keyof typeof dictionaries

export const hasLocale = (locale: string): locale is Locale =>
  locale in dictionaries

export const getDictionary = async (locale: Locale) => dictionaries[locale]()
//filename="app/[lang]/dictionaries.js" switcher
import 'server-only'

const dictionaries = {
  en: () => import('./dictionaries/en.json').then((module) => module.default),
  nl: () => import('./dictionaries/nl.json').then((module) => module.default),
}

export const hasLocale = (locale) => locale in dictionaries

export const getDictionary = async (locale) => dictionaries[locale]()

현재 선택된 언어가 주어졌다면, 우리는 레이아웃이나 페이지 내부에서 이 사전을 가져와서(fetch) 사용할 수 있습니다.

여기서 lang 파라미터는 string 타입으로 지정되어 있기 때문에, hasLocale 함수를 사용하여 타입을 우리가 지원하는 로케일로 좁혀주는 것(type narrowing)이 좋습니다. 이 방법은 만약 번역본이 누락되어 있을 경우 런타임 에러(runtime error)를 발생시키는 대신 안전하게 404 페이지를 반환하도록 보장해 줍니다.

//filename="app/[lang]/page.tsx" switcher
import { notFound } from 'next/navigation'
import { getDictionary, hasLocale } from './dictionaries'

export default async function Page({ params }: PageProps<'/[lang]'>) {
  const { lang } = await params

  if (!hasLocale(lang)) notFound()

  const dict = await getDictionary(lang)
  return <button>{dict.products.cart}</button> // Add to Cart (장바구니에 추가)
}
import { notFound } from 'next/navigation'
import { getDictionary, hasLocale } from './dictionaries'

export default async function Page({ params }) {
  const { lang } = await params

  if (!hasLocale(lang)) notFound()

  const dict = await getDictionary(lang)
  return <button>{dict.products.cart}</button> // Add to Cart (장바구니에 추가)
}

app/ 디렉토리 내의 모든 레이아웃과 페이지는 기본적으로 Server Components (서버 컴포넌트)로 작동하기 때문에, 우리는 번역 파일의 크기가 클라이언트 측의 JavaScript 번들(bundle) 사이즈에 영향을 미칠까 봐 걱정할 필요가 전혀 없어요. 이 코드는 오직 서버에서만 실행되며, 최종적으로 생성된 HTML 결과물만이 브라우저로 전송됩니다.

💡 강사의 실무 팁: 서버 컴포넌트의 강력함이 바로 여기서 드러납니다! 예전(React 기반의 SPA 시절)에는 수만 개의 단어가 들어있는 다국어 JSON 파일을 클라이언트가 통째로 다운로드해야 해서 초기 로딩 속도가 느려지는 이슈가 흔했어요. 하지만 Next.js App 라우터에서는 서버에서 필요한 번역본만 딱 매핑해서 가벼운 HTML로 내려주기 때문에 성능 최적화에 어마어마한 이점이 있죠.

정적 렌더링 (Static Rendering)

주어진 로케일 집합(지원하는 언어들)에 대한 정적 라우트를 생성하고 싶다면, 어떤 페이지나 레이아웃에서든 generateStaticParams 함수를 사용할 수 있어요. 예를 들어 루트 레이아웃(root layout)에서 전역적으로 설정할 수 있습니다.

//filename="app/[lang]/layout.tsx" switcher
export async function generateStaticParams() {
  return [{ lang: 'en-US' }, { lang: 'de' }]
}

export default async function RootLayout({
  children,
  params,
}: LayoutProps<'/[lang]'>) {
  return (
    <html lang={(await params).lang}>
      <body>{children}</body>
    </html>
  )
}
//filename="app/[lang]/layout.js" switcher
export async function generateStaticParams() {
  return [{ lang: 'en-US' }, { lang: 'de' }]
}

export default async function RootLayout({ children, params }) {
  return (
    <html lang={(await params).lang}>
      <body>{children}</body>
    </html>
  )
}

💡 강사의 부연 설명: generateStaticParams를 쓰면 빌드 타임(Build time)에 영어 페이지, 독일어 페이지 등을 미리 다 만들어두게 됩니다(SSG - Static Site Generation). 이 방법은 페이지 로딩 속도를 극한으로 끌어올릴 수 있고, 다국어 사이트의 가장 큰 골칫거리 중 하나인 다국어 SEO(검색 엔진 최적화) 문제를 완벽하게 해결해 줍니다.

리소스 (Resources)

다국어 지원을 위해 참고할 만한 다양한 라이브러리와 예제들입니다:


모든 문서에 대한 의미론적인 개요를 확인하시려면 사이트맵 문서(/docs/sitemap.md)를 참고해 주세요.

사용 가능한 모든 문서의 전체 색인(index)을 확인하시려면 LLM을 위한 색인 파일(/docs/llms.txt)을 참고해 주세요.

profile
프론트에_가까운_풀스택_개발자

0개의 댓글