Knock Blog

I18N와 ICU, 그리고 TS에서의 활용 - next intl

10분 읽기3 조회Frontend

개요

본 문서는, 다양한 I18N 스펙을 효과적으로 대응할 수 있는 ICU의 개념에 대해서 다뤄봅니다.

또한, next-intl에서는 이를 어떻게 다루는지, react-i18next에서는 이를 어떻게 사용하는지에 대해서도 다룹니다.

1. ICU (International Components for Unicode)

ICU Message Format은 다국어 메시지에서 단순 문자열 치환뿐 아니라, 숫자, 날짜, 시간, 복수형, 조건 분기 등을 locale에 맞게 표현하기 위한 메시지 문법입니다.

예를 들어 단순 문자열은 다음과 같이 표현할 수 있습니다.

{
  "hello": "안녕하세요, {name}님"
}

여기서 {name}은 런타임에 전달된 값으로 치환됩니다. 아래부터는 ICU의 다양한 포매팅 형태를 살펴보겠습니다.

(ref: ICU User Guide — Formatting Messages)

1.1 ICU Argument Type 요약

ICU Message Format에서 자주 사용하는 argument type은 다음과 같습니다.

Type 용도 예시
기본 치환 동적 값 삽입 {name}
plural 숫자 개수에 따른 메시지 분기 {count, plural, one {...} other {...}}
select 문자열 enum 값에 따른 메시지 분기 {status, select, pending {...} other {...}}
selectordinal 순서 숫자 표현 {rank, selectordinal, one {...} other {...}}
date 날짜 포맷팅 {createdAt, date, medium}
time 시간 포맷팅 {createdAt, time, short}
number 숫자, 통화, 퍼센트 포맷팅 {price, number, currency}

1.2 Interpolation

Interpolation은 메시지 안에 동적인 값을 삽입하는 가장 기본적인 방식입니다.

{
  "welcome": "{name}님, 환영합니다."
}
t('welcome', {name: '낙현'});

결과:

낙현님, 환영합니다.

이건 ICU Message Format의 가장 기본적인 변수 치환에 해당합니다.

1.3 Plural

plural은 숫자 값에 따라 서로 다른 메시지를 표시하기 위한 ICU Message Format 문법입니다.

영어처럼 단수와 복수에 따라 명사 형태가 달라지는 언어에서는 1 item, 2 items처럼 숫자에 따라 표현이 달라집니다. 이때 plural을 사용하면 locale별 plural rule에 맞춰 메시지를 분기할 수 있습니다.

{
  "itemCount": "{count, plural, one {# item} other {# items}}"
}

위 예시에서 oneother는 plural category입니다. 전달된 count 값과 현재 locale의 plural rule에 따라 어떤 메시지를 사용할지 결정됩니다.

t('itemCount', {count: 1});
// 1 item

t('itemCount', {count: 3});
// 3 items

한국어는 단수/복수 표현 차이가 크지 않아 단순 interpolation으로도 충분한 경우가 많습니다.

{
  "itemCount": "상품 {count}개"
}

하지만 영어 등 다른 언어를 함께 지원하거나, 숫자 값에 따라 문장 자체가 달라져야 하는 경우에는 plural을 사용하는 것이 안전합니다.

1.3.1 Exact Number Match

plural에서는 one, other 같은 locale 기반 plural category 외에도, 특정 숫자와 정확히 일치하는 경우를 직접 지정할 수 있습니다. 이를 exact number match라고 볼 수 있습니다.

{
  "cart": "{count, plural, =0 {장바구니가 비어 있습니다} one {상품이 #개 있습니다} other {상품이 #개 있습니다}}"
}

위 예시에서 =0count 값이 정확히 0일 때 사용됩니다. #은 현재 count 값을 의미합니다.

t('cart', {count: 0});
// 장바구니가 비어 있습니다

t('cart', {count: 1});
// 상품이 1개 있습니다

t('cart', {count: 5});
// 상품이 5개 있습니다

=0, =1, =2처럼 특정 숫자를 직접 지정할 수 있으며, 이 방식은 "0개일 때는 아예 다른 문장을 보여주고 싶다"처럼 특정 숫자에 대한 예외 처리가 필요할 때 유용합니다.

{
  "notification": "{count, plural, =0 {알림이 없습니다} =1 {알림이 1개 있습니다} other {알림이 #개 있습니다}}"
}

여기서 =1one은 비슷해 보일 수 있지만, 완전히 같은 의미는 아닙니다.

  • =1은 숫자 값이 정확히 1일 때만 매칭됩니다.
  • one은 현재 locale의 plural rule에서 one category에 해당할 때 매칭됩니다.

영어에서는 대부분 1one에 해당하기 때문에 비슷하게 동작하지만, locale에 따라 plural category 규칙이 다를 수 있습니다. 따라서 특정 숫자 자체를 예외 처리하고 싶다면 =0, =1 같은 exact number match를 사용하고, 언어별 단수/복수 규칙에 맡기고 싶다면 one, other 같은 plural category를 사용하는 것이 좋습니다.

예시는 아래와 같습니다.

{
  "searchResult": "{count, plural, =0 {검색 결과가 없습니다} other {검색 결과 #건}}"
}

이처럼 plural은 단순히 영어의 단수/복수 처리를 위한 문법만이 아니라, 숫자 값에 따라 자연스러운 문장을 선택하기 위한 메시지 분기 문법으로 이해하는 것이 좋습니다.

1.4 Select

select는 특정 문자열 값에 따라 서로 다른 메시지를 표시하기 위한 ICU Message Format 문법입니다.

주문 상태, 결제 상태, 사용자 역할처럼 enum 형태의 값에 따라 표시해야 하는 문구가 달라지는 경우 사용할 수 있습니다. JavaScript의 switch 문처럼 전달된 값에 따라 적절한 메시지를 선택합니다.

예상하지 못한 값에 대응하기 위해 other 케이스를 함께 정의하는 것이 좋습니다.

예시:

{
  "orderStatus": "{status, select, pending {주문 대기 중입니다} paid {결제가 완료되었습니다} shipped {배송 중입니다} canceled {주문이 취소되었습니다} other {주문 상태를 확인할 수 없습니다}}"
}
t('orderStatus', {status: 'pending'});
// 주문 대기 중입니다

t('orderStatus', {status: 'paid'});
// 결제가 완료되었습니다

1.5 Date / Time / Number Formatting

ICU Message Format은 문자열 치환과 메시지 분기뿐 아니라, locale에 따른 날짜, 시간, 숫자 포맷팅도 지원합니다.

날짜, 시간, 숫자, 통화는 국가와 언어에 따라 표기 방식이 달라질 수 있습니다. 예를 들어 같은 날짜라도 한국어 환경에서는 2026. 6. 8.처럼 보일 수 있고, 영어 환경에서는 June 8, 2026처럼 표시될 수 있습니다. 숫자와 통화 역시 locale에 따라 구분자, 통화 기호, 소수점 표기 방식이 달라질 수 있습니다.

ICU Message Format에서는 다음과 같이 메시지 안에서 값의 포맷 타입을 지정할 수 있습니다.

{
  "createdAt": "생성일: {date, date, medium}",
  "updatedAt": "수정 시간: {time, time, short}",
  "price": "가격: {price, number, currency}"
}

위 예시에서 {date, date, medium}은 전달받은 date 값을 locale에 맞는 중간 길이 날짜 형식으로 변환합니다. {time, time, short}는 시간 값을 짧은 시간 형식으로 표시하고, {price, number, currency}는 숫자 값을 통화 형식으로 표시합니다.

next-intl에서는 메시지 내부에서 ICU 포맷을 사용할 수도 있고, useFormatter()를 사용해 컴포넌트 코드에서 직접 날짜, 시간, 숫자를 포맷팅할 수도 있습니다.

import {useFormatter} from 'next-intl';

export default function ProductPrice({price}: {price: number}) {
  const format = useFormatter();

  return (
    <p>
      {format.number(price, {
        style: 'currency',
        currency: 'KRW'
      })}
    </p>
  );
}

메시지 안에 포함된 자연어 문장의 일부로 값을 표현해야 한다면 ICU 메시지 내부 포맷을 사용할 수 있고, UI 컴포넌트에서 값만 독립적으로 표시해야 한다면 useFormatter()를 사용하는 방식이 더 적합할 수 있습니다.

1.6 SelectOrdinal

selectordinal은 순서를 나타내는 숫자 표현을 처리하기 위한 ICU Message Format 문법입니다.

영어에서는 1st, 2nd, 3rd, 4th처럼 순서에 따라 suffix가 달라질 수 있습니다. 이런 경우 selectordinal을 사용해 locale별 ordinal rule에 맞는 메시지를 작성할 수 있습니다.

한국어는 일반적으로 {rank}위처럼 표현할 수 있어 사용 빈도는 낮지만, 영어권 서비스를 지원하는 경우 알아두면 좋습니다.

1.7 ICU Message 작성 시 주의사항

ICU Message Format은 plural, select, date, number 등 다양한 표현을 하나의 메시지 안에서 처리할 수 있는 강력한 문법입니다. 다만 문법이 강력한 만큼, 메시지가 복잡해지면 가독성이 떨어지고 번역 과정에서 오류가 발생하기 쉽습니다.

따라서 ICU Message를 작성할 때는 escaping, 중첩 메시지, argument type 구조를 이해하고 사용하는 것이 좋습니다.

1.7.1 Escaping

ICU Message Format에서는 {}, #, ' 같은 문자가 특별한 의미를 가질 수 있습니다.

예를 들어 {name}은 단순 텍스트가 아니라 런타임에 전달되는 변수로 해석됩니다.

{
  "welcome": "안녕하세요, {name}님"
}

위 메시지에서 {name}은 실제 렌더링 시 전달된 값으로 치환됩니다.

t('welcome', {name: '낙현'});
// 안녕하세요, 낙현님

또한 plural 내부에서 #은 현재 숫자 값을 의미합니다.

{
  "itemCount": "{count, plural, one {# item} other {# items}}"
}
t('itemCount', {count: 3});
// 3 items

따라서 메시지 안에서 중괄호나 # 문자를 단순 텍스트로 표시해야 하는 경우에는 ICU 파싱 규칙에 주의해야 합니다. 특히 코드 예시, placeholder 문자열, 템플릿 문법을 번역 메시지에 포함해야 하는 경우에는 escaping 처리가 필요할 수 있습니다.

1.7.2 Nested Message

ICU Message Format은 pluralselect를 중첩해서 사용할 수 있습니다.

예를 들어 사용자 역할에 따라 메시지를 먼저 분기하고, 각 분기 안에서 다시 알림 개수에 따라 메시지를 다르게 표시할 수 있습니다.

{
  "notification": "{role, select, admin {{count, plural, =0 {관리자 알림이 없습니다} other {관리자 알림 #개가 있습니다}}} user {{count, plural, =0 {알림이 없습니다} other {알림 #개가 있습니다}}} other {알림 상태를 확인할 수 없습니다}}"
}

위와 같은 방식은 동작할 수 있지만, 실무에서는 신중하게 사용하는 것이 좋습니다. 중첩이 깊어질수록 메시지 구조가 복잡해지고, 중괄호 누락이나 번역 실수로 인해 런타임 오류가 발생하기 쉬워집니다.

복잡한 비즈니스 조건은 가능한 한 코드에서 처리하고, 번역 메시지는 단순한 구조로 유지하는 것이 좋습니다.

예를 들어 다음과 같이 역할별 메시지 key를 분리할 수 있습니다.

{
  "adminNotification": "{count, plural, =0 {관리자 알림이 없습니다} other {관리자 알림 #개가 있습니다}}",
  "userNotification": "{count, plural, =0 {알림이 없습니다} other {알림 #개가 있습니다}}"
}
const keyByRole = {
  admin: 'adminNotification',
  user: 'userNotification'
} as const;

t(keyByRole[role], {count});

이 방식은 메시지 파일의 가독성을 높이고, 번역 작업 중 실수 가능성을 줄일 수 있습니다.

ICU Message Format은 메시지 내부에서 다양한 조건과 포맷팅을 처리할 수 있지만, 모든 비즈니스 로직을 메시지 안에 넣는 것은 좋지 않습니다. 메시지는 locale에 따라 달라지는 표현을 담당하고, 복잡한 조건 판단은 코드에서 처리하는 방식이 유지보수에 더 유리합니다.

2. next-intl의 주요 기능

ICU Message Format은 다국어 메시지를 표현하기 위한 문법입니다. 반면 next-intl은 Next.js 환경에서 ICU 메시지를 로드하고, locale routing, Server Component, Client Component, React rich text rendering 등을 처리하기 위한 라이브러리입니다.

따라서 plural, select, date/time/number formatting은 ICU Message Format에 속하는 기능이고, t.rich(), locale routing, middleware, Server Component 지원 등은 next-intl이 Next.js와 React 환경에 맞게 제공하는 기능으로 분리해서 이해하는 것이 좋습니다.

2.1 Rich Text Rendering

Rich text rendering은 번역 메시지 안에 링크, 강조, 하이라이트, 줄바꿈 같은 UI 요소가 포함되어야 할 때 사용하는 next-intl의 기능입니다.

next-intl에서는 t.rich()를 사용해 메시지 안의 태그 형태 placeholder를 React 컴포넌트로 매핑할 수 있습니다. 이를 통해 문장을 여러 번역 key로 쪼개지 않고도, 하나의 번역 메시지 안에서 자연스러운 문장 구조를 유지할 수 있습니다.

{
  "terms": "<terms>이용약관</terms>과 <privacy>개인정보 처리방침</privacy>에 동의합니다."
}
t.rich('terms', {
  terms: (chunks) => <Link href="/terms">{chunks}</Link>,
  privacy: (chunks) => <Link href="/privacy">{chunks}</Link>
});

위 예시에서 <terms><privacy>는 실제 HTML 태그라기보다는 React 컴포넌트로 치환하기 위한 placeholder에 가깝습니다. 실제 href, className, 이벤트 핸들러 등은 번역 메시지 안에 작성하지 않고 코드에서 관리하는 것이 좋습니다.

다만 t.rich()는 ICU MessageFormat의 core 문법이라기보다는, next-intl이 React 렌더링을 위해 제공하는 rich text 처리 API에 가깝습니다. plural, select와 함께 사용할 수는 있지만, 기능의 출처는 ICU가 아니라 next-intl의 React 통합 기능으로 보는 것이 정확합니다.

2.2 Locale Routing

next-intl은 Next.js의 routing 구조와 함께 locale 기반 URL을 구성할 수 있도록 지원합니다.

예를 들어 다음과 같은 URL 구조를 사용할 수 있습니다.

/ko/products
/en/products
/ja/products

이러한 구조를 사용하면 사용자의 locale에 따라 적절한 메시지 파일을 로드하고, 페이지 URL 자체에서도 현재 언어를 명확하게 표현할 수 있습니다. 공개 페이지나 SEO가 중요한 서비스에서는 locale이 URL에 드러나는 구조가 유리할 수 있습니다.

일반적으로 Next.js App Router에서는 [locale] segment를 사용해 locale별 route를 구성합니다.

app/
  [locale]/
    layout.tsx
    page.tsx
messages/
  ko.json
  en.json

이 구조에서 ko.json, en.json 같은 메시지 파일은 현재 route의 locale 값에 따라 로드됩니다.

2.3 Server Component / Client Component 지원

Next.js App Router에서는 Server Component와 Client Component가 분리되어 있기 때문에, i18n 라이브러리도 이 경계를 고려해야 합니다.

next-intl은 Server Component와 Client Component에서 각각 번역 메시지를 사용할 수 있는 API를 제공합니다. Client Component에서는 일반적으로 useTranslations()를 사용합니다.

'use client';

import {useTranslations} from 'next-intl';

export default function Button() {
  const t = useTranslations('Button');

  return <button>{t('submit')}</button>;
}

Server Component에서는 서버 환경에서 메시지를 가져와 렌더링할 수 있습니다. 이 방식은 초기 HTML 생성 시점에 번역된 문자열을 포함할 수 있어 SEO나 초기 렌더링 측면에서 유리합니다.

따라서 next-intl을 사용할 때는 "이 번역이 Server Component에서 필요한지, Client Component에서 필요한지"를 기준으로 API 사용 위치를 결정하는 것이 좋습니다.

2.4 Metadata 번역

Next.js에서는 페이지의 title, description, Open Graph metadata 같은 값도 locale에 따라 달라져야 할 수 있습니다.

예를 들어 한국어 페이지에서는 다음과 같은 metadata가 필요할 수 있습니다.

title: 상품 목록
description: 판매 중인 상품을 확인하세요.

영어 페이지에서는 다음처럼 달라질 수 있습니다.

title: Products
description: Browse available products.

next-intl을 사용하면 페이지 본문뿐 아니라 metadata 생성 로직에서도 locale에 맞는 메시지를 사용할 수 있습니다. 공개 페이지, 검색 노출, SNS 공유가 중요한 서비스라면 metadata 번역도 i18n 범위에 포함하는 것이 좋습니다.

2.5 메시지 구조와 Namespace

다국어 메시지가 많아지면 하나의 JSON 파일에 모든 key를 넣기보다, 화면 또는 도메인 단위로 namespace를 나누는 것이 좋습니다.

예를 들어 다음과 같이 구성할 수 있습니다.

{
  "Home": {
    "title": "홈",
    "description": "서비스 소개 페이지입니다."
  },
  "Product": {
    "title": "상품",
    "price": "가격"
  },
  "Common": {
    "save": "저장",
    "cancel": "취소"
  }
}

컴포넌트에서는 필요한 namespace만 선택해 사용할 수 있습니다.

const t = useTranslations('Product');

t('title');
t('price');

namespace를 적절히 나누면 메시지 key 충돌을 줄이고, 특정 화면에서 사용하는 번역 범위를 더 쉽게 파악할 수 있습니다.

3. React / Next.js i18n 라이브러리

React와 Next.js 환경에서는 다양한 i18n 라이브러리를 사용할 수 있습니다. 각 라이브러리는 메시지 포맷, 라우팅 지원, Server Component 지원, 번역 리소스 관리 방식에서 차이가 있습니다.

Next.js 프로젝트에서는 단순히 번역 문자열을 치환하는 것뿐만 아니라, locale routing, Server Component, metadata, middleware, SEO까지 함께 고려해야 합니다. 따라서 React 전용 i18n 라이브러리를 그대로 사용할 수도 있지만, Next.js와의 통합을 지원하는 라이브러리를 사용하는 것이 더 적합한 경우가 많습니다.

3.1 next-intl

next-intl은 Next.js 환경에서 사용하기 좋은 i18n 라이브러리입니다. ICU Message Format을 기반으로 plural, select, 날짜/시간/숫자 포맷팅을 지원하며, App Router와 함께 사용할 수 있는 구조를 제공합니다.

예시:

{
  "Home": {
    "title": "홈",
    "itemCount": "{count, plural, =0 {상품이 없습니다} other {상품 #개가 있습니다}}"
  }
}
import {useTranslations} from 'next-intl';

export default function HomePage({count}: {count: number}) {
  const t = useTranslations('Home');

  return (
    <>
      <h1>{t('title')}</h1>
      <p>{t('itemCount', {count})}</p>
    </>
  );
}

next-intl의 주요 특징은 다음과 같습니다.

항목 설명
ICU Message Format plural, select, date, time, number 지원
App Router 지원 Next.js App Router 구조와 함께 사용 가능
Server Component 지원 서버 렌더링 시점에 번역 메시지 사용 가능
Client Component 지원 useTranslations() hook으로 클라이언트 컴포넌트에서 사용 가능
Rich Text Rendering t.rich()를 통해 메시지 안의 태그를 React 컴포넌트로 매핑 가능
Locale Routing /ko, /en 같은 locale 기반 라우팅 구성 가능

Next.js App Router 기반의 신규 프로젝트라면 next-intl을 우선 검토할 수 있습니다.

3.2 react-intl

react-intl은 FormatJS 생태계의 React용 i18n 라이브러리입니다. ICU Message Format을 적극적으로 사용하는 대표적인 라이브러리이며, FormattedMessage, useIntl() 같은 API를 제공합니다.

예시:

import {FormattedMessage} from 'react-intl';

export default function CartMessage({count}: {count: number}) {
  return (
    <FormattedMessage
      id="cart.itemCount"
      defaultMessage="{count, plural, =0 {No items} one {# item} other {# items}}"
      values={{count}}
    />
  );
}

react-intl은 ICU Message Format 중심의 번역 구조를 선호하거나, FormatJS 기반의 메시지 추출 및 관리 워크플로우를 사용하고 싶은 경우에 적합합니다.

다만 Next.js의 locale routing, middleware, Server Component, metadata 번역 등은 직접 설계해야 할 수 있습니다. 따라서 Next.js App Router와의 통합 편의성만 놓고 보면 next-intl이 더 단순할 수 있습니다.

3.3 react-i18next

react-i18next는 i18next 생태계의 React 바인딩 라이브러리입니다. React 진영에서 많이 사용되며, namespace, fallback, lazy loading, 번역 리소스 관리 기능이 강합니다.

기본 i18next 문법은 ICU Message Format과 다릅니다.

{
  "welcome": "Hello {{name}}"
}
import {useTranslation} from 'react-i18next';

export default function Welcome() {
  const {t} = useTranslation();

  return <p>{t('welcome', {name: 'Nakhyeon'})}</p>;
}

ICU Message Format을 사용하고 싶다면 i18next-icu 플러그인을 추가로 사용할 수 있습니다.

import i18n from 'i18next';
import ICU from 'i18next-icu';
import {initReactI18next} from 'react-i18next';

i18n
  .use(ICU)
  .use(initReactI18next)
  .init({
    lng: 'en',
    resources: {
      en: {
        translation: {
          itemCount: '{count, plural, one {# item} other {# items}}'
        }
      }
    }
  });

react-i18next는 기존에 i18next 기반 번역 리소스가 있거나, React/Vite/Next.js 등 여러 프레임워크에서 번역 구조를 공유해야 하는 경우에 유리합니다.

3.4 선택 기준

라이브러리 선택 기준은 다음과 같이 정리할 수 있습니다.

상황 추천 라이브러리
Next.js App Router 신규 프로젝트 next-intl
ICU Message Format과 FormatJS 생태계 중심 react-intl
기존 i18next 리소스가 있음 react-i18next
Next.js에서 locale routing, Server Component, metadata까지 고려 next-intl
React/Vite/Next.js 간 번역 리소스 공유 필요 react-i18next
메시지 추출/컴파일 워크플로우 중요 react-intl 또는 FormatJS 계열

Next.js에서 다국어 기능을 새로 도입한다면 next-intl을 우선 검토하는 것이 좋습니다. React 생태계 전체에서 범용적으로 사용할 번역 시스템이 필요하다면 react-i18next도 좋은 선택입니다. ICU Message Format 중심의 워크플로우를 강하게 가져가고 싶다면 react-intl을 검토할 수 있습니다.

참고. VSCode Extension

i18n ally plugin을 설치하면, 편리하게 i18n 작업이 가능합니다.

(설정 참고 링크: VSCode에 국제화 extension i18n ally 적용하기)

댓글

댓글을 불러오는 중...