글로벌 시장 진출기(1) : next-intl 세팅

sujin·2026년 6월 16일
post-thumbnail

의도치 않게 반년 만에 벨로그에 다시 글을 쓰게 되었다.😇 지난 약 6개월 동안 개인적으로도, 회사에서도 많은 일이 있었다. 아무리 바빠도 한 달에 하나씩은 블로그 글을 쓰자는 나름의 철칙(?)이 있었는데, 바빠지다 보니 자연스럽게 뒤로 밀려나 버렸다. 그래도 좋은 점이라면, 그동안 블로그에 쓸 소스들이 많이 쌓인 것이다. 그래서 이제 다시 예전처럼 하나씩 풀어보려고 한다!

그 첫 번째는 next-intl 세팅이다.

올해 상반기, 회사에서 일본 시장 진출을 목표로 한 서비스 글로벌화 작업에 참여하게 되었다. 프론트엔드 개발 첫 단계가 next-intl 세팅이었는데, 결론부터 말하면 실제 서비스에 도입하지 못했다. 이 글의 제목이 글로벌 시장 진출기(1) : next-intl 세팅 인 이유가 여기에 있다.

왜 실제 도입까지 가지 못했는지는 차차 풀어나가기로 하고, 이번 글에서는 공부한 내용을 정리해 두려고 한다. 요즘은 AI가 왠만한 건 다 해주는 세상이지만, 혹시라고 next-intl 세팅을 하다 해메는 사람이 있다면 이 글이 작은 도움이 되었으면 한다. (어쩌면 AI 학습 소스가 될지도...) 그럼 시작해보겠다!



🤔 i18n? l10n?

글로벌 관련 글을 보다 보면 i18n, l10n 용어를 접하게 된다.

  • 국제화 (i18n, Internationalization) : 다국어를 지원할 수 있도록 코드 구조를 잡아두는 것
  • 지역화 (l10n, Localization) : 번역한 언어에 맞게 날짜·시간 형식, 통화 단위 등을 실제로 반영하는 것

쉽게 말해 i18n이 "다국어를 받을 그릇을 만드는 일"이라면, l10n은 "그 그릇에 지역에 맞는 내용물을 담는 일"이라고 보면 된다. (from 클로드)


⚙️ Next.js는 원래 다국어를 지원한다

Next.js는 v10.0.0부터 기본적으로 국제화 라우팅을 지원하기 때문에, 사실 별도 라이브러리 없이도 다국어를 구현할 수 있다.

1. app/[lang]/ 폴더 구조

모든 페이지를 [lang] 동적 폴더 아래에 넣어서 언어별로 관리한다.

app/
  [lang]/
    layout.tsx
    page.tsx

example.com/ja/...  → locale = "ja"
example.com/ko/...  → locale = "ko"

2. middleware.ts로 언어 감지 & 리디렉션

브라우저의 Accept-Language 헤더를 읽어서 알맞은 언어 경로로 보내준다.

export function middleware(request) {
  const { pathname } = request.nextUrl
  // locale이 없으면 브라우저 언어를 감지해서 /ja/... /ko/... 등으로 리디렉션
  // ex) '/home' 접속 → 브라우저 언어 감지 → 일본어 사용자 → '/ja/home'으로 리디렉션
}

3. 언어별 JSON 생성

언어마다 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> // → "カートに追加"

그럼에도 불구하고, 라이브러리를 선택한 이유는?

이렇게만 보면 기본 기능으로도 충분해 보이는데, 막상 실무에 적용하려고 하면 손이 많이 간다. 정리하자면 이런 한계들이 있다.

  • middleware의 언어 감지 로직을 직접 다 짜야 한다
  • TypeScript 타입이 없어서 번역 키 오타를 잡기 어렵다
  • 번역 누락을 감지할 수 없다
  • 날짜·숫자·통화 포맷을 전부 직접 처리해야 한다
  • 언어별 복수형 처리도 직접 해야 한다
  • 프로젝트가 커질수록 관리가 점점 힘들어진다

결국 직접 다 만들 게 아니라면, 이런 부분을 알아서 챙겨주는 라이브러리를 쓰는 게 낫다고 판단했다.


📦 왜 next-intl 인가 ?

i18n 라이브러리는 대표적으로 세 가지를 비교해봤다.

1. next-i18next

  • 제일 오래됐고 기능이 많으며 커뮤니티도 활성화되어 있다
  • 다만 페이지 라우터 기반이라 앱 라우터는 지원하지 않는다
  • 용량도 4.25MB로 꽤 무거운 편

2. i18next

  • 다운로드 수가 가장 많은, 보편적으로 쓰이는 라이브러리
  • 만들어진 지 13년 정도 됐고 패키지 사이즈는 830KB로 가벼운 편
  • 단, 초기 세팅이 복잡하고 CSR 기준이라 SSR은 별도 세팅이 필요하다

3. next-intl

  • 85.6KB로 가장 가볍고, 문서가 잘 정리되어 있어 설정이 간단하다
  • CSR·SSR 모두 지원하고 Next.js 최신 버전과 호환성이 좋다
  • 단, 프로젝트가 커지면 언어별 메시지 JSON 관리가 다소 번거로울 수 있다

우리 프로젝트에서는 App Router를 쓰고 있고 Next.js 16 환경이었기 때문에, 호환성 좋고 가벼운 next-intl로 결정했다.


🛠️ 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

routing.ts : 지원 언어 & 도메인 정의

어떤 언어를 지원하고, 어떤 도메인에서 어떤 언어를 쓸지를 정의하는 파일이다.

  • 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' },
  ]
})

request.ts : 서버 요청 시 locale 결정

서버에 요청이 들어왔을 때 어떤 언어로 보여줄지를 결정하고, 해당 언어의 번역 파일을 불러오는 파일이다.

  • 로컬에서는 .envNEXT_PUBLIC_LOCALE 값을 읽어서 언어 결정
  • 운영/개발에서는 접속한 도메인을 보고 언어 결정
  • 지원하지 않는 언어로 요청이 오면 defaultLocale로 fallback
import { 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'

middleware.ts : 언어 감지 & 리디렉션

브라우저 언어나 도메인을 보고 알맞은 언어로 자동으로 보내주는 역할이다. 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": "취소"
  }
}

layout.tsx : 공통 레이아웃 설정

모든 페이지가 공통으로 사용하는 레이아웃이다.

  • 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.config : 플러그인 등록

마지막으로 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 / 개발도메인.jp
  • 로컬 : localhost

2️⃣ middleware.ts — locale 결정

  • 운영/개발 : routing.ts 로직에 따라 도메인을 보고 locale = "ja" 결정
  • 로컬 : .envNEXT_PUBLIC_LOCALE = "ja"를 읽어서 결정

3️⃣ app/[locale]/layout.tsx — locale 전달

  • middleware에서 결정된 locale을 params로 받음
  • setRequestLocale("ja") 호출로 next-intl에 현재 locale 전달

4️⃣ request.ts — 번역 파일 로드

  • 운영/개발 : 도메인 기반으로 결정된 locale 확인
  • 로컬 : NODE_ENV = "development" 감지 후 NEXT_PUBLIC_LOCALE 읽음
  • 해당 locale의 번역 JSON 로드 (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 파일 구조 — 두 가지 방식

번역 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만 추가하면 되니 글로벌 서비스 운영에 적합하다는 생각이 들었다.


📚 참고

profile
개발댕발

1개의 댓글

comment-user-thumbnail
2026년 6월 17일

글이 너무 잘읽혀요 !

답글 달기