React, Next에서 i18next를 사용해서 언어셋 설정하기

Explanation

시간이 흐르고 흘러 어느덧 10월의 마지막 주 주말입니다. 올해도 얼마 남지 않았네요(두렵다..)
날이 많이 쌀쌀해지고 있어요 다들 건강 조심하세요.
오늘은!! react와 next에서 i18next를 사용해서 언어셋을 설정하는 방법을 간단하게 적어보려 합니다.

작성된 코드는 https://github.com/falsy/blog-post-example/tree/master/i18next-react-next 에서 확인하실 수 있습니다!

1. React

우선 필요한 패키지를 설치해 줍니다.

$ npm i -D i18next react-i18next

다음으로 간단하게 한국어과 영어로 언어셋을 만들어 줄게요.

// src/locales/ko.json
{
  "안녕하세요": "안녕하세요"
}
// src/localse/en.json
{
  "안녕하세요": "hello"
}

다음으로 i18n 설정도 간단하게,

// src/localse/i18n.ts
import i18n from "i18next"
import { initReactI18next } from "react-i18next"

import en from './en.json'
import ko from './ko.json'

const resources = {
  en: {
    translation: en
  },
  ko: {
    translation: ko
  }
}

i18n
  .use(initReactI18next)
  .init({
    resources,
    lng: "ko",
    fallbackLng: "ko"
  })

export default i18n

그럼 이제 설정한걸 적용해 볼까요?

// src/index.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'

const container = document.getElementById('wrap')
const root = ReactDOM.createRoot(container as HTMLElement)

import './locales/i18n'
import { useTranslation } from 'react-i18next'

const App = () => {
  const { i18n, t } = useTranslation()

  const handleClickChangeLng = () => {
    i18n.changeLanguage(i18n.language === 'ko' ? 'en' : 'ko')
  }

  return (
    <>
      <div>{t('안녕하세요')}</div>
      <p onClick={handleClickChangeLng}>언어변경</p>
    </>
  )
}

root.render(
  <App />
)

끝! 엄청 간단하네요!

2. Next

역시, 우선 패키지 먼저 설치해 줄게요.

$ npm i -D i18next react-i18next next-i18next

react 에서는 src/locales 에 있던 언어셋을 public 에 아래와 같이 넣어줄게요.

// public/locales/ko/common.json
{
  "안녕하세요": "안녕하세요"
}
// public/localse/en/common.json
{
  "안녕하세요": "hello"
}

(React에서 했을때랑 디렉토리명, 파일명 다 조금씩 달라졌어요.)

다음으로 next-i18next.config.js를 만들어서 next.config.js에 적용해줍니다.

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

module.exports = {
  i18n
}

이제 _app 파일로 가서, appWithTranslation로 감싸주고

// src/_app.tsx
import { AppProps } from 'next/app'
import Head from 'next/head'
import { appWithTranslation } from 'next-i18next'


function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <Head>
        <title>next-i18next</title>
      </Head>
      <Component {...pageProps} />
    </>
  )
}

export default appWithTranslation(App)

그럼 이제 적용해 볼까요?

// src/index.tsx
import { GetServerSideProps } from "next"
import { serverSideTranslations } from "next-i18next/serverSideTranslations"
import { useTranslation } from 'next-i18next'

export default function Home() {
  const { i18n, t } = useTranslation('common')

  const handleClickChangeLng = () => {
    const newlng = i18n.language === 'ko' ? '/en' : ''
    const { protocol, host, pathname } = window.location
    const newPath = pathname.replace(/^\/en(\/|$)/g, '/')
    window.location.href = protocol + '//' + host + newlng + newPath
  }

  return (
    <>
      <div>{t('안녕하세요')}</div>
      <p onClick={handleClickChangeLng}>언어변경2</p>
    </>
  )
}

export const getServerSideProps: GetServerSideProps = async ({ locale  }) => {
  return {
    props: {
      ...(await serverSideTranslations(
        locale as string,
        ['common']
      ))
    }
  }
}

next-i18next는 도메인에 첫번째 패스에 언어를 넣는 구성으로 사용이되는 거 같아요.
그래서 언어 변경을 할때, 저는 예시로 간단하게 정규표현식으로 언어 부분의 패스를 바꿔주었어요.

const { i18n, t } = useTranslation('common')

여기서 common은 아까 만든 언어셋 파일명이랍니다(common.json). 생략해도 되지만 그러면 사용할때 'common.' 으로 시작해야 해요.

const { i18n, t } = useTranslation()
  
  ...

<div>{t('common.안녕하세요')}</div>

3. Next (App Directory)

작성하는 김에, 마지막으로! Next의 앱 디랙토리 구조에서의 적용도 해볼게요.

우선 i8n 설정을 먼저 해줄게요.
아까와 같이 언어셋을 app 디랙토리 안에 i18n 폴더를 만들고 그안에 위치해줄게요.

// app/i18n/locales/ko/common.json
{
  "안녕하세요": "안녕하세요"
}
// app/i18n/localse/en/common.json
{
  "안녕하세요": "hello"
}

그리고 설정파일을 만들어줄게요.

// app/i18n/settings.ts
export const fallbackLng = 'ko'
export const languages = [fallbackLng, 'en']
export const defaultNS = 'common'

export function getOptions (lng = fallbackLng, ns = defaultNS) {
  return {
    supportedLngs: languages,
    fallbackLng,
    lng,
    fallbackNS: defaultNS,
    defaultNS,
    ns,
  }
}

다음 useTranslation를 만들어 줄게요.

// app/i18n/index.ts
import { createInstance } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import { initReactI18next } from 'react-i18next/initReactI18next'
import { getOptions } from './settings'

const initI18next = async (lng: string, ns?: string) => {
  const i18nInstance = createInstance()
  await i18nInstance
    .use(initReactI18next)
    .use(resourcesToBackend((language: string, namespace: string) => import(`./locales/${language}/${namespace}.json`)))
    .init(getOptions(lng, ns))
  return i18nInstance
}

export async function useTranslation(lng: string, ns?: string, options: any = {}) {
  const i18nextInstance = await initI18next(lng, ns)
  return {
    t: i18nextInstance.getFixedT(lng, Array.isArray(ns) ? ns[0] : ns, options.keyPrefix),
    i18n: i18nextInstance
  }
}

이제 페이지 디랙토리를 구성해볼까요?

// app/[lng]/layout.ts
import { dir } from 'i18next'
import { languages } from '../i18n/settings'
import { ReactNode } from 'react'

export async function generateStaticParams() {
  return languages.map((lng) => ({ lng }))
}

export default function RootLayout({ 
  children, 
  params: { 
    lng 
  }
}: { 
  children: ReactNode
  params: {
    lng: string
  }
}) {
  return (
    <html lang={lng} dir={dir(lng)}>
      <head />
      <body>
        {children}
      </body>
    </html>
  )
}
// app/[lng]/page.ts
'use client'

import { fallbackLng, languages } from '../i18n/settings'
import { useTranslation } from '../i18n'

export default async function Page({ 
  params: { 
    lng 
  } 
}: { 
  params: { 
    lng: string 
  }
}) {
  if (languages.indexOf(lng) < 0) lng = fallbackLng
  const { t } = await useTranslation(lng)

  const handleClickChangeLng = () => {
    const newlng = lng === 'ko' ? '/en' : '/ko'
    const { protocol, host, pathname } = window.location
    const newPath = pathname.replace(/^\/en|ko(\/|$)/g, '/')
    window.location.href = protocol + '//' + host + newlng + newPath
  }

  return (
    <>
      <p>{t('안녕하세요')}</p>
      <p onClick={handleClickChangeLng}>언어변경</p>
    </>
  )
}

그런데 이렇게 되면, / 패스로 접근하게 되면 404가 뜨기 때문에 middleware를 만들어 줍니다.

// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { fallbackLng, languages } from './app/i18n/settings'

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|assets|favicon.ico|sw.js).*)']
}

export function middleware(req: NextRequest) {
  if (!languages.some(loc => req.nextUrl.pathname.startsWith(`/${loc}`))) {
    return NextResponse.redirect(new URL(`/${fallbackLng}${req.nextUrl.pathname}`, req.url))
  }

  return NextResponse.next()
}

조건문 그대로 URI의 패스가 제가 등록한 언어 이름으로 시작하지 않으면 fallbackLng으로 리다이렉트 시키도록 합니다. 위에 config는, 미들웨어가 실행되는 매칭 기준?? 을 설정하는 거에요.
(api, static, image, assets, favicon, sw이 호출 될때는 아래 미들웨어가 실행되지 않도록)

끝

참고.
* https://react.i18next.com/getting-started
* https://locize.com/blog/next-i18n-static/
* https://locize.com/blog/next-13-app-dir-i18n/
* https://github.com/i18next/next-13-app-dir-i18next-example/tree/main