본문 바로가기

React

[React] 다국어 지원 자동화하기 - i18next, Google Spreadsheet

공식 홈페이지에 있는 이미지

 

현재 진행 중인 프로젝트에서는 클라이언트를 영어를 기준으로 개발하고 있었다.

한국어 지원이 당연히 필요하다고 생각했고, 다국어를 지원할 수 있는 방법을 찾아봤다.

i18next 라이브러리를 활용해 다국어 기능을 구현했지만, key 값이 많아지면 이를 관리하는데 어려움이 생길 것 같았다.

또한, 클라이언트에서 제공하는 서비스에 따라 텍스트가 추가되거나 수정되는 일이 발생할 텐데, 이때 번역 텍스트를 개발자가 일일이 수정해야 하는 문제도 고민이 됐다.

이런 고민을 해결하기 위해 다국어 지원을 자동화할 수 있는 방법을 찾아봤고, 그 도입 과정과 경험했던 시행착오에 대해 작성하고자 한다.

 

i18next 라이브러리 사용법 궁금하거나, 자동화 없이 간단히 사용해보고 싶다면 이전에 작성했던 글을 참고하면 된다.

 

[React] i18next를 이용한 다국어 지원 구현하기

웹 애플리케이션을 개발할 때 다양한 국가의 사용자를 대상으로 한다면, 다국어 지원은 거의 필수적이다.이 글에서는 React에서 i18next, react-i18next 라이브러리를 사용하여 다국어 지원을 간단하게

dan-aram.tistory.com

 

i18n과 i18next의 차이점

두 용어의 차이점에 대해 알고싶어서 찾아봤다.

- i18n(Internationalization) : 다국어 지원을 위한 개념이나 과정

- i18next : 국제화(i18n)을 위한 실제적인 자바스크립트 라이브러리

 

자동화 방법

자동화는 아래의 순서로 구현할 수 있다.

1. 소스 코드에서 key를 스캔한다.
2. key를 Google Spreadsheet에 업로드 한다.
3. 필요할 때, Google Spreadsheet에서 번역된 텍스트를 다운로드 한다.

 

1.  소스 코드에서 key 스캔

라이브러리 설치

먼저 필요한 라이브러리를 설치해보자.

yarn add i18next react-i18next
yarn add -D i18next-scanner i18next-scanner-typescript 
yarn add -D google-auth-library google-spreadsheet
yarn add -D ts-node

 

- i18next-scanner : 코드에 추가된 key를 스캔해주는 라이브러리

- i18next-scanner-typescript : i18next-scanner에서 TypeScript 파일을 스캔하여 번역 키를 추출할 수 있게 해주는 변환기 라이브러리

- google-auth-library : Google API 인증을 위한 라이브러리

- google-spreadsheet : Google Spreadsheet와의 연동을 위한 라이브러리

ts-node : TypeScript 파일을 실행할 수 있도록 해주는 라이브러리

 

resource 생성

resource 파일 구조는 아래처럼 만들었다.

src/
└── locales/
    ├── en-US/
    │   └── translation.json
    └── ko-KR/
        └── translation.json

 

i18next 셋팅 파일 작성

// 위치 : src/plugins/i18next.ts

import i18next from "i18next";
import ko_KR from "../locales/ko-KR/translation.json";
import en_US from "../locales/en-US/translation.json";
import { initReactI18next } from "react-i18next";

// 지원하는 언어와 해당 리소스 매핑
const resources = {
  "ko-KR": { translation: ko_KR },
  "en-US": { translation: en_US },
};

export const initializeI18next = (lng: string = "ko-KR"): void => {
  i18next
    .use(initReactI18next) // React i18next 초기화
    .init({
      resources, // 리소스 객체를 직접 전달
      lng, // 기본 언어 설정
      fallbackLng: false, // 기본 언어로 대체되지 않게 설정
      debug: true, // 디버그 모드 활성화
      keySeparator: false, // 키 분리자 비활성화
      nsSeparator: false, // 네임스페이스 분리자 비활성화
      returnEmptyString: false, // 빈 문자열 반환하지 않음
      interpolation: {
        prefix: "%{",
        suffix: "}",
      },
      // 누락된 키 처리 핸들러
      parseMissingKeyHandler: (key) => {
        console.warn("parseMissingKeyHandler:", `'key': '${key}'`);
        return key.includes("~~") ? key.split("~~")[1] : key;
      },
    });
};

export const i18n = i18next;

 

i18next-scanner 셋팅 파일 작성

처음에 .tsx 파일의 key를 스캔하지 못해서 방법을 찾던 중 i18next-scanner-typescript 라이브러리를 찾았다.

extensions에서 .ts, .tsx를 빼고 transform을 설정해주면 된다.

// 위치 : i18next-scanner.config.js

const path = require("path");
const typescriptTransform = require("i18next-scanner-typescript");

// JavaScript와 TypeScript 파일 확장자
const COMMON_EXTENSIONS = "/**/*.{js,jsx,ts,tsx}";

module.exports = {
  // 스캔 대상 입력 파일/디렉토리
  input: [`./src/pages${COMMON_EXTENSIONS}`],
  options: {
    defaultLng: "ko-KR", // 기본 언어 설정: 'ko-KR'
    lngs: ["ko-KR", "en-US"], // 지원 언어 목록
    func: {
      // 번역 함수 이름 목록
      list: ["i18next.t", "i18n.t", "$i18n.t", "t"],
      extensions: [".js", ".jsx"], // .ts, .tsx 입력 X
    },
    trans: {
      extensions: [".js", ".jsx"], // .ts, .tsx 입력 X
    },
    resource: {
      // 번역 파일 경로. {{lng}}, {{ns}}는 언어 및 네임스페이스로 대체
      loadPath: path.join(__dirname, "src/locales/{{lng}}/{{ns}}.json"),
      savePath: path.join(__dirname, "src/locales/{{lng}}/{{ns}}.json"),
    },
    defaultValue(lng, ns, key) {
      // 기본 언어(ko-KR)의 키값을 기본값으로 사용
      const keyAsDefaultValue = ["ko-KR"];
      if (keyAsDefaultValue.includes(lng)) {
        const separator = "~~";
        return key.includes(separator) ? key.split(separator)[1] : key;
      }

      return ""; // 다른 언어의 기본값은 빈 문자열
    },
    keySeparator: false, // 키 분리자 비활성화
    nsSeparator: false, // 네임스페이스 분리자 비활성화
    prefix: "%{", // 키 접두사
    suffix: "}", // 키 접미사
  },
  
  // TypeScript 파일 변환 설정
  transform: typescriptTransform({ extensions: [".ts", ".tsx"] }),
};

 

스크립트 추가

package.json에 아래의 스크립트를 추가해준다.

"scripts": {
    "scan:i18n": "i18next-scanner --config i18next-scanner.config.js",
},

 

스크립트를 추가하고 아래의 명령어를 실행하면 스캔된 key가 언어별 translation.json 파일에 추가되어 있는걸 확인할 수 있다.

yarn scan:i18n

 

2. 구글 스프레드시트에 업로드 및 다운로드

Google Cloud Plaform API 설정

Google Sheets API를 사용하기 위해 여러 설정을 진행해야 한다.

설정 하는 방법은 이 블로그에 잘 나와있다.

 

Google Spreadsheet 연결

우선 문서를 하나 생성해서 문서 ID, 시트 ID를 확인한다.

ID는 문서의 url에서 확인할 수 있다.

https://docs.google.com/spreadsheets/d/문서ID/edit#gid=시트ID

 

문서 1행에 Key, ko-KR, en-US를 각각 입력한다.

 

업로드 및 다운로드에 필요한 공통 데이터 및 함수 작성

API 키를 src/credentials 폴더에 넣는다.

// 위치 : translate/index.ts

import { GoogleSpreadsheet } from "google-spreadsheet";
import { JWT } from "google-auth-library";
import creds from "../credentials/LanguageProjectIAM.json";
import { options } from "../../i18next-scanner.config";

const spreadsheetDocId = "abcde12345";
const sheetId = 0;
const ns = "translation";
const lngs = options.lngs;
const loadPath = options.resource.loadPath;
const localesPath = loadPath.replace("/{{lng}}/{{ns}}.json", "");
const rePluralPostfix = new RegExp(/_plural|_[\d]/g);
const NOT_AVAILABLE_CELL = "_N/A";
interface ColumnKeyToHeader {
  key: string;
  [key: string]: string;
}

const columnKeyToHeader: ColumnKeyToHeader = {
  key: "Key",
  "ko-KR": "ko-KR",
  "en-US": "en-US",
};

/**
 * getting started from https://theoephraim.github.io/node-google-spreadsheet
 */
async function loadSpreadsheet() {
  console.info(
    "\u001B[32m",
    "=====================================================================================================================\n",
    "# i18next auto-sync using Spreadsheet\n\n",
    "  * Download translation resources from Spreadsheet and make /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[0m)\n`,
    "=====================================================================================================================",
    "\u001B[0m"
  );

  const serviceAccountAuth = new JWT({
    email: creds.client_email,
    key: creds.private_key,
    scopes: ["https://www.googleapis.com/auth/spreadsheets"],
  });

  const doc = new GoogleSpreadsheet(spreadsheetDocId, serviceAccountAuth);

  await doc.loadInfo();
  return doc;
}

const getPureKey = (key: string = ""): string => {
  return key.replace(rePluralPostfix, "");
};

export {
  localesPath,
  loadSpreadsheet,
  getPureKey,
  ns,
  lngs,
  sheetId,
  columnKeyToHeader,
  NOT_AVAILABLE_CELL,
};

 

업로드 함수 작성

// 위치 : translate/upload.ts

import fs from "fs";
import {
  loadSpreadsheet,
  localesPath,
  getPureKey,
  ns,
  lngs,
  sheetId,
  columnKeyToHeader,
  NOT_AVAILABLE_CELL,
} from "./index";

// 스프레드시트의 헤더 값 정의
const headerValues: string[] = ["Key", "ko-KR", "en-US"];

/**
 * 새로운 시트를 추가하는 함수
 */
const addNewSheet = async (doc: any, title: string, sheetId: number) => {
  const sheet = await doc.addSheet({
    sheetId,
    title,
    headerValues,
  });

  return sheet;
};

type Translations = {
  [key: string]: string;
};

/**
 * keyMap을 사용하여 시트의 번역을 업데이트하는 함수
 */
const updateTranslationsFromKeyMapToSheet = async (doc: any, keyMap: any) => {
  const title = "시트1";
  let sheet = doc.sheetsById[sheetId];
  if (!sheet) {
    sheet = await addNewSheet(doc, title, sheetId);
  }

  const rows = await sheet.getRows();
  const existKeys: { [key: string]: boolean } = {};
  const addedRows: any[] = [];

  rows.forEach((row: any) => {
    const key = row.get(columnKeyToHeader.key); // row[columnKeyToHeader.key]에서 수정
    if (keyMap[key]) {
      existKeys[key] = true;
    }
  });

  for (const [key, translations] of Object.entries<Translations>(keyMap)) {
    if (!existKeys[key]) {
      const row: Record<string, string> = {
        [columnKeyToHeader.key]: key,
        ...Object.keys(translations).reduce((result, lng) => {
          const header = columnKeyToHeader[lng];
          result[header] = translations[lng];
          return result;
        }, {} as Record<string, string>), 
      };

      addedRows.push(row);
    }
  }

  await sheet.addRows(addedRows);
};

/**
 * keyMap을 JSON 형식으로 변환하는 함수
 */
const toJson = (keyMap: any) => {
  const json: any = {};

  Object.entries(keyMap).forEach(([__, keysByPlural]: any) => {
    for (const [keyWithPostfix, translations] of Object.entries<
      Record<string, string>
    >(keysByPlural) as [string, Record<string, string>][]) {
      json[keyWithPostfix] = {
        ...translations,
      };
    }
  });

  return json;
};

/**
 * JSON 데이터를 사용하여 keyMap을 구성하는 함수
 */
const gatherKeyMap = (keyMap: any, lng: string, json: any) => {
  for (const [keyWithPostfix, translated] of Object.entries(json)) {
    const key = getPureKey(keyWithPostfix);

    keyMap[key] = keyMap[key] || {};
    const keyMapWithLng = keyMap[key];
    if (!keyMapWithLng[keyWithPostfix]) {
      keyMapWithLng[keyWithPostfix] = lngs.reduce((initObj: any, lng) => {
        initObj[lng] = NOT_AVAILABLE_CELL;

        return initObj;
      }, {});
    }

    keyMapWithLng[keyWithPostfix][lng] = translated;
  }
};

/**
 * JSON 파일로부터 시트를 업데이트하는 메인 함수
 */
const updateSheetFromJson = async () => {
  const doc = await loadSpreadsheet();

  fs.readdir(localesPath, (error, lngs) => {
    if (error) throw error;

    const keyMap = {};

    lngs.forEach((lng) => {
      const json = JSON.parse(
        fs.readFileSync(`${localesPath}/${lng}/${ns}.json`, "utf8")
      );
      gatherKeyMap(keyMap, lng, json);
    });

    updateTranslationsFromKeyMapToSheet(doc, toJson(keyMap));
  });
};

updateSheetFromJson();

 

다운로드 함수 작성

// 위치 : translate/download.ts

import fs from "fs";
import { mkdirp } from "mkdirp";
import {
  loadSpreadsheet,
  localesPath,
  ns,
  lngs,
  sheetId,
  columnKeyToHeader,
  NOT_AVAILABLE_CELL,
} from "./index";
import { GoogleSpreadsheet } from "google-spreadsheet";

/**
 * fetch translations from google spread sheet and transform to json
 * @param {GoogleSpreadsheet} doc GoogleSpreadsheet document
 * @returns [object] translation map
 * {
 *   "ko-KR": {
 *     "key": "value"
 *   },
 *   "en-US": {
 *     "key": "value"
 *   },
 * }
 */
const fetchTranslationsFromSheetToJson = async (
  doc: GoogleSpreadsheet
): Promise<{ [key: string]: { [key: string]: string } }> => {
  const sheet = doc.sheetsById[sheetId];
  if (!sheet) {
    return {};
  }

  const lngsMap: { [key: string]: { [key: string]: string } } = {};
  const rows = await sheet.getRows();

  rows.forEach((row) => {
    const key = row.get(columnKeyToHeader.key);
    lngs.forEach((lng: string | number) => {
      const translation = row.get(columnKeyToHeader[lng]);
      console.log({ lng });
      console.log({ translation });
      // 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;
};

const checkAndMakeLocaleDir = async (
  dirPath: string,
  subDirs: string[]
): Promise<void> => {
  for (let i = 0; i < subDirs.length; i++) {
    const subDir = subDirs[i];
    const path = `${dirPath}/${subDir}`;
    try {
      await mkdirp(path);
    } catch (err) {
      throw err;
    }
  }
};

const updateJsonFromSheet = async (): Promise<void> => {
  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);

      fs.writeFile(localeJsonFilePath, jsonString, "utf8", (err) => {
        if (err) {
          throw err;
        }
      });
    });
  });
};

updateJsonFromSheet();

 

스크립트 추가

package.json에 추가해준다.

  "scripts": {
    "scan:i18n": "i18next-scanner --config i18next-scanner.config.js",
    "upload:i18n": "yarn scan:i18n && ts-node src/translate/upload.ts",
    "download:i18n": "ts-node src/translate/download.ts",
  },

 

yarn upload:i18n을 하면 key를 스캔하고 이걸 구글 시트에 입력해준다.

yarn download:i18n을 하면 구글 시트에 있던 key, value를 각 언어별 translation.json에 다운로드한다.

 

 

정리

프로젝트/
├── src/
│   ├── locales/
│   │   ├── en-US/
│   │   │   └── translation.json
│   │   └── ko-KR/
│   │       └── translation.json
│   ├── plugins/
│   │   └── i18next.ts
│   └── translate/
│       ├── index.ts
│       ├── upload.ts
│       └── download.ts 
├── credentials/
│   └── LanguageProjectIAM.json
└── i18next-scanner.config.js

 

개발자는 번역이 필요한 텍스트를 t로 감싸고 yarn upload:i18n를 실행 한 후 번역을 요청한다.

번역 담당자가 구글 시트에 번역 텍스트를 입력한다.

개발자는 빌드 명령어에 download:i18n을 추가하고 빌드한다.

그럼 끝이다!

 

후기

자동화를 진행하면서 몇 가지 시행착오를 겪었다. 그 중 하나는 참고한 블로그 코드와의 호환성 문제였다.

예를 들어, Google API를 사용하여 문서 정보를 가져올 때, 해당 블로그에서는 useServiceAccountAuth 메서드를 사용했지만, 실제로 해당 메서드가 GoogleSpreadsheet 인스턴스에 포함되어 있지 않았다. 라이브러리가 업그레이드 되면서 변경된 것 같았고, 이에 따라 JWT를 사용하는 방식으로 수정했다.

또한, 원래 JavaScript로 작성된 파일을 TypeScript로 변환하는 과정에서 일부 코드가 호환되지 않아 수정이 필요했다.

이러한 문제들을 해결하면서 프로젝트를 진행하는 데 시간이 소요되었지만, 진행하고 있던 회사 프로젝트에 실질적인 도움이 된 것 같아 뿌듯했다.

 


 

참고한 사이트

https://meetup.nhncloud.com/posts/295

https://sojinhwan0207.tistory.com/200