본문 바로가기

[react-i18next] 다국어 처리(한/영) 및 글자 강조 스타일링하기

[react-i18next] 다국어 처리(한/영) 및 글자 강조 스타일링하기

다국적 언어 지원 기능 구현 

 

아래와 같이 react-i18next 라이브러리를 사용하여 한/영 언어 지원이 되는 사이트를 만들어보았다.

 

구현 화면

 

적용 방법은 어렵지 않고 공식 문서 보고 그대로 따라하면 된다.

  • 특히 주의해야 할 점은 App router 방식이 완전 다르게 구현이 되기 때문에 레퍼런스를 볼 때 꼭 확인을 해야 한다.
  • 즉, next-i18next는 app 라우터에서는 사용되지 않는다.

 

1. 라이브러리 설치 

pnpm add i18next react-i18next i18next-resources-to-backend accept-language

 

2. 폴더 구조 및 파일

└── app
    └── [lng]
        ├── second-page
        |   └── page.js
        ├── layout.js
        └── page.js
        
    └── i18n
        ├── settings.ts
        ├── middleware.js
        ├── index.ts
        ├── client.ts
        ├── en 
        |   ├── home.json  # 홈 컴포넌트에서 영어 언어 데이터 
        |   └── footer.json    # 푸터에서의 영어 언어 데이터 
        └── ko 
            ├── home.json 
            └── footer.json
            
   └── components
            └── Footer
                ├── client.js
                ├── FooterBase.js
                └── index.js

 

각 파일의 내용은 아래와 같다. 

 

app/i18n/settings.js
// 디폴트 언어는 한국어, 그 외 언어는 영어
export const fallbackLng = 'ko';
export const languages = [fallbackLng, 'en'];
export const defaultNS = 'translation';
export const cookieName = 'i18next';

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

 

app/[lng]/page.js
import Link from 'next/link'
import { useTranslation } from '../i18n'
import { Footer } from './components/Footer'

export default async function Page({ params: { lng } }) {
  const { t } = await useTranslation(lng)
  return (
    <>
      <h1>{t('title')}</h1>
      <Link href={`/${lng}/second-page`}>
        {t('to-second-page')}
      </Link>
      <br />
      <Link href={`/${lng}/client-page`}>
        {t('to-client-page')}
      </Link>
      <Footer lng={lng}/>
    </>
  )
}

 

app/[lng]/second-page/page.js
import Link from 'next/link'
import { useTranslation } from '../../i18n'
import { Footer } from '../components/Footer'

export default async function Page({ params: { lng } }) {
  const { t } = await useTranslation(lng, 'second-page')
  return (
    <>
      <h1>{t('title')}</h1>
      <Link href={`/${lng}`}>
        {t('back-to-home')}
      </Link>
      <Footer lng={lng}/>
    </>
  )
}

 

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

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

export default function RootLayout({
  children,
  params: {
    lng
  }
}) {
  return (
    <html lang={lng} dir={dir(lng)}>
      <head />
      <body>
        {children}
      </body>
    </html>
  )
}

 

app/i18n/middleware.js
  • 만약 주소가 /en 도, /ko도 아니고 /로 접속이 된다면 쿠키에 저장된 마지막 언어 설정 값(en or ko)을 추출하여 리다이렉션시킨다.
  • 만약 쿠키에 언어 값이 없다면 사용자의 언어 환경을 웹 서버에 알려주는 Accept-Language 헤더를 체크하고 적절한 언어로 리다이렉션 시킨다. 
  • 이 두 방법으로도 언어 설정을 찾지 못한다면 미리 개발자가 설정해둔 디폴트 언어(한국어) 사이트로 리다이렉션시킨다.
import { NextResponse } from 'next/server'
import acceptLanguage from 'accept-language'
import { fallbackLng, languages, cookieName } from './app/i18n/settings'

acceptLanguage.languages(languages)

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

export function middleware(req) {
  let lng
  if (req.cookies.has(cookieName)) lng = acceptLanguage.get(req.cookies.get(cookieName).value)
  if (!lng) lng = acceptLanguage.get(req.headers.get('Accept-Language'))
  if (!lng) lng = fallbackLng

  // Redirect if lng in path is not supported
  if (
    !languages.some(loc => req.nextUrl.pathname.startsWith(`/${loc}`)) &&
    !req.nextUrl.pathname.startsWith('/_next')
  ) {
    return NextResponse.redirect(new URL(`/${lng}${req.nextUrl.pathname}`, req.url))
  }

  if (req.headers.has('referer')) {
    const refererUrl = new URL(req.headers.get('referer'))
    const lngInReferer = languages.find((l) => refererUrl.pathname.startsWith(`/${l}`))
    const response = NextResponse.next()
    if (lngInReferer) response.cookies.set(cookieName, lngInReferer)
    return response
  }

  return NextResponse.next()
}

 

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 = { keyPrefix: '' },
) {
  const i18nextInstance = await initI18next(lng, ns);
  return {
    t: i18nextInstance.getFixedT(lng, Array.isArray(ns) ? ns[0] : ns, options.keyPrefix),
    i18n: i18nextInstance,
  };
}

 

app/i18n/locales/en/home.json  과  app/i18n/locales/ko/home.json
// 영어 데이터 
{
  "title": "Hi there!",
  "to-second-page": "To second page",
  "to-client-page": "To client page"
}

// 한국어 데이터 
{
  "title": "안녕하세요!",
  "to-second-page": "2번째 페이지로 이동하기",
  "to-client-page": "클라이언트 페이지로 이동하기"
}

 

app/i18n/locales/en/home.json  과  app/i18n/locales/ko/home.json
// 영어 데이터 
{
  "languageSwitcher": "Switch from <1>{{lng}}</1> to: "
}

// 한국어 데이터 
{
  "languageSwitcher": "<1>{{lng}}</1>에서 언어 바꾸기 : " # 예) 영어에서 언어 바꾸기 : 한국어(링크)
}

 

 

SSR에서 useTranslation

 

app/[lng]/page.js
import Link from 'next/link'
import { useTranslation } from '../i18n'

export default async function Page({ params: { lng } }) {
  const { t } = await useTranslation(lng)
  return (
    <>
      <h1>{t('title')}</h1>
      <Link href={`/${lng}/second-page`}>
        {t('to-second-page')}
      </Link>
    </>
  )
}

 

app/[lng]/second-page/page.js
import Link from 'next/link'
import { useTranslation } from '../../i18n'

export default async function Page({ params: { lng } }) {
  const { t } = await useTranslation(lng, 'second-page')
  return (
    <>
      <h1>{t('title')}</h1>
      <Link href={`/${lng}`}>
        {t('back-to-home')}
      </Link>
    </>
  )
}

 

app/[lng]/components/Footer/index.js
import { useTranslation } from '../../../i18n'
import { FooterBase } from './FooterBase'

export const Footer = async ({ lng }) => {
  const { t } = await useTranslation(lng, 'footer')
  return <FooterBase t={t} lng={lng} />
}

 

 

CSR에서 useTranslation

 

CSR에서는 async를 사용할 수 없다. 따라서 렌더링 방식에 따라 개별 훅을 사용해주어야 한다.

 

app/i18n/client.ts
'use client';

import i18next from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import resourcesToBackend from 'i18next-resources-to-backend';
import { useEffect, useState } from 'react';
import { useCookies } from 'react-cookie';
import { initReactI18next, useTranslation as useTranslationOrg } from 'react-i18next';
import { getOptions, languages, cookieName } from './settings';

const runsOnServerSide = typeof window === 'undefined';

//
i18next
  .use(initReactI18next)
  .use(LanguageDetector)
  .use(
    resourcesToBackend(
      (language: string, namespace: string) =>
        import(`./locales/${language}/${namespace}.json`),
    ),
  )
  .init({
    ...getOptions(),
    lng: undefined, // let detect the language on client side
    detection: {
      order: ['path', 'htmlTag', 'cookie', 'navigator'],
    },
    preload: runsOnServerSide ? languages : [],
  });

export const useTranslation = (lng: string, ns: string, options = { keyPrefix: '' }) => {
  const [cookies, setCookie] = useCookies([cookieName]);
  const ret = useTranslationOrg(ns, options);
  const { i18n } = ret;
  if (runsOnServerSide && lng && i18n.resolvedLanguage !== lng) {
    i18n.changeLanguage(lng);
  } else {
    // eslint-disable-next-line react-hooks/rules-of-hooks
    const [activeLng, setActiveLng] = useState(i18n.resolvedLanguage);
    // eslint-disable-next-line react-hooks/rules-of-hooks
    useEffect(() => {
      if (activeLng === i18n.resolvedLanguage) return;
      setActiveLng(i18n.resolvedLanguage);
    }, [activeLng, i18n.resolvedLanguage]);
    // eslint-disable-next-line react-hooks/rules-of-hooks
    useEffect(() => {
      if (!lng || i18n.resolvedLanguage === lng) return;
      i18n.changeLanguage(lng);
    }, [lng, i18n]);
    // eslint-disable-next-line react-hooks/rules-of-hooks
    useEffect(() => {
      if (cookies.i18next === lng) return;
      setCookie(cookieName, lng, { path: '/' });
    }, [lng, cookies.i18next]);
  }
  return ret;
};

 

 

app/[lng]/components/Footer/FooterBase.js
import Link from 'next/link'
import { Trans } from 'react-i18next/TransWithoutContext'
import { languages } from '../../../i18n/settings'

export const FooterBase = ({ t, lng }) => {
  return (
    <footer style={{ marginTop: 50 }}>
      <Trans i18nKey="languageSwitcher" t={t}>
        Switch from <strong>{{lng}}</strong> to:{' '}
      </Trans>
      {languages.filter((l) => lng !== l).map((l, index) => {
        return (
          <span key={l}>
            {index > 0 && (' or ')}
            <Link href={`/${l}`}>
              {l}
            </Link>
          </span>
        )
      })}
    </footer>
  )
}

 

app/[lng]/components/Footer/client.js
'use client'

import { FooterBase } from './FooterBase'
import { useTranslation } from '../../../i18n/client'

export const Footer = ({ lng }) => {
  const { t } = useTranslation(lng, 'footer')
  return <FooterBase t={t} lng={lng} />
}

 

app/[lng]/client-page/page.js
'use client'

import Link from 'next/link'
import { useTranslation } from '../../i18n/client'
import { Footer } from '../components/Footer/client'
import { useState } from 'react'

export default function Page({ params: { lng } }) {
  const { t } = useTranslation(lng, 'client-page')
  const [counter, setCounter] = useState(0)
  return (
    <>
      <h1>{t('title')}</h1>
      <p>{t('counter', { count: counter })}</p>
      <div>
        <button onClick={() => setCounter(Math.max(0, counter - 1))}>-</button>
        <button onClick={() => setCounter(Math.min(10, counter + 1))}>+</button>
      </div>
      <Link href={`/${lng}`}>
        <button type="button">
          {t('back-to-home')}
        </button>
      </Link>
      <Footer lng={lng} />
    </>
  )
}

 

app/i18n/locales/en/client-page.json // 한국어도 똑같이 작성
{
  "title": "Client page",
  "counter_one": "one selected",
  "counter_other": "{{count}} selected",
  "counter_zero": "none selected",
  "back-to-home": "Back to home"
}

 

레퍼런스 

 

react-i18next 공식문서 

https://react.i18next.com/  

 

https://github.com/i18next/next-i18next/tree/master/src   

728x90
⬆︎