여행가는개발자

구글 스프레드 시트를 활용한 번역 기능 본문

개발 스터디

구글 스프레드 시트를 활용한 번역 기능

kimsoonil 2025. 3. 20. 12:23
728x90
반응형

구글 스프레드 시트 번역 기능

구글 스프레드 시트로 번역기능을 사용하는 장점

  1. 번역본을 기획 또는 운영팀에서 확인하여 번역언어를 검수하는 수고가 덜음
  2. json 파일을 일일히 작업하기보다 자동으로 업데이트하는 작업 진행가능
  3. 구글 시트에서 번역기 활용으로 다른 언어들을 자동 번역 작업 할수 있다

 

그래서 이 방법으로 번역을 해라! 라는 보단 이런 방법도 있으니 참고 해보면 좋다 라고 한번 보면 좋을거같다

자동화 방법자동화는 아래의 순서로 구현합니다

  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 파일을 실행할 수 있도록 해주는 라이브러리

다음 하위처럼 파일 구조를 생성한다 ( 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();

 

 

 

 

잘되는 것을 확인했다.

728x90
반응형