의도치 않게 반년 만에 벨로그에 다시 글을 쓰게 되었다.😇 지난 약 6개월 동안 개인적으로도, 회사에서도 많은 일이 있었다. 아무리 바빠도 한 달에 하나씩은 블로그 글을 쓰자는 나름의 철칙(?)이 있었는데, 바빠지다 보니 자연스럽게 뒤로 밀려나 버렸다. 그래도 좋은 점이라면, 그동안 블로그에 쓸 소스들이 많이 쌓인 것이다. 그래서 이제 다시 예전처럼 하나씩 풀어보려고 한다!
그 첫 번째는 next-intl 세팅이다.
올해 상반기, 회사에서 일본 시장 진출을 목표로 한 서비스 글로벌화 작업에 참여하게 되었다. 프론트엔드 개발 첫 단계가 next-intl 세팅이었는데, 결론부터 말하면 실제 서비스에 도입하지 못했다. 이 글의 제목이 글로벌 시장 진출기(1) : next-intl 세팅 인 이유가 여기에 있다.
왜 실제 도입까지 가지 못했는지는 차차 풀어나가기로 하고, 이번 글에서는 공부한 내용을 정리해 두려고 한다. 요즘은 AI가 왠만한 건 다 해주는 세상이지만, 혹시라고 next-intl 세팅을 하다 해메는 사람이 있다면 이 글이 작은 도움이 되었으면 한다. (어쩌면 AI 학습 소스가 될지도...) 그럼 시작해보겠다!
글로벌 관련 글을 보다 보면 i18n, l10n 용어를 접하게 된다.
쉽게 말해 i18n이 "다국어를 받을 그릇을 만드는 일"이라면, l10n은 "그 그릇에 지역에 맞는 내용물을 담는 일"이라고 보면 된다. (from 클로드)
Next.js는 v10.0.0부터 기본적으로 국제화 라우팅을 지원하기 때문에, 사실 별도 라이브러리 없이도 다국어를 구현할 수 있다.
app/[lang]/ 폴더 구조모든 페이지를 [lang] 동적 폴더 아래에 넣어서 언어별로 관리한다.
app/
[lang]/
layout.tsx
page.tsx
example.com/ja/... → locale = "ja"
example.com/ko/... → locale = "ko"
middleware.ts로 언어 감지 & 리디렉션브라우저의 Accept-Language 헤더를 읽어서 알맞은 언어 경로로 보내준다.
export function middleware(request) {
const { pathname } = request.nextUrl
// locale이 없으면 브라우저 언어를 감지해서 /ja/... /ko/... 등으로 리디렉션
// ex) '/home' 접속 → 브라우저 언어 감지 → 일본어 사용자 → '/ja/home'으로 리디렉션
}
언어마다 JSON 파일을 만들어 key-value 형태로 관리한다. 여기서 핵심은 모든 언어의 key는 동일하고 value만 언어별로 다르다는 점이다.
// ja.json
{
"products": {
"cart": "カートに追加"
}
}
// ko.json
{
"products": {
"cart": "장바구니에 추가"
}
}
그리고 요청된 locale에 맞는 JSON을 불러오는 함수(getDictionary)를 만들어 쓰면 된다. 참고로 번역 파일이 아무리 커도 서버에서만 실행(server-only) 되고 결과 HTML만 브라우저로 넘어가기 때문에, 클라이언트 번들 크기에는 영향을 주지 않는다.
// app/[lang]/dictionaries.ts
import 'server-only'
const dictionaries = {
ja: () => import('./dictionaries/ja.json').then((m) => m.default),
ko: () => import('./dictionaries/ko.json').then((m) => m.default),
}
export const getDictionary = async (locale) => dictionaries[locale]()
// app/[lang]/page.tsx
const dict = await getDictionary(lang) // lang = "ja"
return <button>{dict.products.cart}</button> // → "カートに追加"
이렇게만 보면 기본 기능으로도 충분해 보이는데, 막상 실무에 적용하려고 하면 손이 많이 간다. 정리하자면 이런 한계들이 있다.
결국 직접 다 만들 게 아니라면, 이런 부분을 알아서 챙겨주는 라이브러리를 쓰는 게 낫다고 판단했다.
i18n 라이브러리는 대표적으로 세 가지를 비교해봤다.

1. next-i18next
2. i18next
3. next-intl ✅
우리 프로젝트에서는 App Router를 쓰고 있고 Next.js 16 환경이었기 때문에, 호환성 좋고 가벼운 next-intl로 결정했다.
가장 먼저 해야할 일은 프로젝트에 설치하는 일이다.
pnpm add next-intl
전체 폴더 구조는 이렇게 잡았다. 처음엔 파일이 많아 보여서 좀 막막한데, 하나씩 역할을 보면 생각보다 간단하다.
apps/global-project/
messages/ # 언어별 번역 텍스트 관리
ja/
index.ts # 모든 번역 파일을 하나로 모아서 export
common.json # 공통 텍스트
layout.json # 레이아웃 관련 텍스트
app/
[locale]/ # next-intl이 locale을 전달받기 위한 동적 폴더
layout.tsx
page.tsx
i18n/ # next-intl 세팅 파일
routing.ts # 지원 언어, 도메인을 정의
request.ts # 서버 요청 시 locale 결정 및 번역 파일 로드
navigation.ts # locale을 인식하는 Link, useRouter 등 제공
middleware.ts # routing.ts에 정의된 걸 기반으로 접속 시 실행
.env
어떤 언어를 지원하고, 어떤 도메인에서 어떤 언어를 쓸지를 정의하는 파일이다.
locales — 지원할 언어 목록defaultLocale — 기본 언어localePrefix: 'never' — URL에 /ja 같은 언어 코드를 붙이지 않음domains — 로컬에서는 도메인 설정 없이 개발하고, 프로덕션에서는 도메인으로 언어를 분기import { defineRouting } from 'next-intl/routing'
const isLocal = process.env.NODE_ENV === 'development'
export const routing = defineRouting({
locales: ['ja'],
defaultLocale: 'ja',
localePrefix: 'never',
domains: isLocal ? undefined : [
{ domain: 'www.moomooz.jp', defaultLocale: 'ja' },
]
})
서버에 요청이 들어왔을 때 어떤 언어로 보여줄지를 결정하고, 해당 언어의 번역 파일을 불러오는 파일이다.
.env의 NEXT_PUBLIC_LOCALE 값을 읽어서 언어 결정defaultLocale로 fallbackimport { getRequestConfig } from 'next-intl/server'
import { hasLocale } from 'next-intl'
import { routing } from './routing'
export default getRequestConfig(async ({ requestLocale }) => {
const isLocal = process.env.NODE_ENV === 'development'
const requested = isLocal
? process.env.NEXT_PUBLIC_LOCALE
: await requestLocale
const locale = hasLocale(routing.locales, requested)
? requested
: routing.defaultLocale
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default
}
})
Next.js의 기본 Link, useRouter는 현재 언어를 모른다. 그래서 locale을 인식하는 버전으로 교체해줘야 한다. 앞으로 페이지를 이동할 때는 next/navigation 대신 이 파일에서 가져와 쓰면 된다.
import { createNavigation } from 'next-intl/navigation'
import { routing } from './routing'
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing)
// ❌ next/navigation 사용
import { useRouter } from 'next/navigation'
// ✅ 이 파일에서 가져와야 함
import { useRouter } from '@/i18n/navigation'
브라우저 언어나 도메인을 보고 알맞은 언어로 자동으로 보내주는 역할이다. routing.ts 설정을 기반으로 동작하기 때문에, 별도 로직 없이 createMiddleware만 추가해주면 된다.
matcher — 미들웨어를 실행할 경로를 지정 (API, 정적 파일 등은 제외 가능)import createMiddleware from 'next-intl/middleware'
import { routing } from './i18n/routing'
export default createMiddleware(routing)
export const config = {
matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)'
}
언어별 텍스트를 관리하는 JSON 파일이다. 키(key)는 모든 언어가 동일하고 값(value)만 언어별로 다르게 설정한다. 코드에서는 키만 사용하고, 현재 언어에 맞는 값이 자동으로 출력된다.
// ja
{
"common": {
"title": "ようこそ",
"submit": "送信する",
"cancel": "キャンセル"
}
}
// ko
{
"common": {
"title": "안녕하세요",
"submit": "제출",
"cancel": "취소"
}
}
모든 페이지가 공통으로 사용하는 레이아웃이다.
hasLocale — 지원하지 않는 언어로 접근하면 404 처리setRequestLocale — 정적 렌더링 활성화NextIntlClientProvider — Client Component에서도 번역을 쓸 수 있도록 번역 데이터를 전달import { NextIntlClientProvider, hasLocale } from 'next-intl'
import { notFound } from 'next/navigation'
import { setRequestLocale } from 'next-intl/server'
import { routing } from '@/i18n/routing'
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }))
}
export default async function LocaleLayout({
children,
params
}: {
children: React.ReactNode
params: Promise<{ locale: string }>
}) {
const { locale } = await params
if (!hasLocale(routing.locales, locale)) {
notFound()
}
setRequestLocale(locale)
return (
<html lang={locale}>
<body>
<NextIntlClientProvider>
{children}
</NextIntlClientProvider>
</body>
</html>
)
}
이제 실제 페이지에서 써보자. useTranslations 훅으로 번역 JSON의 키를 불러와 텍스트를 출력한다. 앞으로 모든 페이지는 [locale] 폴더 안에 작성해야 한다.
import { useTranslations } from 'next-intl'
import { setRequestLocale } from 'next-intl/server'
import { use } from 'react'
export default function Page({
params
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = use(params)
setRequestLocale(locale)
const t = useTranslations('common')
return (
<div>
<h1>{t('title')}</h1>
<button>{t('submit')}</button>
<button>{t('cancel')}</button>
</div>
)
}
마지막으로 next-intl이 Next.js와 함께 동작하도록 플러그인을 등록해준다. 이때 request.ts 파일 경로를 꼭 지정해줘야 하는데, 이게 없으면 next-intl이 아예 동작하지 않으니 주의하자.
import { NextConfig } from 'next'
import createNextIntlPlugin from 'next-intl/plugin'
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts')
const nextConfig: NextConfig = {}
export default withNextIntl(nextConfig)
파일을 다 만들고 나면, 이 조각들이 실제로 어떤 순서로 맞물려 동작하는지 한 번 정리하고 가는 게 좋다. 접속부터 화면에 번역 텍스트가 찍히기까지의 흐름은 이렇다.
1️⃣ 접속
운영도메인.jp / 개발도메인.jplocalhost2️⃣ middleware.ts — locale 결정
routing.ts 로직에 따라 도메인을 보고 locale = "ja" 결정.env의 NEXT_PUBLIC_LOCALE = "ja"를 읽어서 결정3️⃣ app/[locale]/layout.tsx — locale 전달
params로 받음setRequestLocale("ja") 호출로 next-intl에 현재 locale 전달4️⃣ request.ts — 번역 파일 로드
NODE_ENV = "development" 감지 후 NEXT_PUBLIC_LOCALE 읽음messages/ja/index.ts)5️⃣ 페이지 렌더링 — 번역 텍스트 출력
useTranslations로 ja 번역 텍스트 출력세팅을 하면서 "이건 왜 이렇게 해야 하지?" 싶은 부분들이 있었다.
@/i18n/navigation을 굳이 따로 쓰는 이유Next.js가 기본으로 주는 Link, useRouter는 locale을 모르기 때문에, 페이지를 이동할 때 locale 정보가 빠질 수 있다. @/i18n/navigation은 이 기본 네비게이션을 routing.ts 설정을 인식하는 버전으로 감싼 것이라, next-intl이 알아서 locale을 챙겨준다.
[locale] 폴더의 역할[locale]은 middleware에서 결정된 locale 값을 페이지까지 전달하는 통로다. 만약 [locale] 밖에서 페이지를 만들면 locale을 전달받지 못해서 useTranslations가 동작하지 않는다. 그래서 모든 페이지는 반드시 [locale] 안에서 만들어야 한다.
번역 JSON을 어떻게 쪼갤지도 고민거리였다. 크게 두 가지 방식이 있다.
1. app 디렉토리 구조를 따라가기
messages/
ja/
index.ts
common/
layout.json
footer.json
settings/
menu.json
...
2. 한 파일로 한 번에 관리
messages/
ja/
index.json
{
"metaData": {
...
},
"sideBanner": {
...
}
}
프로젝트 규모에 따라 선택하면 되는데, 작을 땐 한 파일이 편하고 커질수록 디렉토리 구조를 따라가는 게 관리하기 좋다.
처음엔 파일도 많고 설정도 복잡해 보이지만, 한 번 구조를 잡아두면 이후 번역은 JSON에 key-value만 추가하면 되니 글로벌 서비스 운영에 적합하다는 생각이 들었다.
📚 참고
글이 너무 잘읽혀요 !