@imhonglu/json-schema: 타입 추론 개선하기
TypeScript에서 JSON Schema를 다룰 때, 타입 추론이 얼마나 정확하게 동작하는지가 개발 경험과 유지보수성에 큰 영향을 미칩니다. 최근 직접 개발한 @imhonglu/json-schema 라이브러리를 프로젝트에 적용하면서, 기대했던 것만큼 타입 추론이 정밀하게 동작하지 않는 문제를 발견했습니다.
특히, 다음과 같은 문제들이 눈에 띄었습니다.
- 배열 형태의
type
키워드 지원 부족 →["string", "number", "null"]
같은 복합 타입을 지정하면 타입 추론이 깨짐 - 불필요한 검증 문제 → 이미 신뢰할 수 있는 데이터에서도 유효성 검사가 반복 수행됨
default
값에 동적 기본값 설정 불가 → UUID나 현재 시간처럼 실행 시점에서 결정되는 값을 기본값으로 지정할 수 없음
이 문제들을 해결하기 위해 타입 시스템을 전반적으로 개선하고, 타입 추론의 정확성을 높이는 방향으로 최적화를 진행했습니다.
개선 목표
이번 개선의 주요 목표는 다음과 같습니다.
- 스키마 타입 구조 개선
const
,enum
,type
키워드 간의 타입 구조를 정리하여 일관성을 높임
- 배열 형태의
type
키워드 지원- 복합 타입(
["string", "number"]
)도 올바르게 처리하여 정확한 타입 추론 보장
- 복합 타입(
- 클래스 기반 스키마 검증 및 최적화
instanceof
연산자를 지원하여 런타임에서 객체 유형을 안전하게 확인 가능
- 동적 기본값 지원
default
키워드에 함수 사용을 허용하여 실행 시점에서 기본값을 동적으로 설정 가능
문제점
배열 형태의 type
키워드 지원 부족
기존 방식에서는 배열 형태의 type
키워드를 처리하지 못해, 타입 추론이 깨지고 잘못된 키워드가 허용되는 문제가 있었습니다.
단일 타입의 경우는 문제가 없지만,
const stringSchema = new Schema({
type: "string",
maxLength: 10, // ✅ string 타입에 유효한 키워드
minLength: 1, // ✅ string 타입에 유효한 키워드
default: "hello", // ✅ string 타입으로 올바르게 추론됨
});
복합 타입의 경우 문제가 발생합니다.
const unionSchema = new Schema({
type: ["string", "number", "null"],
// ❌ 타입 추론 실패: object 타입 키워드가 잘못 허용됨
maxProperties: 1, // object 타입 전용 키워드
required: ["field"], // object 타입 전용 키워드
// ❌ 타입 추론 실패: 잘못된 타입의 기본값이 허용됨
default: {} // object 타입이 허용되면 안 됨
});
불필요한 데이터 검증 문제
내부 API나 이미 검증된 데이터에서도 모든 유효성 검사가 반복적으로 수행되면서 불필요한 성능 저하가 발생했습니다.
특히, 대량의 데이터를 다룰 때 이러한 과도한 검증 과정이 불필요한 연산 비용을 초래하고, 전체적인 처리 속도를 저하시킬 가능성이 높았습니다.
// Person 스키마 정의
const PersonSchema = new Schema({
type: "object",
properties: {
name: { type: "string" },
age: { type: "number" },
email: {
type: "string",
format: "email"
}
},
required: ["name", "email"]
});
// ❌ 문제점: 이미 검증된 신뢰할 수 있는 데이터소스임에도 매번 유효성 검사가 수행됨
const data = fetch(...).then((res) => PersonSchema.parse(res.text()));
default
값에 동적 기본값 설정 불가
JSON Schema 사양에서는 default
키워드에 정적인 값만 지정할 수 있습니다. 하지만 실제 서비스에서는 현재 시간, UUID, 환경 변수 등 실행 시점에 동적으로 결정되는 값이 필요한 경우가 많습니다.
그러나 기존 버전에서는 이러한 동적 기본값을 설정할 방법이 없었습니다.
const schema = new Schema({
type: "string",
format: "date-time",
// ❌ 문제: `default` 값을 함수로 지정할 수 없음
default: () => new Date().toISOString(),
});
이 제약으로 인해 다음과 같은 경우, 기존 방식으로는 적절한 기본값을 설정하기 어려웠습니다.
- 생성 시간을 자동으로 기록해야 하는 문서 스키마
- 고유 식별자가 필요한 엔티티 스키마 (
UUID
등) - 사용자의 현재 시간대를 반영해야 하는 설정 값
- 환경 변수나 외부 설정을 기반으로 동적 기본값을 지정해야 하는 경우
해결 과정
가장 먼저 해결해야 할 과제는 타입 구조를 개선하는 것이었습니다.
기존 방식에서는 Discriminated Union 패턴을 활용해 타입을 추론했지만, 배열 형태의 type
키워드를 올바르게 처리하지 못하는 문제가 있었습니다.
이 문제를 해결하기 위해 타입 시스템을 전면적으로 재설계하고, SchemaInput
과 InferSchema<T>
타입을 도입하여 복합 타입도 정확하게 추론할 수 있도록 개선했습니다.
스키마 타입 구조 개선
먼저 해결해야 할 부분은 const
, enum
, type
키워드 간의 관계를 명확히 정의하는 것이었습니다.
이 키워드들은 각각 역할이 다르며, 함께 사용할 경우 의미가 모호해질 수 있습니다.
const
: 단일 고정값을 정의- 예:
{ const: "ADMIN" }
- 특정 값만 허용하며, 가장 제한적인 형태
- 예:
enum
: 허용된 값의 집합을 정의- 예:
{ enum: ["ADMIN", "USER", "GUEST"] }
- 선택 가능한 여러 값 중 하나만 허용
- 예:
type
: 데이터의 기본 타입을 정의- 예:
{ type: "string" }
- 보다 유연한 타입을 제공
- 예:
그러나 이 키워드들이 함께 사용될 경우, 서로 충돌하면서 스키마의 의미가 불분명해질 수 있습니다.
const schema = new Schema({
type: "string", // 문자열 타입 명시
const: "hello", // 'hello' 값으로 고정
enum: ["hello"], // ['hello'] 중 선택 가능
// ❌ 세 키워드가 서로 충돌하며, 실제로는 const만으로 충분
});
TypeScript 타입 시스템 분석
이 문제를 더 깊이 이해하기 위해 Generic
을 활용해 실험을 진행해 보았습니다.
interface ConstSchema {
const: string;
}
interface EnumSchema {
enum: string[];
}
interface TypeSchema {
type: string | string[];
}
type SchemaInput = ConstSchema | EnumSchema | TypeSchema;
function createSchema<const T extends SchemaInput>(schema: T) {
return schema;
}
이 구조에서는 const
, enum
, type
이 서로 배타적이어야 하지만, 여전히 하나의 스키마에서 동시에 사용될 수 있는 문제가 발생했습니다.
Generic
을 통해 확장할 경우 각 속성이 상호 배타적인지를 보장하지 않기 때문입니다.
즉,const
,enum
,type
이 함께 존재하는 경우에도 오류가 발생하지 않습니다.
예를 들어, 아래 코드가 문제없이 동작합니다.
const schema = createSchema({
const: "hello",
type: "string",
enum: ["hello", "world"],
// ❌ TypeScript에서 오류가 발생하지 않음
});
이 문제를 해결하기 위해, 각 키워드가 사용될 때 다른 키워드는 자동으로 제외되는 타입 구조를 설계했습니다.
interface ConstSchema {
const: string;
enum?: never;
type?: never;
}
interface EnumSchema {
enum: string[];
const?: never;
type?: never;
}
interface TypeSchema {
type: string | string[];
const?: never;
enum?: never;
}
// ... 생략
이제 다음과 같은 조합을 사용하면 TypeScript에서 오류가 발생합니다.
const schema = createSchema({
const: "hello",
type: "string", // ❌ TypeScript 에러 발생
enum: ["hello", "world"], // ❌ TypeScript 에러 발생
});
JSON Schema 사양에서는 원래 const
, enum
, type
을 함께 사용할 수 있도록 허용하지만,
다음과 같은 이유로 이러한 조합을 의도적으로 제한했습니다.
- 타입 안정성 보장
const
,enum
,type
이 동시에 사용되면 의미가 불명확해지고, 예상치 못한 동작을 초래할 수 있음
- 스키마의 명확성 확보
- 하나의 값(
const
), 여러 개의 값 중 하나(enum
), 데이터 타입 지정(type
)은 서로 다른 개념 - 명확한 의도를 가진 스키마 작성을 유도
- 하나의 값(
이제 const
, enum
, type
을 함께 사용하려고 하면 타입 오류가 발생하며, 잘못된 조합을 방지할 수 있습니다.
type
키워드의 복합 타입 지원
두 번째로, 복합 타입을 정확하게 처리하기 위해 타입 시스템을 세밀하게 구현해야 했습니다.
첫 단계로, 주어진 Tuple 타입을 매핑하는 유틸리티 타입을 만들었습니다.
// 매칭되는 타입을 반환하는 유틸리티
export type Match<T, Matcher> = T extends readonly [infer First, ...infer Rest]
? First extends keyof Matcher
? Matcher[First] | Match<Rest, Matcher>
: never
: T extends keyof Matcher
? Matcher[T]
: never;
타입 매핑을 위한 인터페이스 정의
앞서 정의한 Match<T, Matcher>
를 활용하기 위해, 타입별 매칭 정보를 저장하는 인터페이스를 생성합니다.
// 타입 매핑 인터페이스
export interface InferSchemaMap<T> {
number: StructuralValidation.Numeric;
integer: StructuralValidation.Numeric;
// ... 생략
}
스키마 타입을 추론하는 핵심 유틸리티 구현
다음으로, 입력된 스키마(SchemaInput
)를 기반으로 전체 구조를 추론하는 타입을 구현합니다.
const
,enum
,type
키워드를 개별적으로 처리합니다.default
키워드는BasicMetaData
를 활용하여 추론합니다.type
키워드가 배열이면Match<T, Matcher>
를 사용하여 적절한 타입을 매칭합니다.- 최종적으로
Schema
인스턴스를 처리하여 정확한 타입을 반환합니다.
BasicMetaData 타입은 JSON Schema에서 정의하는 title, description, default 등의 메타데이터를 포함하는 타입입니다.
export type InferSchema<T> = Omit<T, keyof BasicMetaData> extends {
// `ConstSchema` 추론
const: infer U;
}
? T & BasicMetaData<U>
// `EnumSchema` 추론
: T extends { enum: infer U }
? T & BasicMetaData<ArrayElement<U>>
// `TypeSchema` 추론
: T extends { type: infer U }
? Omit<T, Exclude<keyof TypeSchema, "type">> &
Match<U, InferSchemaMap<T>> &
// `BasicMetaData` 에서 제공하는 `default` 키워드 타입을 제외하고 추론
Omit<BasicMetaData<InferSchemaType<T>>, "default"> & {
// `default` 키워드에 함수 타입을 허용하여 동적 기본값 지원
default?:
| InferSchemaType<T>
| Fn.Callable<{ return: InferSchemaType<T> }>;
}
// `Schema` Instance 추론
: T extends Schema<infer U>
? T
: SchemaInput;
클래스 기반 스키마 검증 및 최적화
마지막으로, 스키마 검증 기능을 그대로 유지하면서도, 클래스 기반 설계의 장점 중 하나인 instanceof
연산자를 활용할 수 있도록 createSchemaClass
함수를 설계하여 동적으로 Schema
클래스를 생성하고 확장할 수 있는 구조를 구현했습니다.
특히, Proxy
를 활용하여 Schema
의 기능을 자연스럽게 상속받도록 하여, 기존의 Schema
인스턴스를 활용하면서도 확장성을 극대화할 수 있도록 했습니다.
이를 위해 다음과 같은 단계로 createSchemaClass
함수를 구현했습니다.
SchemaInput
과InferSchema<T>
타입을 활용하여 함수 타입을 정의합니다.- 주어진
schemaDefinition
을 기반으로Schema
의 인스턴스를 생성합니다. - 익명 클래스를 생성하여
Schema
의 기능을 상속받는SchemaBasedClass
를 구현합니다. - 마지막으로,
Proxy
를 활용하여Schema
의 기능을SchemaBasedClass
와 자연스럽게 결합합니다.
이 방식은 기존 Schema
의 강력한 검증 기능을 유지하면서도, 유연한 확장을 실현할 수 있다는 점에서 큰 장점을 가집니다.
// 1. `SchemaInput`과 `InferSchema<T>` 타입을 활용하여 함수 타입을 정의합니다.
export function createSchemaClass<const T extends SchemaInput>(
schemaDefinition: InferSchema<T>,
) {
// 2. 주어진 `schemaDefinition`을 기반으로 `Schema` 인스턴스를 생성합니다.
const schemaContext = new Schema(schemaDefinition);
// 3. 익명 클래스를 생성하여 `Schema`의 기능을 상속받는 `SchemaBasedClass`를 구현합니다.
const SchemaBasedClass = class {
static [SchemaSymbol] = schemaInstance[SchemaSymbol];
data: InferSchemaType<T>;
// ... 추가적인 기능 정의 가능
};
// 4. 마지막으로, `Proxy`를 활용하여 `Schema` 인스턴스의 기능을 `SchemaBasedClass`와 결합합니다.
return new Proxy(SchemaBasedClass, {
get(target, prop) {
// 먼저 `SchemaBasedClass`에서 속성을 찾습니다.
return prop in target
? target[prop as keyof typeof target]
// 찾지 못한 경우, `schemaContext`에서 찾습니다.
: prop in schemaContext
? schemaContext[prop as keyof typeof schemaContext]
// 찾지 못한 경우, `undefined`를 반환합니다.
: undefined;
},
}) as typeof SchemaBasedClass & typeof schemaInstance;
}
이러한 Proxy 패턴을 통해 다음과 같은 이점을 얻을 수 있습니다.
- 사용자는 Proxy의 존재를 인식하지 않고도 자연스럽게 기능을 사용할 수 있습니다.
- 기존
Schema
기능을 유지하면서 클래스 기반 설계의 장점인instanceof
연산자를 활용할 수 있습니다.
개선된 기능 정리
이번 개선을 통해 타입 안정성 강화, 성능 최적화, 확장성 개선 등 다양한 기능을 향상시켰습니다.
아래 표에서는 기존 문제와 이를 해결한 방식, 그리고 개선 효과를 한눈에 확인할 수 있습니다.
개선 항목 | 기존 문제 | 개선 효과 |
---|---|---|
const , enum , type 키워드 관계 정리 | 서로 혼용 가능하여 타입이 불명확함 | 배타적 관계로 설정하여 타입 안정성 강화, 예측 불가능한 오류 방지 |
배열 형태의 type 키워드 지원 | ["string", "number"] 같은 복합 타입을 지정하면 타입 추론이 깨짐 | 배열 내부 요소를 개별적으로 분석하여 정확한 타입 추론 보장 |
불필요한 유효성 검사 최적화 | 신뢰할 수 있는 데이터에서도 매번 유효성 검사가 수행됨 | 불필요한 검증을 제거하여 성능 최적화, 대량 데이터 처리 시 성능 개선 |
default 값의 동적 기본값 지원 | default 값에 정적 값만 지정 가능, UUID나 현재 시간 같은 동적 값 설정 불가 | default 키워드에 함수를 허용하여 실행 시점에서 기본값을 설정할 수 있도록 개선 |
클래스 기반 스키마 검증 및 확장성 강화 | 기존 방식으로는 instanceof 검사 불가능, 확장성이 제한적 | instanceof 지원 및 Proxy 활용으로 클래스 기반 확장성 강화, 런타임에서 객체 유형을 안전하게 확인 가능 |
마무리 및 향후 계획
이번 개선을 통해 JSON Schema의 복합 타입 추론 문제를 해결하고, 불필요한 검증을 줄이며, 동적 기본값 설정을 가능하게 하는 등 다양한 개선을 이뤄냈습니다.
하지만 여기서 멈추지 않고, 앞으로도 더욱 정교한 타입 추론과 성능 최적화를 위해 지속적으로 연구하고 개선해 나갈 계획입니다. 실제 프로젝트에서 계속해서 테스트하며, 실무에서 더욱 직관적이고 안정적인 JSON Schema 기반 타입 시스템을 제공할 수 있도록 노력하겠습니다.
이 글이 JSON Schema와 TypeScript 타입 활용에 도움을 줄 수 있기를 바랍니다.
더 나은 개선을 위해 여러분의 피드백을 적극 반영하겠습니다.
읽어주셔서 감사합니다.