React 에서 i18next를 이용해 다국어 지원을 자동화 해보자

React 에서 i18next를 이용해 다국어 지원을 자동화 해보자

Tag
React
프론트엔드 개발자라는 놈이 프론트엔드 관련 첫 포스팅을 너무 늦게 한 감이 있지만..
오늘은 다국어 자동화에 대해 알아보려 한다. 현재 진행하고 있는 프로젝트에서 다국어 지원을 필요로 하고 있다.
위에서의 아주 친절한 내용을 통해, 직접 json에 하나하나 값을 설정해주는 것이 아닌, 구글 스프레드 시트 를 이용해 다국어 자동화 지원이 가능함을 알게 되었다.
 
notion image
보통 개발자들이 다국어 지원을 하게 되면 많이 보는 json 파일일 것이다.
ko-KR.json, en-US.json 과 같이 국가마다 파일을 모두 만든 이후에 그에 대한 값을 하나하나 세팅해줘야 한다.
이게 아주 골때리는 것이다. 한마디로 노가다지..

왜 스프레드 시트를 이용한 자동화를 해야하는가?

📌
편리하게 협업이 가능하다.
브랜치에 따라 소스코드에 직접 왔다갔다 하며 붙일 필요 없이, 휴먼 에러를 최대한 방지하며 스프레드 시트 하나에서 확인이 가능하다는 것이다.
( 개발자는 세팅만 해두면 번역가가 스프레드 시트에 적어줄거야… 그럼 난 할게 없겠지 ㅎ)

어떤 기능이 있는가?

  1. 소스 코드에서 key를 스캔하고 추가된 key를 구글 스프레드 시트에 업로드한다. ( i18next-parser )
    1. i18next-scanner 라는 모듈 또한 존재 하지만, 버젼의 문제로 인해 i18next-parser를 사용한다.
  1. plural ( 복수 값 ) 의 경우 a dog, two dogs 와 같이 설정이 가능하다.
notion image
  1. 소스 빌드 시 구글 스프레드 시트에서 번역된 문자열을 다운로드하여 빌드한다.

폴더 구조

📦src ┣ 📂@types ┃ ┗ 📜i18next.d.ts ┗ 📜index.tsx 📦translation ┣ 📜default-pack.json ┣ 📜download.js ┣ 📜i18n.ts ┣ 📜i18next-parser.config.js ┣ 📜index.js ┗ 📜upload.js 📦locales ┣ 📂en-US ┃ ┗ 📜translation.json ┣ 📂ja-JP ┃ ┗ 📜translation.json ┣ 📂ko-KR ┃ ┗ 📜translation.json
  • translation : i18n 설정 및 구글 스프레드 시트와의 연동을 위한 폴더
  • locales: i18n-parser 를 통해 생성되는 json 파일들이 존재하는 폴더
 

package.json

// package.json "scripts": { ... // 소스 코드에서 key를 추출하여 key, value로 구성된 언어별 json파일을 만들어 낸다 "parse:i18n": "i18next --config translation/i18next-parser.config.js", // 생성된 여러 개의 언어별 json파일을 하나의 테이블로 만들어 구글 스프레드 시트에 업로드 "upload:i18n": "yarn parse:i18n && node translation/upload.js", // 번역된 값을 각 언어별 json파일에 반영한다 ( ko.json, en.json, ... ) "download:i18n": "node translation/download.js", // 로컬에서의 개발 / 빌드 이전에 수행하여 빌드에 포함할 수 있도록 설정 "predev": "yarn upload:i18n && yarn download:i18n" }, "dependencies": { ... "i18n-iso-countries": "^7.5.0", "i18next": "^22.4.11", "i18next-browser-languagedetector": "^7.0.1", }, "devDependencies": { ... "google-spreadsheet": "^3.3.0", "i18next-parser": "^7.7.0", "mkdirp-classic": "^0.5.3", "dotenv": "^16.0.3", }
  • i18n-iso-countries: ISO 3166-1 국가 코드용 i18n
  • i18next: 번역을 하기 위해서 꼭 필요해용
  • i18next-browser-languagedetector: 브라우저에서 사용자 언어를 감지하는 데 사용되는 i18next 언어 감지 플러그인
  • i18next-parser: 새롭게 코드에 추가된 key 를 추출해주는 라이브러리

translation/i18n.ts

import i18n from 'i18next'; import LanguageDetector from 'i18next-browser-languagedetector'; import { initReactI18next } from 'react-i18next'; import en_US from '../locales/en-US/translation.json'; import ja_JP from '../locales/ja-JP/translation.json'; import ko_KR from '../locales/ko-KR/translation.json'; import localeDefaultPack from './default-pack.json'; // 국가 코드와 json 파일 매핑 const resources = { 'ko-KR': { translation: ko_KR }, 'en-US': { translation: en_US }, 'ja-JP': { translation: ja_JP }, }; i18n .use(LanguageDetector) .use(initReactI18next) .init({ resources, fallbackLng: localeDefaultPack['default-language'], returnNull: false, returnEmptyString: false, keySeparator: false, nsSeparator: false, // template literal 방식이라고 생각하면 편하다. // ex: t('안녕 %{하세요}') interpolation: { prefix: '%{', suffix: '}', }, parseMissingKeyHandler(key, defaultValue) { console.warn('Missing i18n key:', key, defaultValue); const keySeparator = '~~'; const value = key.includes(keySeparator) ? key.split(keySeparator)[1] : key; return value; }, }); export default i18n;

returnNull 설정

필자는 react-i18next 의 useTranslation 훅을 사용하는데, 이때 ReturnType을 살펴보면
TFunction<"translation", undefined, "translation">
와 같다. 이놈이 답답한게 타입이 string | undefined 로 잡혀서 타입을 잡아줘야한다는 것이다.
 
// @types/i18next.d.ts import 'i18next'; declare module 'i18next' { interface CustomTypeOptions { returnNull: false; } }
그래서 위와 같이 설정을 해주어야 편안한 string으로 개발이 가능하다.
 

config 설정

// translation/default-pack.json { "default-language": "ko-KR", "default-translations": { "ko-KR": { "ko-KR": "한국어", "en-US": "영어", "ja-JP": "일본어", }, "en-US": { "ko-KR": "Korean", "en-US": "English", "ja-JP": "Japanese", }, "ja-JP": { "ko-KR": "韓国語", "en-US": "英語", "ja-JP": "日本語", } } }
default-pack.json 의 경우, 어떤 locales가 있는지, 스프레드 시트의 columnKey 값으로 쓰기 위한 등 기본값을 세팅해준다고 생각하면 되겠다.
 
// translation/i18next-parser.config.js const path = require('path'); const localeDefaultPack = require('./default-pack.json'); const COMMON_EXTENSIONS = '**/*.{js,jsx,ts,tsx,html}'; module.exports = { createOldCatalogs: false, keepRemoved: true, keySeparator: false, // default-pack.json에 적어둔 ko-KR, en-US 와 같은 키 값으로 locales 설정 locales: Object.keys(localeDefaultPack['default-translations']), namespaceSeparator: false, // output: 'locales/$LOCALE/$NAMESPACE.json', output: path.join(__dirname, '..', 'locales', '$LOCALE', '$NAMESPACE.json'), // 어떤 파일을을 파싱할 것인지 정함 input: [ `../src/components/${COMMON_EXTENSIONS}`, `../src/pages/${COMMON_EXTENSIONS}`, `../src/utils/${COMMON_EXTENSIONS}`, // ! 를 사용할 경우 해당 파일 혹은 디렉터리 제외 '!**/node_modules/**', '!../.next/**', ], defaultValue: (locale, namespace, key) => { if (localeDefaultPack['default-language'] === locale) { return key; } else { return ''; } }, };
새롭게 code에 t('안녕') 과 같이 “안녕” 이라는 키 값이 추가되었을 경우 추출해주는 라이브러리인i18next-parser 를 이용한다.
 
output의 경우 path 모듈을 활용해 어떤 위치에 json 파일들을 위치할 것인지 정해주면 된다.
input의 경우, 말 그대로 소스코드 중 어떤 파일들을 확인할 것인지 정해주면 된다.
 

구글 스프레드시트와 연동하기

const { GoogleSpreadsheet } = require('google-spreadsheet'); const path = require('path'); const localeDefaultPack = require('./default-pack.json'); require('dotenv').config(); // .env.local 에 GOOGLE_PRIVATE_KEY 설정 require('dotenv').config({ path: `.env.local`, override: true }); const spreadsheetDocId = 'YOUR_SPREAD_SHEET_DOC_ID'; const ns = 'translation'; const defaultTranslations = localeDefaultPack['default-translations']; const lngs = Object.keys(defaultTranslations); const localesPath = path.join(__dirname, '..', 'locales'); const rePluralPostfix = new RegExp(/_plural|_[\d]/g); const sheetId = parseInt(process.env.I18N_SHEET_ID, 10); // your sheet id const NOT_AVAILABLE_CELL = '_N/A'; const columnKeyToHeader = { key: 'key', ...defaultTranslations[localeDefaultPack['default-language']], }; async function loadSpreadsheet() { // eslint-disable-next-line no-console console.info( '\u001B[32m', '=====================================================================================================================\n', // "# i18next auto-sync using Spreadsheet\n\n", // " * Download translation resources from Spreadsheet and make /assets/locales/{{lng}}/{{ns}}.json\n", // " * Upload translation resources to Spreadsheet.\n\n", `The Spreadsheet for translation is here (\u001B[34mhttps://docs.google.com/spreadsheets/d/${spreadsheetDocId}/#gid=${sheetId}\u001B[32m)\n`, '=====================================================================================================================', '\u001B[0m', ); const doc = new GoogleSpreadsheet(spreadsheetDocId); // auth 정보 await doc.useServiceAccountAuth({ client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL, private_key: process.env.GOOGLE_PRIVATE_KEY, }); await doc.loadInfo(); // loads document properties and worksheets return doc; } function getPureKey(key = '') { return key.replace(rePluralPostfix, ''); } module.exports = { localesPath, loadSpreadsheet, getPureKey, ns, lngs, sheetId, columnKeyToHeader, NOT_AVAILABLE_CELL, };
이 상황에서 필요한 값 같은 경우는 아래의 상황이다.
 
  1. 소스코드에서 사용할 스프레드 시트 아이디 : spreadSheetDocId
  1. google-spreadsheet 를 사용하여 스프레드 시트를 조작하기에 인증이 필요
    1. ( client_email, private_key )
 
 

스프레드 시트 업로드

// translation/upload.js const fs = require('fs'); const { loadSpreadsheet, localesPath, getPureKey, ns, lngs, sheetId, columnKeyToHeader, NOT_AVAILABLE_CELL, } = require('./index'); const headerValues = Object.values(columnKeyToHeader); //["키", "한국어", "영어", "일본어"]; async function addNewSheet(doc, title, sheetId) { const sheet = await doc.addSheet({ sheetId, title, headerValues, }); return sheet; } async function updateTranslationsFromKeyMapToSheet(doc, keyMap) { const title = process.env.I18N_SHEET_TITLE; let sheet = doc.sheetsById[sheetId]; if (!sheet) { sheet = await addNewSheet(doc, title, sheetId); } const rows = await sheet.getRows(); // find exsit keys const existKeys = {}; const addedRows = []; rows.forEach((row) => { const key = row[columnKeyToHeader.key]; if (keyMap[key]) { existKeys[key] = true; } }); for (const [key, translations] of Object.entries(keyMap)) { if (!existKeys[key]) { const row = { [columnKeyToHeader.key]: key, ...Object.keys(translations).reduce((result, lng) => { const header = columnKeyToHeader[lng]; result[header] = translations[lng]; return result; }, {}), }; addedRows.push(row); } } if (addedRows.length) { await sheet.insertDimension( 'ROWS', { startIndex: 1, endIndex: addedRows.length + 1 }, false, ); // upload new keys await sheet.addRows(addedRows); } } function toJson(keyMap) { const json = {}; Object.entries(keyMap).forEach(([__, keysByPlural]) => { for (const [keyWithPostfix, translations] of Object.entries(keysByPlural)) { json[keyWithPostfix] = { ...translations, }; } }); return json; } function gatherKeyMap(keyMap, lng, json) { for (const [keyWithPostfix, translated] of Object.entries(json)) { const key = getPureKey(keyWithPostfix); if (!keyMap[key]) { keyMap[key] = {}; } const keyMapWithLng = keyMap[key]; if (!keyMapWithLng[keyWithPostfix]) { keyMapWithLng[keyWithPostfix] = lngs.reduce((initObj, lng) => { initObj[lng] = NOT_AVAILABLE_CELL; return initObj; }, {}); } keyMapWithLng[keyWithPostfix][lng] = translated; } } async function updateSheetFromJson() { const doc = await loadSpreadsheet(); fs.readdir(localesPath, (error, lngs) => { if (error) { throw error; } const keyMap = {}; lngs.forEach((lng) => { const localeJsonFilePath = `${localesPath}/${lng}/${ns}.json`; // eslint-disable-next-line no-sync const json = fs.readFileSync(localeJsonFilePath, 'utf8'); gatherKeyMap(keyMap, lng, JSON.parse(json)); }); updateTranslationsFromKeyMapToSheet(doc, toJson(keyMap)); }); } updateSheetFromJson();
 

스프레드 시트 다운로드

const fs = require('fs'); const mkdirp = require('mkdirp-classic'); const { loadSpreadsheet, localesPath, ns, lngs, sheetId, columnKeyToHeader, NOT_AVAILABLE_CELL, } = require('./index'); async function fetchTranslationsFromSheetToJson(doc) { const sheet = doc.sheetsById[sheetId]; if (!sheet) { return {}; } const lngsMap = {}; const rows = await sheet.getRows(); rows.forEach((row) => { const key = row[columnKeyToHeader.key]; lngs.forEach((lng) => { const translation = row[columnKeyToHeader[lng]]; // NOT_AVAILABLE_CELL("_N/A") means no related language if (translation === NOT_AVAILABLE_CELL) { return; } if (!lngsMap[lng]) { lngsMap[lng] = {}; } lngsMap[lng][key] = translation || ''; // prevent to remove undefined value like ({"key": undefined}) }); }); return lngsMap; } function checkAndMakeLocaleDir(dirPath, subDirs) { return new Promise((resolve) => { subDirs.forEach((subDir, index) => { mkdirp(`${dirPath}/${subDir}`, (err) => { if (err) { throw err; } if (index === subDirs.length - 1) { resolve(); } }); }); }); } async function updateJsonFromSheet() { await checkAndMakeLocaleDir(localesPath, lngs); const doc = await loadSpreadsheet(); const lngsMap = await fetchTranslationsFromSheetToJson(doc); fs.readdir(localesPath, (error, lngs) => { if (error) { throw error; } lngs.forEach((lng) => { const localeJsonFilePath = `${localesPath}/${lng}/${ns}.json`; const jsonString = (JSON.stringify(lngsMap[lng], null, 2) || '').concat( '\n', ); fs.writeFile(localeJsonFilePath, jsonString, 'utf8', (err) => { if (err) { throw err; } }); }); }); } updateJsonFromSheet();
 

dayjs를 사용한 locale 값 설정

당연히도 다국어 지원을 하게되면 시간 값 또한 설정해 주어야 할 것이다.
본인은 dayjs 라이브러리를 아주 잘 사용하고 있는데, 아래와 같이 설정해주면 된다.
 
// src/index.tsx import dayjs from 'dayjs'; import i18n from '../translation/i18n'; if (i18n.language?.length) { dayjs.locale(i18n.language.substring(0, 2)); }
 

Language 변경

import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; import localeDefaultPack from '../translation/default-pack.json'; dayjs.extend(relativeTime); const languages = Object.entries( localeDefaultPack['default-translations'], ).map<string>(([k, v]) => { const [locale, label] = Object.entries(v).find(([key]) => key.startsWith(k)) || []; return locale; }); const Component: React.FC = () => { const { i18n, t } = useTranslation(); // 뭐 이건 본인이 원하는 방식에 따라 어디에 저장을 할 지 결정을 하면 되겠당 const [localeLanguage, setLocaleLanguage] = useRecoilState(LocaleLanguage); const handleChange = useCallback< Required<BaseDropdownProps<LanguageProps>>['onClickItem'] >( ({ locale }) => { // locale 바꾸는 함수 i18n.changeLanguage(locale); }, [i18n], ); useEffect(() => { const localeLang = i18n.language.substring(0, 2); dayjs.locale(localeLang); setLocaleLanguage(localeLang); }, [i18n.language, setLocaleLanguage]); return ( ... ) }
당연히도 우리는 한국인이기에 브라우저에 접근을 하면 한국어로 뜨게 될 것이다.
이 때 locale을 변경하기 위해서는 위와 같은 코드를 작성해주면 되겠다.

드디어 사용해보자

사실 스프레드 시트와 관련된 코드들의 설명은 누락되어있는데, 한번씩 읽어보길 바란다.
읽으면 뭐 별거 없는 코드다.
실제로 다국어를 적용하여 개발을 할 때도, json 파일은 건드릴 필요 없이
const { t } = useTranslation(); ... return ( <button type="submit">{t('로그인')}</button> )
과 같이 사용하면 아주 간단간단하게 다국어를 적용할 수 있다.
 
yarn dev를 이용하여 실행하게 되면, scriptspredev를 통해
notion image
와 같은 로그를 확인할 수 있고, 스프레드 시트 및 json 파일에도 t('key') 내부에 들어간 key값 들이 자동으로 추가된 것을 볼 수 있을 것이다. ( 연동이 아주 잘된다 ㅎㅎ )
 
notion image