
글로벌 진출을 위해 다국어 번역 프로세스 재정비가 필요했다. 기존에는 개발자가 직접 JSON 파일을 관리했다. 키 설정, 번역 등은 모두 개발자가 임의로 진행했다. 키가 몇 개 없을 때는 그럭저럭 돌아갔지만, 서비스가 고도화되어가면서 점점 병목이 되어가고 있었다. 키를 추가하고 JSON 업데이트를 빠뜨리거나, 번역 결과를 잘못된 키에 넣는 실수가 언제든 일어날 수 있었다.
번역 프로세스를 재정립하고 자동화가 필요했다. SaaS 도입은 내부 사정 상 보류되었기 때문에 Google Translate API 기반으로 직접 구현이 필요했다.
전체 파이프라인은 다음과 같다.
TSX를 스캔 가능한 형태로 변환 — Babel코드를 스캔할 때 i18next-scanner 라이브러리를 사용했다. 이 라이브러리는 acorn 이라는 파서에 의존하고 있는데, 기본적으로 .js / .jsx 파일을 대상으로만 동작하기 때문에 TypeScript 파일을 스캔할 때는 제대로 동작하지 않았다. 따라서 스캔 전에 Babel로 TypeScript 문법을 제거해서 순수 JS로 변환하는 전처리 단계가 필요했다.
module.exports = {
plugins: [
// TS 타입 제거
['@babel/plugin-transform-typescript', { isTSX: true, allExtensions: true }],
// JSX 구문은 변환하지 않고 파싱만
'@babel/plugin-syntax-jsx',
// 클래스 필드, 데코레이터 등 최신 문법 대응
['@babel/plugin-proposal-decorators', { legacy: true }],
'@babel/plugin-proposal-class-properties',
'@babel/plugin-proposal-private-methods',
'@babel/plugin-proposal-export-default-from',
'@babel/plugin-proposal-export-namespace-from',
],
}
트랜스파일된 결과물은 프로젝트의 임시 폴더에 저장되고, i18next-scanner가 이 폴더를 스캔한다. 변환 결과는 아래와 같다.
;
var _nextI18next = require("next-i18next");
var _constants = require("@/constants");
var _i18n = require("@/utils/i18n");
var StatusEnum;
StatusEnum["WAIT"] = "\uB300\uAE30"; // '대기'
StatusEnum["DONE"] = "\uC644\uB8CC"; // '완료'
StatusEnum["CANCEL"] = "\uCDE8\uC18C"; // '취소'
const MyComponent = ({ status }) => {
const { t } = (0, _nextI18next.useTranslation)('common');
return (
<div>
<p>{(0, _i18n.t)('상태')}</p>
<p>{(0, _i18n.t)(_constants.StatusEnum['WAIT'])}</p>
<_nextI18next.Trans i18nKey="이용약관에 <1>동의</1>합니다">
이용약관에 <strong>동의</strong>합니다
</_nextI18next.Trans>
</div>
)
}
TypeScript 코드가 Babel로 트랜스파일되면서 원본과 다른 코드 패턴이 여럿 생겨났고, 기본 파서가 이를 인식하지 못했다. transform 함수를 직접 구성하여 원본과 다른 코드 패턴을 직접 처리했다.
const LANGS = [ ... ]
module.exports = {
input: ['tmp/pages/**/*.{js,jsx}', 'tmp/shared/**/*.{js,jsx}'],
options: {
// ...,
resource: {
loadPath: path.join(__dirname, '.tmp/locales/{{lng}}/{{ns}}.json'),
savePath: path.join(__dirname, '.tmp/locales/{{lng}}/{{ns}}.json'),
},
},
transform(file, enc, done) { /* ... */ },
}
i18n에서 제공하는 t 함수를 사용하는 가장 일반적인 코드 패턴이다.
parser.parseFuncFromString(content, {
list: ['i18next.t', 'i18n.t', '$i18n.t', 't'],
extensions: ['.js', '.jsx'],
}, (key, options) => {
parser.set(key, { ...options, ns: ns || DEFAULT_NS })
})
Babel이 TypeScript의 named import를 CommonJS로 변환할 때 코드 형태가 바뀐다.
// 원본 TypeScript
import { t } from '@/utils/i18n'
t('번역키')
// Babel 트랜스파일 후
var _i18n = require('@/utils/i18n')
;(0, _i18n.t)('번역키')
(0, fn)() 패턴은 strict mode에서 this 바인딩을 undefined로 만들기 위한 Babel의 변환 방식이다. i18next-scanner의 파서는 이 패턴을 인식하지 못하기 때문에 require 별칭을 먼저 추출한 뒤 정규식으로 직접 스캔했다.
const aliasMatch = content.match(
/var\s+(_\w+)\s*=\s*require\(['"]@\/utils\/i18n['"]\)/
)
if (aliasMatch) {
const alias = aliasMatch[1] // '_i18n'
const tFunctionRegex = new RegExp(
`\\(0,\\s*${alias}\\.t\\)\\(\\s*['"\`](.*?)['"\`]`, 'gs'
)
// ...
}
i18n에서 제공하는 Trans 컴포넌트도 Babel 트랜스파일 후 형태가 바뀐다. next-i18next의 Trans가 _nextI18next.Trans로 변환되기 때문에 이것도 직접 파싱해야 했다.
const transRegex =
/<_nextI18next\.Trans\b[\s\S]*?i18nKey\s*=\s*(['"])([\s\S]*?)\1[\s\S]*?>/g
이제 새로 추출한 문구를 Google Translate API를 통해 번역하고 동기화한다.
execSync(`aws s3 sync s3://${S3_BUCKET} ${LOCALES_DIR} --delete`, { stdio: 'inherit' })
S3 버킷이 단일 진실 공급원(Single Source of Truth)이다. --delete 옵션으로 S3에 없는 파일은 로컬에서도 제거되어 항상 S3 상태와 일치한다.
{
"scripts": {
"i18n-transpile": "babel src --out-dir tmp --extensions .ts,.tsx --config-file ./babel.i18next-scanner.config.js",
"i18n-scan": "i18next-scanner --config i18next-scanner.config.js && pnpm run i18n-transpile"
}
}
i18next-scanner로 코드베이스를 스캔한다. 스캔 결과는 S3에서 받아온 기존 JSON 위에 신규 키가 빈 값으로 추가되는 방식이기 때문에, 이미 번역된 기존 키의 값은 그대로 유지된다.
const allKeys = Object.keys(dataByLocale[DEFAULT_LOCALE])
const newKeys = allKeys.filter(
(key) => LOCALES.slice(1).every((locale) => !dataByLocale[locale][key])
)
모든 언어 JSON에서 값이 없는 키만 신규로 판별한다. 다시 말해 "하나라도 값이 있으면 번역이 진행 중인 문구" 로 본다. 부분 번역된 키를 신규로 다시 처리하면 담당자가 이미 수정한 번역을 덮어쓸 위험이 있기 때문이다.
신규 키는 Sheets 하단에 append되고, 그 행에 신규 문구라는 표식을 해둔다.
// 신규 키 append
const appendRes = await sheets.spreadsheets.values.append({
spreadsheetId: LANGUAGE_GOOGLE_SPREAD_SHEET_ID,
range: `${LANGUAGE_GOOGLE_SPREAD_SHEET_NAME}!A:E`,
valueInputOption: 'RAW',
insertDataOption: 'INSERT_ROWS',
requestBody: { values: newKeys.map((key) => [key, key, '', '', '']) },
})
// 추가된 행 위치 파악
const updatedRange = appendRes.data.updates.updatedRange // e.g. 'Sheet1!A100:E102'
const startRow = parseInt(/!(?:[A-Z]+)(\d+):/.exec(updatedRange)[1], 10)
// 노란색 배경 지정
formatRequests.push({
repeatCell: {
cell: {
userEnteredFormat: {
backgroundColor: { red: 0.94, green: 0.94, blue: 0.36 }, // #F0F05C
},
},
fields: 'userEnteredFormat.backgroundColor',
},
})
신규 문구에 노란색 배경색을 지정하기로 컨벤션을 맞췄다. 담당자가 번역 검토 후 색을 제거하면 "완료"로 간주한다. append 응답의 updatedRange에서 행 번호를 파싱하는 부분이 중요한데, 이 값이 없으면 어느 행에 색을 칠해야 할지 알 수 없기 때문에 예외 처리를 해두었다.
신규로 추가한 문구를 Google Cloud Translate API를 통해 번역한다. Google Cloud Translate API는 요청 하나당 최대 128개의 문구를 번역할 수 있으므로, 128개씩 잘라서 배치를 돌린다.
const BATCH_SIZE = 128
for (const locale of LOCALES.slice(1)) {
for (let i = 0; i < newKeys.length; i += BATCH_SIZE) {
const batch = newKeys.slice(i, i + BATCH_SIZE)
const [trans] = await translateClient.translate(batch, locale)
translations[locale].push(...(Array.isArray(trans) ? trans : [trans]))
// rate limit 방지: 배치 사이 1초 대기
if (i + BATCH_SIZE < newKeys.length) {
await new Promise((resolve) => setTimeout(resolve, 1000))
}
}
}
API 비용은 사용량 기반이다. 신규 키는 코드 변경이 있을 때만 생기기 때문에 구독형 SaaS 대비 비용이 훨씬 낮다.
자동 번역 결과를 append된 행에 다시 써넣는다. batchUpdate로 한 번에 처리한다.
const translationUpdateRequests = newKeys.map((key, idx) => ({
range: `${LANGUAGE_GOOGLE_SPREAD_SHEET_NAME}!A${startRow + idx}:E${startRow + idx}`,
values: [[key, key, ...LOCALES.slice(1).map((locale) => translations[locale][idx])]],
}))
await sheets.spreadsheets.values.batchUpdate({
spreadsheetId: LANGUAGE_GOOGLE_SPREAD_SHEET_ID,
requestBody: { valueInputOption: 'RAW', data: translationUpdateRequests },
})
이 시점에서 Google Sheets에는 신규 키와 자동 번역 결과가 모두 담겨 있다. 노란색 배경은 유지된 채로, 담당자가 확인하고 검토할 수 있다.
Google Sheets를 읽어서 언어별 JSON 파일을 생성하고 S3에 동기화한다.
const loadLanguage = async () => {
const response = await sheets.spreadsheets.values.get({
spreadsheetId: process.env.LANGUAGE_GOOGLE_SPREAD_SHEET_ID,
range: `${sheetName}!${startColumn}:${endColumn}`,
})
return response?.data?.values ?? []
}
const createLanguageFiles = (dataList) => {
const headerRowIndex = Number(process.env.LANGUAGE_GOOGLE_SPREAD_SHEET_HEADER_ROW)
const headerList = dataList[headerRowIndex] // e.g. ['key', 'lang1', 'lang2', ...]
const keyAndValueList = dataList.slice(headerRowIndex + 1)
const languageObjList = Array.from({ length: headerList.length - 1 }, () => ({}))
keyAndValueList.forEach((row) => {
const [key, ...values] = row
values.forEach((value, index) => {
languageObjList[index][key] = value
})
})
languageObjList.forEach((obj, index) => {
const lang = headerList[index + 1]
const dirPath = `public/locales/${lang}`
if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true })
fs.writeFileSync(`${dirPath}/translation.json`, JSON.stringify(obj, null, 2))
})
}
JSON 파일은 항상 Sheets에서 생성된다. 번역 담당자가 Sheets에서 직접 수정한 내용도 다음 파이프라인 실행 시 자동으로 반영된다.
execSync(
`aws s3 sync ${path.resolve(__dirname, 'public', 'locales')} s3://${S3_BUCKET} --delete`
)
public/locales를 S3에 업로드해서 싱크를 맞추는 것으로 완료된다.
일반적으로 번역 Key는 home.title, error.not_found 같은 영문 식별자를 사용한다. 하지만 이 방식은 Key만 봐서는 실제 어떤 문구인지 바로 알 수 없고, Key와 번역 값을 별도로 관리해야 한다는 번거로움이 있다.
우리는 한국어 문구 자체를 Key로 사용하기로 했다. 예를 들어 t('로그인'), t('장바구니에 담기') 처럼 쓰는 방식이다.
이 결정이 파이프라인 구조에 직접적인 영향을 준다. 한국어가 Key이자 기본 번역값이 되기 때문에, 신규 Key가 추가되면 한국어는 이미 확정된 상태다. Google Translate API로 나머지 언어를 자동 번역할 때 한국어 Key를 그대로 사용할 수 있다.
Google Sheets와 Google 번역 API를 조합한 파이프라인 덕분에 기존 대비 다국어 관리 리소스가 크게 줄어들었다. 개발자는 코드에 t('문구')를 쓰기만 하면 되고, 이후 키 추출, 번역, 동기화까지 자동으로 처리된다.
하지만 트레이드오프도 있다. Google Translate API의 번역 품질이 그다지 좋지 않다는 점이다. 브랜드명을 전혀 다른 단어로 번역하거나, 문맥을 고려하지 못한 어색한 번역이 나오는 경우가 있었다. 사람의 검토가 필수적이고, 서비스 톤앤매너에 맞는 번역을 기대하기는 어렵다. AI 번역이 아니기 때문에 세밀한 조정에는 한계가 있다.
그럼에도 SaaS 도입이 부담스럽거나 서비스 규모가 크지 않다면 충분히 도입해볼 만하다고 생각한다.