다국적 언어 지원 기능 구현
아래와 같이 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 공식문서
728x90
'📌 PROJECT > 2309 다국어 지원 포트폴리오' 카테고리의 다른 글
[CI/CD] lint-staged, husky로 린트 검사 자동화하기 (+Jest test) (0) | 2023.10.02 |
---|---|
[react-typist] React 18 이상에서 호환 안 되는 문제 (0) | 2023.09.27 |
[Stylelint] scss 일관적인 스타일 규칙 적용시키기 (0) | 2023.09.24 |
[JEST] next-router-mock으로 next/link 테스트 (버그 issue 진행 중) (0) | 2023.09.23 |
[JEST] react-i18next 테스트하기 (0) | 2023.09.22 |