본문 바로가기
개발/리액트

[리액트] 글로벌한 웹을 향하여 (react-i18next, 다국어지원)

by 핸디(Handy) 2022. 5. 29.

들어가며

이번 글에서는 글로벌한 웹을 위해서 react i18n를 적용하는 방법과 나름의 best practice에 대해 적어보겠습니다.

이 글은 아직 이제 막 프로젝트에 i18n을 던지려는 용기 있는 개발자를 위한 글이며, 더욱 위대한 웹을 위한 글이기도 합니다.

이직이 어느정도 마무리되었고, 사이드 프로젝트도 마무리하고 새로운 아이템을 찾는 피벗 중이어서 갑자기 시간이 붕 떴습니다.

최근 깃헙 잔디 ㅜㅜ

그래서 이래저래 책을 읽으며 여유있는 시간을 즐기고 있는데 기존 프로젝트를 정리도 하면서 문서화를 하다 i18n에 대해 정리해보려고 이렇게 글을 작성하게 되었습니다.

best practice는 아니지만 better practice이기를 바라며 react- i18n 적용하는 방법을 시작해보겠습니다.

i18n 이란?

i18n?? 언뜻보면 아무렇게나 타자를 친 것 같은 애매한 이 글자는 국제화(Internationalization; i18n)을 줄임말입니다.

쿠버네티스의 k8s나 접근성의 A11y(accessibility)과 같은 느낌으로 보시면 될 듯합니다.

쨋든 일반적인 국제화 i18n는 텍스트 번역을 의미하곤 합니다. 

상세한 설명은 검색하면 더 훌륭하신 분들이 작성해둔 글이 있으니 여기까지 설명하고 이제 방법론으로 넘어가겠습니다.

말이 방법론이지.. 그냥 제가 경험해본 것을 풀어낸 것입니다.

i18n 방법론

제가 경험한 i18n은 매우 한정적임으로 정답이 아닙니다. 다만 나름의 방식으로 사용하고자 하는 것이니 참고 바랍니다.

회사 프로젝트 | csv 활용

제가 출근해서 개발을 열심히 하고 있는 프로젝트에서도 i18n를 적용하고 있습니다.

그리고 key, value1, value2의 형태로 이뤄진 csv로 관리를 하고 있습니다. 

// key , value(ko), value(en), value(ch)
name, 이름, name, 名字,

이렇게 모듈단위로 나눠져 있고 빌드할 때 csv파일들을 스크립트로 json으로 변환합니다.

변환된 json을 가지고 이제 각 프로젝트의 라이브러리, 프레임워크에 적용하여 사용합니다.

다행스럽게도 현재 프로젝트는 리액트를 사용하고 있고, 이를 react-i18 next를 통해 사용하고 있습니다.

다만 이 방법의 단점이 있는데, 위에서 언급하다시피 빌드될 때 json으로 만들어지기 때문에 바로 값을 못 본다는 불편함이 있습니다.

그래도 별도의 csv파일로 관리하고 json보다 직관적인 느낌 때문에 나름 좋은 방법입니다.
( 실은 마이그레이션 전에 csv로 관리하고 있어서 그대로 한 거임)

또한 csv 파일도 git으로 관리할 수 있기 때문에 버전 관리도 가능하다는 점에서 좋습니다.

타회사 방법 | 구글 sheet 이용

바로 근본 회사의 글을 투척드리겠습니다.

 

국제화(i18n) 자동화 가이드 : NHN Cloud Meetup

프런트엔드 개발을 하다 보면 국제화와 번역을 수작업과 막일로 하는 경우가 있습니다."복붙"이나 반복적인 수작업으로 인해 고통받는 모든 프런트엔드 개발자를 자동화 가이드를 작성하였습

meetup.toast.com

일단 이름부터 자동화입니다.

글을 보시면 알겠지만 공유 가능한 구글 시트를 통해 간편히 통합 관리를 하고 번역가에게 해당 파일을 공유하여 프로젝트를 진행할 수 있다는 점이 압도적인 편리함을 제공합니다.

물론 번역가가 없는 제 프로젝트는 제가 파파고를 통해 번역을 함으로 제가 csv만 관리하면 됩니다. (오버 엔지니어링??)

여기도 빌드할 때 시트를 읽어와서 사용한다는 점에서 csv와 비슷한 느낌입니다. 

그냥 git으로 관리 안 하고 구글시트로 공유해놓고 관리하겠다 정도의 차이라고 보시면 되겠습니다.

번외로 안드로이드의 경우 안드로이드 스튜디오 내에서 i18n를 제공해주는 압도적인 편리성을 보입니다.

근본 안드로이드

아무튼 이러한 방법들이 있습니다. 

마지막 방법 | json으로만 관리

마지막 방법은 모조리 json으로만 관리하는 방법입니다.

위의 방법과는 달리 csv 나 구글시트를 사용하지 않아 그냥 단일 프로젝트에서 바로 사용할 수 있다는 편리함이 장점입니다.

오늘 차근차근 설명해볼 방법이기도 하고, 현재 제 포트폴리오 사이트에 적용하고 있는 방법입니다.

 현재 프로젝트는 react, typescript, next를 사용하고 있으니 참고 바랍니다.

적용된 코드는 아래 깃 헙에서 확인할 수 있습니다.

 

GitHub - gyeongseokKang/kang_portfolio: next.js + typescript + vercel 로 만드는 포트폴리오

next.js + typescript + vercel 로 만드는 포트폴리오. Contribute to gyeongseokKang/kang_portfolio development by creating an account on GitHub.

github.com

react-i18next

설치부터

# npm
$ npm install react-i18next i18next --save

기본 구조

디렉터리 구조는 어떻게 가져가도 상관은 없지만 저는 src/locale에 넣었습니다.

// src/locale/i18n.ts 파일

import i18next from "i18next";
import { initReactI18next } from "react-i18next";

const resources = {
  en: {
    translation: {
      intro: {
        편리함을_추구하는_개발자: "Developer Seeking Convenience",
      },
    },
  },
  ko: {
    translation: {
      intro: {
        편리함을_추구하는_개발자: "편리함을 추구하는 개발자",
      },
    },
  },
};

i18next.use(initReactI18next).init({
  resources,
  lng: "ko",
  interpolation: {
    escapeValue: false,
  },
});

export default i18next;

그리고 이 파일을 메인 index.tsx에서 import 합니다.

제 프로젝트는 next.js를 사용하고 있으므로 다음과 같은 구조가 되겠네요.

기본 사용법

일단 react- i18 next는 4가지 방법으로 i18n를 제공합니다.

  1. useTranslation (hook)
  2. withTranslation (HOC)
  3. Translation (render prop)
  4. Trans Component

여기서는 1번 방법인 훅을 이용해서 제공해보겠습니다.

import { useTranslation } from "react-i18next";

const Component = () => {
  const { t } = useTranslation();

  return (
    <div>
      <strong>Handy | {t(`intro.편리함을_추구하는_개발자`)}</strong>
      <br />
    </div>
  );
};

보시다시피 가장 직관적인 방법입니다. 함수 컴포넌트에 맞춰 훅으로 제공합니다.

하지만 2,3,4번 방법의 경우도 쓰기는 간편하지만 결국 컴포넌트로 한 번 더 감싸야해서 컴포넌트가 깊어집니다.

또한 i18n의 경우 텍스트를 번역하는데 중점을 준 것인데 이를 위해 별도로 컴포넌트를 쓴다는 게 맞지 않아 보였습니다.

그리고 상단 메뉴바 코드에   i18 next.changeLanguage()로 언어를 변경해주면 됩니다.

import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import i18next from "src/locale/i18n";

const TopMenuIcon = () => {
  const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
  const isLanguageMenuOpen = Boolean(anchorEl);

  const changeLanguage = (lang: string) => {
    i18next.changeLanguage(lang);
    closeLanguageMenu();
  };

  const openLanguageMenu = (event: React.MouseEvent<HTMLButtonElement>) => {
    setAnchorEl(event.currentTarget);
  };
  const closeLanguageMenu = () => {
    setAnchorEl(null);
  };

  return (
    <Menu
      id="basic-menu"
      anchorEl={anchorEl}
      open={isLanguageMenuOpen}
      onClose={closeLanguageMenu}
      MenuListProps={{
        "aria-labelledby": "basic-button",
      }}
    >
      <MenuItem
        onClick={() => {
          changeLanguage("ko");
        }}
      >
        한국어
      </MenuItem>
      <MenuItem
        onClick={() => {
          changeLanguage("en");
        }}
      >
        English
      </MenuItem>
    </Menu>
  );
};

intro 섹션에 적용

이제 기본적인 사용법은 끝났으니 좀 더 깊게 들어가 보겠습니다.

Better 사용법

이 방법은 제가 현재 프로젝트에서 사용하는 방법입니다. 

디렉터리 분리

다음과 같은 디렉터리가 있습니다.

src/locale

locale/section에 보면 json 파일이 두 개 보입니다.

두개의 locale json 파일

원래의 기본구조 방법대로 i18n.ts 안에 resources를 다 넣어도 괜찮습니다. 하지만 관리하기가 불편하겠죠.

그래서 파일을 분리해야 합니다.

근데 파일을 분리해서 끝이 아니고 이를 resources에 통합해서 i18 next.use로 실행을 해야 하는데 import로 끌고 오기도 매우 귀찮습니다. 

import experience  from "./section/experience.json";
import intro  from "./section/intro.json";
//import 추가 추가 추가

여기에서는 어느 정도 자동화 과정이 필요합니다.

이번엔 node의 require.context와 정규식을 이용해서 특정 디렉터리의 locale 파일을 가져와보겠습니다.

디렉토리 내부 파일 가져오기

위와 같은 디렉터리에서 locale 하위 폴더 안에서 json으로 끝나는 파일들을 전부 가져와보겠습니다.

각각의 인자는 (시작위치, 하위디렉터리 체크여부, 가져올 이름 형식)입니다. 모드는 안썼어요

const getLocaleResource = (requireContext: __WebpackModuleApi.RequireContext) => {
  return requireContext.keys().map(requireContext);
};

const localeResource = getLocaleResource(require.context("../locale/", true, /.json$/));

그리고 localeResource를 console.table로 찍어보면

console.table 화면

정규식이 이상한지 중복으로 나오네요. 이건 나중에 다시 수정해볼게요.

2022.07.11 추가

정규식을 해보니 locale 하위에 있는 것을 포함하여 src/locale/module/... 과 ../moldule/... 으로 같은 파일에 대해 2번 확인하는 것을 확인했습니다.

따라서 src를 포함하고 있는 절대경로에 해당하는 값을 가져오기 위해 추가로 src를 포함하는 경로만 찾는 정규식으로 수정합니다.

require.context("../locale/", true, /(.*)src(.*).(json)$/) // src 정규식 추가

원하는 locale 만 합치기

json 파일들이 제대로 들어왔으니 이제 resource에 합치는 작업이 필요합니다.

const mergeLocaleResource = () => {
  const targetRes: any = [...localeResource];
  targetRes.forEach((res: { namespace: string; locale: { [x: string]: any } }) => {
    const namespace = res.namespace;
    for (let key in res.locale) {
      const newLocale = res.locale[key];
      if (key in resources) {
        resources[key] = {
          translation: {
            ...resources[key]?.translation,
            ...{
              [namespace]: { ...newLocale },
            },
          },
        };
      }
    }
  });
};
mergeLocaleResource();

깔끔한 로직이 아니라서 좀더 다듬긴 해야합니다만,

로직을 설명하자면 resources에 미리 선언된 locale(en, ko)의 translation에 해당 값을 합치는 과정입니다.

미리 선언된 키값 합치는 이유는 여기 객체에서 원하는 locale만 제공하기 위함입니다.
하지만 json에서는 미래를 대비하여 얼마든지 다른 언어로 번역해서 들고 있을 수 있겠죠. 

예를 들어, ko만 제공하고 싶다면 아래와 같이 코드를 입력하고 실행을 하게 되면 ko만 들어간 리소스 파일을 얻을 수 있습니다.

const resources: any = {
  // en: {
  //   translation: {},
  // },
  ko: {
    translation: {},
  },
};

최종 리소스 파일에 ko만

그리고 resources에 key값만 export 해서 menu 바의 menuItem 컴포넌트를 제공하는 locale에 맞춰 자동으로 만들어 줄 수 도 있겠네요.

menu 컴포넌트 업데이트

요 부분은 생략하겠습니다 ㅎㅎ

최종 파일 i18n.ts

코드를 다시 한번 본다면 크게 불러오는 부분, 파싱 하는 부분, 합치는 부분, react-i18n 실행 부분으로 나눌 수 있겠네요.

import i18next from "i18next";
import { initReactI18next } from "react-i18next";

const getLocaleResource = (requireContext: __WebpackModuleApi.RequireContext) => {
  return requireContext.keys().map(requireContext);
};

const localeResource = getLocaleResource(require.context("../locale/", true, /\.(json)$/));

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

const mergeLocaleResource = () => {
  const targetRes: any = [...localeResource];
  targetRes.forEach((res: { namespace: string; locale: { [x: string]: any } }) => {
    const namespace = res.namespace;
    for (let key in res.locale) {
      const newLocale = res.locale[key];
      if (key in resources) {
        resources[key] = {
          translation: {
            ...resources[key]?.translation,
            ...{
              [namespace]: { ...newLocale },
            },
          },
        };
      }
    }
  });
};
mergeLocaleResource();

i18next.use(initReactI18next).init({
  resources,
  lng: "ko",
  interpolation: {
    escapeValue: false,
  },
});

export default i18next;

최종 결과

변수받아 처리하기

i18를 사용하다보면 유저의 입력을 받아 지원해야할때가 있습니다. 

예를 들어 "안녕하세요 OOO님, 반갑습니다",  "Hello OOO, Nice to meet you"의 상황입니다.

이런 상황을 위해 i18next에서는 변수를 받을 수 있도록 할 수 있습니다.

// json
{
    "로그인_유저네임": "LogIn {{userName}}",
}

//react
  <div>
  	{t("로그인_유저네임", { userName: "Handy" })}
  </div>

 

에러 핸들링

간혹 다음과 같은 에러들이 뜨는 상황이 있습니다. 타입스크립트를 사용하기 때문에 해당 namespace를 찾지 못했다고 알려주는 것인데요.

이 상황은 아래 타입 라이브러리와 tsconfig 설정으로 해결할 수 있습니다.

npm i @types/webpack-env @types/node -D

 

// tsconfig.json
{
  "compilerOptions": {
	... 중략 ...
    // types에 다음 항목들을 추가해줍니다.
    "types": ["node", "webpack-env"],
}

마치며

이렇게 react 프로젝트에 i18n를 던지는 작업은 마무리되었습니다.

이게 과연 좋은 방법일지는 모르겠습니다만, 그래도 아직까지 사용하는데 별다른 문제는 없는 걸 보니 나쁘지 않은 방법인 것 같습니다.

그리고 json의 키를 한글로 쓴 부분에서 불편함을 느끼신 분이 계실 것 같습니다.

하지만 제 생각은 애매한 영어 사용은 오히려 코드 가독성이 떨어진다는 주의라 당당하게 key값에 한글을 박아 넣었습니다.

그리고 key에 해당하는 값이 너무 길면 suffix에 "_"를 3개 붙이는 것으로 표현하자고 컨벤션을 잡았습니다.

아무튼 react-i18n을 적용하여 글로벌한 웹을 향하자는 글이었습니다.

끝.

댓글