$ yh.log
i18n은 어떻게 동작할까? next-intl로 보는 요청 흐름과 설계 원칙

i18n은 어떻게 동작할까? next-intl로 보는 요청 흐름과 설계 원칙

i18nnext-intlNext.js다국어Intl

작성자 : 오예환 | 작성일 : 2026-07-29 | 수정일 : 2026-07-29 | 조회수 :

i18n은 어떻게 동작할까?

서비스에 번역 JSON 파일을 추가하고 t('key')를 호출하는 일은 어렵지 않다. 하지만 실제 다국어 서비스는 그보다 훨씬 많은 결정을 요구한다. 사용자의 언어는 어디서 결정할까? 공용 UI와 리전별 UI의 번역은 어떻게 합칠까? zh는 간체일까, 번체일까? 날짜·통화·SEO의 언어 표기도 번역 라이브러리에 맡기면 충분할까?

이번 글에서는 i18n을 공부하면서, 여러 나라에 서비스하는 Next.js 앱을 예로 next-intl이 요청 하나를 처리하는 흐름을 정리한다. 끝에는 다른 i18n 방식과, 이런 구조에서 계속 지켜야 할 설계 원칙도 함께 비교한다.

예제 서비스는 "한국·일본·대만에 각각 앱을 두고, 공용 패키지를 함께 쓰는 Next.js 모노레포"로 가정했다. 코드는 설명을 위해 단순화했다.

i18n, 번역보다 넓은 문제

**i18n(internationalization)**은 제품을 여러 언어·지역에서 동작하도록 만드는 설계이고, **l10n(localization)**은 그 설계 위에서 특정 지역에 맞게 실제 문구·날짜·통화·이미지를 맞추는 작업이다.

그래서 아래 두 코드는 모두 i18n의 일부지만, 성격이 다르다.

// 번역 메시지
const t = useTranslations('Profile');
return <h1>{t('edit')}</h1>;
 
// 지역에 맞는 상대 시간
return new Intl.RelativeTimeFormat(locale).format(-1, 'day');

문자열을 코드에서 이어 붙이면 번역가는 단어 순서를 바꿀 수 없다. 변수, 복수형, 성별에 따라 달라지는 문장은 하나의 메시지로 두는 것이 안전하다. ICU도 이 이유로 변수와 문장 구조를 한 메시지 안에 두고 plural, select를 사용하라고 권장한다. ICU MessageFormat 가이드

{
  "unread": "읽지 않은 메시지가 {count}개 있습니다.",
  "invite": "{count, plural, =0 {아직 초대된 사람이 없습니다.} other {#명을 초대했습니다.}}"
}

예제 구조: Next.js에 맞춘 next-intl

예제 서비스는 Next.js App Router에서 next-intl을 사용한다. next-intl은 요청 단위 설정을 만들어 Server Component에서 쓰고, NextIntlClientProvider로 Client Component에도 같은 설정을 전달하는 형태를 지원한다. next-intl의 App Router 문서

라이브러리가 정해 준 것은 메시지를 읽고 포맷하는 엔진이다. 반면 어떤 locale을 지원하고, 지역별 메시지를 어떤 우선순위로 합치며, URL에 locale을 보일지는 서비스가 직접 정해야 하는 정책이다.

1. 지원 언어와 기본 언어를 정한다

설정에는 en, ko, ja, zh를 선언한다. 기본 언어는 배포 리전을 바탕으로 결정한다. 여기서 주목할 점은 localePrefix: 'never'다. 사용자가 /ko/dashboard처럼 언어가 드러난 URL을 보는 대신, URL은 그대로 두고 NEXT_LOCALE 쿠키로 선택을 유지한다.

export const i18nConfig = {
  locales: [
    { id: 'en', name: 'English' },
    { id: 'ko', name: '한국어' },
    { id: 'ja', name: '日本語' },
    { id: 'zh', name: '繁體中文' },
  ],
  defaultLocale: getDefaultLocale(),
  localePrefix: 'never',
};

미들웨어는 예전 방식의 /ko/... URL을 감지하면 prefix를 제거하고 쿠키를 심는다. 그 다음 요청에서는 쿠키 값을 기본 locale로 넣어 내부 라우팅을 맞춘다. URL 정책을 하나로 유지하면서도 예전 주소를 끊지 않기 위한 호환 계층이다.

2. 요청마다 locale과 메시지를 조립한다

getRequestConfig는 cookie → 요청 locale → 리전 기본값 순서로 유효한 locale을 고른다. 이어서 두 메시지 묶음을 합친다.

  • packages/i18n/locales/*: 모든 앱이 함께 쓰는 기본 메시지
  • apps/{국가별 앱}/locales/{REGION}/*: 해당 국가 앱이 덮어쓰거나 추가하는 메시지

deepMerge(baseMessages[locale], appMessages)는 재귀 병합을 수행한다. 즉 같은 키가 있으면 국가별 앱 메시지가 최종 값을 덮는다. 공용 컴포넌트의 번역은 한 번 유지하고, 리전 특화 문구만 가까운 앱에 둘 수 있는 모노레포 방식이다.

요청 단위 i18n 메시지 조립 흐름

export default getRequestConfig(async ({ requestLocale }) => {
  const cookieLocale = (await cookies()).get('NEXT_LOCALE')?.value;
  const locale = isSupported(cookieLocale)
    ? cookieLocale
    : isSupported(await requestLocale)
      ? await requestLocale
      : getDefaultLocale();
 
  const appMessages = (
    await import(`../locales/${REGION}/${locale}.json`)
  ).default;
 
  return {
    locale,
    messages: deepMerge(baseMessages[locale], appMessages),
    timeZone: 'Asia/Seoul',
  };
});

위 코드의 isSupported가 locale이 허용 목록 안에 있는지 확인하는 부분이다. 외부 입력인 쿠키나 URL 값을 파일 경로와 메시지 선택에 바로 쓰지 않는 것은 작지만 중요한 경계 방어다.

3. 서버와 클라이언트가 같은 메시지를 사용한다

서버 컴포넌트에서는 getTranslations('Index')처럼 비동기 API를 쓰고, 클라이언트 컴포넌트에서는 useTranslations('Profile')을 쓴다. 루트 레이아웃의 NextIntlClientProvider가 현재 locale과 메시지를 내려주기 때문이다.

// Server Component
const t = await getTranslations('Index');
 
// Client Component
const tProfile = useTranslations('Profile');
return <button>{tProfile('edit')}</button>;

이 구조는 SSR 첫 화면과 클라이언트 상호작용이 같은 언어를 보게 한다. next-intl도 요청 설정을 서버 렌더링에서 한 번 만들고, Provider가 locale·messages·timeZone 같은 값을 상속하도록 설계했다. 요청 설정 문서

가장 중요한 교훈: 내부 locale id는 표준 locale이 아니다

예제 서비스는 대만 사용자를 위해 zh를 번체 중국어로 쓰기로 정했다고 하자. 하지만 zh라는 문자열이 어디에서나 번체를 의미하지는 않는다.

소비처zh의 의미필요한 처리
프로젝트 메시지 파일번체로 작성한 zh.json그대로 사용
HTML lang스크립트 구분이 부족함zh-Hant로 변환
dayjs localezh는 간체 localezh-tw로 변환

그래서 <html lang>에는 getHtmlLang('zh') → 'zh-Hant'처럼, 댓글의 상대 시간에는 toDayjsLocale('zh') → 'zh-tw'처럼 변환을 거쳐야 한다. 변환 없이 dayjs에 zh를 그대로 넘기면 번체 화면에 “5 分钟前”처럼 간체 문구가 섞여 나온다.

내부 locale id와 외부 시스템 locale id의 경계

import 'dayjs/locale/zh-tw';
 
// "이 프로젝트의 zh는 번체"라는 규칙을 한 곳에 둔다.
export function toDayjsLocale(locale: string) {
  return locale === 'zh' ? 'zh-tw' : locale;
}
 
dayjs(comment.createdAt).locale(toDayjsLocale(locale)).fromNow();

이 사례에서 문제는 dayjs가 아니라, 프로젝트의 축약 id를 외부 시스템의 표준 id라고 가정한 것이다. Unicode locale 식별자는 언어뿐 아니라 스크립트와 지역 정보를 가질 수 있다. 번체와 간체를 함께 지원할 가능성이 있다면 zh-Hant, zh-Hans처럼 처음부터 의미가 드러나는 BCP 47 계열 식별자가 장기적으로 더 안전하다. Unicode LDML locale identifier

i18n 방식은 어떤 것들이 있을까?

“어떤 i18n 라이브러리가 최고인가?”보다, 렌더링 환경·번역 운영 방식·제품의 확장 계획에 맞는지를 묻는 편이 정확하다.

방식대표 도구/형태잘 맞는 경우주의할 점
프레임워크 통합형next-intl, Next.js i18nSSR, SEO, App Router가 중요한 Next.js 제품라우팅·쿠키·서버 메시지 전달 정책을 함께 설계해야 한다
범용 런타임형i18next + react-i18nextReact SPA, React Native, namespace 단위 지연 로딩프레임워크별 SSR 연결과 타입 안전을 별도로 챙긴다
Intl/ICU 중심형FormatJS, react-intl복수형·날짜·숫자 포맷을 ICU 메시지 문법으로 강하게 관리하고 싶을 때메시지 추출과 번역 운영 도구를 프로젝트에 맞춰 구성한다
정적 생성형locale별 빌드/콘텐츠 파일언어 수가 적은 마케팅·문서 사이트로그인 후 언어 변경, 사용자별 지역화 요구가 커지면 관리 비용이 급증한다
TMS 연동형Crowdin, Phrase 등번역가·기획자와 대규모로 협업할 때TMS는 엔진을 대체하지 않는다. 키·fallback·검수 규칙은 여전히 코드의 책임이다

예제처럼 Next.js 서버 렌더링, 여러 국가별 앱, 공용 패키지가 함께 있는 경우에는 next-intl + JSON 메시지 + 공용/앱별 계층화가 자연스러운 선택이다. 반대로 서버 렌더링이 없는 단일 React 앱이라면 i18next의 namespace와 백엔드 로더가 더 편할 수 있다.

이 구조는 베스트 프랙티스일까?

결론부터 말하면, 좋은 방향이다. 특히 “공용 기본 메시지 + 국가별 오버라이드”, 요청 단위 설정, type-safe message key는 모노레포 다국어 서비스에 잘 맞는다. 다만 베스트 프랙티스는 한 번의 선택이 아니라, locale이 외부 경계를 지날 때 규칙을 지키는 운영 방식까지 포함한다.

유지할 점더 단단하게 만들 점
공용 기본 메시지와 앱별 메시지를 분리한다zh처럼 의미가 축약된 id는 zh-Hant/zh-Hans로 전환할 장기 계획을 세운다
IntlMessages 타입으로 번역 키를 체크한다dayjs, Intl, API, analytics 등 모든 외부 소비처에 mapping adapter를 둔다
쿠키 값을 허용 locale 목록으로 검증한다언어(language), 국가(region), 시간대(time zone), 통화(currency)를 하나의 locale 문자열에 과하게 싣지 않는다
서버와 클라이언트에 같은 설정을 제공한다큰 메시지 번들은 route/namespace 단위 전달도 검토한다
공용 UI의 메시지는 공용 패키지에서 관리한다PR마다 모든 locale의 키 누락·ICU 문법·대표 화면을 CI에서 검증한다

실무 체크리스트

  1. 새 UI 문구는 하드코딩하지 말고 모든 지원 locale에 같은 키를 추가한다.
  2. 문장 조각을 이어 붙이지 말고, 변수와 복수형을 메시지 전체에 넣는다.
  3. locale을 API 경로, <html lang>, 날짜 라이브러리, Intl.*에 넘길 때 해당 시스템이 기대하는 값을 확인한다.
  4. fallback이 동작하더라도 개발·CI에서는 누락 키를 경고나 실패로 보이게 한다. fallback은 사용자 경험을 위한 안전망이지 번역 누락을 숨기는 장치가 아니다.
  5. 언어별로 최소 한 화면씩은 실제 브라우저에서 확인한다. 특히 긴 독일어, 복수형이 복잡한 언어, RTL 언어를 추가할 때는 레이아웃 검증도 포함한다.
  6. 중국어를 확장할 때는 zh의 의미를 추측하지 말고 번체·간체를 별도 locale, 별도 메시지 파일, 별도 외부 매핑으로 분리한다.

마무리

i18n 라이브러리는 t('key')를 반환하는 도구에 가깝다. 실제 서비스 품질은 locale을 정하는 정책, 메시지를 조립하는 계층, 날짜·통화·접근성에 적용하는 규칙, 그리고 그 경계를 테스트하는 방식에서 나온다.

i18n을 공부하며 가장 크게 남은 문장은 이것이다.

내부 locale id를 외부 세계의 표준 id라고 가정하지 말자.

이 원칙만 지켜도 zh 하나가 간체와 번체를 오가는 것처럼, 다국어 서비스에서 가장 찾기 어려운 종류의 버그를 크게 줄일 수 있다.