FE에서 다국어(i18n)를 붙일 때 가장 자주 겪는 사고는 거창한 게 아니다. t("profile:titel") 처럼 키를 한 글자 틀리는 것이다. 문자열 키는 런타임에 화면이 비어 보이기 전까지 아무도 모른다.
이 글은 실무 프로젝트에서 다국어 처리를 담당하며 구성한 구조를 정리한 것이다. 목표는 하나였다 — 번역 키 오타를 빌드 전에 잡는 것. 그 과정에서 i18next 위에 JSON Schema → TypeScript 타입 자동 생성 파이프라인을 얹었고, 그 구현과 판단 배경을 공유한다.
💡 이 글은 라이브러리 사용법 나열이 아니라 **"왜 이렇게 설계했는가"**에 무게를 둔다. i18n이 처음이라면 앞부분(정의·선택 배경)부터, 이미 익숙하다면 「namespace 구조」부터 읽으면 된다.
i18n은 _internationalization_의 줄임말이다. i와 n 사이에 글자가 18개라서 i18n. 소프트웨어를 특정 언어·지역에 종속시키지 않고, 번역과 로케일을 나중에 갈아 끼울 수 있게 만드는 작업을 말한다.
핵심은 이거다. i18n을 한다는 건 코드 곳곳에 박힌 하드코딩 문자열을 키(key)로 바꾸는 것이다.
// ❌ 하드코딩 — 영어를 추가하려면 이 파일을 뜯어야 한다
<h1>고객센터</h1>
// ✅ 키 기반 — 언어는 리소스 파일이 결정한다
<h1>{t("dashboard:title")}</h1>코드는 "dashboard:title"이라는 주소만 안다. 실제로 "대시보드"를 보여줄지 "Dashboard"를 보여줄지는 현재 언어 설정과 번역 리소스가 결정한다. 코드와 문구가 분리되는 것, 이게 i18n의 전부다.
i18next는 자바스크립트 진영에서 사실상 표준으로 쓰이는 i18n 프레임워크다. 위에서 말한 "키 → 번역 문자열" 매핑을 실제로 처리해주는 엔진이라고 보면 된다. React에서는 react-i18next 바인딩과 함께 쓴다.
i18next가 해주는 일:
| 기능 | 설명 |
|---|---|
| 키 → 번역 조회 | t("key") 호출 시 현재 언어의 리소스에서 문구를 찾아 반환 |
| 언어 감지 | 브라우저 언어·localStorage 등에서 사용자 언어 자동 판별 |
| fallback | 번역이 없는 키는 지정한 기본 언어로 대체 |
| namespace | 번역을 도메인 단위로 쪼개 관리 |
| 보간(interpolation) | t("greeting", { name }) → "안녕하세요 {{name}}님" 처럼 값 삽입 |
💬 언제 쓰나? 지원 언어가 2개 이상이거나, 지금은 1개여도 나중에 언어가 늘어날 가능성이 있으면 처음부터 i18next를 얹는 게 이득이다. 문구가 코드 전역에 하드코딩된 뒤에 걷어내는 것은 훨씬 비싼 작업이기 때문이다.
Next.js 생태계에는 next-intl, next-i18next 같은 대안도 있다. 우리가 순수 i18next + react-i18next 조합을 택한 이유는:
i18next-browser-languagedetector) 같은 주변 도구가 잘 갖춰져 있다.⚠️ 경계: 우리 구성은 클라이언트 사이드 i18n이다. 만약 서버 컴포넌트에서 번역이 필요한 SSR 시나리오라면
next-intl쪽 접근이 더 맞을 수 있다. 우리는 그게 필요 없어서 택하지 않았을 뿐이다.
:)와 depth(.)를 나눈 이유번역이 커지면 파일 하나에 다 몰아넣을 수 없다. 그래서 파일 = namespace로 도메인을 나눴다.
src/locales/
├─ ko/
│ ├─ common.json
│ ├─ dashboard.json → namespace "dashboard"
│ ├─ profile.json
│ └─ ...
└─ en/
└─ (동일한 파일 구조)키를 부를 때는 두 종류의 구분자를 쓴다.
t("dashboard:title") // : 앞은 namespace(파일)
t("profile:account.title") // . 은 키의 depth(중첩):) — namespace 구분. 어느 파일에서 찾을지 결정한다..) — 번역 JSON 내부의 중첩 depth. 자유롭게 깊어질 수 있다.이 분리가 주는 것:
common:title과 profile:title이 공존)실제 i18n.ts에서 이 namespace들을 등록한다.
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 방어
});여기가 이 구조에서 가장 공들인 부분이다. 앞서 말한 오타 문제를 근본적으로 없애기 위해, 번역 키를 컴파일 타임에 검증하도록 만들었다.
t()의 인자는 그냥 string이다. 즉 아래 코드는 타입 에러 없이 통과한다.
t("dashboard:titel") // 오타지만 string이라 컴파일은 통과 → 런타임에 빈 화면이걸 막으려면 t()가 받을 수 있는 키를 "실제로 존재하는 키의 유니온"으로 좁혀야 한다. 문제는 그 유니온을 손으로 유지할 수 없다는 것. 번역이 바뀔 때마다 타입도 같이 틀어지기 때문이다.
그래서 파이프라인을 만들었다.
💡 핵심 아이디어: 번역 구조의 "진실의 원천(source of truth)"을 JSON Schema로 두고, 거기서 타입을 자동 생성한다. 사람이 타입을 손으로 쓰지 않는다.
번역 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가 있다.
// 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를 뽑아낸다.
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
}결과물:
// src/locales/types/dashboard.d.ts (자동 생성)
export interface Dashboard {
title: string;
}⚠️ 생성된
.d.ts는 산출물이라 Git에 커밋하지 않는다. 소스는 어디까지나 스키마다. (뒤에서 다룰 build gate가 이걸 항상 최신으로 유지해준다.)
생성된 인터페이스는 { title: string } 같은 객체 모양이다. 하지만 t()가 원하는 건 "dashboard:title" 같은 문자열 키다. 이 변환을 타입 레벨에서 해내는 게 i18n-keys.ts다.
// 중첩 객체에서 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에서 안전하게 멈춘다. 타입 레벨 재귀를 쓸 때 꼭 필요한 안전장치다.
마지막으로 t()의 인자를 TTranslationKey로 좁히는 얇은 래퍼를 만든다.
// useTypedTranslation.ts
export function useTypedTranslation() {
const { t, ...rest } = useTranslation();
return {
t: (key: TTranslationKey) => t(key), // 인자 타입을 유니온으로 제약
...rest,
};
}이제 컴포넌트에서:
const { t } = useTypedTranslation();
t("profile:title") // ✅ OK
t("profile:titel") // ❌ 컴파일 에러 — 존재하지 않는 키오타는 이제 빌드 전에 빨간 줄로 잡힌다.
에디터에서 번역 JSON을 편집할 때 스키마 검증이 실시간으로 걸리게 하려면 VS Code에 스키마를 연결해야 한다. 이때 언어별 경로를 일일이 나열하는 대신 와일드카드를 썼다.
// .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을 만들면 설정을 건드리지 않아도 같은 스키마에 자동 매칭된다. 에디터가 즉시 검증해준다.
생성된 타입이 오래되면 검증이 무의미해진다. 그래서 타입 생성을 빌드 파이프라인의 선행 단계로 강제했다.
// 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 &&로 시작한다. 즉 개발 서버를 켜거나 빌드하는 순간 타입이 자동 재생성된다. 개발자가 "타입 생성하는 걸 깜빡"할 여지 자체를 없앤 것이다.
// ko/dashboard.json
{
"title": "대시보드",
"contact": {
"email": "이메일",
"phone": "전화"
}
}{
"type": "object",
"additionalProperties": false,
"required": ["title"],
"properties": {
"title": { "type": "string" },
"contact": {
"type": "object",
"additionalProperties": false,
"properties": {
"email": { "type": "string" },
"phone": { "type": "string" }
}
}
}
}pnpm dev 또는 pnpm build 재실행 — 타입이 재생성되어 t("dashboard:contact.email")이 타입으로 인식된다.src/locales/fr/ 폴더에 기존과 동일한 파일 구조로 번역 JSON 작성i18n.ts의 resources와 supportedLngs에 fr 등록⚠️ 가장 자주 하는 실수: JSON만 고치고 스키마를 안 고치는 것. 이 구조에서 스키마는 옵션이 아니라 진실의 원천이다. JSON ↔ schema는 항상 짝으로 움직여야 한다.
내가 이 구조에서 얻고 싶었던 건 딱 하나 — "번역 키 오타를 사람이 아니라 컴파일러가 잡게 하는 것" 이었다. 그걸 위해 쌓은 레이어를 요약하면:
:는 namespace, .은 depth → 도메인 분리와 키 충돌 방지json-schema-to-typescript → .d.ts 자동 생성 → 산출물이라 커밋 안 함Leaves + WithNamespace 타입 유도 → 객체 스키마를 "ns:key" 유니온으로 변환, Prev[D]로 재귀 안전장치useTypedTranslation → t()를 유니온으로 조여 컴파일 타임 검증 (단, 보간 타입은 포기)fileMatch) → 언어 확장 시 설정 불필요i18n:types && build gate → 타입 최신성 자동 보장문자열 키 기반 i18n의 고질적인 취약점을 타입 시스템으로 메우는 방식이다. 처음 파이프라인을 짜는 비용은 있지만, 그 뒤로는 번역이 아무리 늘어도 오타가 프로덕션까지 새어나가지 않는다. 다국어를 진지하게 오래 끌고 갈 프로젝트라면 충분히 남는 장사였다.