Published on

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

Authors
  • avatar
    Name
    Na Hyunwoo
    Twitter

뤼튼에서는 글로벌한 유저들에 대비하기 위해 국제화를 진행하였습니다. 국제화는 한국어로 된 문구들을 다른 나라 언어로도 제공하는 기능을 말합니다.

그런데 만약 국제화를 한 내용이 자주 수정된다면 어떻게 될까요 ?

그러면 번역가에게 번역을 계속해서 요청해야 하고, 요청한 값을 다시 json 파일에 직접 입력해야 합니다.

따라서, 굉장히 고통스럽고 귀찮은 과정일 수 있습니다.

그런데, 만약 자동화를 하게 된다면, download라는 script 명령어만 입력하면 이 번거로운 과정을 해결할 수 있습니다.

따라서, 자동화는 국제화에 꼭 필요한 과정입니다.

아래에서는 구글 스프레드 시트와 연동하여 국제화를 자동화하는 방법에 대해서 얘기해보도록 하겠습니다.

1. 구글 스프레드 시트를 연결합니다.

  1. cloud platform으로 이동합니다.
  2. 사용자 인증정보 탭에서 api를 생성합니다.
  3. 사용자 인증 정보(서비스 계정)를 만듭니다.
  4. 해당 계정 서비스 계정(키)을 수정합니다.
  5. 키 추가 - 새 키 만들기를 합니다.
  6. JSON 형식을 만듭니다.
  7. 해당 파일을 저장합니다. (프로젝트에서 필요)
  8. 해당 계정 이메일 구글 스프레드 시트에 공유 설정합니다.

2. translation/.credentials 폴더 생성 후 구글 스프레드시트에서 다운 받은 JSON 파일을 저장합니다.

  • 해당 JSON파일 git ignore에 추가합니다.

3. root에 i18next-scanner.config.js 파일 생성 후 설정 파일을 수정합니다.

i18next-scanner는 소스 코드에서 i18next.t(), i18n.t()와 같은 지정된 패턴의 함수를 스캔하여 key를 추출하고 언어별 json파일을 생성합니다.

const path = require('path')

const COMMON_EXTENSIONS = '/**/*.{js,jsx,ts,tsx,vue,html}'

module.exports = {
  input: [
    `./src/pages${COMMON_EXTENSIONS}`,
    `./src/components${COMMON_EXTENSIONS}`,
    `./src/container${COMMON_EXTENSIONS}`,
    `./src/data${COMMON_EXTENSIONS}`,
  ],
  options: {
    defaultLng: 'ko',
    lngs: ['ko', 'en', 'ja'],
    func: {
      list: ['i18next.t', 'i18n.t', '$i18n.t'],
      extensions: ['.js', '.jsx', '.ts', '.tsx', '.vue', '.html'],
    },
    resource: {
      loadPath: path.join(__dirname, 'src/i18n/locales/{{lng}}/{{ns}}.json'),
      savePath: path.join(__dirname, 'src/i18n/locales/{{lng}}/{{ns}}.json'),
    },
    defaultValue(lng, ns, key) {
      const keyAsDefaultValue = ['ko']
      if (keyAsDefaultValue.includes(lng)) {
        const separator = '~~'
        const value = key.includes(separator) ? key.split(separator)[1] : key

        return value
      }

      return ''
    },
    keySeparator: false,
    nsSeparator: false,
    prefix: '%{',
    suffix: '}',
  },
}

4. translation/index.js 파일 생성

const { GoogleSpreadsheet } = require('google-spreadsheet')
//구글 sheet json 파일
const creds = require('./.credentials/wrtn-training-eff8faaba5ee.json')
const i18nextConfig = require('../../i18next-scanner.config')

const spreadsheetDocId = '1mZ62jG2xcDyj0UGCfaaG_GUddmh4MQc3f5CjdA9UwfA'
const ns = 'translation'
const lngs = i18nextConfig.options.lngs
//구글 스프레드시트의 gid
const sheetId = 20220825
const loadPath = i18nextConfig.options.resource.loadPath
const localesPath = loadPath.replace('/{{lng}}/{{ns}}.json', '')
const rePluralPostfix = new RegExp(/_plural|_[\d]/g)
//번역이 필요없는 부분
const NOT_AVAILABLE_CELL = 'N/A'
//스프레드시트에 들어갈 header 설정
const columnKeyToHeader = {
  key: '키',
  ko: '한글',
  en: '영어',
  ja: '일본어',
}

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 /src/locales//.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'
  )

  // spreadsheet key is the long id in the sheets URL
  const doc = new GoogleSpreadsheet(spreadsheetDocId)

  // load directly from json file if not in secure environment
  await doc.useServiceAccountAuth(creds)

  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,
}

5. translation/upload.js 파일 생성

// upload.js
const fs = require('fs')

const {
  loadSpreadsheet,
  localesPath,
  getPureKey,
  ns,
  lngs,
  sheetId,
  columnKeyToHeader,
  NOT_AVAILABLE_CELL,
} = require('./index')

const headerValues = columnKeyToHeader

async function addNewSheet(doc, title, sheetId) {
  const sheet = await doc.addSheet({
    sheetId,
    title,
    headerValues,
  })

  return sheet
}

async function updateTranslationsFromKeyMapToSheet(doc, keyMap) {
  //시트 타이틀
  const title = 'wrtn-io-landing'
  let sheet = doc.sheetsById[sheetId]
  if (!sheet) {
    sheet = await addNewSheet(doc, title, sheetId)
  }

  const rows = await sheet.getRows()

  // find exsit keys
  const exsitKeys = {}
  const addedRows = []

  rows.forEach((row) => {
    const key = row[columnKeyToHeader.key]
    if (keyMap[key]) {
      exsitKeys[key] = true
    }
  })

  //스프레트시트에 row 넣는 부분
  for (const [key, translations] of Object.entries(keyMap)) {
    if (!exsitKeys[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)
    }
  }

  // upload new keys
  await sheet.addRows(addedRows)
}

// key값에 따른 언어 value
function toJson(keyMap) {
  const json = {}

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

  return json
}

// 언어 key : value 값 저장
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`

      //.json file read
      // eslint-disable-next-line no-sync
      const json = fs.readFileSync(localeJsonFilePath, 'utf8')

      gatherKeyMap(keyMap, lng, JSON.parse(json))
    })
    //스프레드 시트에 업데이트
    updateTranslationsFromKeyMapToSheet(doc, toJson(keyMap))
  })
}

updateSheetFromJson()

6. translation/download.js 파일 생성

// download.js
const fs = require('fs')
const mkdirp = require('mkdirp')
const {
  loadSpreadsheet,
  localesPath,
  ns,
  lngs,
  sheetId,
  columnKeyToHeader,
  NOT_AVAILABLE_CELL,
} = require('./index')

// 스프레드시트 -> json
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()
        }
      })
    })
  })
}

//json 파일 업데이트
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)

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

updateJsonFromSheet()

7. package.json에 스크립트 추가

{
  "scan:i18n": "i18next-scanner --config i18next-scanner.config.js",
  "upload:i18n": "npm run scan:i18n && node src/translation/upload.js",
  "download:i18n": "node src/translation/download.js",
  "serve": "npm run download:i18n && vue-cli-service serve"
}
  • scan:i18n은 소스코드에서 key를 추출하여 key, value로 구성된 언어별 json 파일을 만들어 냅니다.
  • upload:i18n은 생성된 여러 개의 언어별 json파일을 하나의 테이블로 만들어 구글 스프레드 시트에 업로드합니다.
  • download:i18n은 반대로 동작하여 번역된 값을 각 언어별 json파일에 반영합니다. 로컬에서 개발할 때나 프로덕션을 빌드하기 전에 수행하여 번역 파일이 빌드에 포함될 수 있도록 npm 스크립트에 추가합니다.

이렇게 설정이 완료되면, npm run upload:i18n을 실행하여 소스 코드상에 있는 모든 key가 다음과 같이 업로드됩니다.

googlesheet-empty

그 뒤에 번역가에게 스프레드 시트의 번역을 요청합니다.

googlesheet-empty

그러면, npm run download:i18n을 실행하여 스프레드 시트로부터 최신 번역값이 빌드에 반영됩니다.

만약 이 구글 스프레드 시트가 자주 변동이 된다면 script에 다음의 내용을 추가하여 로컬 서버 구동이나, 프로덕션 빌드 등이 될때마다 구글 스프레드 시트로부터 번역된 값을 받아오게 할 수 있습니다.

{
	"build": "npm run download:i18n && webpack"
}

자동화가 완성되었습니다.

이제부터는 번역값이 바뀌거나, 랜딩페이지에 있는 한글 텍스트가 바뀐다면

npm run download 혹은 npm run build 이 한 가지 과정만 거치면 됩니다.

자동화를 한다는 것은 굉장히 번거롭고 어렵습니다. 그러나, 그만큼 하고나서 뿌듯함이 제일 큰 작업 중 하나입니다.

맨 처음 세팅할 때 조금 고생하면 나중에는 너무나도 간편하기 때문입니다.

이런 자동화 하는 것에 관심이 계속 생기고, 앞으로는 개발을 하면서 자동화가 필요한 것에는 무엇이 있을지 그리고 어떻게 하면 자동화를 할 수 있을지에 대해서도 고민을 해봐야겠습니다 !