본문 바로가기
카테고리 없음

i18next-react와 Google Sheets를 활용한 국제화(i18n) 자동화 도입기(3) - 자동화 적용하기 (최종)

by 복숭아 우유씨 2024. 11. 10.

어쩌다보니 3편까지 작성하게 된 자동화 도입기

지난 글에서는 i18next를 적용하는 것까지 작성했었고, 이번에는 본격적으로 자동화를 도입한 방법을 소개하려고 한다.

참고자료를 기반으로 코드를 작성하였으며, 프로젝트에 맞게 추가/변경하여 사용하였다

 

2024.09.18 - [React] - i18next-react와 Google Sheets를 활용한 국제화(i18n) 자동화 도입기(1) - intro/도구 선정

 

i18next-react와 Google Sheets를 활용한 국제화(i18n) 자동화 도입기(1) - intro/도구 선정

도입기라고 하기엔 별거 없지만,다른 블로그 글과 달라진 부분도 있고,나중에 또 적용해야 할 나를 위해 써보는 도입기! (1)을 붙인 건 더 업데이트해야 할 부분이 많기도 하고,한번에 다 쓰려고

peach-milk.tistory.com

2024.10.27 - [React] - i18next-react와 Google Sheets를 활용한 국제화(i18n) 자동화 도입기(2) - i18next적용하기(namespace 구분하기)

 

i18next-react와 Google Sheets를 활용한 국제화(i18n) 자동화 도입기(2) - i18next적용하기(namespace 구분하기)

지난 글에서는 i18n 자동화 도입 배경과 사용 도구 및 도구 선정 배경에 대해 살펴보았다.2024.09.18 - [React] - i18next-react와 Google Sheets를 활용한 국제화(i18n) 자동화 도입기(1) - intro/도구 선정 이번에

peach-milk.tistory.com

 

TL;DR
step1. 필요한 패키지를 설치한다.
step2. i18next-scanner를 사용해서 i18next의 key/value를 추출한다.
step3. 구글 시트와 연동을 위해 Google sheets api를 사용할 수 있도록 계정을 설정한다.
step4. 구글 시트와 연동되도록 설정한다.
step5. script를 이용해서 실행한다.

 

 

패키지 설치

npm install -D i18next-scanner google-spreadsheet google-auth-library
  • i18next-scanner: 코드를 스캔해서 key를 추출해줌
  • google-spreadsheet : JS/TS 생태계에서 Google Sheets API wrapper 중 유명한 라이브러리
  • google-auth-library : google auth를 google-spreadsheet와 함께 쓸수 있게 해줌

 

i18next-scanner로 i18next의 key 추출하기

1) i18next-scanner란?

i18next-scanner는 코드를 스캔해서, i18next에서 사용중인 key/value를 정적으로 추출해주는 라이브러리이다.

이외에도 i18next-parser, bable-plugin-i18next-extract 등도 동일한 기능을 지원해준다. JSON v4에서 v3와 복수 표기법이 달라졌기에 이를 지원하는지 확인해서 선택하는 것이 좋다. i18next-scanner와 i18next-parser는 모두 v4를 지원한다.

 

왜 사용하는가?

i18next의 key/value를 자동으로 추출하기 위해 사용했다.

i18next를 사용하는 곳에서 key만 입력해두면 key-value를 관리하는 locales 파일에 자동으로 해당 키가 추가 된다. value의 초기값을 설정할 수 있으며, 함수로도 설정할 수 있어서 상황에 맞게 초기값을 설정할 수 있다.

2) 적용방법

2-1) config 파일 작성

설정 파일을 작성한다.

// commonJS 사용, src폴더가 아닌 root에서 config폴더 추가
// 📁 /config/translation/i18next-scanner.config.js

const path = require("path");

const COMMON_EXTENSIONS = "/**/*.{js,jsx,ts,tsx,html}";

module.exports = {
  input: [
  	// 스캔할 경로 추가, 포함하고 싶지 않은 경로는 !를 앞에 붙인다.
    `./src/pages${COMMON_EXTENSIONS}`,
    `./src/components${COMMON_EXTENSIONS}`,
    "!**/node_modules/**",
  ], 
  options: {
    compatibilityJSON: "v4",  // 4가지 버전 중의 하나 입력: 'v1', 'v2', 'v3', 'v4'
    defaultLng: "ko-KR", // 기본값은 'en'
    lngs: ["ko-KR", "en-US"],
    func: {
      list: ["i18next.t", "i18n.t", "$i18n.t", "t"],
      extensions: [".js", ".jsx", ".ts", ".tsx", ".html"],
    },
    ns: ["common", "login", "translation"],
    resource: {
      // 리소스들을 로드해오는 경로
      loadPath: path.join(
        __dirname,
        "../../src/translation/locales/{{lng}}/{{ns}}.json",
      ),
      // 리소스들을 저장하는 경로
      savePath: path.join(
        __dirname,
        "../../src/translation/locales/{{lng}}/{{ns}}.json",
      ),
    },
    defaultValue: "", // 문자열 혹은 함수로 입력 가능하며 기본값은 ""이다.
    keySeparator: ".",
    nsSeparator: ":",
    prefix: "%{",
    suffix: "}",
  },
};

 

defaultValue는 아래와 같이 함수로도 입력가능하다.

{
    // @param {string} lng The language currently used.
    // @param {string} ns The namespace currently used.
    // @param {string} key The translation key.
    // @return {string} Returns a default value for the translation key.
    defaultValue: function(lng, ns, key) {
        if (lng === 'en') {
            // Return key as the default value for English language
            return key;
        }
        // Return the string '__NOT_TRANSLATED__' for other languages
        return '__NOT_TRANSLATED__';
    }
}

 

옵션에 대한 더 자세한 내용은 공식 문서를 참고바란다.

 

2-2) script 작성 및 실행

i18next-scanner를 실행하기 위해 script를 작성하였다.

이를 실행하면 config 파일의 input 경로에서 코드를 스캔하고, 여기서 i18next의 키를 추출하여 resource 경로에 저장하는 과정이 자동으로 이뤄지게 된다.

// package.json

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

 

 

Google Sheets API 사용을 위한 계정 설정

구글에서 구글 제품을 사용할 수 있는 다양한 api를 제공하고 있다. 그 중 Google Sheets API를 사용하면 구글 시트에 업로드/다운로드를 자동으로 할 수 있다.

사용을 위해서는 GCP (Google cloud platform)에 가입하고 해당 계정에서 api를 사용할 수 있도록 설정하고, api key를 발행하여 사용해야 한다. 이 방법은 다른 블로그들에서 많이 소개하고 있기에 아래의 글을 비롯한 다른 블로그들을 참고하면 되겠다.

https://sojinhwan0207.tistory.com/200

 

Google Sheets API 를 통해 스프레드시트 데이터 읽기/쓰기

회사 업무 특성상 구글 스프레드시트에 데이터를 기록하고 주기적으로 관리해야 하는 문서들이 많다. 예를들면 매월 첫째주가 되면 현재 운영중인 스토리지의 디스크 사용량을 기록하고, AWS S3

sojinhwan0207.tistory.com

 

구글 시트와 연동하기

1) Google-spreadsheet 기본 사용법

왜 사용하는가?

Google-spreadsheet는 JS/TS 생태계에서 가장 유명한 Google sheets api wrapper 이다.

다양한 auth option들을 사용할 수 있으며, 시트를 cell이나 row를 기반으로 관리 할 수 있게 해주며, worksheet나 docs의 관리가 편리하며 다양한 형식으로 export할 수 있는 기능을 제공한다. 즉, Google sheets api를 보다 편리하게 사용할 수 있게 해주어 사용하게 되었다.

 

가장 기본적인 초기 사용법

사용을 위해서는 아래의 방법을 기본적으로 사용한다.

① 문서의 초기화를 진행하고, 

const doc = new GoogleSpreadsheet(id, auth)

② 초기화한 문서를 load하여 사용한다. (async)

문서를 load해야만 해당 문서의 properties에 접근할 수 있게되며, 하위 worksheet들도 load되게 된다.

await doc.loadInfo()

 

이를 적용한 공식문서의 기본 예시는 아래와 같다.

import { GoogleSpreadsheet } from 'google-spreadsheet';
import { JWT } from 'google-auth-library';

// Initialize auth - see https://theoephraim.github.io/node-google-spreadsheet/#/guides/authentication
const serviceAccountAuth = new JWT({
  // env var values here are copied from service account credentials generated by google
  // see "Authentication" section in docs for more info
  email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
  key: process.env.GOOGLE_PRIVATE_KEY,
  scopes: ['https://www.googleapis.com/auth/spreadsheets'],
});

const doc = new GoogleSpreadsheet('<the sheet ID from the url>', serviceAccountAuth);

await doc.loadInfo(); // loads document properties and worksheets
console.log(doc.title);
await doc.updateProperties({ title: 'renamed doc' });

const sheet = doc.sheetsByIndex[0]; // or use `doc.sheetsById[id]` or `doc.sheetsByTitle[title]`
console.log(sheet.title);
console.log(sheet.rowCount);

// adding / removing sheets
const newSheet = await doc.addSheet({ title: 'another sheet' });
await newSheet.delete();

 

2) 구글 시트 만들기

2-1) 기본적인 형태

기본적으로는 아래의 그림과 같이 1행을 제목행으로 설정하여 사용할 수 있다.

또한 네임스페이스별로 worksheet를 구분하여 사용하도록 하였다.

 

2-2) 프로젝트에 맞춰 변경한 형태

1행이 아닌 다른 행을 제목행으로 설정할 수 있다. 

3) upload/download 로직 작성하기

3-1) 공통 로직 작성하기 (index.js)

upload/download에서 모두 사용하는 공통 로직을 별도로 작성하여 관리한다.

이때, rows를 기반으로 한 method를 사용하면 cell 기반보다 간단하게 사용할 수 있다. 

// 📁 /config/translation/index.js

const { JWT } = require("google-auth-library");
const { GoogleSpreadsheet } = require("google-spreadsheet");

const i18nextConfig = require("./i18next-scanner.config");
const creds = require("../../.key/i18n-auto-key.json");

const ns = i18nextConfig.options.ns;
const lngs = i18nextConfig.options.lngs;

const spreadsheetId = "sheetID"; // 시트의 id
const sheetIdForNamespaces = [
  // namespace마다 id가 있다. url의 gid 값이다.
  { namespace: "common", sheetId: 1 },
  { namespace: "login", sheetId: 2 },
];

// 2-1의 예처럼 사용할 경우의 headerValues
// const headerValues = ["key", "ko-KR", "en-US"];

// 2-2의 예처럼 사용할 경우의 headerValues
const headerValues = [
  "RowNo.",
  "key",
  "value",
  "desc",
  "Reference",
  "ko-KR",
];
const KEY_COLUMN_NUMBER = 1;
const HEADER_ROW_NUMBER = 2; // 제목행을 지정할 수 있다.

const rePluralPostfix = new RegExp(/_plural|_[\d]/g);
const NOT_AVAILABLE_CELL = "_N/A";

const loadPath = i18nextConfig.options.resource.loadPath; //./locales/{{lng}}/{{ns}}.json
const localesPath = loadPath.replace("/{{lng}}/{{ns}}.json", "");

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

/**
 * getting started from https://theoephraim.github.io/node-google-spreadsheet
 */
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/${spreadsheetId}/#gid=0\u001B[0m)\n`,
    "=====================================================================================================================",
    "\u001B[0m",
  );

  const doc = new GoogleSpreadsheet(spreadsheetId, serviceAccountAuth); //존재하는 spreadsheet로 작업

  await doc.loadInfo(); // loads document properties and worksheets

  return doc;
}

function getPureKey(key = "") {
  return key.replace(rePluralPostfix, "");
}

/**
 * 제목행을 설정하는 함수
 */
async function getSheetRows(sheet) {
  await sheet.setHeaderRow(headerValues, HEADER_ROW_NUMBER);
  const rows = await sheet.getRows();

  return rows;
}

module.exports = {
  loadSpreadsheet,
  getPureKey,
  getSheetRows,
  localesPath,
  ns,
  lngs,
  sheetIdForNamespaces,
  headerValues,
  KEY_COLUMN_NUMBER,
  NOT_AVAILABLE_CELL,
};
  • 이때, spreadsheetId는 url에서 확인할 수 있다.

  • 제목행 변경이 필요한 경우, header row가 될 행의 수도 변경하고, headerValues를 변경하여 사용할 수 있으며, 제목행을 설정하는 함수를 추가하여 이를 확인 할 수 있도록 하였다.

3-2) upload/download 로직 작성

각각 upload.js, download.js 파일을 생성하고 로직을 작성한다.

 

upload.js

// 📁 /config/translation/upload.js

const fs = require("fs");

const {
  lngs,
  ns,
  sheetIdForNamespaces,
  headerValues,
  localesPath,
  KEY_COLUMN_NUMBER,
  NOT_AVAILABLE_CELL,
  getSheetRows,
  loadSpreadsheet,
  getPureKey,
} = require("./index");

/**
 * sheet가 없는 경우 sheet를 추가하는 함수
 */
async function addNewSheet(doc, title, sheetId) {
  const sheet = await doc.addSheet({
    sheetId,
    title,
    headerValues,
  });

  return sheet;
}

/**
 * sheet에 이미 존재하는 key인지 확인하는 객체를 생성하는 함수
 */
function getExistKeysInSheet(rows, rowsFromJson) {
  const KEY_COLUMN = headerValues[KEY_COLUMN_NUMBER];
  const existKeys = {};

  rows.forEach((row) => {
    const keyInSheet = row.get(KEY_COLUMN);

    if (rowsFromJson[keyInSheet]) {
      existKeys[keyInSheet] = true;
    }
  });

  return existKeys;
}

async function updateSheet(doc, namespace, rowsFromJson) {
  const { namespace: title, sheetId } = sheetIdForNamespaces.find(
    (item) => item.namespace === namespace,
  );

  let sheet = doc.sheetsById[sheetId];
  if (!sheet) {
    sheet = await addNewSheet(doc, title, sheetId);
  }
  const rows = await getSheetRows(sheet);

  // 구글 시트에 없는 key를 새로운 row로 추가하기
  const existKeys = getExistKeysInSheet(rows, rowsFromJson);
  const newRows = [];

  for (const [key, translations] of Object.entries(rowsFromJson)) {
    if (existKeys[key]) continue;

    const newRow = { key: key, ...translations };
    newRows.push(newRow);
  }

  if (newRows.length) await sheet.addRows(newRows);
}

function toJson(rowsFromJson) {
  const json = {};

  Object.entries(rowsFromJson).forEach(([__, keysByPlural]) => {
    for (const [keyWithPostfix, translations] of Object.entries(keysByPlural)) {
      json[keyWithPostfix] = {
        ...translations,
      };
    }
  });

  return json;
}

function setRow(rowsFromJson, key, translated, lng) {
  if (!rowsFromJson[key]) {
    rowsFromJson[key] = {};
  }

  const row = rowsFromJson[key];

  if (!row[key]) {
    row[key] = {};
    lngs.forEach((lng) => {
      row[key][lng] = NOT_AVAILABLE_CELL;
    });
  }

  row[key][lng] = translated;
}

function setRowsFromJson(rowsFromJson, lng, json) {
  for (const [keyWithPostfix, translated] of Object.entries(json)) {
    const key = getPureKey(keyWithPostfix);

    if (typeof translated !== "string") {
      for (const [nestedKeyWithPostfix, nestedTranslated] of Object.entries(
        translated,
      )) {
        const nestedKey = key + "." + getPureKey(nestedKeyWithPostfix);
        setRow(rowsFromJson, nestedKey, nestedTranslated, lng);
      }
    }

    if (typeof translated === "string") {
      setRow(rowsFromJson, key, translated, lng);
    }
  }
}

/**
 * 번역 JSON 파일을 구글시트로 업로드 하는 메인 함수
 */
async function upload() {
  const doc = await loadSpreadsheet();

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

    ns.forEach((namespace) => {
      const rowsFromJson = {}; // { [key] : { 'ko-KR': string, 'en-US': string } }

      lngs.forEach((lng) => {
        const localeJsonFilePath = `${localesPath}/${lng}/${namespace}.json`;
        const json = fs.readFileSync(localeJsonFilePath, "utf8");

        setRowsFromJson(rowsFromJson, lng, JSON.parse(json));
      });
      updateSheet(doc, namespace, toJson(rowsFromJson));
    });
  });
}

upload();

 

download.js

// 📁 /config/translation/download.js
const fs = require("fs");

const {
  lngs,
  ns,
  sheetIdForNamespaces,
  localesPath,
  NOT_AVAILABLE_CELL,
  getSheetRows,
  loadSpreadsheet,
} = require("./index");

async function fetchSheetToJson(doc, namespace) {
  const sheetId = sheetIdForNamespaces.find(
    (item) => item.namespace === namespace,
  ).sheetId;

  const sheet = doc.sheetsById[sheetId];
  if (!sheet) {
    return {};
  }

  const lngsMap = {};
  const rows = await getSheetRows(sheet);

  rows.forEach((row) => {
    lngs.forEach((lng) => {
      const translation = row.get(lng);
      const key = row.get("key");

      if (translation === NOT_AVAILABLE_CELL) return;
      if (!lngsMap[lng]) lngsMap[lng] = {};

      if (key?.includes(".")) {
        const [namespace, innerKey] = key.split(".");
        if (!lngsMap[lng][namespace]) lngsMap[lng][namespace] = {};

        lngsMap[lng][namespace][innerKey] = translation || "";
      }

      if (!key?.includes(".")) lngsMap[lng][key] = translation || ""; // prevent to remove undefined value like ({"key": undefined})
    });
  });

  return lngsMap;
}

async function download() {
  lngs.forEach(
    async (lng) =>
      await fs.promises.mkdir(`${localesPath}/${lng}`, { recursive: true }),
  );

  const doc = await loadSpreadsheet();

  ns.forEach(async (namespace) => {
    const lngsMap = await fetchSheetToJson(doc, namespace);

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

      lngs.forEach((lng) => {
        const localeJsonFilePath = `${localesPath}/${lng}/${namespace}.json`;

        const jsonString = (JSON.stringify(lngsMap[lng], null, 2) || "").concat(
          "\n",
        );

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

download();

 

 

script 추가 및 실행하기

package.json에 scripts를 추가하고, 각각 실행하면 자동으로 i18next의 key를 추출하고 이를 구글 시트에 upload하며 해당 파일을 번역가에게 공유하여 번역 파일을 관리할 수 있고, 번역가가 번역을 완료하면 다시 download 받아서 사용할 수 있다.

// package.json

{
  "scripts": {
    "scan:i18n": "i18next-scanner --config config/translation/i18next-scanner.config.js",
    "download:i18n": "node config/translation/download.js",
    "upload:i18n": "node config/translation/upload.js"
  },
}

 

 

더 논의/보완해야 하는 부분

  • 구글 시트의 제목행을 어떤 형식으로 관리하는 것이 좋을지 기획자와 협의가 더 필요하다.
  • 구글 시트의 문서 크기 제한(문서 최대 크기 50mb, 최대 지원 행수 1백만개)이 있으므로 어떻게 대응 할 것인지 논의가 필요하다. 별도의 서버나 시스템을 개발하여 사용하는 방향도 함께 고려해야 할 것 같다.
  • 프로젝트마다 폴더 구조 등이 다르므로 이를 대응 할 수 있는 방법이나 전파 방법 등을 추가로 고려해야한다.

refs.

1. Google Sheets API를 통해 스프레드시트 데이터 읽기/쓰기

2. Google-spreadsheet 공식문서

3. 구글스프레드시트를 200% 활용한 국제화(i18n) 자동화 사례

4. 국제화(i18n) 자동화 가이드

5. i18next in react

6. 구글 스프레드시트를 통한 국제화 자동화

7. 다국어 지원 서비스 자동화로 리소스 최소화하기 (+구글 스프레드 시트, i18next)

 

댓글