마지막 업데이트

TypeScript로 JSON Schema 구현: 타입 추론, 테스트 자동화, RFC 표준 적용

최근 몇 년간 TypeScript 생태계에서는 데이터 유효성 검증을 위한 다양한 라이브러리들이 각자의 특색을 가지고 발전해왔습니다.

대표적으로 Zod는 선언적 API를 제공하고, AjvJSON Schema 초안을 충실히 따르며, class-validatorDecorator 기반의 유효성 검사를 지원합니다.

JSON Schema는 유연한 스키마 정의 방식과 확장 가능한 구조 덕분에 다양한 환경에서 활용될 수 있습니다. 여러 라이브러리를 검토하던 중 이러한 장점이 인상 깊었고, 더 깊이 활용해 보고 싶었습니다.

Ajv 사용을 먼저 고려했으나, TypeScript 환경에서의 타입 추론 한계를 발견했습니다. 결국, JSON Schema를 깊이 이해하는 가장 좋은 방법은 직접 구현하는 것이라 판단하여 구현을 결정하게 되었습니다.

배경

Ajv는 TypeScript 생태계에서 가장 널리 사용되는 라이브러리지만, 다음과 같은 타입 추론의 한계가 있었습니다.

import Ajv from 'ajv';

const ajv = new Ajv();

const validate = ajv.compile({
  type: 'object',
  // ❌ 여기서 무언가 입력을 시작해도 `object` 타입에 유효한 키워드를 추론하지 않습니다.
});

const data = {
  foo: 1,
  bar: 'hello',
}

// ❌ validate()의 반환값이 boolean이므로, TypeScript는 타입 안전성을 보장할 수 없습니다.
if (validate(data)) {
  data.foo // ❌ `foo` 속성에 접근할 수 없습니다.
}

물론, 이 문제는 Ajv Utility Types를 통해 해결할 수 있지만,

Ajv 팀에서는 TypeScript 타입 정의를 위한 유틸리티 타입을 제공합니다.

이는 스키마와 타입 정의를 이중으로 관리해야 하는 새로운 문제를 야기합니다. 이상적으로는, 스키마 정의 자체가 타입 정보를 제공할 수 있어야 한다고 판단했습니다.

접근 방식

이러한 문제를 해결하기 위해 두 가지 접근 방식을 고민했습니다.

  1. 기존 라이브러리를 확장하여 타입 추론 기능을 개선하는 방법
  2. 처음부터 타입 추론에 최적화된 새로운 구현체를 개발하는 방법

여러 가능성을 검토한 끝에, 새로운 구현체를 만드는 방법을 선택했습니다.

처음부터 직접 구현하면 타입 시스템을 근본적으로 설계 할 수 있고, JSON Schema의 동작 원리를 더 깊이 이해하는 데 도움이 됩니다. 또한, 더 나은 개발자 경험을 제공할 수 있다고 판단했기 때문입니다.

주요 과제

새로운 라이브러리를 개발하며 여러 기술적 도전에 직면했습니다. 특히, JSON Schema의 타입 추론을 개선하고, 테스트 자동화를 구축하며, RFC 표준을 반영하는 과정에서 다음과 같은 과제를 해결해야 했습니다.

  1. 테스트 자동화 – JSON Schema의 방대한 테스트 케이스를 체계적으로 검증하는 방법
  2. Format 키워드 구현 – RFC 표준을 준수하면서 확장 가능한 형식 검증 시스템 구축
  3. 타입 추론 구현 – JSON Schema의 복잡한 구조를 정확하게 반영하는 타입 시스템 설계

이 과정에서 어떤 문제를 마주했고, 어떻게 해결했는지 하나씩 살펴보겠습니다.

테스트 자동화

JSON Schema 팀에서는 JSON-Schema-Test-Suite 저장소를 통해 다양한 JSON Schema 테스트 케이스를 제공하고 있었습니다.

이 테스트 케이스를 활용하면 구현체의 정확성을 검증하는 데 큰 도움이 되었지만, 이를 효율적으로 관리하고 실행하는 과정에서 몇 가지 어려움에 부딪혔습니다.

특히, 테스트를 자동화하고 일관된 방식으로 실행할 수 있도록 만드는 것이 목표였습니다.

초기 설계 방향

처음에는 다음과 같은 방식으로 테스트 자동화를 시도했습니다.

  1. 저장소를 동기화하여 최신 테스트 케이스를 가져온다.
  2. 디렉토리 구조를 분석하고 버전별 테스트 파일을 수집한다.
  3. TS Compiler API를 활용해 각 테스트 케이스에 대한 타입 정의를 생성한다.
  4. 생성된 타입을 활용해 자동화된 테스트 함수를 작성하고 실행한다.

TypeScript 팀에서는 TypeScript 컴파일러의 내부 기능을 프로그래밍 방식으로 제어할 수 있는 API를 제공합니다.

구현 과정

우선, 저장소 동기화를 자동화하기 위해 CLI 스크립트와 필요한 유틸리티를 직접 작성했습니다.

이를 통해 최신 테스트 케이스를 빠르고 안정적으로 로컬 환경으로 가져올 수 있도록 했습니다.

#!/usr/bin/env node

// 저장소 동기화
await gitFetch({
  org: "json-schema-org",
  repo: "JSON-Schema-Test-Suite",
});

// 타입 정의 생성
await writeTsFile(createInterface("Vocabulary", vocabulary));
await writeTsFile(createInterface("Alias", alias));
await writeTsFile(createType("Version", Object.keys(vocabulary)));

다음으로, 테스트 실행을 쉽게 관리할 수 있도록 TestCaseManager 클래스를 설계했습니다.

export class TestCaseManager<T extends keyof Vocabulary = keyof Vocabulary> {
  constructor(public readonly version: T) {}

  load = memoize(
    async <K extends Vocabulary[T]>(
    keyword: K,
    options?: { skip?: string[] },
    ) => { ... },
  );
}

// 최신 버전 테스트를 위한 인스턴스
export const latestTestCase = new TestCaseManager("latest");

이러한 준비 작업을 마친 후, 실제 테스트 코드에서는 TestCaseManager를 활용해 JSON Schema 검증을 자동화할 수 있었습니다.

import { expect, test } from "vitest";
import { Schema } from "../../schema.js";
import {
  TestCaseManager,
  latestTestCase,
} from "../../utils/test-case-manager.js";

test.concurrent.for(await latestTestCase.load("additionalProperties"))(
  TestCaseManager.format,
  (testCase) => {
    const schema = new Schema(testCase.schema);
    expect(schema.validate(testCase.data)).toBe(testCase.expected);
  },
);

문제점 분석 및 개선사항

처음에는 이 방식이 효과적이라고 생각했지만, 몇 가지 중요한 문제를 발견하게 되었습니다.

  1. 성능 문제 – 모든 테스트 파일을 동적으로 읽어들이는 방식은 실행 속도가 현저히 느려질 수밖에 없었습니다.
  2. 유지보수 복잡성 – 자동화 도구를 만들었지만, 정작 그 도구를 유지보수하는 데 더 많은 시간이 들게 되는 역설적인 상황이 벌어졌습니다.

이 문제를 해결하기 위해 기존 도구를 재활용한 테스트 파일 자동 생성 CLI를 개발했습니다.

하지만 개발을 마친 후, JSON Schema 팀이 이미 Bowtie라는 공식 CLI 테스팅 도구를 제공하고 있다는 사실을 알게 되었습니다.

이미 해결하고자 했던 문제들을 효과적으로 다루고 있는 도구였지만, 도구 개발은 끝난 상태였습니다.
다음 프로젝트에서는 꼭 먼저 기존 도구들을 더 꼼꼼히 찾아보기로 했습니다.

JSON Schema 팀은 Bowtie를 통해 구현체의 호환성을 검증하고 테스트를 자동화합니다. 구현체가 테스트를 통과하면 웹사이트에 Report와 Badge를 표시할 수 있어, 사용자들이 해당 라이브러리의 JSON Schema 표준 준수 여부를 쉽게 확인할 수 있습니다.

Format 키워드 구현

JSON Schema의 Format 키워드는 다양한 RFC 표준을 기반으로 한 검증 기능을 제공합니다. 예를 들어, date-time이나 uri와 같은 특정 문자열 형식을 검증할 수 있습니다.

저는 이 기능이 단순히 동일한 라이브러리에 포함되기에는 그 범위가 너무 넓다고 판단했습니다.

따라서, 이 기능을 별도의 라이브러리로 분리하고, 개발자에게 친숙한 네이티브 JSON APIDate API와 유사한 인터페이스를 제공하고자 했습니다.

import { FullTime } from "@imhonglu/format";

// RFC 표준에 따른 시간 문자열 파싱
const time = FullTime.parse("15:59:60.123-08:00", {
  year: 1998,
  month: 12,
  day: 31,
});
// 결과:
// {
//   hour: 15,        // 시간
//   minute: 59,      // 분
//   second: 60,      // 초 (윤초 고려)
//   secfrac: ".123", // 소수점 이하
//   offset: {        // 시간대 오프셋
//     sign: "-",     // 부호
//     hour: 8,       // 시간
//     minute: 0      // 분
//   }
// }

// 표준 형식으로 문자열 변환
console.log(FullTime.stringify(time));
// '15:59:60.123-08:00'

// JSON 직렬화 지원
console.log(JSON.stringify(time));
// '"15:59:60.123-08:00"'

// 객체 내부에서도 자동 직렬화
console.log(
  JSON.stringify({
    name: "John",
    createdAt: time,  // FullTime 인스턴스가 자동으로 문자열로 변환됨
  })
)
// '{"name":"John","createdAt":"15:59:60.123-08:00"}'

직렬화를 위한 Decorator 설계

우선, 반복적으로 사용되는 Formatter를 쉽게 정의할 수 있도록 Serializable Decorator를 먼저 구현했습니다.

Formatter는 라이브러리 내부에서 이메일 주소나 URL 같은 특정 형식의 문자열을 검증하고 처리하는 모듈을 의미합니다. 각 형식은 독립적인 구성 요소로 구현되었습니다.

import {
  type Fn,
  type SafeResult,
  createSafeExecutor,
} from "@imhonglu/toolkit";

export function Serializable<
  T extends Fn.Newable & {
    parse: Fn.Callable<{ return: InstanceType<T> }>;
    safeParse: Fn.Callable<{ return: SafeResult<InstanceType<T>> }>;
    stringify: Fn.Callable<{ args: [InstanceType<T>]; return: string }>;
  },
>(targetClass: T) { ... }

Decorator를 사용한 이유는 두 가지였습니다.

첫번째는 Generic을 활용해 Abstract Implement Class처럼 동작하게 만들 수 있다는 점입니다. 이는 TypeScript의 관련 이슈에서 논의된 것처럼, Decorator를 통해 제약 조건을 강제할 수 있습니다.

TypeScript에서는 추상 클래스의 구현을 강제하는 메커니즘이 제한적이라는 이슈가 있습니다. Decorator를 활용하면 필수 메서드의 구현을 강제할 수 있어, 추상 클래스의 한계를 보완할 수 있습니다.

@Serializable
// ^^^^^^^^^^
// ❌ Generic 타입 제약으로 인해 `parse`, `stringify`, `safeParse`가 
// 구현되지 않은 경우 컴파일 오류 발생
class MyClass { }

두번째는 첫 번째 조건을 만족하면서 특정 메서드(toString, toJSON, safeParse 등)를 자동으로 구현한다는 점입니다.

@Serializable
class MyClass {
  public static parse() { ... }
  public static stringify() { ... }
  public static safeParse: SafeExecutor<typeof MyClass.parse>;
}

MyClass.safeParse(...); // ✅ 자동으로 구현된 `safeParse` 메서드 호출

이러한 Decorator를 활용하면 타입 안전성을 보장하면서도 반복적인 코드를 줄일 수 있습니다.

ABNF 기반 정규식 최적화

RFC 문서에서 제공하는 ABNF 문법을 정규식으로 변환하는 과정에서 코드의 재사용성과 디버깅이 어려운 문제를 겪었습니다. 특히, RFC 3986uri 구현에서 이런 한계가 두드러졌죠.

ABNF(Augmented Backus-Naur Form)는 RFC 5234에서 정의된 문법 표기법으로, 프로토콜이나 형식을 명확하게 기술하기 위한 표준입니다.

예를 들어, 다음과 같은 정규식은 특정 문자가 누락되거나 업데이트될 경우 어디에서 문제가 발생했는지 파악하기가 매우 어려웠습니다.

const userinfo = /[a-zA-Z0-9\-._~!$&'()*+,;=:]+/;

이 문제를 해결하기 위해 Builder Pattern을 적용한 @imhonglu/pattern-builder 라이브러리를 개발했습니다. 이를 활용하면 ABNF 문법을 보다 직관적이고 유지보수하기 쉬운 코드로 변환할 수 있습니다.

우선, 가장 기본이 되는 패턴들을 정의합니다.

import { characterSet, concat, hexDigit } from "@imhonglu/pattern-builder";

export const unreserved = characterSet(alpha, digit, /[\-._~]/);
export const pctEncoded = concat("%", hexDigit.clone().exact(2));
export const subDelims = characterSet(/[!$&'()*+,;=]/);

이렇게 정의된 패턴을 조합해 더 복잡한 규칙을 만들 수 있습니다.

export const pchar = oneOf(
  pctEncoded,
  characterSet(unreserved, subDelims, /[:@]/),
);

그리고 최종적으로 URI 경로 패턴을 완성합니다.

const slash = characterSet("/").optional();

const path = concat(
  concat(slash, pchar.clone().nonCapturingGroup().oneOrMore())
    .nonCapturingGroup()
    .zeroOrMore(),
  // 선택적 종료 슬래시
  slash,
)
  .anchor()
  .toRegExp();

패턴을 단계적으로 정의하는 방식 덕분에,

  • ABNF 규칙의 의도와 역할을 더 명확하게 표현할 수 있습니다.
  • 각각의 패턴을 독립적으로 수정하고 재사용할 수 있습니다.
  • 특정 부분만 따로 테스트하고 디버깅할 수 있습니다.

이러한 패턴 빌더 접근 방식을 통해 코드의 가독성과 유지보수성이 크게 향상되었고, 이는 @imhonglu/format 라이브러리의 핵심 기능을 구현하는 데 중요한 기반이 되었습니다.

타입 추론 구현

앞서 살펴본 Format 키워드 구현과 테스트 자동화를 통해 라이브러리의 기반을 다졌습니다. 하지만 TypeScript 환경에서 가장 중요한 것은 타입 안전성입니다.

이를 위해 JSON Schema의 타입 추론을 효과적으로 구현하는 것이 마지막 과제였습니다.

단순히 type 키워드를 기준으로 추론하는 것이 아니라, 스키마의 구조적 특성을 반영한 확장 가능한 타입 시스템이 필요했습니다.

타입 시스템의 구조화

우선, type 키워드를 Discriminated Union 형태로 정의하여 가능한 유형을 미리 좁혀두었습니다. 이를 통해 TypeScript의 타입 분석 기능을 활용하여 자동으로 타입을 추론할 수 있도록 했습니다. (Control Flow Analysis)

하지만 JSON Schema는 단순한 type 속성만으로 정의되지 않습니다. 검증을 위한 다양한 키워드들이 존재하며, 이를 체계적으로 관리할 필요가 있었습니다.

따라서, JSON Schema에서 사용되는 주요 키워드를 재사용 가능한 인터페이스로 분리하여, 각 타입별로 필요한 속성을 명확하게 정의하는 방식을 채택했습니다.

이를 문서를 기준으로 크게 여섯 가지 항목으로 정리하였습니다.

  1. BasicMetaData - title, description, default 등의 메타데이터 속성
  2. StructuralValidation - 숫자, 문자열, 배열 등의 구조적 검증을 위한 속성
  3. StringEncodedData - contentEncoding, contentMediaType 등 문자열 데이터 관련 속성
  4. Format - date-time, email, uri 등의 형식 검증
  5. ApplyingSubSchema - $ref, allOf, oneOf, anyOf 등 서브 스키마 관련 속성
  6. UnevaluatedLocations - unevaluatedProperties, unevaluatedItems 등 추가 속성 정의

이제 StructuralValidation을 예시로 살펴보겠습니다.

export namespace StructuralValidation {
  /**
   * @see {@link https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.2 | Numeric}
   */
  export interface Numeric {
    multipleOf?: number;
    maximum?: number;
    exclusiveMaximum?: number;
    minimum?: number;
    exclusiveMinimum?: number;
  }

  /**
   * @see {@link https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#section-6.3 | String}
   */
  export interface String {
    maxLength?: number;
    minLength?: number;
    pattern?: string;
  }
  // ... 생략

이렇게 각 속성을 독립적인 인터페이스로 나누면, 필요한 속성만 조합하여 원하는 스키마 타입을 구성할 수 있습니다.

JSON Schema의 타입 정의

위에서 분리한 구조를 바탕으로, 최상위 JSON Schema 타입을 정의할 수 있습니다.

/**
 * @see {@link https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-01#section-4.3.1 | ObjectSchema}
 */
export interface ObjectSchema
  extends Core<JsonSchema>,
    BasicMetaData,
    StructuralValidation.All,
    StringEncodedData<JsonSchema>,
    Format,
    ApplyingSubSchema.All<JsonSchema>,
    UnevaluatedLocations.All<JsonSchema> {}

/**
 * @see {@link https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-01#section-4.3.2 | BooleanSchema}
 */
export type BooleanSchema = boolean;

export type JsonSchema = ObjectSchema | BooleanSchema;

BooleanSchema는 type: "boolean" 속성과는 다른 개념입니다. true는 모든 값을 허용하고, false는 모든 값을 거부하는 단순한 스키마로, 데이터의 존재 여부만을 검증합니다.

이제 JSON Schema의 핵심적인 타입 구조를 하나로 통합할 수 있습니다.

그리고 이를 확장하면 보다 구체적인 타입을 정의할 수 있습니다.

export namespace SchemaDefinition {
  // ... 생략

  export interface NumericType
    extends Core<Type>,
      BasicMetaData,
      Pick<StructuralValidation.Any, "type">,
      StructuralValidation.Numeric {
    type: "number" | "integer";
  }

  export type Type =
    | BooleanSchema
    | ConstType
    | EnumType
    | NullType
    | BooleanType
    | ObjectType
    | ArrayType
    | StringType
    | NumericType
    | Schema;
}

타입 추론 적용

위에서 정의한 타입을 Schema 클래스에 Generic으로 적용함으로써, JSON Schema의 구조를 정적으로 추론할 수 있게 되었습니다.

export class Schema<T extends SchemaDefinition.Type = SchemaDefinition.Type> {
  constructor(public schema: T) {}
  // ... 생략
}
// Schema 인스턴스 생성 예시
const schema = new Schema({
  type: "object",
  // ✅ TypeScript가 'object' 타입에 허용되는 키워드만 자동 추론
  // 예: properties, required, additionalProperties 등
  properties: {
    name: { 
      type: "string",
      // ✅ TypeScript가 'string' 타입에 허용되는 키워드만 자동 추론
      // 예: maxLength, minLength, pattern 등
      maxLength: 10,
    },
  },
});

이를 통해 스키마를 정의할 때, TypeScript의 타입 검사를 적극적으로 활용하여 개발자의 실수를 줄이고, 안전한 데이터 검증을 수행할 수 있습니다.

소개

지금까지 설명한 여러 과제들을 해결하며 개발한 라이브러리의 실제 동작을 살펴보겠습니다.

아래 데모는 타입 추론이 어떻게 작동하는지 보여줍니다.

../../../../public/blog/post-1/demo.gif

특징

  • JSON Schema 2020-12 Draft 사양을 준수합니다.
  • 스키마를 정의할 때 알맞는 키워드를 정적으로 추론합니다.
  • 결정된 스키마의 타입을 정적으로 추론합니다.
  • 중첩된 Schema Instance에 대한 재귀적 타입 추론을 지원합니다.
  • required 키워드에 따른 타입 추론이 가능합니다.
  • parse, stringify 메서드를 제공하여 스키마를 쉽게 변환하고 활용할 수 있습니다.
  • JSON-Schema-test-suite를 기반으로 검증되었습니다.

JSON-Schema-test-suite를 활용한 테스트 자동화와 RFC 사양 기반의 format 키워드 구현에는 예상보다 더 많은 시간이 필요했습니다.

계획보다 개발 기간이 길어졌지만, 이 과정에서 얻은 경험이 프로젝트의 완성도를 한층 더 높이는 계기가 되었습니다.

계획

현재 기본적인 기능은 구현되었지만, 아직 개선의 여지가 많이 있습니다. 앞으로 다음과 같은 부분들을 중점적으로 발전시켜 나갈 계획입니다.

  1. 사용자 정의 기능
    • 유효성 검사 메시지 커스터마이징
    • 사용자 정의 에러 핸들링
  2. 개발자 경험 개선
    • 더 자세한 에러 메시지와 디버깅 정보 제공
    • 문서화 개선
  3. 성능 최적화
    • 스키마 컴파일 과정 최적화
    • 메모리 사용량 개선

참여하기

이 프로젝트는 아직 발전 중이며, 커뮤니티의 피드백과 기여를 환영합니다. 관심 있으신 분들은 저장소를 방문해 주세요.

여러분의 의견과 제안이 이 라이브러리를 더욱 발전시키는 원동력이 될 것입니다. 감사합니다!