일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
- yup
- ESLint #Prettier
- useState
- social login #facebook login
- cafe24
- social login #google login
- 옵셔널 체이닝
- social login #kakao login
- redux
- react editor #TinyMCE editor
- social login #Microsoft login
- Material UI
- 쇼핑몰
- useref
- git flow
- Today
- Total
여행가는개발자
구글 스프레드 시트를 활용한 번역 기능 본문
구글 스프레드 시트 번역 기능
구글 스프레드 시트로 번역기능을 사용하는 장점
- 번역본을 기획 또는 운영팀에서 확인하여 번역언어를 검수하는 수고가 덜음
- json 파일을 일일히 작업하기보다 자동으로 업데이트하는 작업 진행가능
- 구글 시트에서 번역기 활용으로 다른 언어들을 자동 번역 작업 할수 있다
그래서 이 방법으로 번역을 해라! 라는 보단 이런 방법도 있으니 참고 해보면 좋다 라고 한번 보면 좋을거같다
자동화 방법자동화는 아래의 순서로 구현합니다
- 소스 코드에서 key를 스캔
- key를 Google Spreadsheet에 업로드
- 필요할 때, 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 파일을 실행할 수 있도록 해주는 라이브러리
다음 하위처럼 파일 구조를 생성한다 ( locales 하위에 추가할 언어 나열)
packages/find-parts/
├── src/
│ ├── locales/
│ │ ├── en/
│ │ │ └── translation.json
│ │ └── ko/
│ │ └── translation.json
│ ├── plugins/
│ │ └── i18next.ts
│ └── translate/
│ ├── index.ts
│ ├── upload.ts
│ └── download.ts
├── credentials/
│ └── LanguageProjectIAM.json
└── i18next-scanner.config.js
package.json에 아래의 스크립트를 추가해준다.
"scan:i18n": "i18next-scanner --config i18next-scanner.config.js",
"upload:i18n": "yarn scan:i18n && ts-node --transpile-only src/translate/upload.ts",
"download:i18n": "ts-node --transpile-only src/translate/download.ts",
i18next 셋팅 파일 작성
*//i18next.ts*
'use client';
import { initReactI18next } from 'react-i18next';
// 번역 리소스 파일 가져오기
import cnTranslation from '@/locales/cn/translation.json';
import enTranslation from '@/locales/en/translation.json';
import jaTranslation from '@/locales/ja/translation.json';
import koTranslation from '@/locales/ko/translation.json';
import twTranslation from '@/locales/tw/translation.json';
import i18n from 'i18next';
// 리소스 객체 생성
const resources = {
ko: {
translation: koTranslation,
},
en: {
translation: enTranslation,
},
ja: {
translation: jaTranslation,
},
tw: {
translation: twTranslation,
},
cn: {
translation: cnTranslation,
},
};
// 브라우저에서 저장된 언어 설정 가져오기
const getSavedLanguage = () => {
if (typeof window !== 'undefined') {
return localStorage.getItem('i18nextLng') || navigator.language.split('-')[0] || 'ko';
}
return 'ko';
};
// i18n 초기화 설정
const i18nConfig = {
resources,
lng: getSavedLanguage(), // 저장된 언어 또는 기본 언어
fallbackLng: 'ko', // 번역이 없을 경우 사용할 언어
interpolation: {
escapeValue: false, // XSS 방지를 위한 이스케이프 비활성화 (React에서는 기본적으로 처리됨)
},
};
// i18n 초기화
i18n.use(initReactI18next).init(i18nConfig);
// 언어 변경 시 localStorage에 저장하는 함수
i18n.on('languageChanged', lng => {
if (typeof window !== 'undefined') {
localStorage.setItem('i18nextLng', lng);
}
});
export default i18n;
i18next-scanner 셋팅 파일 작성
//i18next-scanner.config.js
const fs = require('fs');
const path = require('path');
const typescript = require('typescript');
// TypeScript 파일 처리를 위한 사용자 정의 변환기
const customTypescriptTransform = (options = {}) => {
const extensions = options.extensions || ['.ts', '.tsx'];
return (resource, options) => {
const { content, filepath } = resource;
if (extensions.includes(path.extname(filepath))) {
const result = typescript.transpileModule(content, {
compilerOptions: {
target: typescript.ScriptTarget.ES2015,
jsx: typescript.JsxEmit.React,
module: typescript.ModuleKind.ESNext,
},
});
return { ...resource, content: result.outputText };
}
return resource;
};
};
// JavaScript와 TypeScript 파일 확장자
const COMMON_EXTENSIONS = '/**/*.{js,jsx,ts,tsx}';
module.exports = {
input: ['./src/**/*.{js,jsx,ts,tsx}'],
options: {
defaultLng: 'ko',
lngs: ['ko', 'en'],
func: {
list: ['t', 'i18n.t', 'i18next.t'],
extensions: ['.js', '.jsx'],
},
trans: {
extensions: ['.js', '.jsx'],
},
resource: {
loadPath: path.join(__dirname, 'src/locales/{{lng}}/{{ns}}.json'),
savePath: path.join(__dirname, 'src/locales/{{lng}}/{{ns}}.json'),
},
transform: customTypescriptTransform({ extensions: ['.ts', '.tsx'] }),
},
};
구글 스프레드시트에 업로드 및 다운로드
구글 스프레드시트는 다음 링크를 보고 세팅해주면된다
https://sojinhwan0207.tistory.com/200
Google Sheets API 를 통해 스프레드시트 데이터 읽기/쓰기
회사 업무 특성상 구글 스프레드시트에 데이터를 기록하고 주기적으로 관리해야 하는 문서들이 많다. 예를들면 매월 첫째주가 되면 현재 운영중인 스토리지의 디스크 사용량을 기록하고, AWS S3
sojinhwan0207.tistory.com
https://docs.google.com/spreadsheets/d/문서ID/edit#gid=시트ID
2. key를 Google Spreadsheet에 업로드
//translate/index.ts
import * as fs from 'fs';
import { JWT } from 'google-auth-library';
import { GoogleSpreadsheet } from 'google-spreadsheet';
import * as path from 'path';
// 환경 설정
export const ns = 'translation';
export const lngs = ['ko', 'en', 'ja', 'tw', 'cn'];
export const localesPath = path.join(__dirname, '../locales');
export const NOT_AVAILABLE_CELL = '_N/A';
// 스프레드시트 설정
// URL: <https://docs.google.com/spreadsheets/d/1IyECHZ-mVqgVWxwFy_ZMBrFpBrCTE2tl8oO41tsWCP0/edit?gid=0>
export const SPREADSHEET_ID = '1IyECHZ-mVqgVWxwFy_ZMBrFpBrCTE2tl8oO41tsWCP0';
export const SHEET_ID = '0'; // 일반적으로 첫 번째 시트는 0
export const CREDENTIALS_PATH = path.join(__dirname, '../../credentials/LanguageProjectIAM.json');
// 스프레드시트 로드 함수
export const loadSpreadsheet = async (): Promise => {
try {
// 인증 파일 존재 여부 확인
if (!fs.existsSync(CREDENTIALS_PATH)) {
throw new Error(`인증 파일이 없습니다: ${CREDENTIALS_PATH}`);
}
// 스프레드시트 ID 확인
if (!SPREADSHEET_ID) {
throw new Error(
'유효한 스프레드시트 ID가 설정되지 않았습니다. 환경 변수 GOOGLE_SPREADSHEET_ID를 설정하거나 코드에서 직접 설정해주세요.'
);
}
const credentials = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf8'));
const jwt = new JWT({
email: credentials.client_email,
key: credentials.private_key,
scopes: ['<https://www.googleapis.com/auth/spreadsheets>'],
});
const doc = new GoogleSpreadsheet(SPREADSHEET_ID, jwt);
await doc.loadInfo();
console.log(`스프레드시트 "${doc.title}" 로드 완료`);
return doc;
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('404')) {
console.error(
`스프레드시트 ID(${SPREADSHEET_ID})를 찾을 수 없습니다. 올바른 ID인지 확인하고 해당 스프레드시트에 접근 권한이 있는지 확인하세요.`
);
} else {
console.error('스프레드시트 로드 오류:', error.message);
}
} else {
console.error('스프레드시트 로드 오류:', error);
}
throw error;
}
};
업로드 셋팅 파일 작성
//translate/upload.ts
import { SHEET_ID, lngs, loadSpreadsheet, localesPath, ns } from './index';
import * as fs from 'fs';
import * as path from 'path';
// 번역 키 업로드 함수
const uploadTranslationsToSheet = async (): Promise<void> => {
try {
// 인증 파일이 없는 경우를 위한 임시 처리
const credentialsPath = path.join(__dirname, '../../credentials/LanguageProjectIAM.json');
if (!fs.existsSync(credentialsPath)) {
console.log('Google 인증 파일이 없습니다. 로컬 파일만 업데이트합니다.');
// 각 언어별 디렉토리 및 파일 생성
lngs.forEach(lng => {
// 언어 디렉토리가 없으면 생성
if (!fs.existsSync(path.join(localesPath, lng))) {
fs.mkdirSync(path.join(localesPath, lng), { recursive: true });
console.log(`${lng} 디렉토리 생성 완료`);
}
// 언어별 translation.json 파일 생성 및 기본 데이터 추가
const translationPath = path.join(localesPath, lng, `${ns}.json`);
// 기본 번역 데이터 설정
let translationData = {};
// 파일 쓰기
fs.writeFileSync(translationPath, JSON.stringify(translationData, null, 2), 'utf8');
console.log(`${lng}/${ns}.json 파일 생성 및 기본 데이터 추가 완료`);
});
console.log('로컬 파일 업데이트 완료!');
return;
}
// 기존 Google Spreadsheet 연동 코드
const doc = await loadSpreadsheet();
const sheet = doc.sheetsById[SHEET_ID];
if (!sheet) {
throw new Error(`Sheet with ID ${SHEET_ID} not found`);
}
console.log(`시트 "${sheet.title}" 로드 완료`);
// 모든 셀 로드
await sheet.loadCells();
console.log('모든 셀 로드 완료');
// 헤더 행 확인 (첫 번째 행)
const headerRow: string[] = [];
for (let col = 0; col < sheet.columnCount; col++) {
const cell = sheet.getCell(0, col);
if (cell.value) {
headerRow.push(cell.value.toString());
}
}
console.log(`헤더 행: ${headerRow.join(', ')}`);
// key 컬럼 인덱스 찾기
const keyColumnIndex = headerRow.indexOf('key');
if (keyColumnIndex === -1) {
console.error('key 컬럼을 찾을 수 없습니다. 헤더 행을 추가합니다.');
// 헤더 행 추가
for (let col = 0; col < lngs.length + 1; col++) {
const cell = sheet.getCell(0, col);
if (col === 0) {
cell.value = 'key';
} else {
cell.value = lngs[col - 1];
}
}
await sheet.saveUpdatedCells();
console.log('헤더 행 추가 완료');
}
// 현재 번역 파일 읽기 또는 생성
let koTranslations = {};
const koTranslationsPath = path.join(localesPath, 'ko', `${ns}.json`);
if (fs.existsSync(koTranslationsPath)) {
try {
koTranslations = JSON.parse(fs.readFileSync(koTranslationsPath, 'utf8'));
if (Object.keys(koTranslations).length === 0) {
// 파일은 있지만 비어있는 경우 기본 데이터 추가
koTranslations = { Category: '카테고리', test: '테스트' };
fs.writeFileSync(koTranslationsPath, JSON.stringify(koTranslations, null, 2), 'utf8');
console.log('ko/translation.json 파일이 비어있어 기본 데이터 추가');
}
} catch (e) {
console.log('ko/translation.json 파일을 읽는 중 오류 발생, 새 파일 생성');
koTranslations = { Category: '카테고리', test: '테스트' };
fs.writeFileSync(koTranslationsPath, JSON.stringify(koTranslations, null, 2), 'utf8');
}
} else {
console.log('ko/translation.json 파일이 없음, 기본 데이터로 생성');
koTranslations = { Category: '카테고리', test: '테스트' };
// ko 디렉토리가 없으면 생성
if (!fs.existsSync(path.join(localesPath, 'ko'))) {
fs.mkdirSync(path.join(localesPath, 'ko'), { recursive: true });
}
fs.writeFileSync(koTranslationsPath, JSON.stringify(koTranslations, null, 2), 'utf8');
}
// 키 목록 생성
const keys = Object.keys(koTranslations);
console.log(`번역 키 목록: ${keys.join(', ')}`);
// 스프레드시트에서 기존 키 목록 가져오기
const existingKeys: string[] = [];
for (let row = 1; row < sheet.rowCount; row++) {
const keyCell = sheet.getCell(row, keyColumnIndex);
if (keyCell.value) {
existingKeys.push(keyCell.value.toString());
}
}
console.log(`기존 키 목록: ${existingKeys.join(', ')}`);
// 언어 컬럼 인덱스 찾기
const langColumnIndices: Record<string, number> = {};
lngs.forEach(lng => {
const index = headerRow.indexOf(lng);
if (index !== -1) {
langColumnIndices[lng] = index;
console.log(`${lng} 컬럼 인덱스: ${index}`);
} else {
console.log(`${lng} 컬럼을 찾을 수 없습니다.`);
}
});
// 새로운 키 추가
let rowIndex = sheet.rowCount;
for (const key of keys) {
if (!existingKeys.includes(key)) {
console.log(`새 키 추가: ${key}`);
// key 컬럼에 키 추가
const keyCell = sheet.getCell(rowIndex, keyColumnIndex);
keyCell.value = key;
// 각 언어별 번역 값 설정
lngs.forEach(lng => {
const colIndex = langColumnIndices[lng];
if (colIndex !== undefined) {
const translationPath = path.join(localesPath, lng, `${ns}.json`);
let translation = '';
if (fs.existsSync(translationPath)) {
try {
const translations = JSON.parse(fs.readFileSync(translationPath, 'utf8'));
translation = translations[key] || '';
} catch (e) {
console.log(`${lng}/translation.json 파일을 읽는 중 오류 발생`);
translation = lng === 'ko' ? key : '';
}
} else {
translation = lng === 'ko' ? key : '';
}
const cell = sheet.getCell(rowIndex, colIndex);
cell.value = translation;
}
});
rowIndex++;
}
}
// 변경사항 저장
await sheet.saveUpdatedCells();
console.log('스프레드시트 업데이트 완료!');
} catch (error) {
console.error('Error uploading translations:', error);
}
};
uploadTranslationsToSheet();
3.필요할 때, Google Spreadsheet에서 번역된 텍스트를 다운로드
다운로드 셋팅 파일 작성
//translate/download.ts
import { NOT_AVAILABLE_CELL, lngs, loadSpreadsheet, localesPath, ns } from './index';
import * as fs from 'fs';
import * as path from 'path';
// 스프레드시트에서 번역 가져오기
const fetchTranslationsFromSheetToJson = async (
doc: any
): Promise<Record<string, Record<string, string>>> => {
try {
const sheet = doc.sheetsById[0]; // 일반적으로 첫 번째 시트 사용
if (!sheet) {
throw new Error(`시트를 찾을 수 없습니다.`);
}
console.log(`시트 "${sheet.title}" 로드 완료, 행 개수: ${sheet.rowCount}`);
// 모든 셀 로드
await sheet.loadCells();
console.log('모든 셀 로드 완료');
// 헤더 행 확인 (첫 번째 행)
const headerRow: string[] = [];
for (let col = 0; col < sheet.columnCount; col++) {
const cell = sheet.getCell(0, col);
if (cell.value) {
headerRow.push(cell.value.toString());
}
}
console.log(`헤더 행: ${headerRow.join(', ')}`);
// key 컬럼 인덱스 찾기
const keyColumnIndex = headerRow.indexOf('key');
if (keyColumnIndex === -1) {
console.error('key 컬럼을 찾을 수 없습니다.');
return {};
}
// 언어 컬럼 인덱스 찾기
const langColumnIndices: Record<string, number> = {};
lngs.forEach(lng => {
const index = headerRow.indexOf(lng);
if (index !== -1) {
langColumnIndices[lng] = index;
console.log(`${lng} 컬럼 인덱스: ${index}`);
} else {
console.log(`${lng} 컬럼을 찾을 수 없습니다.`);
}
});
const lngsMap: Record<string, Record<string, string>> = {};
lngs.forEach(lng => {
lngsMap[lng] = {};
});
// 데이터 행 처리 (헤더 행 이후)
for (let row = 1; row < sheet.rowCount; row++) {
const keyCell = sheet.getCell(row, keyColumnIndex);
if (!keyCell.value) {
continue; // 키가 없는 행은 건너뜀
}
const key = keyCell.value.toString();
console.log(`번역 키 처리 중: "${key}"`);
// 각 언어별 번역 값 가져오기
lngs.forEach(lng => {
const colIndex = langColumnIndices[lng];
if (colIndex !== undefined) {
const cell = sheet.getCell(row, colIndex);
const translation = cell.value ? cell.value.toString() : '';
if (translation && translation !== NOT_AVAILABLE_CELL) {
lngsMap[lng][key] = translation;
console.log(`${lng} 언어의 "${key}" 키에 대한 번역: "${translation}"`);
}
}
});
}
// 디버깅: 최종 번역 맵 출력
console.log('최종 번역 맵:');
lngs.forEach(lng => {
console.log(`${lng}: ${Object.keys(lngsMap[lng] || {}).length}개 항목`);
});
return lngsMap;
} catch (error) {
console.error('번역 데이터 가져오기 오류:', error);
throw error;
}
};
// 디렉토리 확인 및 생성
const checkAndMakeLocaleDir = async (dirPath: string, subDirs: string[]): Promise<void> => {
for (const subDir of subDirs) {
const path = `${dirPath}/${subDir}`;
try {
if (!fs.existsSync(path)) {
fs.mkdirSync(path, { recursive: true });
console.log(`디렉토리 생성: ${path}`);
}
} catch (err) {
throw err;
}
}
};
// 스프레드시트에서 JSON 파일로 업데이트
const updateJsonFromSheet = async (): Promise<void> => {
try {
await checkAndMakeLocaleDir(localesPath, lngs);
// 인증 파일이 없는 경우를 위한 임시 처리
const credentialsPath = path.join(__dirname, '../../credentials/LanguageProjectIAM.json');
if (!fs.existsSync(credentialsPath)) {
console.log('Google 인증 파일이 없습니다. 로컬 파일만 확인합니다.');
// 각 언어별 기본 파일 생성
lngs.forEach(lng => {
const localeJsonFilePath = `${localesPath}/${lng}/${ns}.json`;
// 파일이 없으면 빈 객체로 생성
if (!fs.existsSync(localeJsonFilePath)) {
fs.writeFileSync(localeJsonFilePath, '{}', 'utf8');
console.log(`파일 생성: ${localeJsonFilePath}`);
} else {
console.log(`파일 확인: ${localeJsonFilePath}`);
}
});
console.log('로컬 파일 확인 완료!');
return;
}
// 기존 Google Spreadsheet 연동 코드
const doc = await loadSpreadsheet();
const lngsMap = await fetchTranslationsFromSheetToJson(doc);
// 스프레드시트에서 데이터를 가져오지 못한 경우 기본 데이터 추가
if (
Object.keys(lngsMap).length === 0 ||
lngs.every(lng => Object.keys(lngsMap[lng] || {}).length === 0)
) {
console.log('스프레드시트에서 데이터를 가져오지 못했습니다. 기본 데이터를 추가합니다.');
}
lngs.forEach(lng => {
const localeJsonFilePath = `${localesPath}/${lng}/${ns}.json`;
const jsonString = JSON.stringify(lngsMap[lng] || {}, null, 2);
fs.writeFileSync(localeJsonFilePath, jsonString, 'utf8');
console.log(`${lng} 번역 업데이트 완료: ${Object.keys(lngsMap[lng] || {}).length}개 항목`);
});
console.log('모든 번역 다운로드 완료!');
} catch (error) {
console.error('번역 다운로드 오류:', error);
}
};
updateJsonFromSheet();
잘되는 것을 확인했다.
'개발 스터디' 카테고리의 다른 글
이펙티브 타입스크립트 (0) | 2025.03.20 |
---|---|
Github Action을 활용한 branch 배포 (1) | 2024.11.28 |
[FE개발 스터디] 함께 자라기 - 애자일로 가는 길 (29) | 2024.11.15 |