지난 글을 통해 i18n 프로젝트를 설정하고 Next.js, next-intl 라이브러리를 활용하여 실제 사용하는 방법에 대해 알아보았다. 이제 마음껏 기능 개발을 하면서 국제화된 텍스트를 앱에 적용해볼 수 있다. 그런데.. 어플리케이션 규모가 커지고 지원하는 언어/국가가 늘어날수록 뭔가 불편한 구석이 생겨날 것이다. 바로 다국어 텍스트를 수작업으로 관리하는 어려움이다.
개발자가 다국어 메세지 하나 추가하려면, 1. 언어 갯수만큼 존재하는 en.json 같은 파일에 들어가서, 2. namespace에 해당하는 적절한 위치를 찾아 중복되지 않는 key를 만들고, 3. 다국어 번역본을 복붙해줘야 한다. 지원 언어가 많아질수록, 텍스트와 도메인이 방대해질수록 이것은 보통 노가다가 아닐 뿐더러, 개발자의 실수가 발생할 확률도 급격하게 높아진다.
그래서 이번에는 구글 스프레드 시트로 다국어 메세지들을 편리하게 관리하는 방법을 소개해보고자 한다. 물론 구글 스프레드 시트도 완벽한 툴은 아니지만, 경험적으로 개발자가 코드베이스에 존재하는 json을 일일이 건드리면서 복붙하는 것보다는 생산성이 훨씬 좋았다.
구글 스프레드 시트 준비하기
구글 스프레드 시트를 데이터베이스처럼 사용하기 위해서는 key와 언어별 컬럼이 들어가야 한다. 다음 형태를 참고해서 만들어보자. 1행에는 컬럼명이, 이후 행에는 다국어 데이터가 들어가야 한다.
키 | 한국어 | 영어 | 일본어 | 중국어 |
stock | 재고 | stock | 在庫 | 库存 |
추가적으로 컴포넌트에서 사용할 문구들은 도메인 별로 namespace 를 분리하여 사용할 것이 권장된다. (next-intl 도큐먼트 참고)
next-intl 기준으로 useTranslations 훅을 사용할 때 네임스페이스를 지정할 수 있는 기능이 있기도 하고, 이걸 잘 분리해놓으면 Lazy Loading 시 성능적으로도 이점이 있기 때문에,적어도 페이지 별로는 네임스페이스를 분리하는 것이 좋다. 커머스 웹사이트를 개발한다면, 구글 스프레드 시트 상으로는 대략 user, product, cart, order, payment, history 정도의 시트들로 분리할 수 있겠다. 다국어를 유지보수할 때, 각 도메인에 맞는 시트에 들어가서 텍스트를 관리하면 된다.
구글 시트 API 연동하기
우선 구글 스프레드 시트의 sheet key를 가져와야 한다. 시트를 만들었다면, url에서 키를 확인할 수 있다.
이어서 GCP 콘솔에 접속하여 Google SpreadSheet API 를 사용 설정해준다. (Google SpreadSheet API를 사용하기 위해서는 GCP 프로젝트가 있어야 한다. 없다면 만들고 오자.)
그리고 인증에 사용할 서비스 계정을 만들어야 한다. (API Key 방식은 권한 문제로 권장되지 않는다. 데이터의 보안이 중요한 프로젝트라면 서비스 계정을 생성하는 방식으로 구현해보자.) 사용자 인증 정보 만들기 > 서비스 계정을 클릭하여 필요한 설정들을 해주면 된다.
서비스 계정을 만들었다면, 해당 계정의 키를 만들어야 한다. 서비스 계정 세부정보 페이지 > 키 탭으로 진입하면 관련 버튼이 나온다. 키 유형은 JSON을 사용하는 것이 권장된다.
이렇게 서비스 계정의 Credential 정보까지 받아와야 한다.
마지막으로 방금 생성한 서비스 계정이 구글 스프레드 시트의 데이터를 읽고 쓸 수 있도록 스프레드 시트의 공유 권한을 수정해주자. 스프레드 시트를 변경할 사람들과 함께, 해당 봇 계정에도 편집자 권한이 필요하다.
권한 설정까지 마무리되었다면 스크립트에 필요한 설정은 모두 완료된 셈이다.
스크립트 작성하기
i18n 관련 프로젝트 설정이 되어 있다는 전제 하에, 구글 스프레드 시트 연동을 위해 아래 패키지들을 설치해주자.
yarn add google-spreadsheet
yarn add google-auth-library
yarn add dotenv
yarn add mkdirp
구글 스프레드를 읽어들이고, 관련 권한을 설정하고, 디렉토리를 조작하는 데 사용되는 패키지들이다.
그리고 스크립트를 관리할 폴더를 만든 뒤, 다음과 같이 스크립트를 작성한다.
// index.js
const path = require('path');
const { GoogleSpreadsheet } = require('google-spreadsheet');
const dotenv = require('dotenv');
const { JWT } = require('google-auth-library');
dotenv.config();
const resource = {
loadPath: path.join(__dirname, '..', 'messages/{{lng}}/{{ns}}.json'),
savePath: path.join(__dirname, '..', 'messages/{{lng}}/{{ns}}.json'),
};
const LOCALES = ['ko', 'en', 'ja', 'zh'];
const loadPath = 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: '일본어',
zh: '중국어',
};
const SCOPES = ['https://www.googleapis.com/auth/spreadsheets', 'https://www.googleapis.com/auth/drive.file'];
async function loadSpreadsheet() {
if (!process.env.GOOGLE_SHEET_ID) {
console.error('Missing necessary environment variables.');
return;
}
console.info(
'\u001B[32m',
'=====================================================================================================================\n',
'# i18n auto-sync using Spreadsheet\n\n',
' * Download translation resources from Spreadsheet and make /messages/{locales}.json\n',
' * Upload translation resources to Spreadsheet.\n\n',
`The Spreadsheet for translation is here (\u001B[34mhttps://docs.google.com/spreadsheets/d/${process.env.SHEET_ID}/#gid=0\u001B[0m)\n`,
'=====================================================================================================================',
'\u001B[0m',
);
// 서비스 계정에서 생성한 credential json에 담긴 client_email, private_key 정보
// 안전하게 환경변수로 관리하는 것이 권장된다.
const jwt = new JWT({
email: process.env.GOOGLE_CLIENT_EMAIL,
key: process.env.GOGGLE_PRIVATE_KEY,
scopes: SCOPES,
});
const doc = new GoogleSpreadsheet(process.env.GOOGLE_SHEET_ID, jwt);
await doc.loadInfo();
await doc.updateProperties({ title: 'i18n' });
return doc;
}
function getPureKey(key = '') {
return key.replace(rePluralPostfix, '');
}
module.exports = {
localesPath,
loadSpreadsheet,
getPureKey,
LOCALES,
columnKeyToHeader,
NOT_AVAILABLE_CELL,
};
// download.js
const fs = require('fs');
const { loadSpreadsheet, localesPath, LOCALES, columnKeyToHeader, NOT_AVAILABLE_CELL } = require('./index');
const { mkdirp } = require('mkdirp');
// 스프레드시트 -> json
async function fetchTranslationsFromSheetToJson(doc, sheetId) {
const sheet = doc.sheetsById[sheetId];
if (!sheet) {
return {};
}
const langMap = {};
// 영문으로 된 sheet만 읽어들인다.
if (sheet?.title && /^[a-zA-Z]*$/.exec(sheet?.title)) {
console.log(`${sheet?.title} 스프레드 시트 불러오는 중...`);
const rows = await sheet.getRows();
rows.forEach((row) => {
const key = row.get(columnKeyToHeader.key);
LOCALES.forEach((lang) => {
let translation = row.get(columnKeyToHeader[lang]);
// NOT_AVAILABLE_CELL("_N/A") means no related language
if (translation === NOT_AVAILABLE_CELL) {
return;
}
if (!langMap[lang]) {
langMap[lang] = {};
}
langMap[lang][key] = translation || ''; // prevent to remove undefined value like ({"key": undefined})
});
});
}
return {
ns: sheet?.title,
langMap,
};
}
function checkAndMakeLocaleDir(dirPath, subDirs) {
return new Promise((resolve) => {
subDirs.forEach((subDir, index) => {
mkdirp(`${dirPath}/${subDir}`).then((err) => {
if (err) {
throw err;
}
if (index === subDirs.length - 1) {
resolve();
}
});
});
});
}
function dataParser(obj) {
const result = {};
for (const key in obj) {
if (key.includes('.')) {
const nestedKeys = key.split('.');
let nestedObj = result;
for (let i = 0; i < nestedKeys.length; i++) {
const currentKey = nestedKeys[i];
if (!nestedObj[currentKey]) {
nestedObj[currentKey] = {};
}
if (i === nestedKeys.length - 1) {
nestedObj[currentKey] = obj[key];
} else {
nestedObj = nestedObj[currentKey];
}
}
} else {
result[key] = obj[key];
}
}
return result;
}
async function makeJson() {
const start = new Date();
await checkAndMakeLocaleDir(localesPath, LOCALES);
let doc;
try {
doc = await loadSpreadsheet();
console.log(`타겟 스프레드시트 불러오기 완료`);
} catch (e) {
console.error(`타겟 스프레드시트 불러오기 실패`);
throw new Error(e);
}
const keyMap = {};
for (const key of Object.keys(doc._rawSheets)) {
try {
const { langMap, ns } = await fetchTranslationsFromSheetToJson(doc, key);
for (const lang of Object.keys(langMap)) {
if (!keyMap[lang]) keyMap[lang] = {};
keyMap[lang][ns] = langMap[lang];
}
} catch (e) {
console.error(`개별 스프레드 시트 불러오기 실패`);
throw new Error(e);
}
}
for (const lng of Object.keys(keyMap)) {
// 다국어 데이터가 위치할 Path 지정
const localeJsonFilePath = `messages/${lng}.json`;
for (const ns of Object.keys(keyMap[lng])) {
keyMap[lng][ns] = dataParser(keyMap[lng][ns]);
}
const jsonString = JSON.stringify(keyMap[lng], null, 2);
console.info(jsonString);
try {
fs.writeFileSync(localeJsonFilePath, jsonString, 'utf8');
console.log(lng, '생성완료');
} catch (e) {
console.log('i18n 파일 생성 실패');
throw new Error(e);
}
}
const end = new Date();
console.log(`실행 시간 ${(end - start) / 1000}s`);
}
makeJson()
.then((r) => console.log('i18n translation done.'))
.catch((e) => console.error(e));
완성된 스크립트를 package.json 스크립트로 만들어두고, 필요할 때마다 yarn download:i18n 을 실행하면 된다.
"scripts": {
...,
"download:i18n": "node i18n/download.js"
},
트러블슈팅
본인은 빌드 환경에서 CI/CD가 실행될 때마다 같이 다국어가 다운로드되도록 설정해두었는데, 구글 시트 쿼터가 다 찼다는 에러를 종종 마주하게 되었다.
CI/CD 횟수가 그렇게 많은 편이 아니었는데도 불구하고 할당량 한도에 자꾸 걸리는 것이었다. 참고로 Google Sheets API의 읽기 요청 할당량 한도는 분당 60회다. 60번이나 스크립트를 돌리는 것이 아닌 이상 자꾸 쿼터 에러가 나는 이유를 확인해보았다.
알고보니, 스프레드시트 하나를 읽더라도 google-spreadsheet api 내부적으로 개별 시트 별로 요청을 보내고 있었다. 프로젝트가 커짐에 따라 시트의 개수가 많이 늘어났는데, 따라서 구조적으로 요청이 많이 갈 수밖에 없는 것이었다. 무료 API인 만큼 어느 정도 쿼터 제한으로 인한 실패를 감수해야 했고, 따라서 API 호출횟수 최적화를 진행해보았다.
매번 빌드 타이밍마다 다운로드 스크립트를 실행하게 하는 것이 아닌, 번역본이 업데이트된 경우에만 플래그를 전달하여 스크립트를 실행하게끔 하는 방법이었다. Jenkins를 사용하는 프로젝트였기 때문에, 다운로드만을 수행하는 별도의 Jenkins Job을 구성하고, 젠킨스 내부 디렉토리에 번역본 데이터를 캐싱하도록 작업하여, 불필요한 API 호출을 방지하는 방식으로 이슈를 해결할 수 있었다. Jenkins가 아니더라도 Github Action 등 사용하는 툴에 맞게 최적화를 진행해볼 수 있을 것이다. 또는 git에 다국어 데이터를 추가하여, 로컬 환경에서 개발자가 원하는 타이밍에만 데이터를 내려받고, 커밋에 포함하는 방식으로 구현해도 괜찮다고 생각된다.
Reference
- https://developers.google.com/sheets/api/guides/concepts?hl=ko
- https://developers.google.com/sheets/api/limits?hl=ko
'Web' 카테고리의 다른 글
다국어 프로젝트 시작해보기 (feat. Next.js, next-intl) (0) | 2024.10.13 |
---|---|
서로 다른 윈도우 간 안전하게 통신하는 방법 (0) | 2024.03.17 |
Axios 인터셉터로 JWT 토큰 로테이션 구현하기 (0) | 2024.02.18 |
헷갈리는 줄바꿈, 올바르게 제어해보자 (15) | 2023.12.10 |
JavaScript로 이미지 파일 데이터 다루기 (0) | 2023.08.31 |