스키마와 TypeScript 타입의 양방향 동기화
핵심 요약: 타입과 스키마는 각자 한 구간만 덮습니다. 하나는 컴파일 타임, 하나는 런타임. 질문은 어느 쪽을 고르느냐가 아니라 두 개를 자동으로 동기화하는 방법입니다.

핵심부터 말하면, 먼저 단일 진실 원천을 하나 고르세요. 백엔드가 계약을 소유하면 스키마 우선, 프런트엔드가 소유하면 zod식 타입 우선입니다. 그런 다음 다른 사본을 생성하고 CI에서 diff하세요. 생성기가 표현할 수 없는 의미(브랜디드 타입 등)만 손으로 쓰세요.
두 정의를 모두 손으로 쓰면 반드시 어긋납니다. 어긋남의 값은 "컴파일은 통과하고 런타임에서 500을 반환"입니다.
1. 사본을 하나만 유지하면 안 되는 이유
| 능력 | TypeScript 타입 | JSON Schema |
|---|---|---|
| 컴파일 타임 검사 | ✓ | ✗ |
| 런타임 검증 | ✗ (타입은 지워짐) | ✓ |
| 언어 간 사용 | ✗ | ✓ (Java/Go/Python이 모두 읽을 수 있음) |
| IDE 완성과 리팩터링 | ✓ | 그럭저럭 |
| 문서 생성 | 그럭저럭 | ✓ (OpenAPI 생태계) |
API의 입력은 네트워크에서 오며 항상 unknown이므로 런타임 검증을 생략할 수 없고, 업무 코드를 쓰면서도 타입 완성은 필요합니다.
현명한 접근은 하나를 진실 원천으로 고르고 다른 하나를 생성하는 것입니다.
2. 경로 A: 스키마 우선
적합: 백엔드 주도 작업, 언어 간 사용, 공개 계약.
pnpm add -D json-schema-to-typescript
import { compileFromFile } from 'json-schema-to-typescript';
import { writeFileSync } from 'node:fs';
/**
* JSON Schema에서 TypeScript 타입 정의를 생성합니다.
* @param schemaPath 스키마 파일 경로
* @param outputPath 출력 파일 경로
* @returns 생성이 끝나면 해결되는 Promise
*/
async function generateTypes(schemaPath: string, outputPath: string): Promise<void> {
const typeText = await compileFromFile(schemaPath, {
bannerComment: '/* Auto-generated from the schema. Do not edit by hand. */',
additionalProperties: false,
style: { singleQuote: true },
});
writeFileSync(outputPath, typeText, 'utf8');
}
출력의 관례 몇 가지는 알아 둘 만합니다:
required: ["id"]→id: string;required에 없는 키 →id?: string"type": ["string", "null"]→string | nullenum→ 리터럴 타입 유니언,string보다 훨씬 정밀additionalProperties: false가 인덱스 시그니처 생성 여부를 결정
빌드 없는 선택지도 있습니다. json-schema-to-ts는 타입 수준에서 직접 추론합니다:
import type { FromSchema } from 'json-schema-to-ts';
const userSchema = {
type: 'object',
properties: { id: { type: 'string' }, age: { type: 'integer' } },
required: ['id'],
additionalProperties: false,
} as const;
type User = FromSchema<typeof userSchema>;
// { id: string; age?: number }
장점은 생성 단계가 없고 타입이 항상 스키마와 동기화된다는 것입니다. 단점은 스키마를 as const로 써야 하고, 복잡한 $ref 시나리오는 컴파일이 느립니다.
3. 경로 B: 타입 우선
적합: zod / valibot 같은 라이브러리와 함께 프런트엔드 주도 작업, "스키마가 곧 타입".
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
export const userSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(64),
age: z.number().int().min(0).max(150).optional(),
tags: z.array(z.string()).default([]),
});
export type User = z.infer<typeof userSchema>;
/** The contract published to other languages */
export const userJsonSchema = zodToJsonSchema(userSchema, {
target: 'jsonSchema2020-12',
$refStrategy: 'none',
});
여기서 zod가 "타입 정의"와 "런타임 검증" 두 역할을 모두 하고, JSON Schema는 단지 내보내는 산출물입니다. 전체 노력이 가장 적지만 두 가지를 기억하세요:
z.infer와 생성된 JSON Schema는default,transform,refine에서 정확히 대응하지 않습니다.- 생성된 스키마가
$ref와definitions를 포함할 수 있으므로, 발행 전에 대상 명세와 맞는지 확인하세요.
4. 역방향도 점검이 필요
어느 경로를 고르든 CI 점검을 더하세요. 생성 산출물이 저장소의 파일과 일치해야 합니다.
# Generate into a temp directory and compare; fail CI on any difference
node scripts/generate-types.mjs --out .tmp/types.d.ts
diff -u src/types/api.d.ts .tmp/types.d.ts
이 단계가 없으면 3개월 뒤에는 api.d.ts가 최신인지 아무도 모릅니다.
5. 빈도 높은 네 가지 함정
5.1 optional과 nullable은 다름
type A = { name?: string }; // the key may be absent
type B = { name: string | null }; // the key must exist, but the value may be null
스키마로는:
{ "properties": { "name": { "type": "string" } } } // optional
{ "required": ["name"], "properties": { "name": { "type": ["string", "null"] } } } // nullable
생성기는 두 경우를 완전히 다르게 처리합니다. 계약이 어느 쪽인지 명시해야 합니다. 그렇지 않으면 프런트엔드의 data.name?.length가 null에서 깨집니다.
5.2 열거의 진화
type Status = 'pending' | 'paid' | 'refunded';
백엔드가 'cancelled'를 더했는데 프런트엔드 타입을 갱신하지 않으면 코드가 default 분기로 빠집니다.
열거형 필드는 알 수 없는 값을 명시적으로 처리해야 합니다:
function normalizeStatus(raw: string): Status | 'unknown' {
return raw === 'pending' || raw === 'paid' || raw === 'refunded' ? raw : 'unknown';
}
5.3 날짜와 돈에는 단순히 string을 쓰면 안 됨
스키마에서 format: "date-time"은 주석일 뿐이고, TypeScript에서는 그냥 string입니다.
더 강한 보장이 필요하면 "브랜디드 타입"을 쓰세요:
type IsoDateTime = string & { readonly __brand: 'IsoDateTime' };
/**
* 문자열을 ISO 타임스탬프로 표시합니다.
* @param value 형식이 이미 검증된 문자열
* @returns 브랜디드 타임스탬프 문자열
*/
function asIsoDateTime(value: string): IsoDateTime {
return value as IsoDateTime;
}
5.4 큰 정수
type: "integer"는 값이 2^53을 넘으면 number와 동등하지 않습니다. 계약에는 이렇게 쓰세요:
{ "type": "string", "pattern": "^[0-9]+$", "description": "Order number; exceeds 2^53, must be a string" }
6. 권장 도입 프로세스
- 계약 정의: 요청/응답 구조를 스키마(또는 zod)로 먼저 씁니다.
- 타입 생성: CI에서
.d.ts를 생성하고 저장소에 커밋해 차이를 쉽게 리뷰합니다. - 검증기 생성: 같은 스키마를 Ajv 검증 함수로 컴파일하고 서버 진입점마다 호출합니다.
- 문서 생성: 스키마에서 Markdown이나 OpenAPI를 내보내 문서가 손으로 낡지 않게 합니다.
- 회귀: API 픽스처를 "타입 검사"와 "스키마 검증" 둘 다 통과시킵니다. 둘 다 통과해야 성공입니다.
7. 체크리스트
- [ ] 진실 원천이 정확히 하나이고, 다른 사본은 스크립트로 생성됩니다
- [ ] 생성 스크립트가 CI에서 실행되고, 산출물 차이가 있으면 빌드가 실패합니다
- [ ]
optional과nullable을 계약에서 명시적으로 구분합니다 - [ ] 열거 필드에 알 수 없는 값의 폴백이 있습니다
- [ ] 큰 정수, 돈, 시간에 스키마와 타입 모두 명시적 관례가 있습니다
- [ ] 생성 타입 파일에 "손으로 편집하지 말 것" 헤더가 있습니다
FAQ
스키마 우선과 타입 우선 중 어떻게 결정하나?
백엔드가 계약을 소유하거나 언어 간 사용이 필요하거나 계약을 공개한다면 스키마 우선을 고르고 json-schema-to-typescript로 타입을 생성하세요. 프런트엔드가 주도하며 타입과 검증을 한 번에 정의하고 싶다면 zod식 "스키마가 곧 타입" 라이브러리를 고른 뒤 JSON Schema를 내보내세요. 어느 경로든 생성 산출물과 저장소 파일을 비교하는 CI 단계가 필요합니다.
손으로 쓴 타입과 스키마 정의가 왜 항상 어긋나나?
타입은 컴파일 타임에 지워지고 스키마는 런타임에서만 효력을 갖는데, 둘을 다른 사람이 다른 시점에 편집합니다. 생성과 비교가 없으면 한 사본을 갱신하는 동안 다른 사본은 멈춰 있게 되어 "컴파일은 통과하고 런타임에서 500"이 생깁니다.
생성 타입 파일을 저장소에 커밋해야 하나?
예. 커밋하면 리뷰어가 계약 변경을 볼 수 있고, CI가 새 생성 출력을 저장소 파일과 비교해 차이가 있으면 실패할 수 있습니다. 산출물을 로컬 폴더나 빌드 캐시에만 두면 3개월 뒤 최신 여부를 아무도 모릅니다.