마지막 수정: 2026년 3월 7일
멀티 엔티티 빌링 플랫폼(GCP, Datadog 등)을 개발하면서 한국어, 영어, 일본어 3개 언어를 지원해야 했다. 번역 데이터의 관리 주체는 PO(Product Owner)이고, 개발자는 코드에서 키만 참조하는 구조가 필요했다.
핵심 요구사항:
pnpm i18n:pull 명령어 실행으로 최신 번역을 동기화t() 함수에서 타입 안전한 키 참조 (as any 제로)┌─────────────────────┐ pnpm i18n:pull ┌─────────────────────┐
│ Google Spreadsheet │ ──────────────────▶ │ pull-i18n.mjs │
│ PO 번역 관리 │ ◀────────────────── │ (변환 스크립트) │
└─────────────────────┘ Service Account └──────────┬──────────┘
+ Drive API │
XLSX 파싱 + 검증
│
▼
┌─────────────────────┐
│ JSON 리소스 │
│ src/config/locale/ │
│ {ko,en,ja}/*.json │
└──────────┬──────────┘
│
static import
│
▼
┌─────────────────────┐ ┌─────────────────────┐
│ i18next.d.ts │ CustomTypeOptions │ i18n.ts │
│ (타입 선언) │ ──────────────────▶ │ (i18next 초기화) │
└─────────────────────┘ └──────────┬──────────┘
│
typed t()함수
│
▼
┌─────────────────────┐
│ React 컴포넌트 │
│ useTranslation() │
└─────────────────────┘시트 하나가 네임스페이스 하나에 대응된다. 각 시트의 컬럼 구조는 고정이다.
| key | ko | en | ja |
|---|---|---|---|
| dashboard | 대시보드 | Dashboard | ダッシュボード |
| costValidation | 매입 관리 | Cost Validation | 仕入管理 |
| message.saveSuccess | 저장되었습니다. | Saved successfully. | 保存しました。 |
시트 목록 = 네임스페이스: common, account, cost_validation, customer_contract, setting 등
pull-i18n.mjs)초기에는 API Key 방식을 시도했지만 비공개 시트에서 401 오류가 발생했다. Google Service Account + google-auth-library로 전환하여 해결했다.
Google Sheets API + Google Drive API 둘 다 사용 설정i18n-sheet-reader (용도가 명확한 이름 권장)서비스 계정은 메일함이 없어서 초대 수락이 필요 없다. 시트에서 공유 추가하면 즉시 접근 가능하다.
키 JSON 파일은 credentials/ 디렉토리에 두고 .gitignore에 추가한다.
# .env
I18N_SHEET_ID=your_google_sheet_id_here
GOOGLE_SERVICE_ACCOUNT_PATH=credentials/google-service-account.json# .gitignore
credentials/
.envNode 20.6+의 --env-file 플래그를 사용하므로 dotenv 의존성이 불필요하다.
// package.json
{
"scripts": {
"i18n:pull": "node --env-file=.env scripts/pull-i18n.mjs"
}
}설치 의존성:
pnpm add -D xlsx google-auth-libraryconst auth = new GoogleAuth({
keyFile: path.resolve(ROOT, process.env.GOOGLE_SERVICE_ACCOUNT_PATH),
scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'],
})
const client = await auth.getClient()
const token = await client.getAccessToken()Google Sheets export URL로 XLSX를 한 번에 내보내고, xlsx 라이브러리로 파싱한다. XLSX 1회 다운로드로 전체 시트 이름 + 데이터를 한번에 가져오므로 네임스페이스를 하드코딩할 필요가 없다.
// XLSX 한 번에 다운로드
const url = `https://docs.google.com/spreadsheets/d/${SHEET_ID}/export?format=xlsx`
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token.token}` },
})
const buffer = await res.arrayBuffer()
const workbook = XLSX.read(buffer, { type: 'array' })
// 시트 이름 자동 감지 → 네임스페이스 하드코딩 불필요
const namespaces = workbook.SheetNames
// 시트별 파싱
const rows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { defval: '' })PO가 입력하는 키의 품질을 자동으로 검증한다.
| 패턴 | 규칙 | 예시 |
|---|---|---|
| flat key | camelCase | dashboard, costValidation |
| message 접두사 | message.{camelCase} | message.saveSuccess |
| error 접두사 | error.{코드} | error.NOT_FOUND |
| plural suffix | i18next 문법 허용 | item_one, item_other |
검증 항목:
message.saveSuccess)각 시트를 언어별 JSON 파일로 출력한다.
src/config/locale/
├── ko/
│ ├── common.json # { "dashboard": "대시보드", ... }
│ ├── account.json
│ └── setting.json
├── en/
│ ├── common.json # { "dashboard": "Dashboard", ... }
│ └── ...
└── ja/
├── common.json # { "dashboard": "ダッシュボード", ... }
└── ...생성된 JSON을 static import로 가져와 i18next에 등록한다.
// src/config/i18n.ts
import koCommon from './locale/ko/common.json'
import enCommon from './locale/en/common.json'
import jaCommon from './locale/ja/common.json'
export const resources = {
ko: { common: koCommon, account: koAccounts, ... },
en: { common: enCommon, account: enAccounts, ... },
ja: { common: jaCommon, account: jaAccounts, ... },
} as const // ← as const로 리터럴 타입 추론i18next.d.ts에서 CustomTypeOptions를 확장하면 t() 함수가 JSON 키만 허용한다.
// src/types/i18next.d.ts
import type { defaultNamespace, resources } from '../config/i18n'
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: typeof defaultNamespace
resources: (typeof resources)\['en'\]
}
}이 선언만으로 t('dashboard')는 자동완성되고, t('typoKey')는 컴파일 타임에 에러가 발생한다.
as any 완전 제거네비게이션 메뉴처럼 런타임에 키를 조합하는 경우가 문제였다. i18next의 typed t()는 string 타입 키를 거부하기 때문에 처음에는 (t as any)(key)로 우회했다.
이를 해결하기 위해 JSON 리소스 타입에서 유효한 키를 자동 추출하는 유틸리티 타입을 도입했다.
// NavChildKeys — 리소스 JSON에서 sub-nav 키 자동 추출
type NavChildKeys<NS extends Namespace> =
NS extends NS // ← distributive conditional type 트릭
? keyof (typeof resources)\['en'\]\[NS\] & string
: neverNS extends NS 트릭은 TypeScript의 distributive conditional type을 강제로 활성화하여, 유니온 타입의 각 멤버를 독립적으로 평가하게 만든다.pnpm i18n:pull 실행 시 아래와 같은 리포트가 출력된다.
🌐 Google Spreadsheet → i18n JSON 변환 시작
📥 스프레드시트 다운로드 중...
📋 감지된 시트: common, account, cost_validation, setting
📄 common ... ✅ 45개 키
📄 account ... ✅ 32개 키
📄 cost_validation ... ✅ 28개 키
📄 setting ... ✅ 18개 키
──────────────────────────────────────────────────
✅ 완료: 4개 네임스페이스, 123개 키, 3개 언어
📁 출력: src/config/locale/{ko,en,ja}/*.json
📝 번역 누락 (2건):
⚠️ [setting] 번역 누락: "timezone" → ja (행 15)
⚠️ [account] 번역 누락: "billingType" → en (행 8)| 결정 | 선택지 | 선택 이유 |
|---|---|---|
| 번역 관리 도구 | Crowdin / Phrase vs Google Spreadsheet | PO가 이미 익숙한 도구, 추가 비용 없음, 빠른 도입 |
| 인증 방식 | API Key vs Service Account | 비공개 시트 접근 필요, API Key는 401 발생 |
| 리소스 로딩 | dynamic import (lazy) vs static import | Vite 빌드 타임에 JSON이 번들에 포함되어 as const 타입 추론 가능 |
| 타입 안전성 | as any 캐스팅 vs 유틸리티 타입 추출 | 런타임 키 조합에서도 컴파일 타임 검증, 오타 방지 |
| JSON 편집 정책 | 개발자 직접 수정 vs 스크립트만 허용 | SSOT(Single Source of Truth)를 Spreadsheet로 고정, 충돌 방지 |
잘된 점:
as const + CustomTypeOptions로 별도 코드젠 없이 타입 안전성 확보아쉬운 점:
i18n.ts에 수동 등록 필요다음 개선:
i18n:pull + diff 체크 통합 검토i18n.ts 코드젠 자동화새 팀원이 pnpm i18n:pull을 실행하기 위한 최소 설정:
credentials/google-service-account.json에 배치.env 파일에 I18N_SHEET_ID, GOOGLE_SERVICE_ACCOUNT_PATH 설정pnpm install (xlsx, google-auth-library 자동 설치)pnpm i18n:pull 실행서비스 계정 키 파일은 팀 비밀 관리 도구(1Password, Vault 등)를 통해 공유하고, 절대 git에 커밋하지 않는다.