ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Next.js 14] App router 기반 Localization / Internationalization [i18next]
    웹 개발/Nextjs 14 2024. 2. 24. 20:12

    다국어를 지원하는 웹에 들어가보면 드롭다운으로 언어를 바꾸고, 그에 맞게 텍스트가 휙휙 바뀌는 것을 본 적이 있을 것이다.

    이번 포스팅에서는 i18next 모듈을 이용해서 앱 라우터 기반의 Next.js 프로젝트에 다중 언어 기능을 추가하는 방법을 다루고자 한다.

    나라마다 중요하게 받아들이는 정보가 다르고, 익숙한 디자인도 다르므로 사실 정확하게 현지화를 한다고 하면 국가별로 따로 웹 페이지를 만들어야 한다. 하지만, 우리는 개발 리소스가 한정되어 있고 언어만 바뀌면 웹 서비스를 사용할 수 있으니 이 방법으로도 충분하다.

     

    사실 웹 개발이 어느 정도 진행된 지금, 초반에 추가한 Localization(Internationalization라고도 하지만 앞으로의 내용에서는 Localization으로 통일하겠다) 기능을 설명할 사진이 없어서 포스팅을 작성할까 말까 고민했는데, 나중에 고생할 다른 사람들을 위해 적어보도록 하겠다. 역시 App router 기반으로 작성하는 법을 찾는 게 하늘의 별 따기였고, 또 그 사이에 업데이트 되어서 바꿔줘야하는 부분이 있었다. 이 글도 나중에는 expired 되겠지만 복기할 겸 새로운 프로젝트를 파서 진행하도록 하겠다.

    새 프로젝트를 만드는 방법은 아래의 포스팅 참고.

     

    [웹 개발] 리액트 개발환경 세팅 및 Next.js 프로젝트 생성

    나는 맥북을 사용 중이라 MacOS 기준으로 작성하겠다. 윈도우에 개발환경을 세팅하는 방법도 크게 다르진 않을듯. 1. node.js 설치 아래의 링크를 클릭한 후, 왼쪽의 LTS 버전을 눌러 다운로드 및 설

    hotsunchip.tistory.com

     

     

    [Next.js] 프로젝트 폴더 구조 구성 및 git repository 연결

    프로젝트 설치가 끝나면 아래와 같이 app, public 등등의 여러 폴더가 생성되었을 것이다. 일단 '프로젝트 생성'이라는 큰 산을 하나 넘었으니 이제 개발을 시작하면 된다. 다만 일반적으로, 특히

    hotsunchip.tistory.com


    Dynamic Route

    본격적으로 들어가기 앞서, Localization에 사용하는 Next.js의 (App) router 기능을 알아보자.

    내용은 아래의 Next.js 문서에서 발췌하였다.

     

    Routing: Dynamic Routes | Next.js

    Dynamic Routes can be used to programmatically generate route segments from dynamic data.

    nextjs.org

     

    Dynamic Route란?

    이 포스팅에서 소개하는 Localization 기능뿐만 아니라 blog 형태의 웹 사이트처럼 동적 데이터에서 경로를 생성하려는 경우에 사용한다. Dynamic route 시 세그먼트는 요청 시 채워지거나 빌드 시 미리 렌더링된다.
    동적 세그먼트는 폴더 이름을 [id] 또는 [slug]와 같이 대괄호([])로 묶어 생성할 수 있다. 이렇게 생성한 세그먼트는 layout, page, route 및 generateMetadata function에 params prop으로 전달된다.

     

    간단하게 설명하자면, [locale] 폴더를 만들어 이 폴더 하위에 페이지들을 관리하면, 앞에 en / ja / ko 등을 붙였을 때 텍스트는 다르지만 레이아웃은 같도록 할 수 있다.이 기능이 없다면, 지원하는 언어만큼의 폴더를 만들어 각각의 폴더 안에 또 페이지들을 따로 관리해줘야 한다. 바로 유지보수성 사라져버림...

    글로만 보면 살짝 어려울 수 있다. 나머지 포스팅을 따라하면서 감을 잡아보자.

     

     

    i18next 설치 및 script 작성

    i18next는 자바스크립트에서 사용하는 국제화(localization / internationalization) 프레임워크이다.

    설치 방법

    터미널에서 아래의 명령어를 실행하면 i18next가 설치된다.

    npm i i18next react-i18next i18next-resources-to-backend i18next-browser-languagedetector

     

     

    Localization 스크립트 작성

    웹 페이지 전체에서 사용되는 스크립트기 때문에 utils 폴더 안에 localilzation 폴더를 만들어 아래의 세 파일을 생성해준다. 본인이 원하는 다른 경로도 괜찮지만, 이 경우 아래에서 진행될 코드에서 경로를 수정해주는 것을 잊지 말자!

    // settings.ts
    
    import type {InitOptions} from 'i18next';
    
    export const fallbackLng = 'en';
    export const locales = [fallbackLng, 'ko', 'ja'] as const;
    export type LocaleTypes = (typeof locales)[number];
    export const defaultNS = 'common';
    
    export function getOptions(lang = fallbackLng, ns = defaultNS): InitOptions {
        return {
            // debug: true, // Set to true to see console logs
            supportedLngs: locales,
            fallbackLng,
            lng: lang,
            fallbackNS: defaultNS,
            defaultNS,
            ns,
        };
    }
    // server.ts
    
    import {createInstance} from 'i18next';
    import resourcesToBackend from 'i18next-resources-to-backend';
    import {initReactI18next} from 'react-i18next/initReactI18next';
    import {getOptions, LocaleTypes} from './settings';
    
    const initI18next = async (lang: LocaleTypes, ns: string) => {
        const i18nInstance = createInstance();
        await i18nInstance
            .use(initReactI18next)
            .use(
                resourcesToBackend(
                    (language: string, namespace: typeof ns) =>
                        import(`./locales/${language}/${namespace}.json`),
                ),
            )
            .init(getOptions(lang, ns));
    
        return i18nInstance;
    };
    
    export async function createTranslation(lang: LocaleTypes, ns: string) {
        const i18nextInstance = await initI18next(lang, ns);
    
        return {
            t: i18nextInstance.getFixedT(lang, Array.isArray(ns) ? ns[0] : ns),
        };
    }
    // client.ts
    
    'use client';
    
    import {useEffect} from 'react';
    import i18next, {i18n} from 'i18next';
    import {initReactI18next, useTranslation as useTransAlias} from 'react-i18next';
    import resourcesToBackend from 'i18next-resources-to-backend';
    import LanguageDetector from 'i18next-browser-languagedetector';
    import {type LocaleTypes, getOptions, locales} from './settings';
    
    const runsOnServerSide = typeof window === 'undefined';
    
    // Initialize i18next for the client side
    i18next
      .use(initReactI18next)
      .use(LanguageDetector)
      .use(
        resourcesToBackend(
          (language: LocaleTypes, namespace: string) => {
            return import(`./locales/${language}/${namespace}.json`);
          },
        ),
      )
      .init({
        ...getOptions(),
        lng: undefined, // detect the language on the client
        detection: {
          order: ['path'],
        },
        preload: runsOnServerSide ? locales : [],
      });
    
    export function useTranslation(lng: LocaleTypes, ns: string) {
      const translator = useTransAlias(ns);
      const {i18n} = translator;
    
      // Run content is being rendered on server side
      if (runsOnServerSide && lng) { // && i18n.resolvedLanguage !== lng) {
        i18n.changeLanguage(lng);
      } else {
        // Use our custom implementation when running on client side
        // eslint-disable-next-line react-hooks/rules-of-hooks
        useCustomTranslationImplem(i18n, lng);
      }
      return translator;
    }
    
    function useCustomTranslationImplem(i18n: i18n, lng: LocaleTypes) {
      // This effect changes the language of the application when the lng prop changes.
      useEffect(() => {
        if (!lng) return; // || i18n.resolvedLanguage === lng) return;
        i18n.changeLanguage(lng);
      }, [lng, i18n]);
    }

     

    Locale json 파일 생성

    이제 같은 폴더(localization) 안에 locale 라는 폴더를 만든 후 아래에 언어 코드에 맞게 json 파일을 생성한다.

    아래는 예시이다.

    더보기
    // /locales/en/common.json
    {
      "home": "Home",
      "about": "About"
    }
    // /locales/ja/common.json
    {
      "home": "ホーム",
      "about": "概要"
    }
    // /locales/ko/common.json
    {
      "home": "홈",
      "about": "어바웃"
    }

    여기까지 진행하면 utils 아래의 폴더 및 파일 구성이 다음과 같아진다. 나는 common.json 외에도 페이지별로 따로 json 파일을 나누어주었다. 파일 하나에 key-value로 나누어도 상관없으니 이 부분은 편한 방식대로 진행하면 된다.

     

     

    페이지 작성

    [locale] 폴더 생성

    나는 간단하게 home 페이지([locale]/page.tsx)와 about 페이지([locale]/about/page.tsx, 그리고 이 사이를 이동할 수 있는 간단한 Header(Navigation bar, [locale]/layout.tsx에 추가)로 구성해보았다.

    이 부분은 각자 원하는대로 구성하면 되는데, 어떻게 해야할지 감이 안 잡히는 분들을 위해 아래에 예시 코드를 올려놓겠다.

    아마 앞으로 해당 레포에 포스팅별로 브랜치 파서 샘플 코드를 정리할 것 같다. 햐 설레~

     

    미들웨어(middleware.ts) 작성

    여기까지 따라왔으면 다 됐다. 위의 과정까지만 한 후 실행을 해보면 localhost:3000/[locale]로는 접속이 되는데, 이를 en, ja, ko로 변경하면 404 에러가 뜰 것이다. 아직 미들웨어를 작성해주지 않았으니 당연한 부분이다. 미들웨어에서 언어 코드 부분을 파싱해서 [locale] 세그먼트로 route하는 과정이 필요하다.

     

    아래의 코드를 src 폴더 바로 하위에 작성해준다. (app 폴더와 같은 레벨이어야 한다 - src 폴더를 사용하지 않는다면 프로젝트 root에 만들어준다.)

    // middleware.ts
    import {NextResponse, NextRequest} from 'next/server';
    import {fallbackLng, locales} from '@/utils/localization/settings';
    
    export function middleware(request: NextRequest) {
        // Check if there is any supported locale in the pathname
        const pathname = request.nextUrl.pathname;
    
        // Check if the default locale is in the pathname
        if (
            pathname.startsWith(`/${fallbackLng}/`) ||
            pathname === `/${fallbackLng}`
        ) {
            // e.g. incoming request is /en/about
            // The new URL is now /about
            return NextResponse.redirect(
                new URL(
                    pathname.replace(
                        `/${fallbackLng}`,
                        pathname === `/${fallbackLng}` ? '/' : '',
                    ),
                    request.url,
                ),
            );
        }
    
        const pathnameIsMissingLocale = locales.every(
            locale => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`,
        );
    
        if (pathnameIsMissingLocale) {
            // We are on the default locale
            // Rewrite so Next.js understands
    
            // e.g. incoming request is /about
            // Tell Next.js it should pretend it's /en/about
            return NextResponse.rewrite(
                new URL(`/${fallbackLng}${pathname}`, request.url),
            );
        }
    }
    
    export const config = {
        // Do not run the middleware on the following paths
        matcher:
            ['/((?!api|.*\\..*|_next/static|_next/image|manifest.json|assets|favicon.ico).*)']
    };

     

    여기서 내가 겪은 몇 가지의 문제가 있었다.

    첫 번째는 실수로 해당 파일을 src 파일이 아닌 root 폴더에 생성하여 route가 파싱이 되지 않는 문제이다. 앞서 설명한대로 app 폴더와 같은 레벨(src 폴더 바로 아래)로 옮겨주니 잘 작동하였다.

    두 번째는 이전까지는 잘 불러와지던 next.svg와 같은 아이콘이 불러와지지 않는 문제가 발생했다. public 폴더에 접근을 아예 못 하는 문제였는데, 이것은 해당 파일 아래의 config에 .*\\..* 를 추가하여 해결했다. image 태그에서 public 폴더에 접근하는 링크 또한 미들웨어에서 걸러버려서 생기는 문제였다.

     

     

    참고용 코드

    백문이 불여일견이라고, 이 포스팅을 작성하면서 같이 생성한 프로젝트 링크를 걸어두겠다. W-13_Localization 브랜치를 참고하면 된다. 클론해서 실행해보면 바로 감이 잡힐 것이다.

     

    GitHub - hotsunchip/nextjs-app-router

    Contribute to hotsunchip/nextjs-app-router development by creating an account on GitHub.

    github.com

     

     

    지금은 json으로 관리하는데, 이걸 구글 스프레드시트로 관리하는 방법도 찾아내보려고 한다.

    진행하는 프로젝트에 적용하는 것을 성공하면 추가로 포스팅을 작성해서 연결해놓겠다.