JSON Toolkit

스키마와 TypeScript 타입의 양방향 동기화

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

중급약 11분 소요2026-10-30 발행2026-10-30 업데이트
스키마와 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 | null
  • enum → 리터럴 타입 유니언, 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. 권장 도입 프로세스

  1. 계약 정의: 요청/응답 구조를 스키마(또는 zod)로 먼저 씁니다.
  2. 타입 생성: CI에서 .d.ts를 생성하고 저장소에 커밋해 차이를 쉽게 리뷰합니다.
  3. 검증기 생성: 같은 스키마를 Ajv 검증 함수로 컴파일하고 서버 진입점마다 호출합니다.
  4. 문서 생성: 스키마에서 Markdown이나 OpenAPI를 내보내 문서가 손으로 낡지 않게 합니다.
  5. 회귀: API 픽스처를 "타입 검사"와 "스키마 검증" 둘 다 통과시킵니다. 둘 다 통과해야 성공입니다.

7. 체크리스트

  • [ ] 진실 원천이 정확히 하나이고, 다른 사본은 스크립트로 생성됩니다
  • [ ] 생성 스크립트가 CI에서 실행되고, 산출물 차이가 있으면 빌드가 실패합니다
  • [ ] optional과 nullable을 계약에서 명시적으로 구분합니다
  • [ ] 열거 필드에 알 수 없는 값의 폴백이 있습니다
  • [ ] 큰 정수, 돈, 시간에 스키마와 타입 모두 명시적 관례가 있습니다
  • [ ] 생성 타입 파일에 "손으로 편집하지 말 것" 헤더가 있습니다

FAQ

스키마 우선과 타입 우선 중 어떻게 결정하나?

백엔드가 계약을 소유하거나 언어 간 사용이 필요하거나 계약을 공개한다면 스키마 우선을 고르고 json-schema-to-typescript로 타입을 생성하세요. 프런트엔드가 주도하며 타입과 검증을 한 번에 정의하고 싶다면 zod식 "스키마가 곧 타입" 라이브러리를 고른 뒤 JSON Schema를 내보내세요. 어느 경로든 생성 산출물과 저장소 파일을 비교하는 CI 단계가 필요합니다.

손으로 쓴 타입과 스키마 정의가 왜 항상 어긋나나?

타입은 컴파일 타임에 지워지고 스키마는 런타임에서만 효력을 갖는데, 둘을 다른 사람이 다른 시점에 편집합니다. 생성과 비교가 없으면 한 사본을 갱신하는 동안 다른 사본은 멈춰 있게 되어 "컴파일은 통과하고 런타임에서 500"이 생깁니다.

생성 타입 파일을 저장소에 커밋해야 하나?

예. 커밋하면 리뷰어가 계약 변경을 볼 수 있고, CI가 새 생성 출력을 저장소 파일과 비교해 차이가 있으면 실패할 수 있습니다. 산출물을 로컬 폴더나 빌드 캐시에만 두면 3개월 뒤 최신 여부를 아무도 모릅니다.