데이터 소스별로 언어를 분리하여 다국어 에러 메시지를 관리할 수 있습니다. 데이터 소스에 따라 스프레드시트 탭, 별도 파일, Airtable 테이블, Notion 데이터베이스로 언어를 분리합니다. 비개발자(기획자, 콘텐츠 담당)도 각 소스에서 번역을 직접 관리할 수 있습니다.
데이터 소스 구조
Google Sheets / XLSX
각 언어를 별도 탭(시트)으로 분리합니다. 모든 탭은 동일한 컬럼 구조와 동일한 trackId 목록을 사용합니다.
[Google Sheet / XLSX]
+----------+ +----------+ +----------+
| 한국어 | | English | | 日本語 | <- 탭(시트) = 언어
+----------+ +----------+ +----------+
| trackId | | trackId | | trackId |
| type | | type | | type |
| message | | message | | message |
| ... | | ... | | ... |
+----------+ +----------+ +----------+Airtable
같은 Base 내에서 각 언어를 별도 테이블로 분리합니다. 모든 테이블은 동일한 컬럼 구조와 동일한 trackId 목록을 사용합니다.
[Airtable Base]
+------------------+ +------------------+ +------------------+
| tblKorean | | tblEnglish | | tblJapanese | <- 테이블 = 언어
+------------------+ +------------------+ +------------------+
| trackId | | trackId | | trackId |
| type | | type | | type |
| message | | message | | message |
| ... | | ... | | ... |
+------------------+ +------------------+ +------------------+Notion
각 언어를 별도 데이터베이스로 분리합니다. 모든 데이터베이스는 동일한 속성 구조와 동일한 trackId 목록을 사용합니다.
[Notion]
+------------------+ +------------------+ +------------------+
| DB 한국어 | | DB English | | DB 日本語 | <- 데이터베이스 = 언어
+------------------+ +------------------+ +------------------+
| trackId | | trackId | | trackId |
| type | | type | | type |
| message | | message | | message |
| ... | | ... | | ... |
+------------------+ +------------------+ +------------------+CSV
각 언어를 별도 파일로 분리합니다.
[CSV Files]
errors.ko.csv errors.en.csv errors.ja.csv <- 파일 = 언어
+----------+ +----------+ +----------+
| trackId | | trackId | | trackId |
| type | | type | | type |
| message | | message | | message |
| ... | | ... | | ... |
+----------+ +----------+ +----------+TIP
type, actionType은 모든 언어에서 동일해야 합니다 (렌더링 구조가 같으므로). message, title, actionLabel만 번역합니다.
설정
.huh.config.json에 i18n 옵션을 추가합니다. i18n 옵션이 없으면 기존과 동일하게 단일 언어로 동작합니다.
Google Sheets
{
"source": {
"type": "google-sheets",
"sheetId": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"
},
"output": "./src/huh",
"i18n": {
"defaultLocale": "ko",
"locales": {
"ko": { "range": "한국어" },
"en": { "range": "English" }
}
}
}각 로케일의 range는 Google Sheets 탭 이름입니다.
XLSX
{
"source": {
"type": "xlsx",
"filePath": "./errors.xlsx"
},
"output": "./src/huh",
"i18n": {
"defaultLocale": "ko",
"locales": {
"ko": { "sheet": "한국어" },
"en": { "sheet": "English" }
}
}
}각 로케일의 sheet는 XLSX 시트 이름입니다.
Airtable
{
"source": {
"type": "airtable",
"baseId": "appXXXXXXXXXXXXXX",
"tableId": "tblKorean"
},
"output": "./src/huh",
"i18n": {
"defaultLocale": "ko",
"locales": {
"ko": { "tableId": "tblKorean" },
"en": { "tableId": "tblEnglish" }
}
}
}각 로케일의 tableId는 해당 언어의 Airtable 테이블 ID입니다. 모든 테이블은 같은 Base에 있어야 합니다.
Notion
{
"source": {
"type": "notion",
"databaseId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
},
"output": "./src/huh",
"i18n": {
"defaultLocale": "ko",
"locales": {
"ko": { "databaseId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" },
"en": { "databaseId": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }
}
}
}각 로케일의 databaseId는 해당 언어의 Notion 데이터베이스 ID입니다.
CSV
CSV는 탭이 없으므로 파일을 분리합니다:
{
"source": {
"type": "csv",
"filePath": "./errors.ko.csv"
},
"output": "./src/huh",
"i18n": {
"defaultLocale": "ko",
"locales": {
"ko": { "filePath": "./errors.ko.csv" },
"en": { "filePath": "./errors.en.csv" }
}
}
}TypeScript 설정 (defineConfig)
import { defineConfig } from '@sanghyuk-2i/huh-cli';
export default defineConfig({
source: {
type: 'google-sheets',
sheetId: 'abc123',
},
output: './src/huh',
i18n: {
defaultLocale: 'ko',
locales: {
ko: { range: '한국어' },
en: { range: 'English' },
},
},
});설정 타입
defaultLocale: string;
locales: Record<string, LocaleSourceOverride>;
}
interface LocaleSourceOverride {
sheet?: string; // XLSX 시트 이름
range?: string; // Google Sheets 탭 이름
filePath?: string; // CSV 파일 경로
tableId?: string; // Airtable 테이블 ID
databaseId?: string; // Notion 데이터베이스 ID
}출력 형식
i18n 모드에서 huh pull을 실행하면 output에 지정된 디렉토리에 로케일별 파일이 생성됩니다:
src/huh/
ko.json <- 한국어 ErrorConfig
en.json <- English ErrorConfig
index.ts <- 자동 생성 barrel 파일각 JSON은 기존과 동일한 ErrorConfig 형태입니다:
// ko.json
{
"ERR_AUTH": {
"type": "MODAL",
"title": "인증 만료",
"message": "{{userName}}님의 인증이 만료되었습니다."
}
}// en.json
{
"ERR_AUTH": {
"type": "MODAL",
"title": "Session Expired",
"message": "{{userName}}'s session has expired."
}
}index.ts는 자동 생성됩니다:
import ko from './ko.json';
import en from './en.json';
export type HuhLocale = 'ko' | 'en';
export const defaultLocale: HuhLocale = 'ko';
export const locales: Record<HuhLocale, ErrorConfig> = { ko, en };TIP
파일 분리 방식의 장점: - Tree-shaking: 사용하지 않는 언어는 번들에 포함되지 않음 - Lazy loading: 필요한 시점에 import('./huh/en.json')으로 동적 로드 가능 - 기존 호환: validate 명령어가 개별 파일에 그대로 동작
프레임워크 연동
React
import { locales } from './huh';
import { HuhProvider, useHuh } from '@sanghyuk-2i/huh-react';
function App() {
const [lang, setLang] = useState('ko');
return (
<HuhProvider locales={locales} defaultLocale="ko" locale={lang} renderers={renderers}>
<YourApp />
</HuhProvider>
);
}
// 언어 전환
function LanguageSwitcher() {
const { locale, setLocale } = useHuh();
return (
<select value={locale} onChange={(e) => setLocale(e.target.value)}>
<option value="ko">한국어</option>
<option value="en">English</option>
</select>
);
}Vue
<script setup lang="ts">
import { locales } from './huh';
import { HuhProvider } from '@sanghyuk-2i/huh-vue';
import { ref } from 'vue';
const lang = ref('ko');
</script>
<template>
<HuhProvider :locales="locales" default-locale="ko" :locale="lang" :renderers="renderers">
<YourApp />
</HuhProvider>
</template><!-- LanguageSwitcher.vue -->
<script setup lang="ts">
import { useHuh } from '@sanghyuk-2i/huh-vue';
const { locale, setLocale } = useHuh();
</script>
<template>
<select :value="locale" @change="(e) => setLocale(e.target.value)">
<option value="ko">한국어</option>
<option value="en">English</option>
</select>
</template>Svelte
<script lang="ts">
import { locales } from './huh';
import { HuhProvider } from '@sanghyuk-2i/huh-svelte';
let lang = $state('ko');
</script>
<HuhProvider
{locales}
defaultLocale="ko"
locale={lang}
{renderers}
>
<YourApp />
</HuhProvider><!-- LanguageSwitcher.svelte -->
<script lang="ts">
import { useHuh } from '@sanghyuk-2i/huh-svelte';
const { locale, setLocale } = useHuh();
</script>
<select value={locale} onchange={(e) => setLocale(e.target.value)}>
<option value="ko">한국어</option>
<option value="en">English</option>
</select>TIP
locale prop을 전달하면 외부에서 언어를 제어합니다. 전달하지 않으면 Provider 내부 상태로 관리되며, useHuh()의 setLocale()로 변경할 수 있습니다.
Cross-Locale 검증
huh pull 실행 시 로케일 간 일관성을 자동 검증합니다:
| 검증 항목 | 레벨 | 설명 |
|---|---|---|
| trackId 누락 | error | 한 로케일에 있는 trackId가 다른 로케일에 없으면 |
| type 불일치 | warning | 같은 trackId인데 로케일마다 type이 다르면 |
| actionType 불일치 | warning | 같은 trackId인데 로케일마다 action.type이 다르면 |
| 개별 로케일 검증 | 그대로 | 각 로케일별로 기존 validateConfig 적용 |
Cross-locale validation:
ERROR: trackId "ERR_NETWORK" is missing in locale(s): ja
WARNING: trackId "ERR_AUTH" has different "type" across locales: ko=MODAL, en=TOASTtrackId 누락은 에러(pull 실패), type/actionType 불일치는 경고(pull 계속 진행)입니다.
프로그래밍 방식으로 검증하려면 @sanghyuk-2i/huh-core의 validateLocales를 사용할 수 있습니다:
import { validateLocales } from '@sanghyuk-2i/huh-core';
import type { LocalizedErrorConfig } from '@sanghyuk-2i/huh-core';
const locales: LocalizedErrorConfig = { ko: koConfig, en: enConfig };
const result = validateLocales(locales);
if (!result.valid) {
result.errors.forEach((e) => console.error(e.message));
}
result.warnings.forEach((w) => console.warn(w.message));기존 사용자 마이그레이션
i18n 옵션이 없으면 모든 것이 기존과 동일하게 동작합니다:
output은 여전히 파일 경로 (./src/huh.json)HuhProvider의sourceprop 그대로 사용- Zero breaking change
다국어 지원이 필요할 때만 i18n 옵션을 추가하면 됩니다.
