NestJS 프로젝트에서 DTO 및 내부 로직 Validation 성능을 비교하기 위한 벤치마크 프로젝트.
두 가지 검증 접근 방식을 4단계 복잡도의 DTO에 대해 비교한다:
| 라이브러리 | 스키마 정의 방식 | 검증 코드 생성 시점 | Swagger 생성 방식 |
|---|---|---|---|
| Typia + Nestia | 순수 TypeScript 인터페이스 + Typia tags | 컴파일 타임 (ts-patch 트랜스폼) | @nestia/sdk swagger CLI |
| AJV + TypeBox | TypeBox 스키마 (JSON Schema 호환 값) | 런타임 (ajv.compile() 사전 수행) | @nestjs/swagger + 스키마 직접 전달 |
test-validator/
├── package.json
├── tsconfig.json # ts-patch + typia/nestia 트랜스폼 플러그인
├── nest-cli.json
├── nestia.config.ts # Nestia SDK swagger 생성 설정
├── src/
│ ├── main.ts # NestJS bootstrap + 이중 Swagger 설정
│ ├── app.module.ts
│ │
│ ├── shared/
│ │ ├── interfaces/ # Typia용 순수 TypeScript 인터페이스 (4단계)
│ │ │ ├── simple.interface.ts
│ │ │ ├── medium.interface.ts
│ │ │ ├── complex.interface.ts
│ │ │ └── very-complex.interface.ts
│ │ └── test-data/ # 각 레벨별 valid/invalid 테스트 데이터
│ │ ├── simple.data.ts
│ │ ├── medium.data.ts
│ │ ├── complex.data.ts
│ │ └── very-complex.data.ts
│ │
│ ├── typia-module/
│ │ ├── typia.module.ts
│ │ ├── typia.controller.ts # @TypedBody() + @TypedRoute 사용
│ │ └── validators/
│ │ └── typia-validators.ts # typia.is<T>() 래퍼
│ │
│ ├── typebox-module/
│ │ ├── typebox.module.ts
│ │ ├── typebox.controller.ts # @Body() + AJV 수동 검증
│ │ ├── schemas/ # TypeBox 스키마 정의 (4단계)
│ │ │ ├── simple.schema.ts
│ │ │ ├── medium.schema.ts
│ │ │ ├── complex.schema.ts
│ │ │ └── very-complex.schema.ts
│ │ └── validators/
│ │ └── ajv-validators.ts # AJV compile + validate
│ │
│ └── benchmark/
│ ├── benchmark.module.ts
│ ├── benchmark.controller.ts # GET /benchmark/run (HTTP 벤치마크)
│ ├── benchmark-runner.ts # 타이밍 수집 + 통계 계산
│ └── benchmark-reporter.ts # cli-table3 콘솔 테이블 출력
│
└── benchmark/
└── run-benchmark.ts # 독립 CLI 벤치마크 스크립트
가장 기본적인 DTO. 2개 필드, format + length 제약.
| 필드 | 타입 | 검증 규칙 |
|---|---|---|
email |
string |
format: "email" |
password |
string |
minLength: 8, maxLength: 64 |
검증 포인트: 이메일 포맷 정규식, 문자열 길이 범위
8개 필드. uuid, enum, nullable, optional, array, pattern, date-time 포함.
| 필드 | 타입 | 검증 규칙 |
|---|---|---|
id |
string |
format: "uuid" |
username |
string |
minLength: 3, maxLength: 30, pattern: ^[a-zA-Z0-9_]+$ |
email |
string |
format: "email" |
role |
enum |
"admin" | "user" | "moderator" | "guest" |
age |
number | null |
uint32, minimum: 13, maximum: 150, nullable |
tags |
string[] |
각 항목 minLength: 1, maxLength: 20, 배열 maxItems: 10 |
bio |
string? |
optional, maxLength: 500 |
createdAt |
string |
format: "date-time" |
검증 포인트: UUID 포맷, enum 멤버십, nullable union, optional 필드, 배열 요소 + 크기 제약, 정규식 패턴, ISO 8601 날짜
11개 필드. 중첩 객체 (Address, OrderItem[]), union 타입, 다중 format 검증.
| 필드 | 타입 | 검증 규칙 |
|---|---|---|
orderId |
string |
format: "uuid" |
customerId |
string |
format: "uuid" |
status |
enum |
"pending" | "confirmed" | "shipped" | "delivered" | "cancelled" |
items |
OrderItem[] |
minItems: 1, maxItems: 100 |
shippingAddress |
Address |
중첩 객체 (street, city, state, zipCode, country) |
billingAddress |
Address | null |
nullable 중첩 객체 |
paymentMethod |
enum |
"credit_card" | "paypal" | "bank_transfer" | "crypto" |
totalAmount |
number |
minimum: 0 |
currency |
string |
pattern: ^[A-Z]{3}$ |
notes |
string? |
optional, maxLength: 1000 |
orderedAt |
string |
format: "date-time" |
중첩 타입:
Address: street, city, state (minLength/maxLength), zipCode (US 우편번호 정규식), country (2자리 코드)OrderItem: productId (uuid), name, quantity (uint32, 1-9999), unitPrice (0 < x < 1000000), discount (0-100)
검증 포인트: 깊은 중첩 객체 검증, 배열 내 객체 각각 검증, nullable union 중첩 객체, 여러 format 동시 검증
20+ 필드. 재귀 타입 (FilterGroup), Record<string, T>, 4단계 이상 깊은 중첩, URI/geo 좌표 검증.
| 필드 | 타입 | 검증 규칙 |
|---|---|---|
metadata |
RequestMetadata |
requestId(uuid), timestamp(date-time), clientVersion(semver pattern), locale(pattern), timezone |
resource |
string |
pattern: ^[a-z][a-z0-9_]*(/[a-z][a-z0-9_]*)*$ |
action |
enum |
"read" | "create" | "update" | "delete" | "batch" |
priority |
enum |
"low" | "normal" | "high" | "critical" |
filters |
FilterGroup? |
재귀 타입 — groups?: FilterGroup[] |
sort |
SortField[]? |
minItems: 1, maxItems: 5 |
pagination |
Pagination? |
page(1-10000), pageSize(1-200) |
payload |
Record<string, unknown>? |
임의 key-value |
includes |
string[]? |
minItems: 1, maxItems: 20, 각 요소 minLength: 1, maxLength: 100 |
location |
GeoLocation? |
latitude(-90 |
callback |
CallbackConfig? |
url(format: "uri"), method(enum), headers(Record), retryCount(0-5) |
dryRun |
boolean |
required |
idempotencyKey |
string? |
format: "uuid" |
tags |
Record<string, string>? |
key-value 쌍 |
재귀 타입 구조 (FilterGroup):
FilterGroup
├── logic: "AND" | "OR"
├── conditions: FilterCondition[]
│ ├── field: string
│ ├── operator: "eq" | "ne" | "gt" | "gte" | "lt" | "lte" | "in" | "like"
│ └── value: string | number | boolean | (string | number)[]
└── groups?: FilterGroup[] ← 재귀 참조
└── (같은 구조 반복)
검증 포인트: 재귀 타입 검증, Record 타입 검증, 4단계 이상 깊은 중첩, URI 포맷, 지리 좌표 범위, semver 패턴, 복합 union 타입
TypeScript Interface + Typia Tags
│
▼ [ts-patch transform at compile time]
│
Optimized JavaScript validation code (inline, no schema parsing)
│
▼ [runtime]
│
typia.is<T>(data) → boolean (직접 타입 체크 코드 실행)
- 스키마 정의: 순수 TypeScript
interface+typia.tags(예:tags.Format<"email">) - 코드 생성:
ts-patch가 컴파일 시typia/lib/transform플러그인을 실행하여 각 타입에 대한 검증 코드를 인라인으로 생성 - 런타임 검증: 생성된 JavaScript 코드가 직접 실행됨 (스키마 파싱/해석 없음)
- Swagger:
@nestia/sdk swaggerCLI가 TypeScript 소스를 분석하여 OpenAPI 3.1 문서 생성
TypeBox Schema Definition (JSON Schema compatible)
│
▼ [ajv.compile() at startup]
│
Optimized validation function (JIT compiled from schema)
│
▼ [runtime]
│
validateFn(data) → boolean (스키마에서 생성된 함수 실행)
- 스키마 정의: TypeBox의
Type.Object(),Type.String()등으로 정의 → JSON Schema 객체 생성 - 컴파일: 서버 시작 시
ajv.compile(schema)로 스키마를 최적화된 검증 함수로 변환 - 런타임 검증: 컴파일된 검증 함수가 실행됨
- Swagger: TypeBox 스키마가 JSON Schema이므로
@nestjs/swagger의@ApiBody({ schema })에 직접 전달
4가지 복잡도 × 2가지 라이브러리 × 2가지 데이터(valid/invalid) = 16개 시나리오
| # | 복잡도 | 라이브러리 | 데이터 | 설명 |
|---|---|---|---|---|
| 1 | Simple | Typia | valid | 유효한 UserLogin → true 반환 |
| 2 | Simple | Typia | invalid | 잘못된 email/password → false 반환 |
| 3 | Simple | AJV | valid | 유효한 UserLogin → true 반환 |
| 4 | Simple | AJV | invalid | 잘못된 email/password → false 반환 |
| 5 | Medium | Typia | valid | 유효한 UserProfile → true 반환 |
| 6 | Medium | Typia | invalid | 잘못된 uuid/enum/age → false 반환 |
| 7 | Medium | AJV | valid | 유효한 UserProfile → true 반환 |
| 8 | Medium | AJV | invalid | 잘못된 uuid/enum/age → false 반환 |
| 9 | Complex | Typia | valid | 유효한 Order → true 반환 |
| 10 | Complex | Typia | invalid | 잘못된 중첩 객체/배열 → false 반환 |
| 11 | Complex | AJV | valid | 유효한 Order → true 반환 |
| 12 | Complex | AJV | invalid | 잘못된 중첩 객체/배열 → false 반환 |
| 13 | VeryComplex | Typia | valid | 유효한 ApiRequest → true 반환 |
| 14 | VeryComplex | Typia | invalid | 잘못된 재귀/Record/geo → false 반환 |
| 15 | VeryComplex | AJV | valid | 유효한 ApiRequest → true 반환 |
| 16 | VeryComplex | AJV | invalid | 잘못된 재귀/Record/geo → false 반환 |
| 항목 | 값 |
|---|---|
| Warmup | 5,000회 (JIT 최적화 보장) |
| 측정 | 50,000회 |
| 타이밍 | process.hrtime.bigint() (나노초 단위) |
| 통계 | avg, median, p95, p99, min, max, ops/sec |
| 비교 | ratio = AJV avg / Typia avg (>1이면 Typia가 빠름) |
- AJV:
ajv.compile(schema)를 서버 시작 시 사전 수행. 벤치마크에서는 컴파일된 함수만 호출 - Typia: ts-patch가 컴파일 타임에 코드 생성. 벤치마크에서는 생성된 코드만 실행
- GC 영향: warmup 5,000회로 JIT 컴파일 및 인라인 캐싱 안정화 후 측정
- 동일 데이터: 양쪽 모두 완전히 동일한 JavaScript 객체를 검증
| 경로 | 라이브러리 | 생성 방식 | 스키마 출처 |
|---|---|---|---|
/api/typia |
Typia (Nestia) | @nestia/sdk swagger CLI가 TypeScript 소스 분석 → swagger-typia.json 생성 |
TypeScript interface + Typia tags |
/api/typebox |
AJV (TypeBox) | @nestjs/swagger의 SwaggerModule.createDocument() |
TypeBox 스키마 → @ApiBody({ schema }) 직접 전달 |
- Typia Swagger에는 각 인터페이스가
components/schemas에 named 스키마로 등록됨 (예:$ref: "#/components/schemas/UserLogin") - TypeBox Swagger에는 각 엔드포인트의 requestBody에 인라인 JSON Schema가 포함됨
npm install # 의존성 설치 + ts-patch install + typia patch (prepare 스크립트)npm run build # nest build + nestia swagger 생성npm run benchmark # 16개 시나리오 실행, 콘솔 테이블 출력출력 예시:
╔══════════════════════════════════════════════════════╗
║ Typia (Nestia) vs AJV (TypeBox) Validation Benchmark ║
╚══════════════════════════════════════════════════════╝
Sanity Check:
✓ Typia Simple (valid): true (expected: true)
✓ AJV Simple (valid): true (expected: true)
...
=== Comparison Summary ===
┌───────────────┬──────────┬───────────────┬───────────────┬────────────┬──────────┐
│ Complexity │ Data │ Typia (avg) │ AJV (avg) │ Ratio │ Winner │
├───────────────┼──────────┼───────────────┼───────────────┼────────────┼──────────┤
│ Simple │ valid │ 76 ns │ 103 ns │ 1.36x │ Typia │
│ Medium │ valid │ 190 ns │ 484 ns │ 2.55x │ Typia │
│ Complex │ valid │ 143 ns │ 186 ns │ 1.30x │ Typia │
│ VeryComplex │ valid │ 655 ns │ 1.15 μs │ 1.75x │ Typia │
└───────────────┴──────────┴───────────────┴───────────────┴────────────┴──────────┘
npm run start- Typia Swagger UI: http://localhost:3000/api/typia
- TypeBox Swagger UI: http://localhost:3000/api/typebox
# 기본 설정 (warmup: 5000, iterations: 50000)
curl http://localhost:3000/benchmark/run
# 커스텀 설정
curl "http://localhost:3000/benchmark/run?iterations=10000&warmup=1000"# Typia - valid
curl -X POST http://localhost:3000/typia/simple \
-H 'Content-Type: application/json' \
-d '{"email":"user@example.com","password":"securepassword"}'
# TypeBox - valid
curl -X POST http://localhost:3000/typebox/simple \
-H 'Content-Type: application/json' \
-d '{"email":"user@example.com","password":"securepassword"}'
# Typia - invalid (400 에러 + 타입 에러 상세)
curl -X POST http://localhost:3000/typia/simple \
-H 'Content-Type: application/json' \
-d '{"email":"bad","password":"x"}'
# TypeBox - invalid (400 에러 + AJV 에러 상세)
curl -X POST http://localhost:3000/typebox/simple \
-H 'Content-Type: application/json' \
-d '{"email":"bad","password":"x"}'| 패키지 | 용도 |
|---|---|
typia |
컴파일 타임 TypeScript 타입 검증 코드 생성 |
@nestia/core |
NestJS용 Typia 통합 (@TypedBody, @TypedRoute) |
@nestia/sdk |
Typia 인터페이스에서 Swagger 문서 생성 CLI |
@sinclair/typebox |
TypeScript-first JSON Schema 빌더 |
ajv + ajv-formats |
JSON Schema 기반 런타임 검증 엔진 |
@nestjs/swagger |
NestJS Swagger 모듈 (TypeBox 엔드포인트용) |
ts-patch |
TypeScript 컴파일러 플러그인 시스템 (Typia 트랜스폼 실행) |
cli-table3 + chalk |
벤치마크 결과 콘솔 테이블 출력 |