
안녕하세요🖐🏻
회사에서 진행한 두 개의 프로젝트 모두 다국어를 지원하는 서비스였습니다.
운 좋게도 이 프로젝트들에서 react-i18next 초기 설정부터 운영, 유지보수 사이클을 경험했기에 효율적인 다국어 기능 구현을 위해 제가 겪은 경험과 배운 점을 공유하고자 합니다.
실서비스에서 다국어 지원을 고려 중이신 분들에게 이 글이 도움이 되길 바랍니다. 😎
이 글을 통해 얻을 수 있는 인사이트는 다음과 같습니다 :
- 다국어 지원 도입전 프론트엔드 개발자가 고려할 사항들
- 프론트엔드 측 초기 설정 가이드
- React (CRA) + TypeScript
- react-i18next
- CTA 디자인 기준
- 언어별 폰트 선정
- RTL 언어 지원 여부
- 번역 파일과 key name 컨벤션
- 번역 파일을 어디에 관리할까?
- 번역 파일을 어떤 기준으로 나눌까?


먼저 UI/UX 파트입니다.
위 이미지처럼 같은 의미여도 두 글자로 끝나는 언어가 있는 반면, 열 글자 이상이 되는 언어도 있습니다.
지원하는 언어가 2-3개 정도라면 큰 문제가 되지 않을 수도 있겠지만, 만약 지원하는 언어가 9개가 된다면 어떨까요? (제 얘기입니다.)
프론트엔드 개발자 입장에서 디자인 가이드를 보고 UI 작업을 시작할텐데, 가이드는 영어로만 되어있는 상황이라면? 디자인이 이미 픽스된 상황에서, 아직도 번역 작업이 완료되지 않아서 각 국의 번역 문구를 확인 할 수조차 없는 상황이라면?
뭐부터 어떻게 해야할지 하염없이 눈물이 나오는 상황입니다.
이런 멘탈 브레이킹을 막기 위해 사전에 디자이너와 CTA 디자인 협의가 필요합니다.
저 같은 경우는 두 방법을 모두 혼용해서
디자인상 width의 가변성이 허용되지 않는 컴포넌트에서는 두 번째 방법을 채택했고, 일반적으로는 첫 번째 방법을 선택해서 가변적으로 너비를 조정하였습니다.
두 번째는 폰트 설정에 관련된 사항인데요,
적용할 폰트가 특정 언어를 지원하는지 사전에 확인해야합니다.
저는 아무것도 모른채 디자이너가 지정해준 Roboto 폰트를 일괄 적용했는데,
일본어 적용시 안드로이드에서 웹 서비스를 확인했을때 묘하게 폰트가 달라지는 제보를 받았었습니다.

(위) aos 삼성 인터넷 (아래) window 웹 크롬 브라우저
한쪽 눈 감고 봐도 다른 폰트
알고보니 안드로이드에서 일본어를 Roboto에서 Noto Sans로 강제로 변환시킨다는 이슈가 있었습니다. 🥲
애초에 Roboto가 일본어를 지원하지 않기도 하구요.
그래서 결국, 코드 내부적으로 일본어를 선택할때는 M PLUS Rounded 1c 라는 폰트를 별도로 적용했습니다.
세 번째로는 RTL(Right To Left) 언어 지원 여부입니다.
아랍어, 히브리어 같은 경우 한글(LTR)과는 달리 오른쪽에서 왼쪽으로 읽히는 언어입니다.
이것을 CSS 속성으로 일괄 변경할 경우, 디자인적으로 예쁘지 않은 화면이 만들어질 수 있으니
dir="rtl" 로 일괄 적용할 것인지사전에 협의가 필요하겠습니다.
다음으로는 개발 파트 부분입니다.
번역 파일과 key name을 어떻게 관리할 것인지 정한다.
i18next 공식 문서에 따르면, 번역 파일은 json으로 key-value 형태를 띠고 있어야 합니다. 개발자와 번역가 사이에 이 json 파일을 누가, 어떻게 관리할 것인지 플로우를 정합니다. 저희 프로젝트의 경우 구글 스프레드 시트에 관리하였습니다. (이하 구글 스프레드 시트 기준으로 설명)
key name 같은 경우에는 프론트엔드 개발자가 코드내에서 직접 사용하므로 개발의 일관성을 위해 컨벤션을 정하는 것이 좋습니다.
예를 들어 Login 페이지에서 사용할 아이디 라는 단어의 key는 Login_Id, 페이지 전반적으로 사용될 환영합니다! 이라는 단어의 key는 Common_Welcome 과 같이 컨벤션이 있으면 누가 key를 정의하든 일관된 key 관리가 가능합니다.
번역 파일을 어디에 관리할 것인지 정한다.
구글 스프레드 시트를 json으로 추출하는 방법이 가장 일반적입니다.
이 json 파일을 관리하는 방법은 두 가지가 있습니다.
자세한 내용은 후술할 초기 세팅 방법에서 다루겠습니다.
번역 파일을 어떤 기준으로 나눌 것인지 정한다. (namespaces)
react-i18next 공식 문서에서는 namespaces를 다음과 같이 정의합니다.
namespaces는 여러 파일에 로드되는 번역을 분리할 수 있는 i18next 국제화 프레임워크의 기능입니다.
소규모 프로젝트에서는 모든 것을 하나의 파일에 넣는 것이 합리적일 수 있지만,
번역을 여러 파일로 나누고 싶은 시점이 올 수 있습니다.
다음과 같은 이유가 있을 수 있습니다:
- You start losing the overview having more than 300 segments in a file
- Not every translation needs to be loaded on the first page, speed up load time
즉 key-value 번역 문구들이 300개 이상으로 주체 할 수 없이 많아지거나,
모든 번역들이 첫 페이지에서 한 번에 로딩 될 필요가 없을때 (웹 서비스 성능 최적화를 위해) namespaces 지정의 필요성이 대두됩니다.
첫 번째 프로젝트는 한국어-영어 정도의 간단한 다국어 지원이었는데요.
경험상 이 경우 굳이 namespace를 별도로 지정할 필요성을 느끼지 못했습니다.
프로젝트 내부에서 번역 json 파일을 한국어와 영어 두 개만 관리했습니다.
그러나 두 번째 프로젝트에서 번역 segments가 늘어나고 9개국의 언어를 지원하게 되면서 웹 실행 초기에 모든 번역이 로딩 되는것이 비효율적으로 느껴졌습니다.
페이지 단위로 namespace를 분리해 특정 페이지에 접근했을때 해당 페이지에서 쓰일 번역 파일만 load 하는것이 합리적이라는 생각이 들었습니다.
공식 문서에서 제안하는 namespaces 분리 방법은 크게 Semantic 방법론과 Technical 방법론이 있습니다만, 저는 페이지별로 동적으로 호출되길 원했기 때문에 Technical하게 페이지 단위로 namespaces를 분리했습니다.
지금부터는
두 가지 방법을 설명해보겠습니다.
경험상 2-3개 정도의 다국어 지원을 하는 소규모 프로젝트에 적합해보입니다.
개발자 모두가 파일에 접근 할 수 있고 관리에 용이합니다.
단, json 파일을 프로젝트 내부에 갖고있다보니 번역 파일을 업데이트 할 때마다 배포를 해야하는 단점이 있습니다.
폴더구조는 다음과 같습니다.
// 폴더 구조
📦src
┣ 📂locales
┃ ┣ 📂en
┃ ┃ ┗ 📜translation.json
┃ ┣ 📂ko
┃ ┃ ┗ 📜translation.json
┃ ┗ 📜i18n.ts
다음 패키지를 설치해줍니다.
i18next
i18next-browser-languagedetector
react-i18next
typescript 사용시 추가 설치 :
@types/i18next
@types/react-i18next
번역 파일은 간단히 다음과 같다고 가정합니다.
// locales/ko/translation.json
{
"login": "로그인",
"logout": "로그아웃",
"header_1": "메뉴",
}
// locales/en/translation.json
{
"login": "Sign in",
"logout": "Sign out",
"header_1": "Menu",
}
// i18n.ts 파일
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
// json 파일을 import 해서 resources로 넣어줍니다.
import tranEn from './en/translation.json';
import tranKo from './ko/translation.json';
const resources = {
en: { translation: tranEn },
ko: { translation: tranKo },
};
i18n
.use(LanguageDetector) // 해당 플러그인을 사용할 경우, 브라우저 언어를 감지해 기본 언어로 세팅해줍니다.
.use(initReactI18next) // react-i18next를 i18n 객체에 바인딩해줍니다.
.init({
resources,
fallbackLng: 'ko' // 원하는 fallback 언어를 설정합니다.
debug: process.env.NODE_ENV === 'development', // 기본 true이나, development 환경일때만 debug를 true로 설정할 수도 있습니다.
});
// 언어를 바꾸는 함수도 이곳에 정의하여 필요한 곳에 import해 사용해주었습니다.
export const handleChangeLng = (languageCode: string) => {
i18n.changeLanguage(languageCode);
document.documentElement.setAttribute('lang', languageCode); // 언어별 css 스타일링 설정을 위해 lang attribute도 동적으로 변경해주세요.
};
index.tsx 파일에서 i18n 파일을 import 합니다.
// index.tsx
import './locales/i18n';
번역 적용은 다음과 같이 합니다.
// 번역을 적용할 리액트 컴포넌트 내부에서
import { useTranslation } from 'react-i18next';
import { handleChangeLng } from 'locales/i18n';
...
const { t } = useTranslation();
...
<button onChange={() => handleChangeLng('en')}>change to English</button>
<div>{t('login')}</div>
앞서 말했듯 관리해야할 번역 파일들이 많아지면서, 외부에서 호출해서 사용하는 편이 낫겠다 판단했습니다.
json 파일을 어떻게 받아올지에 대해 백엔드와 별도의 논의가 필요합니다.
번역 파일이 수정되거나 추가될 때마다 프론트엔드가 배포할 필요가 없습니다.
다만, 배포 후에도 새롭게 추가 및 수정된 번역 파일들이 브라우저에서 캐싱되는 이슈가 있어 json 파일 뒤에 쿼리스트링으로 타임 스탬프를 찍어주었습니다.
이번에는 번역 파일을 백엔드로부터 호출하고, namespace까지 지정해보겠습니다.
추가로 해당 패키지를 설치해주세요.
i18next-http-backend
프로젝트 내부에 Json 파일을 갖고있지 않으므로 앞서 설정했던 폴더 구조는 불필요합니다.
namespace와 백엔드 호출 적용을 위해 변경된 i18n.ts 파일은 다음과 같습니다.
다양한 Backend 옵션은 이곳에서 확인할 수 있습니다.
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import HttpBackend, { HttpBackendOptions } from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';
// 해당 loadPath는 json 파일을 fetch해오는 경로입니다.
// 백엔드와 논의해서 설정해주세요
const loadPath = process.env.REACT_APP_LOAD_BASE_URL + '/service_name/locales/{{lng}}/{{ns}}.json';
const detectorOptions = {
order: ['querystring', 'localStorage', 'cookie'], // detect 우선순위를 변경할 수 있습니다. 앞에서부터 우선순위가 높습니다.
lookupQuerystring: 'lng',
lookupCookie: 'i18next',
lookupLocalStorage: 'i18nextLng',
caches: ['localStorage'],
htmlTag: document.documentElement,
};
i18n
.use(HttpBackend)
.use(LanguageDetector)
.use(initReactI18next)
.init<HttpBackendOptions>({
fallbackLng: 'ko',
debug: process.env.NODE_ENV === 'development',
keySeparator: false,
nsSeparator: false,
load: 'currentOnly',
ns: ['common'], // ns는 동적으로 불러올 것이므로, 기본 common만 설정해 주었습니다.
defaultNS: ['common'],
backend: {
loadPath,
crossDomain: true,
queryStringParams: { t: String(Date.now()) },// translation json 파일이 브라우저에 캐시되는것을 방지하기 위해 타임스탬프를 찍어주었습니다.
},
detection: detectorOptions,
partialBundledLanguages: true, // 초기 로드 시 필요한 최소한의 번역 리소스만 포함시킬 수 있습니다.
});
위와 동일하게 i18n.ts를 index.tsx 에 import 해줍니다.
한국어 기준으로 번역 파일은 다음과 같이 적용했다고 가정합니다.
/register 페이지에서 불러올 register.json 파일{
"register": "가입하기",
"id": "아이디",
"password": "패스워드",
}
/customer 페이지에서 불러올 customer.json 파일{
"customer_welcome": "환영합니다, 고객님",
"information": "고객 정보",
}
위의 i18n.ts에서 호출 경로를 /service_name/locales/{{lng}}/{{ns}}.json 라고 정의해 주었으므로, 한국어 기준 register namespace 파일 호출 경로는 /service_name/locales/ko/register.json
가 되겠네요.
customer namespace 파일 호출 경로는
/service_name/locales/ko/customer.json가 되고요.
이제 이 json 번역 파일을 특정 페이지에 접근할 때마다 페이지 경로로부터 namespace를 가져와 동적으로 로딩될 수 있도록 커스텀 훅을 추가해주었습니다.
// hooks/useTranslationNamespace.tsx
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useTranslation, UseTranslationOptions, Trans } from 'react-i18next';
/**
*
* @param namespace
* @param options
* @returns useTranslation의 return values인 t function, i18n instance
*
*/
const useTranslationNamespace = (namespace?: string | string[], options?: UseTranslationOptions) => {
const { pathname: _pathname } = useLocation();
const pathname = _pathname.split('/')[1];
const { t, i18n } = useTranslation(namespace ?? pathname, options);
useEffect(() => {
i18n.loadNamespaces(namespace ?? pathname);
}, [pathname]);
return { t, i18n, Trans };
};
export default useTranslationNamespace;
이제 이 useTranslationNamespace 커스텀 훅을 import 하여 사용해줍니다.
// register 페이지에서 쓰일 컴포넌트
import useTranslationNamespace from 'hooks/useTranslationNamespace';
// useTranslationNamespace 인자로 아무것도 넘기지 않으면, 해당 컴포넌트가 쓰이고있는 페이지의 pathname을 자동으로 namespace로 사용합니다.
// 만약 register 외 특정 namespace 번역키가 필요하다면, 인자로 넘겨줍니다.
// 예 : useTranslationNamespace('customer')
const { t } = useTranslationNamespace();
<h1>{t('register')}</h1>
이제 개발자도구를 켠 뒤 /register 페이지에 접근해서 register.json?t=12482350와 같은 파일이 잘 fetch 되었는지 확인해봅니다.
이렇게 react-i18next와 다양한 플러그인을 이용해서 개발 환경을 셋팅하고, 사전에 협의되어야 할 사항들을 정리해보았습니다.
다국어 서비스 셋팅이 처음이라 어떤 사항이 개발 전에 협의 되어야 하는지 잘 알지 못했기 때문에, 맨 땅에 헤딩 식으로 작업을 시작했고 많이 헤맸던 기억이 있습니다.
이 글을 보시면서 다국어 서비스를 도입하는데 필요한 사항들을 이해하는데 도움이 된다면 좋겠습니다.
다양한 플러그인 옵션을 확인하고 상황에 맞게 적용해보세요!
step-by-step으로 고려해야 할 부분, 케이스별 trade-off같은 부분들을 말끔히 정리해주셔서 기술적으로나, 협업측면으로나 큰 도움이 될 것 같습니다!!
감사합니다☺️