본문 바로가기

공부/Next

next-intl을 이용한 다국어 처리

반응형

 

국내 서비스라고 하더라도 글로벌 사용자를 고려해 다국어 지원을 지원하고 있습니다. 이번 프로젝트에서는 `next-intl`을 사용해 간단하고 일관성 있는 다국어 처리 구조를 만들었고, 그 구조와 사용 패턴에 대해 소개해보려고 합니다.

next-intl 사용 이유 ✨

서버와 클라이언트에서 일관된 처리

`next-intl` 은 Next.js의 서버 사이드 렌더링(SSR)과 클라이언트 사이드 렌더링(CSR) 모두에서 일관된 방식으로 데이터를 처리할 수 있습니다.

 

자동 로딩 및 타입 지원 

다국어 메시지 파일을 자동으로 로드하고, Typescript와의 호환성을 제공하여 타입 안정성을 보장해 주기 때문에 실수를 줄이고 편리하게 작업할 수 있습니다.

 

locale 기반 포맷팅 지원

숫자, 날짜, 시간 등 다양한 포맷을 locale에 맞게 자동으로 처리할 수 있어 포맷팅 로직을 신경 쓸 필요 없이 간편하게 구현할 수 있습니다.

 

폴더 구조 📁

/messages
  ├── en.json         // 영어 번역
  ├── ko.json         // 한국어 번역
  └── en.d.json.ts    // 타입 선언 (optional)
  
/i18n
  ├── request.ts      // API 요청과 관련된 다국어 로직
  └── type.d.ts       // 다국어 타입 정의
  
/next.config.mjs

 

messages 폴더

// messages > en.json
{
  "OnboardingMsg": {
    "slide1-title": "Welcome to KINS!",
    "slide1-subTitle": "KINS is the largest job opening/job search service in Korea"
  },
  "FacebookLogin": "Continue with Facebook",
  
  ...
  
}
// messages > ko.json
{
  "OnboardingMsg": {
    "slide1-title": "KINS에 오신걸 환영해요!",
    "slide1-subTitle": "KINS는 국내 최대 Nanny 구인/구직 서비스에요."
  },
  "FacebookLogin": "Facebook으로 계속하기",
  
  ...
  
}
// messages > en.d.json.ts

declare const messages: {
  "OnboardingMsg": {
    "slide1-title": "Welcome to KINS!",
    "slide1-subTitle": "KINS is the largest job opening/job search service in Korea",
  },
  "FacebookLogin": "Continue with Facebook",
  "TabLabel": {
    "Home": "Home",
    "Recruiting": "Recruiting",
    "JobSearch": "JobSearch",
    "Chat": "Chat",
    "Mypage": "Mypage"
  },
}

 

타입 안정성을 위해 `en.d.json.ts` 파일을 두고 `messages` 객채의 타입을 정의했습니다.

 

request.ts 파일

`request.ts` 파일은 서버에서 다국어 메시지를 로드하고 포맷팅 규칙을 설정하는 파일입니다.

`getRequestConfig`와 `Formats`를 활용해 서버사이드 렌더링에서 사용할 locale 및 메시지를 설정했습니다.

import type { Formats } from 'next-intl';

import { cookies } from 'next/headers';
import { getRequestConfig } from 'next-intl/server';
import { EnumCookieKeys } from '@src/types/EnumCookieKeys';

export const formats = {
  number: {
    precise: {
      maximumFractionDigits: 5
    }
  },
  list: {
    enumeration: {
      style: 'long',
      type: 'conjunction'
    }
  },
  dateTime: {
    short: {
      day: 'numeric',
      month: 'short',
      year: 'numeric'
    },
    ampm: {
      // ex) 오전 9:30 | PM 6:20
      hour12: true,
      hour: 'numeric',
      minute: 'numeric'
    },
    withDay: {
      // ex) 2025년 0월 00일 0요일 | Wednesday, April 2, 2025
      month: 'long',
      day: 'numeric',
      weekday: 'long',
      year: 'numeric'
    },
    reportDate: {
      // ex) 4/2/2025, 3:48 PM
      hour12: false,
      day: 'numeric',
      hour: 'numeric',
      year: 'numeric',
      month: 'numeric',
      minute: 'numeric'
    }
  }
} satisfies Formats;

export default getRequestConfig(async () => {
  // Provide a static locale, fetch a user setting,
  // read from `cookies()`, `headers()`, etc.

  // const headersList = headers();
  const cookieStore = cookies();
  const locale = (cookieStore.get(EnumCookieKeys.locale)?.value as KINS_LOCALE) || 'en';

  return {
    locale,
    formats,
    messages: (await import(`../../messages/${locale}.json`)).default
  };
});

export type KINS_LOCALE = 'ko' | 'en';

 

formats 설정

 

formats 객체는 날짜, 시간, 숫자 등의 포맷 규칙을 통해 locale을 정의합니다.

💡 `short`, `ampm`, `withDay` 형식으로 다양하게 설정할 수 있습니다.

dateTime: {
    short: {
      day: 'numeric',
      month: 'short',
      year: 'numeric'
    },
    ampm: {
      hour12: true,
      hour: 'numeric',
      minute: 'numeric'
    },
    withDay: {
      month: 'long',
      day: 'numeric',
      weekday: 'long',
      year: 'numeric'
    },
}

 

getRequestConfig 설정

 

서버에서 쿠키에 있는 현재 locale을 결정하고 해당 locale에 맞게 메시지를 동적으로 로드합니다.

이를 통해 locale에 맞는 메시지를 `messages` 폴더에서 불러와 반환합니다.

// i18n/request.ts

export default getRequestConfig(async () => {
  const cookieStore = cookies();
  const locale = (cookieStore.get(EnumCookieKeys.locale)?.value as KINS_LOCALE) || 'en';

  return {
    locale,
    formats,
    messages: (await import(`../../messages/${locale}.json`)).default
  };
});

 

type.d.ts 파일

`type.d.ts`파일은 next-intl의 타입을 확장하는 역할을 합니다.

import messages from '@src/../messages/en.json';

import { formats } from './request';

declare module 'next-intl' {
  interface AppConfig {
    Locale: 'ko' | 'en';
    Formats: typeof formats;
    Messages: typeof messages;
  }
}

 

`AppConfig` 인터페이스를 통해 `Locale`, `Formats`, `Messages` 에 대한 타입을 정의 및 안정성을 확보하고 다국어 메시지를 안전하게 사용할 수 있도록 합니다.

 

next.config.mjs 설정

import createNextIntlPlugin from 'next-intl/plugin';

const withNextIntl = createNextIntlPlugin({
  experimental: {
    createMessagesDeclaration: './messages/en.json'
  }
});

/**
 * @type {import('next').NextConfig}
 */
const nextConfig = {
  experimental: {
    appDir: true
  },

  reactStrictMode: false,

  ...
 
};

export default withNextIntl(nextConfig);

 

experimental.createMessagesDeclaration 설정

 

다국어 메시지 선언 파일을 자동으로 생성하는 기능을 제공합니다.

const withNextIntl = createNextIntlPlugin({
  experimental: {
    createMessagesDeclaration: './messages/en.json'
  }
});

 

 

아래와 같은 형식으로 자동 생성됩니다.

// messages > en.d.json.ts
declare const messages: {
  "OnboardingMsg": {
    "slide1-title": "Welcome to KINS!",
    "slide1-subTitle": "KINS is the largest job opening/job search service in Korea",
    "slide2-title": "Various Recruitment Announcements",
    "slide2-subTitle": "KINS offers a variety of custom job openings/job openings",
    "slide3-title": "Easy and Easy to Write a Resume",
    "slide3-subTitle": "KINS' resume is easy and easy to write",
    "slide4-title": "It's easy to chat",
    "slide4-subTitle": "Let's find a job/job easily through chatting"
  },
  "FacebookLogin": "Continue with Facebook",
  "TabLabel": {
    "Home": "Home",
    "Recruiting": "Recruiting",
    "JobSearch": "JobSearch",
    "Chat": "Chat",
    "Mypage": "Mypage"
  },
}

 

사용법 📖

useTranslations 훅

next-inlt의 `useTranslations` 훅을 사용하면 메시지를 안전하게 가져올 수 있습니다.

import { useTranslations } from 'next-intl';

const t = useTranslations();
t('OnboardingMsg.slide1-title'); // Welcome to KINS!

 

컴포넌트 내부에서 각 메시지 키를 직접 호출하며 사용되고, Chat, jobSearch, Mypage 등 각 도메인별로 네이밍을 구분해 관리했습니다.

 

locale 전환

언어 전환 시 페이지 전체를 새로고침 하지 않고 `useLocale` 훅으로 현재 언어를 확인하는 버튼 UI를 구성했습니다.

const HomeMainSection = () => {
  const t = useTranslations();

  const locale = useLocale(); // ✅ 현재 언어 확인

  return (
    <div className="overflow-y-auto scrollbar-hide">
      <Header
        leftIcon={<ICON_MAP.KinsLogo />}
        rightIcon={
          <NormalButton
            variant="outline"
            size="thin"
            className="aspect-square rounded-full"
            onClick={() => {
              changeLocale(); // ✅ 현재 언어 변경
            }}
          >
            {locale === 'en' ? 'EN' : 'KO'}
          </NormalButton>
        }
      />
	
    ...

    </div>
  );
};

 

`locale`전환은 `changeLocale()` 함수를 따로 유틸로 분리해서 처리했습니다.

export const changeLocale = async () => {
  const cookieStore = await cookies();
  const currentLocale = (cookieStore.get(EnumCookieKeys.locale)?.value as KINS_LOCALE) || 'en';
  const newLocale = currentLocale === 'en' ? 'ko' : 'en';
  cookieStore.set(EnumCookieKeys.locale, newLocale);
  return newLocale;
};

 

ChatRoom에 적용

`useChatRoomSettings` 훅 안에서도 다국어 처리를 활용했습니다.

const t = useTranslations();
const language = useLocale();
const format = useFormatter();

const jobTypeText =
  currentRoom?.post_type === 'seeker'
    ? t('ChatRoom.jobTypeText.seeker')
    : t('ChatRoom.jobTypeText.employer');

 

또한, 날짜도 아래와 같이 다국어로 포맷팅 해서 사용할 수 있습니다.

const formattedDate = format.dateTime(new Date(curr.created_at), 'withDay')
withDay: {
  // ex) 2025년 0월 00일 0요일 | Wednesday, April 2, 2025
  month: 'long',
  day: 'numeric',
  weekday: 'long',
  year: 'numeric'
},

 

테스트 화면 📖

테스트 영상

회고 🧐

첫 번째 목차

이번 `next-intl`을 적용하면서 가장 크게 느낀점은

서버와 클라이언트 모두 자연스럽게 locale을 처리할 수 있도록 설계하는 과정을 경험해 보면서

타입 안정성과, 포맷 관리, 쿠키 기반의 locale 추적 등 재사용 가능한 형태로 확장할 수 있다는 점에서 의미가 있었던 작업이었습니다.

 

앞으로 더 다양한 언어를 지원할 수 있도록 확장할 계획입니다.

 

아직 많이 부족하기 때문에 조언은 언제나 환영입니다 :)


출처 🏷️

https://next-intl.dev/

반응형