logo
홈블로그소개
4,186

Built with Next.js, Bun, Tailwind CSS and Shadcn/UI

·개인정보처리방침
ReactNext.jsWeb

타입 안전한 i18next 다국어 처리: 스키마에서 컴파일 타임 키 검증까지

Toma
2026년 7월 8일
약 16분
목차
🌐 타입 안전한 i18next 다국어 처리: 스키마에서 컴파일 타임 키 검증까지
📖 그래서 i18n이 뭔데?
🔧 i18next는 뭐고, 언제 쓰나
왜 i18next를 선택했나
🗂️ 구조 1: namespace(:)와 depth(.)를 나눈 이유
🧩 구조 2: 스키마 기반 타입화 — 이 설계의 핵심
왜 필요했나
전체 데이터 흐름
① 스키마가 진실의 원천
② 스키마 → 타입 자동 생성
③ 타입에서 키 유니온 유도 — 타입 레벨 프로그래밍
④ typed 훅으로 t() 조이기
🔗 구조 3: glob 패턴으로 언어 확장 자동화
⚙️ 구조 4: build gate — 타입을 항상 최신으로
📝 실전: 번역을 추가·수정하는 워크플로우
번역 속성 추가하기
새 언어 추가하기 (예: 프랑스어)
✅ 정리하며
이전 포스트멀티레포에서 Turborepo 모노레포로: 개념부터 실전 이슈까지

목차

🌐 타입 안전한 i18next 다국어 처리: 스키마에서 컴파일 타임 키 검증까지
📖 그래서 i18n이 뭔데?
🔧 i18next는 뭐고, 언제 쓰나
왜 i18next를 선택했나
🗂️ 구조 1: namespace(:)와 depth(.)를 나눈 이유
🧩 구조 2: 스키마 기반 타입화 — 이 설계의 핵심
왜 필요했나
전체 데이터 흐름
① 스키마가 진실의 원천
② 스키마 → 타입 자동 생성
③ 타입에서 키 유니온 유도 — 타입 레벨 프로그래밍
④ typed 훅으로 t() 조이기
🔗 구조 3: glob 패턴으로 언어 확장 자동화
⚙️ 구조 4: build gate — 타입을 항상 최신으로
📝 실전: 번역을 추가·수정하는 워크플로우
번역 속성 추가하기
새 언어 추가하기 (예: 프랑스어)
✅ 정리하며

🌐 타입 안전한 i18next 다국어 처리: 스키마에서 컴파일 타임 키 검증까지

FE에서 다국어(i18n)를 붙일 때 가장 자주 겪는 사고는 거창한 게 아니다. t("profile:titel") 처럼 키를 한 글자 틀리는 것이다. 문자열 키는 런타임에 화면이 비어 보이기 전까지 아무도 모른다.

이 글은 실무 프로젝트에서 다국어 처리를 담당하며 구성한 구조를 정리한 것이다. 목표는 하나였다 — 번역 키 오타를 빌드 전에 잡는 것. 그 과정에서 i18next 위에 JSON Schema → TypeScript 타입 자동 생성 파이프라인을 얹었고, 그 구현과 판단 배경을 공유한다.

💡 이 글은 라이브러리 사용법 나열이 아니라 **"왜 이렇게 설계했는가"**에 무게를 둔다. i18n이 처음이라면 앞부분(정의·선택 배경)부터, 이미 익숙하다면 「namespace 구조」부터 읽으면 된다.


📖 그래서 i18n이 뭔데?

i18n은 _internationalization_의 줄임말이다. i와 n 사이에 글자가 18개라서 i18n. 소프트웨어를 특정 언어·지역에 종속시키지 않고, 번역과 로케일을 나중에 갈아 끼울 수 있게 만드는 작업을 말한다.

핵심은 이거다. i18n을 한다는 건 코드 곳곳에 박힌 하드코딩 문자열을 키(key)로 바꾸는 것이다.

typescript
// ❌ 하드코딩 — 영어를 추가하려면 이 파일을 뜯어야 한다
<h1>고객센터</h1>

// ✅ 키 기반 — 언어는 리소스 파일이 결정한다
<h1>{t("dashboard:title")}</h1>

코드는 "dashboard:title"이라는 주소만 안다. 실제로 "대시보드"를 보여줄지 "Dashboard"를 보여줄지는 현재 언어 설정과 번역 리소스가 결정한다. 코드와 문구가 분리되는 것, 이게 i18n의 전부다.


🔧 i18next는 뭐고, 언제 쓰나

i18next는 자바스크립트 진영에서 사실상 표준으로 쓰이는 i18n 프레임워크다. 위에서 말한 "키 → 번역 문자열" 매핑을 실제로 처리해주는 엔진이라고 보면 된다. React에서는 react-i18next 바인딩과 함께 쓴다.

i18next가 해주는 일:

기능설명
키 → 번역 조회t("key") 호출 시 현재 언어의 리소스에서 문구를 찾아 반환
언어 감지브라우저 언어·localStorage 등에서 사용자 언어 자동 판별
fallback번역이 없는 키는 지정한 기본 언어로 대체
namespace번역을 도메인 단위로 쪼개 관리
보간(interpolation)t("greeting", { name }) → "안녕하세요 {{name}}님" 처럼 값 삽입

💬 언제 쓰나? 지원 언어가 2개 이상이거나, 지금은 1개여도 나중에 언어가 늘어날 가능성이 있으면 처음부터 i18next를 얹는 게 이득이다. 문구가 코드 전역에 하드코딩된 뒤에 걷어내는 것은 훨씬 비싼 작업이기 때문이다.

왜 i18next를 선택했나

Next.js 생태계에는 next-intl, next-i18next 같은 대안도 있다. 우리가 순수 i18next + react-i18next 조합을 택한 이유는:

  • ✅ 클라이언트 사이드에 국한된 요구사항 — 이 프로젝트의 다국어는 브라우저에서 사용자가 언어를 전환하는 방식이다. 서버 컴포넌트에서 번역을 렌더링하는 SSR i18n이 꼭 필요하지 않았다.
  • ✅ 성숙한 타입·플러그인 생태계 — 언어 감지(i18next-browser-languagedetector) 같은 주변 도구가 잘 갖춰져 있다.
  • ✅ 프레임워크 결합도가 낮음 — i18next 코어 자체는 Next.js에 묶이지 않아, 구조를 우리 방식대로 설계할 여지가 컸다.

⚠️ 경계: 우리 구성은 클라이언트 사이드 i18n이다. 만약 서버 컴포넌트에서 번역이 필요한 SSR 시나리오라면 next-intl쪽 접근이 더 맞을 수 있다. 우리는 그게 필요 없어서 택하지 않았을 뿐이다.


🗂️ 구조 1: namespace(:)와 depth(.)를 나눈 이유

번역이 커지면 파일 하나에 다 몰아넣을 수 없다. 그래서 파일 = namespace로 도메인을 나눴다.

javascript
src/locales/
 ├─ ko/
 │   ├─ common.json
 │   ├─ dashboard.json          → namespace "dashboard"
 │   ├─ profile.json
 │   └─ ...
 └─ en/
     └─ (동일한 파일 구조)

키를 부를 때는 두 종류의 구분자를 쓴다.

typescript
t("dashboard:title")             // : 앞은 namespace(파일)
t("profile:account.title")       // . 은 키의 depth(중첩)
  • 콜론(:) — namespace 구분. 어느 파일에서 찾을지 결정한다.
  • 점(.) — 번역 JSON 내부의 중첩 depth. 자유롭게 깊어질 수 있다.

이 분리가 주는 것:

  • ✅ 도메인별 파일 분리로 키 충돌 방지 (common:title과 profile:title이 공존)
  • ✅ 담당 도메인만 열어보면 되니 협업 시 파일 소유권이 명확
  • ✅ 언어·도메인이 늘어도 구조가 흐트러지지 않음

실제 i18n.ts에서 이 namespace들을 등록한다.

typescript
i18n
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    resources,                    // { ko: {...}, en: {...} }
    defaultNS: "common",
    ns: ["common", "dashboard", "profile", "settings"],
    fallbackLng: "ko",            // 번역 없으면 한국어로
    supportedLngs: ["ko", "en"],
    detection: {
      order: ["localStorage", "navigator"],   // 언어 감지 우선순위
      caches: ["localStorage"],
    },
    load: "languageOnly",
    interpolation: { escapeValue: false },     // React가 이미 XSS 방어
  });

🧩 구조 2: 스키마 기반 타입화 — 이 설계의 핵심

여기가 이 구조에서 가장 공들인 부분이다. 앞서 말한 오타 문제를 근본적으로 없애기 위해, 번역 키를 컴파일 타임에 검증하도록 만들었다.

왜 필요했나

t()의 인자는 그냥 string이다. 즉 아래 코드는 타입 에러 없이 통과한다.

typescript
t("dashboard:titel")   // 오타지만 string이라 컴파일은 통과 → 런타임에 빈 화면

이걸 막으려면 t()가 받을 수 있는 키를 "실제로 존재하는 키의 유니온"으로 좁혀야 한다. 문제는 그 유니온을 손으로 유지할 수 없다는 것. 번역이 바뀔 때마다 타입도 같이 틀어지기 때문이다.

그래서 파이프라인을 만들었다.

💡 핵심 아이디어: 번역 구조의 "진실의 원천(source of truth)"을 JSON Schema로 두고, 거기서 타입을 자동 생성한다. 사람이 타입을 손으로 쓰지 않는다.

전체 데이터 흐름

javascript
번역 JSON (ko/en/*.json) ──┐
                            ├──→ i18n.ts (resources) ──→ I18nProvider ──→ useTypedTranslation().t()
스키마 (*.schema.json) ─────┘              │                                           ▲
      │                                   ▼                                          │
      │  generate-i18n-types.ts (json-schema-to-typescript)                          │
      ▼                                                                              │
  types/*.d.ts ──→ i18n-keys.ts (Leaves + WithNamespace) ──→ TTranslationKey ────────┘

하나씩 뜯어보자.

① 스키마가 진실의 원천

각 번역 파일마다 짝이 되는 JSON Schema가 있다.

json
// src/locales/schemas/dashboard.schema.json
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["title"],
  "additionalProperties": false,
  "properties": {
    "title": { "type": "string" }
  }
}

additionalProperties: false가 중요하다. 스키마에 없는 키를 JSON에 넣으면 검증에서 걸린다. 스키마와 번역 JSON의 구조가 어긋나는 것 자체를 막는 장치다.

② 스키마 → 타입 자동 생성

scripts/generate-i18n-types.ts가 schemas/*.schema.json을 순회하며 json-schema-to-typescript의 compileFromFile로 .d.ts를 뽑아낸다.

typescript
import { compileFromFile } from "json-schema-to-typescript";

const files = readdirSync(SCHEMA_DIR).filter((f) => f.endsWith(".schema.json"));
for (const file of files) {
  const ts = await compileFromFile(schemaPath, { bannerComment: "" });
  writeFileSync(outPath, ts);   // types/dashboard.d.ts
}

결과물:

typescript
// src/locales/types/dashboard.d.ts (자동 생성)
export interface Dashboard {
  title: string;
}

⚠️ 생성된 .d.ts는 산출물이라 Git에 커밋하지 않는다. 소스는 어디까지나 스키마다. (뒤에서 다룰 build gate가 이걸 항상 최신으로 유지해준다.)

③ 타입에서 키 유니온 유도 — 타입 레벨 프로그래밍

생성된 인터페이스는 { title: string } 같은 객체 모양이다. 하지만 t()가 원하는 건 "dashboard:title" 같은 문자열 키다. 이 변환을 타입 레벨에서 해내는 게 i18n-keys.ts다.

typescript
// 중첩 객체에서 leaf 키를 점(.)으로 이어붙여 유니온으로 추출
type Leaves<T, D extends number = 5> =
  [D] extends [never]
    ? never
    : T extends object
      ? {
          [K in keyof T]: T[K] extends object
            ? JoinDot<K & string, Leaves<T[K], Prev[D]>>   // 객체면 재귀
            : K & string;                                   // primitive면 leaf
        }[keyof T]
      : never;

// namespace를 앞에 붙여 최종 키 조립
type WithNamespace<NS extends string, T> = `${NS}:${LeafKeys<T>}`;

export type TTranslationKey =
  | WithNamespace<"dashboard", Dashboard>   // "dashboard:title"
  | WithNamespace<"profile", Profile>
  | ...;

Leaves<T>는 { contact: { email: string } } 같은 구조를 "contact.email"로 펼친다. 그리고 WithNamespace가 "dashboard:contact.email"로 완성한다.

💡 Prev = [never, 0, 1, 2, 3, 4, 5] 이 배열이 재귀 깊이 카운터다. Prev[5]는 4, Prev[4]는 3... Prev[0]은 never. TypeScript는 재귀 타입이 너무 깊어지면 컴파일이 터지는데, 이 트릭으로 depth 5에서 안전하게 멈춘다. 타입 레벨 재귀를 쓸 때 꼭 필요한 안전장치다.

④ typed 훅으로 t() 조이기

마지막으로 t()의 인자를 TTranslationKey로 좁히는 얇은 래퍼를 만든다.

typescript
// useTypedTranslation.ts
export function useTypedTranslation() {
  const { t, ...rest } = useTranslation();
  return {
    t: (key: TTranslationKey) => t(key),   // 인자 타입을 유니온으로 제약
    ...rest,
  };
}

이제 컴포넌트에서:

typescript
const { t } = useTypedTranslation();

t("profile:title")     // ✅ OK
t("profile:titel")     // ❌ 컴파일 에러 — 존재하지 않는 키

오타는 이제 빌드 전에 빨간 줄로 잡힌다.


🔗 구조 3: glob 패턴으로 언어 확장 자동화

에디터에서 번역 JSON을 편집할 때 스키마 검증이 실시간으로 걸리게 하려면 VS Code에 스키마를 연결해야 한다. 이때 언어별 경로를 일일이 나열하는 대신 와일드카드를 썼다.

json
// .vscode/settings.json
"json.schemas": [
  {
    "fileMatch": ["src/locales/*/dashboard.json"],   // * = 언어 폴더
    "url": "./src/locales/schemas/dashboard.schema.json"
  }
]

src/locales/*/dashboard.json의 *가 언어 폴더 자리다. 그래서 fr 언어를 추가해 src/locales/fr/dashboard.json을 만들면 설정을 건드리지 않아도 같은 스키마에 자동 매칭된다. 에디터가 즉시 검증해준다.


⚙️ 구조 4: build gate — 타입을 항상 최신으로

생성된 타입이 오래되면 검증이 무의미해진다. 그래서 타입 생성을 빌드 파이프라인의 선행 단계로 강제했다.

json
// package.json
"scripts": {
  "dev":   "pnpm i18n:types && next dev",
  "build": "pnpm i18n:types && next build",
  "i18n:types": "ts-node --project tsconfig.scripts.json scripts/generate-i18n-types.ts"
}

dev도 build도 pnpm i18n:types &&로 시작한다. 즉 개발 서버를 켜거나 빌드하는 순간 타입이 자동 재생성된다. 개발자가 "타입 생성하는 걸 깜빡"할 여지 자체를 없앤 것이다.


📝 실전: 번역을 추가·수정하는 워크플로우

번역 속성 추가하기

  1. 번역 JSON 수정 — 모든 언어 파일에 동일 구조로.
json
// ko/dashboard.json
{
  "title": "대시보드",
  "contact": {
    "email": "이메일",
    "phone": "전화"
  }
}
  1. 스키마도 반드시 같이 수정 — 구조가 어긋나면 검증·타입 생성에서 터진다.
json
{
  "type": "object",
  "additionalProperties": false,
  "required": ["title"],
  "properties": {
    "title": { "type": "string" },
    "contact": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "email": { "type": "string" },
        "phone": { "type": "string" }
      }
    }
  }
}
  1. pnpm dev 또는 pnpm build 재실행 — 타입이 재생성되어 t("dashboard:contact.email")이 타입으로 인식된다.

새 언어 추가하기 (예: 프랑스어)

  1. src/locales/fr/ 폴더에 기존과 동일한 파일 구조로 번역 JSON 작성
  2. i18n.ts의 resources와 supportedLngs에 fr 등록
  3. VS Code 스키마 연결·타입 스키마는 손댈 필요 없음 — glob과 스키마 재사용 덕분

⚠️ 가장 자주 하는 실수: JSON만 고치고 스키마를 안 고치는 것. 이 구조에서 스키마는 옵션이 아니라 진실의 원천이다. JSON ↔ schema는 항상 짝으로 움직여야 한다.


✅ 정리하며

내가 이 구조에서 얻고 싶었던 건 딱 하나 — "번역 키 오타를 사람이 아니라 컴파일러가 잡게 하는 것" 이었다. 그걸 위해 쌓은 레이어를 요약하면:

  • 파일 = namespace, :는 namespace, .은 depth → 도메인 분리와 키 충돌 방지
  • JSON Schema = 진실의 원천 → 사람이 타입을 손으로 쓰지 않는다
  • json-schema-to-typescript → .d.ts 자동 생성 → 산출물이라 커밋 안 함
  • Leaves + WithNamespace 타입 유도 → 객체 스키마를 "ns:key" 유니온으로 변환, Prev[D]로 재귀 안전장치
  • useTypedTranslation → t()를 유니온으로 조여 컴파일 타임 검증 (단, 보간 타입은 포기)
  • glob(fileMatch) → 언어 확장 시 설정 불필요
  • i18n:types && build gate → 타입 최신성 자동 보장

문자열 키 기반 i18n의 고질적인 취약점을 타입 시스템으로 메우는 방식이다. 처음 파이프라인을 짜는 비용은 있지만, 그 뒤로는 번역이 아무리 늘어도 오타가 프로덕션까지 새어나가지 않는다. 다국어를 진지하게 오래 끌고 갈 프로젝트라면 충분히 남는 장사였다.