ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [React] 다국어 지원 자동화 - i18next, Google Spreadsheet, i18next-parser
    React.js 2025. 1. 14. 16:30

     

     

     

     

     

     

     

    현재 작업중인 프로젝트에서는 전체 영어로 개발되었는데,

    국내 결제를 추가로 연동하려면 한국어도 제공되어야 PG사 이용이 가능했다.

     

    처음에는 translate.google.com 스크립트를 추가해서 구글 전체 번역으로 간단하게 구현을 했다.

    예상했듯 엉망으로 번역되는 녀석,,,,,,,,

     

     

     

     

    이왕 다국어를 지원하려면 번역자가 실시간으로 직접 번역 데이터를 삽입하면 개발자는 데이터를 가져오기만 하도록 자동화 하기로 했다. 기획자가 번역 관련 텍스트를 수정 요청 시에도 추가적인 코드 작업이 없도록 하고싶었다.

     

    분명 까먹을게 뻔하기 때문에 구현 완료 후 미래의 나를 위한 기록을 남긴다. 

     

    시작해보자.

     

     

     

    1. 필요한 라이브러리를 설치한다.

    npm install i18next react-i18next
    npm install i18next-parser
    npm install google-auth-library
    npm install google-spreadsheet

     

     

    2. root에 i18next-parser config 파일을 생성한다.

    (skipOnVariables: false로 설정해도 변수로 삽입된 키값은 스캔하지 못하더라,,,,,)

    // i18next-parser.config.cjs
    
    module.exports = {
      input: ['src/**/*.{js,jsx,ts,tsx}'],
      output: 'src/locales/$LOCALE/$NAMESPACE.json',
      locales: ['en', 'ko'],
      defaultNamespace: 'common',
      defaultValue: (locale, namespace, key) => key,
      keySeparator: false,
      namespaceSeparator: false,
      useKeysAsDefaultValue: true,
      failOnWarnings: false,
      createOldCatalogs: false,
      skipOnVariables: false,
      lexers: {
        js: ['JavascriptLexer'],
        jsx: ['JsxLexer'],
        ts: ['TypescriptLexer'],
        tsx: ['JsxLexer'],
      },
      func: {
        list: ['t'],
        extensions: ['.js', '.jsx', '.ts', '.tsx'],
      },
      resource: {
        loadPath: 'src/locales/{{lng}}/{{ns}}.json',
        savePath: 'src/locales/{{lng}}/{{ns}}.json',
      },
    };

     

    3. /src 내 아래 이미지 구조와 같이 폴더 및 파일을 생성하고, 각 로직을 작성한다.

     

     

     

    *** 구글 관련 환경 변수 및 스프레드 시트 세팅은 아래와 같다.

     

    1) 구글 클라우드 콘솔에서 프로젝트를 생성 후,

    구글 콘솔 -> API 및 서비스 -> 라이브러리 -> google sheets API 검색해서 사용 버튼을 눌러준다.

     

    2) 구글 콘솔 -> API 및 서비스 -> 사용자 인증 정보 -> 사용자 인증 정보 만들기 -> API 키

    API 키를 생성한 후 환경 변수(VITE_GOOGLE_API_KEY)에 넣어준다.

     

    3) 구글 콘솔 -> IAM 및 관리 -> 서비스 계정 -> 서비스 계정 만들기

    생성한 서비스 계정 클릭 -> 키 -> 키 추가 -> 새 키 만들기 (키 유형 : JSON)

    해당 JSON 파일을 다운로드하면 나머지 환경 변수들(VITE_GOOGLE_PRIVATE_KEY, VITE_GOOGLE_CLIENT_EMAIL, VITE_GOOGLE_PROJECT_ID, VITE_GOOGLE_TOKEN_URL)을 얻을 수 있다. 다 쮸셔넣자.

     

    VITE_SHEET_ID는 시트 URL에서 얻을 수 있다.

     

    4) 구글 스프레드 시트 -> 공유 

    사용자에 아까 생성한 서비스 계정편집자로 추가한다.

     

    5) 시트명을 common으로 바꾸고 최상단에 en, ko를 세팅한다.

    시트명
    열 세팅

     

     

     

    // i18next.js
    
    import i18next from 'i18next';
    import { initReactI18next } from 'react-i18next';
    import ko_translation from '../locales/ko/common.json';
    import en_translation from '../locales/en/common.json';
    
    i18next.use(initReactI18next).init({
      resources: {
        en: {
          translation: en_translation,
        },
        ko: {
          translation: ko_translation,
        },
      },
      lng: 'en', // 기본 언어
      fallbackLng: 'en', // 대체 언어
      debug: true,
      interpolation: {
        escapeValue: false,
      },
    });
    
    export default i18next;

     

     

    // sheetDownload.js
    
    import fs from 'fs';
    import { GoogleSpreadsheet } from 'google-spreadsheet';
    import dotenv from 'dotenv';
    
    dotenv.config();
    
    (async function makeJson() {
      const doc = new GoogleSpreadsheet(process.env.VITE_SHEET_ID, {
        apiKey: process.env.VITE_GOOGLE_API_KEY,
      });
    
      await doc.loadInfo();
      const sheets = doc.sheetCount;
    
      for (let i = 0; i < sheets; i++) {
        const sheet = doc.sheetsByIndex[i];
        await sheet.loadCells();
        const rows = await sheet.getRows();
        const langs = await sheet._headerValues;
    
        const jsonData = {};
    
        langs.forEach((language, index) => {
          for (let j = 1; j < rows.length + 1; j++) {
            jsonData[sheet.getCell(j, 0).value] = sheet.getCell(j, index).value;
          }
    
          const jsonString = JSON.stringify(jsonData, null, 2);
          fs.writeFileSync(`./src/locales/${language}/${sheet.title}.json`, jsonString);
        });
      }
    })();

     

     

    // sheetUpload.js
    
    import { GoogleSpreadsheet } from 'google-spreadsheet';
    import { JWT } from 'google-auth-library';
    import dotenv from 'dotenv';
    import fs from 'fs';
    import path from 'path';
    import { dirname } from 'path';
    import { fileURLToPath } from 'url';
    
    dotenv.config();
    
    async function uploadJsonToGoogleSheet() {
      try {
        const serviceAccountEmail = process.env.VITE_GOOGLE_CLIENT_EMAIL;
        const privateKey = process.env.VITE_GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n');
        const sheetId = process.env.VITE_SHEET_ID;
    
        const __filename = fileURLToPath(import.meta.url);
        const __dirname = dirname(__filename);
    
        if (!serviceAccountEmail || !privateKey || !sheetId) {
          throw new Error('환경 변수에서 필요한 서비스 계정 정보가 누락되었습니다.');
        }
    
        const serviceAccountAuth = new JWT({
          email: serviceAccountEmail,
          key: privateKey,
          scopes: ['https://www.googleapis.com/auth/spreadsheets'],
        });
    
        const doc = new GoogleSpreadsheet(sheetId || '', serviceAccountAuth);
        await doc.loadInfo();
        const sheet = doc.sheetsByIndex[0];
        if (!sheet) {
          throw new Error('첫 번째 시트를 찾을 수 없습니다.');
        }
    
        const enJson = JSON.parse(fs.readFileSync(path.join(__dirname, 'en', 'common.json'), 'utf-8'));
        await addJsonToSheet(enJson, sheet);
      } catch (error) {
        console.error('Google Sheet 업데이트 중 오류 발생:', error.message);
        console.error(error.stack);
      }
    }
    
    async function addJsonToSheet(jsonData, sheet) {
      try {
        await sheet.loadHeaderRow();
        const existingRows = await sheet.getRows();
    
        const existingKeys = existingRows
          .filter((row) => row._rawData && row._rawData[0])
          .map((row) => row._rawData[0].trim().toLowerCase());
    
        for (const [key] of Object.entries(jsonData)) {
          const trimmedKey = key.trim().toLowerCase();
    
          if (existingKeys.includes(trimmedKey)) {
            console.log(`이미 존재하는 키: ${key}, 추가하지 않음.`);
            continue;
          }
          await sheet.addRow({
            en: key,
          });
        }
      } catch (err) {
        console.error('Google Sheet 업데이트 중 오류 발생:', err.message);
      }
    }
    
    uploadJsonToGoogleSheet();

     

     

    4. package.json에 번역 및 스캔 scripts를 추가한다.

        "translate_en": "node ./src/locales/sheetDownload.js && powershell -Command \"Get-Content ./src/locales/en/*.json\"",
        "translate_ko": "node ./src/locales/sheetDownload.js && powershell -Command \"Get-Content ./src/locales/ko/*.json\"",
        "translate": "npm run translate_en",
        "scan:i18n": "npx i18next-parser --config i18next-parser.config.cjs && node src/locales/sheetUpload.js"

     

     

    5. Language 상태를 바꿔주는 UI 컴포넌트를 생성한다.

    import { language } from '../../store/language';
    import { useTranslation } from 'react-i18next';
    
    export const LANGUAGES = [
      { code: 'ko', name: '한국어', flag: 'kr' },
      { code: 'en', name: 'English', flag: 'us' },
    ];
    
    export const Language = () => {
      const { i18n } = useTranslation();
      const [chooseCountry, setChooseCountry] = useState({
        code: 'en',
        name: 'English',
        flag: 'us',
      });
      const [isHovered, setIsHovered] = useState(false);
      const setLng = useSetRecoilState(language);
    
      const handleLanguageChange = async (lang) => {
        setChooseCountry(lang);
        setLng(lang.code);
        i18n.changeLanguage(lang.code);
      };
    
      return (
        <>
          <ButtonCotainer onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}>
            <Flag code={chooseCountry.flag} />
            {chooseCountry.name}
    
            {isHovered && (
              <LanguageList onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}>
                {LANGUAGES.map((lang) => (
                  <LanguageItem key={lang.code} onClick={() => handleLanguageChange(lang)}>
                    <Flag code={lang.flag} />
                    {lang.name}
                  </LanguageItem>
                ))}
              </LanguageList>
            )}
          </ButtonCotainer>
        </>
      );
    };

     

     

    6. 아무 컴포넌트 내 번역을 원하는 부분을 t 함수로 감싸준다.

    (동적인 텍스트 즉, 변수는 지원되지 않는다. 젠장)

    const { t } = useTranslation();
    
    // 개행을 원할 시 \n 추가 후 white-space: pre-line; 스타일을 먹여준다.
    <h3>{t('Master\n the Art of 3D')}</h3>
    <p>{t('test')}</p>

     

     

    이제 해당 script를 실행해보자! 🙃

    npm run scan:i18n 실행 시,

    parser.config에서 지정한 파일을 스캔 후 t 함수가 사용된 부분을 찾아 키값을 각 common.json 파일에 추가 후 시트에 업로드한다. 구글 스프레드 시트를 직접 확인해보면 키값이 삽입되어 있는 것을 확인할 수 있다.

     

    이제 번역자가 업로드된 시트의 키값을 확인 후 ko열에 번역한 텍스트를 삽입한다. 작업을 마치고 이번엔 npm run translate를 실행하면, 번역된 파일이 각 common.json에 추가 된 것을 확인할 수 있다.

     

     

     

    끗! 뿌듯

     

     

     

     

     

     

     

    'React.js' 카테고리의 다른 글

    [React] 환경 변수(.env) 사용법  (0) 2023.06.17

    댓글