Next.js로 다국어(i18n) 제공하기

전병민·2023년 3월 25일
19

프론트엔드

목록 보기
1/3
post-thumbnail

i18n(internationalization)은 글로벌 시장에서 제품과 서비스를 제공하기 위한 중요한 요소 중 하나이며, 전 세계적으로 다양한 언어와 문화를 고려한 서비스 제공을 위해 필수적인 기술입니다. 이번 포스팅에서는 Next.js에서 국제화 혹은 다국어 지원하는 방법에 대해 알아보겠습니다.

많은 자료들을 참고하여 작성하였지만, 개인적인 생각이 포함되어 있으며 사실과 다를 수 있습니다.


사용자의 지역을 어떻게 알 수 있을까?

클라이언트 - window.navigator

일반적으로 다국어를 지원하기 위해서는 유저가 어떤 locale 의 유저인지 알 수 있어야 합니다. 유저가 브라우저를 사용하고 있는 환경이라면 Web API을 이용할 수 있습니다. navigator.language 는 유저가 사용 중인 브라우저 UI 언어를 저장하고 있으며 이는 브라우저가 직접 세팅합니다. navigation.language 를 가져오면 ko, en, en-US 등 과 같은 UTS Locale Identifiers 형식의 string 을 가져올 수 있습니다.

const locale = window.navigator.language

서버 - HTTP Header 의 Accept-Language

다만 Next.js는 기본적으로 모든 페이지를 서버 사이드 렌더링하기 때문에, 수화 이후에 locale을 가져오는 방법은 UX를 고려한다면 썩 좋은 방법은 아닙니다. 따라서 서버에서 유저가 사용 중인 언어를 알 수 있어야 합니다. Next.js는 사용자가 웹 애플리케이션의 루트에 방문했을 때 요청하는 HTTP의 Accept-Language 헤더에 기반하여 사용자의 locale을 감지합니다. 이 Accept-Languagenavigator.language 와 마찬가지로 브라우저가 설정합니다.

Accept-Language는 브라우저의 버전 및 사용자 설정과 함께 핑거프린팅이 될 수 있기 때문에 직접 변경하는 것은 권장되지 않습니다.


Next.js의 Internationalized Routing

  1. 서버는 Accept-Language 값을 통해 사용자의 언어를 감지합니다.
  2. locale에 맞는 URL로 라우팅 됩니다.
  3. html tag에 lang 속성을 추가합니다.

왜 Next.js는 i18n을 위해 라우팅하는 방법을 선택했을까?

사용자는 충분히 다른 언어로 애플리케이션을 이용하고 싶을 수 있습니다. 하지만 수화 이후 메모리 값을 통해 언어를 수정하는 방법은 마찬가지로 Next.js가 기본적으로 서버 사이드 렌더링을 하기 때문에 UX를 고려한다면 좋은 선택은 아닙니다. 위에서 언급한대로 HTTP 헤더에서 Accept-Language 를 직접 수정하는 방법도 권장되지 않습니다. 따라서 Next.js에서는 정적 페이지에서 i18n을 지원하는 방법(URL을 기반으로 i18n을 지원)을 따라가고 있다고 생각합니다.

핵심은 Next.js에서 i18n을 제공하기 위해서는 example.com/ko, example.com/en-US 와 같은 식으로 라우팅이 이루어져야 한다는 것입니다. 다행히도 Next.js는 v10.0.0 부터 i18n Routing을 기본적으로 제공합니다. 이를 이용하기 위해서 next.config.js 를 설정해야 합니다.

next.config.js 설정하기

//next.config.js
module.exports = {
  ...  
  i18n: {
    locales: ['en-US', 'en', 'ko'],
    defaultLocale: 'en-US',
  },
  ...
}

locales 에는 지원한 언어를 설정합니다. 언어 코드는 여기서 확인해볼 수 있습니다. 모든 언어를 지원한다면 더할 나위 없이 좋겠지만 아주 글로벌한 서비스를 운영하는 것이 아니라면 그럴만한 여유가 없을 것입니다. 따라서 기본적으로 제공할 locale을 defaultLocale 에서 설정할 수 있습니다. 다음은 위의 config 코드를 기반으로 defaultLocale을 설정하는 순서입니다.

  1. 서버가 감지한 locale이 ko일 때, locales 배열에 ko가 있기 때문에 최종적인 localeko 가 됩니다.
  2. 서버가 감지한 locale이 en-GB일 때, locales 배열에 en-GB가 없는 대신 en가 있기 때문에 최종적인 localeen 이 됩니다.
  3. 서버가 감지한 locale이 fr일 때, locales 배열에 fr가 없기 때문에 최종적인 localedefaultLocaleen-US 가 됩니다.

이러한 특성을 고려하여 locales 와 defaultLocale을 잘 설정하는 것이 좋겠습니다. 만약 한국어와 영어만 제공할 예정이라면 영어를 defaultLocale로 설정하는 것이 글로벌한 서비스를 위해 적절할 것입니다.

Next.js 은 locale strategies 로 두가지 방법을 제공합니다.

  1. Sub-path Routing: example.com/ko, example.com 처럼 서브 경로로 라우팅됩니다.
  2. Domain Routing: example.com, example.fr 처럼 다른 도메인으로 라우팅 됩니다.

자세한 내용은 여기를 참고해주세요.

Default Locale 도 Sub-path 적용하기

default locale 에도 sub-path를 적용하고 싶다면 Next.js의 미들웨어를 사용해야 합니다. 먼저 next.config.js를 다음과 같이 수정해주세요.

// next.config.js

module.exports = {
  i18n: {
    // default는 잘못 입력한 것이 아닙니다. default를 제외하고 수정 해주세요.
    locales: ['default', 'en', 'ko'],
    defaultLocale: 'default',
    localeDetection: false,
  },
  trailingSlash: true,
}

그 다음 pages 디렉토리가 존재하는 디렉토리 내에 middleware.ts 를 생성한 뒤 다음과 같이 작성해주세요.

// middleware.ts

import { NextRequest, NextResponse } from 'next/server'

const PUBLIC_FILE = /\.(.*)$/

export async function middleware(req: NextRequest) {
  if (
    req.nextUrl.pathname.startsWith('/_next') ||
    req.nextUrl.pathname.includes('/api/') ||
    PUBLIC_FILE.test(req.nextUrl.pathname)
  ) {
    return
  }

  if (req.nextUrl.locale === 'default') {
    // 만약 default locale을 수정하고 싶다면 여기의 `en`을 변경하세요.
    const locale = req.cookies.get('NEXT_LOCALE')?.value || 'en'

    return NextResponse.redirect(
      new URL(`/${locale}${req.nextUrl.pathname}${req.nextUrl.search}`, req.url)
    )
  }
}

번역을 위한 라이브러리 next-i18next

i18n Routing 을 위한 설정은 모두 마쳤지만 아직 다국어를 지원하는 것은 아닙니다. 애플리케이션 내의 텍스트를 추출하고 locale 에 따라 다른 언어를 제공해야 할 것입니다. 저는 next-i18next 라이브러리를 이용하였습니다.

next-i18next는 i18next 와 react-i18next 패키지를 사용합니다. i18next는 다국어 지원을 위한 훌륭한 라이브러리이며 현재도 지속적으로 커밋이 이뤄지고 있습니다. 또 다른 라이브러리들과는 다르게 서버 사이드 렌더링을 제공합니다. 저는 이러한 점 때문에 next-i18next를 선택했지만 SSR이 필요없는 서비스라면 유명한 react-intl 를 사용하여도 무방합니다.

next-i18next.config.js

앞서 next.config.js 에 작성하였던 i18n을 next-i18next.config.js로 옮겨줄 것입니다. 굳이 이렇게 하는 이유는 next-i18next에게 defaultLocale이나 기타 locale이 무엇인지 알려주어 서버에서 번역을 preload하기 위함입니다.

//next-i18next.config.js
module.exports = {
  i18n: {
    locales: ['default', 'en', 'ko'],
    defaultLocale: 'default',
    localeDetection: false,
  },
}
//next.config.js
const { i18n } = require('./next-i18next.config')

module.exports = {
  ...
  i18n,
  ...
}

appWithTranslation

appWithTranslation는 i18nextProvider 를 추가하기 위한 HOC입니다. _app 을 감싸주어야 합니다.

import { appWithTranslation } from 'next-i18next'

const MyApp = ({ Component, pageProps }) => (
  <Component {...pageProps} />
)

export default appWithTranslation(MyApp)

Translation 콘텐츠

이제 앱 내에서 번역을 위한 텍스트를 추출할 단계입니다. public 디렉토리에 아래 처럼 번역을 위한 json 파일을 생성합니다.

.
└── public
    └── locales
        ├── en
        |   └── common.json
        |   └── header.json
        |   └── ...
        └── ko
            └── common.json
            └── header.json
            └── ...

다국어를 위해 텍스트를 json 에 모아두는 것은 굉장히 일반적입니다. 위의 예시에서 enko 에 각각 동일한 파일 이름, 동일한 key로 번역할 텍스트를 맵핑하여 Translation 기능을 제공할수 있습니다. 예를 들면 이런식 입니다.

// public/locales/ko.json
{
  "title" : '제목입니다.'
  "author": {
    "name": '작성자 이름입니다.', 
    "email": '작성자 이메일입니다.'
  }
}

// public/locales/en.json
{
  "title" : "This is title."
  "author": {
    "name": "This is author's name.", 
    "email": "This is author's email ."
  }
}

serverSideTranslations

서버 사이드 렌더링을 지원하기 위해서는 마지막 이 단계가 필수적입니다. getStaticProps 혹은 getServerSideProps 에서 다음과 같이 입력해주세요. default locale이 en 이 아니라면 다른 것을 입력하면 됩니다.

import { serverSideTranslations } from 'next-i18next/serverSideTranslations'

export const getServerSideProps: GetServerSideProps = async (ctx) => {
  return {
    props: {
	  ...생략,
      ...(await serverSideTranslations(ctx.locale ?? 'en', ['common', 'result-page'])),
    },
  };
};

useTranslation

이제 앱 내에 텍스트를 주입하기 위한 과정은 끝났습니다. 이제 앱 내에 텍스트를 주입해봅시다. useTranslationpublic/locales/ 경로의 json 파일들의 텍스트를 앱에 주입하기 위한 훅입니다. 아래와 같이 사용할 수 있습니다.

// 하나의 json 에서 가져오는 경우
import { useTranslation } from 'next-i18next'

export const Footer = () => {
  const { t } = useTranslation('footer')

  return (
    <footer>
      {/* 여기서 description은 footer.json 내 key 입니다. */}
      <p>{t('description')}</p>
    </footer>
  )
}
// 여러 json 파일을 사용할 경우
import { useTranslation } from 'next-i18next'

export const Footer = () => {
  // string 대신 배열을 넣습니다.
  const { t } = useTranslation(['footer', 'common'])

  return (
    <footer>
      {/* key 앞에 `filename:` 을 추가합니다. */}
      <p>{t('footer:description')}</p>
    </footer>
  )
}

변수가 포함된 텍스트 번역하기

번역할 텍스트 내에 변수가 포함되는 일은 비일비재하게 일어납니다. 예를 들면 다음과 같이 번역할 텍스트에 변수를 추가할 수 있습니다.

// json
{
  "greeting": "Welcome, {{name}}!" 
}
import { useTranslation } from 'next-i18next'

export const Header = () => {
  const { t } = useTranslation(['header', 'common'])
  const name = '전병민'

  return (
    <header>
      <p>{t('header:greeting', {name})}</p>
    </header>
  )
}

그 외의 내용은 i18next 공식문서를 확인해주세요.


함께 고민해보아야 하는 것

서로 다른 언어 문법에 따른 UI 구조 변경

번역이 추가되면서 기존 UI가 변경되어야 할 수 있습니다. 시간을 선택할 수 있는 Select 를 예로들 수 있겠습니다. 한국어로 제공해야 한다면 아래의 배치가 자연스럽습니다.

[ Start Time Select ] 부터 [ End Time Select ] 까지 

하지만 만약 해당 부분을 영어로 제공해야 한다면 아래와 같이 변경해야 합니다.

From [ Start Time Select ] to [ End Time Select ]

하지만 언어마다 다른 UI 구조를 변경하는 것이 과연 옳은 방법일까요? 저는 그렇게 생각하지 않습니다. 만약 영어가 아니라 프랑스어, 독일어 등 다른 언어를 추가해야 한다면? 언어를 추가할 때마다 UI 변경을 고려할 수는 없습니다. 뿐만 아니라 언어마다 UI가 달라지는 것은 통일성 측면에서도 좋지 않으며 코드의 유지보수 측면에서도 좋지 않을 것입니다. 이 문제는 다음과 같이 해결할 수 있겠습니다.

// 한국어
시간을 선택해주세요.
[ Start Time Select ] ~ [ End Time Select ]

// 영어
Please select time
[ Start Time Select ] ~ [ End Time Select ]

이처럼 언어와 상관없는 UI를 만들어 앱의 통일성을 유지하는 것이 중요할 것 같습니다.

JSON 파일 구성 방식

계속 고민 중인 상태이지만 많은 개발자들은 page 단위로 json 파일을 나누는 것 같습니다.


Troubleshooting

With Vercel

만약 배포를 위해 Vercel을 사용한다면 다음과 같이 next-i18next.config.js 를 수정해야 합니다.

const path = require('path');

module.exports = {
    i18n: {
      defaultLocale: 'en',
      locales: ['en', 'cs'],
      // 추가된 부분
      localePath: path.resolve('./public/locales')
    },
};
profile
JavaScript/React 개발자

0개의 댓글