Skip to content

데이터 소스별로 언어를 분리하여 다국어 에러 메시지를 관리할 수 있습니다. 데이터 소스에 따라 스프레드시트 탭, 별도 파일, 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.jsoni18n 옵션을 추가합니다. i18n 옵션이 없으면 기존과 동일하게 단일 언어로 동작합니다.

Google Sheets

json
{
  "source": {
    "type": "google-sheets",
    "sheetId": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"
  },
  "output": "./src/huh",
  "i18n": {
    "defaultLocale": "ko",
    "locales": {
      "ko": { "range": "한국어" },
      "en": { "range": "English" }
    }
  }
}

각 로케일의 range는 Google Sheets 탭 이름입니다.

XLSX

json
{
  "source": {
    "type": "xlsx",
    "filePath": "./errors.xlsx"
  },
  "output": "./src/huh",
  "i18n": {
    "defaultLocale": "ko",
    "locales": {
      "ko": { "sheet": "한국어" },
      "en": { "sheet": "English" }
    }
  }
}

각 로케일의 sheet는 XLSX 시트 이름입니다.

Airtable

json
{
  "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

json
{
  "source": {
    "type": "notion",
    "databaseId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
  },
  "output": "./src/huh",
  "i18n": {
    "defaultLocale": "ko",
    "locales": {
      "ko": { "databaseId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" },
      "en": { "databaseId": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }
    }
  }
}

각 로케일의 databaseId는 해당 언어의 Notion 데이터베이스 ID입니다.

CSV

CSV는 탭이 없으므로 파일을 분리합니다:

json
{
  "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)

ts
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' },
    },
  },
});

설정 타입

ts
  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 형태입니다:

json
// ko.json
{
  "ERR_AUTH": {
    "type": "MODAL",
    "title": "인증 만료",
    "message": "{{userName}}님의 인증이 만료되었습니다."
  }
}
json
// en.json
{
  "ERR_AUTH": {
    "type": "MODAL",
    "title": "Session Expired",
    "message": "{{userName}}'s session has expired."
  }
}

index.ts는 자동 생성됩니다:

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

tsx
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

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>
vue
<!-- 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

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>
svelte
<!-- 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=TOAST

trackId 누락은 에러(pull 실패), type/actionType 불일치는 경고(pull 계속 진행)입니다.

프로그래밍 방식으로 검증하려면 @sanghyuk-2i/huh-corevalidateLocales를 사용할 수 있습니다:

ts
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)
  • HuhProvidersource prop 그대로 사용
  • Zero breaking change

다국어 지원이 필요할 때만 i18n 옵션을 추가하면 됩니다.

Released under the MIT License.