NestJS standard-schema 기반 유효성 검사 오픈소스를 만들었어요

OpenSource 2025. 12. 2. 20:42
728x90
728x90

 

 

NestJS에서 최근 발행된 이슈를 트래킹하다가 아이디어를 얻어 유효성 검사 라이브러리를 하나 만들게 되었습니다.

간단하게 왜 개발하게 되었는지, 어떤 차별점들이 있는지 등을 소개하려고 합니다.

 

 

 

왜 만들었는지?

Javascript 진영에도 무수히 많은 Validation 라이브러리가 존재합니다.

Zod, Valibot, Joi, ArkType, Yup 등등 대표적인 것들만 해도 손에 꼽기 어려울 정도로 많습니다.

 

이미 커뮤니티에서 검증된 오픈소스들이 있음에도 불구하고, 아래와 같은 이유로 직접 만들게 되었습니다.

 

 

커뮤니티의 니즈

NestJS의 이슈를 트래킹하다가 최근 Validation을 다룬 이슈를 발견했습니다. 내용을 요약하자면, NestJS에서 공식적으로 zod를 패키징하기를 원했습니다. nestjs-zod라는 서드파티 라이브러리가 이미 존재했지만, 공식적으로 이관될지 별도의 패키지가 나올지 아무도 모릅니다. 불확실하죠. nestjs-zod 오픈소스에서 논의되었던 내용은 2년이 지난 지금 중단된 상태입니다.

 

항상 오픈소스 커뮤니티에서 원하는 것은 좋은 기능들과 더불어 꾸준히 관리되는 패키지라고 생각합니다.

 

 

TypeScript, NestJS를 떠나면서 남기는 결과물

저는 최근 이직을 했습니다. 새로운 환경에서 전혀 다른 스택들을 사용하기 때문에 잠시 TypeScript 진영을 떠나게 되었습니다. 물론 계속해서 관심을 가지고 있으나, 만 2년 TypeScript로, NestJS로 개발을 했기 때문에 2년 동안 개발자로서 얼마나 성숙하게 되었는지 확인해보고 싶었습니다.

 

 

항상 오픈소스를 만들고싶었습니다. 한국에서 TypeScript 진영의 오픈소스 거장(?)이신 삼촌님, 동윤님의 레포를 항상 구경하면서 언젠가 나만의 작은 오픈소스(?)를 만들고 싶었고, 아이디어가 없다는 핑계로 계속 미뤄왔었던 것 같아요.

 

이번 기회에 작은 니즈를 발견했고, 직접 해결해보면서 꾸준히 운영할 수 있는 오픈소스 운영자가 되어보기로 했습니다. 물론 커뮤니티의 선택을 받아야 하겠지만요

 

 

 

어떤 차별점이 있는지?

기존 NestJS의 방식

먼저 기존 방식을 짚어볼게요.

 

NestJS는 기본적으로 class-validator + class-transformer 조합과 통합되어 있습니다. ValidationPipe를 글로벌로 등록하면 DTO 클래스의 데코레이터를 읽어 자동으로 검증해줘요.

class CreateUserDto {
  @IsString()
  @MinLength(1)
  name: string;

  @IsEmail()
  email: string;
}

 

만약 Zod, Valibot으로 교체하고 싶다면 기존 cv/cf 외에 원하는 Validator을 설치해야해요.

 

NestJS의 Pipe는 내부적으로 cv/cf와 결합되어있기 때문에, 필요한 Pipe를 직접 구현해야하는 불편함도 있어요.

// zod를 이용한 Pipe
import { PipeTransform, BadRequestException } from '@nestjs/common';
import { ZodSchema, ZodError } from 'zod';

export class ZodValidationPipe implements PipeTransform {
  constructor(private schema: ZodSchema) {}

  transform(value: unknown) {
    const result = this.schema.safeParse(value);

    if (!result.success) {
      throw new BadRequestException({
        message: 'Validation failed',
        errors: result.error.errors.map(err => ({
          path: err.path,
          message: err.message,
        })),
      });
    }

    return result.data;
  }
}

@Post()
create(@Body(new ZodValidationPipe(CreateUserSchema)) body: CreateUserDto) {
  return body;
}

 

Zod가 아닌 다른 Validator로 교체하더라도 다른 Pipe를 구현해야하죠.

// valibot
import { PipeTransform, BadRequestException } from '@nestjs/common';
import { BaseSchema, safeParse } from 'valibot';

export class ValibotValidationPipe implements PipeTransform {
  constructor(private schema: BaseSchema) {}

  transform(value: unknown) {
    const result = safeParse(this.schema, value);

    if (!result.success) {
      throw new BadRequestException({
        message: 'Validation failed',
        errors: result.issues.map(issue => ({
          path: issue.path?.map(p => p.key),
          message: issue.message,
        })),
      });
    }

    return result.output;
  }
}

 

 

또한, OpenAPI - Swagger와의 통합도 직접 구현해야해요. NestJS는 @nestjs/swagger 패키지를 통해 class-validator 데코레이터 기반으로 자동으로 스키마를 생성하기 때문입니다.

class CreateUserDto {
  @ApiProperty()
  @IsString()
  name: string;

  @ApiProperty({ format: 'email' })
  @IsEmail()
  email: string;
}

 

Pipe 예시처럼 Zod를 예시로 들어볼게요. Zod를 사용하면 스키마와 Swagger 데코레이터를 따로 관리해야 합니다.

스키마와 DTO를 따로 관리하다보니 필드 추가/변경 시 두 곳 모두 수정이 필요해요. 유지보수 포인트가 늘어나게 되겠죠.

const CreateUserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
});

// Swagger용으로 별도 클래스 정의 필요
class CreateUserDto {
  @ApiProperty({ description: 'User name' })
  name: string;

  @ApiProperty({ format: 'email', description: 'User email' })
  email: string;
}

 

물론, 별도의 라이브러리인 zod-to-openapi 등을 사용할 수 있지만, 변환 로직을 별도로 구현해야해요.

import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';

extendZodWithOpenApi(z);

const CreateUserSchema = z.object({
  name: z.string().openapi({ description: 'User name' }),
  email: z.string().email().openapi({ format: 'email' }),
});

/**
 * (TODO): OpenAPI 문서 생성 로직 별도 구현
 */

 

마지막으로 각 Validator마다 다른 인터페이스에 대한 학습이 필요해요.

 

요약하자면 Validator을 변경하기 위해 NestJS와의 통합 레이어를 처음부터 다시 만들어야 할 수도 있어요.

 

 

해결하고자 한 것

저는, 위에서 설명한 이런 불편함들을 해소하고자 standard-schema 기반으로 NestJS 통합 Validator 레이어를 구현했어요.

standard-schema는 JavaScript/TypeScript validation 라이브러리들이 공통으로 구현하는 인터페이스 스펙으로
각 Validator마다 API가 다른 문제들을 해결하기 위한 공통 인터페이스를 정의한 라이브러리에요.
현재 Zod, Valibot, ArkType, TypeBox 등 23개 이상의 validator가 이 스펙을 구현하고 있어요.

 

import { StandardValidationPipe, createStandardDto } from '@mag123c/nestjs-stdschema';

// Zod
import { z } from 'zod';

const CreateUserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
});

// Valibot
import * as v from 'valibot';

const CreateUserSchema = v.object({
  name: v.string(),
  email: v.pipe(v.string(), v.email()),
});


// DTO 생성 (OpenAPI 메타데이터 자동 연동)
class CreateUserDto extends createStandardDto(CreateUserSchema) {}

// 사용 - Zod든 Valibot이든 동일한 Pipe
@Post()
create(@Body(new StandardValidationPipe(CreateUserSchema)) body: CreateUserDto) {
  return body;
}

 

만약 Validator을 변경하더라도, Pipe와 DTO 구조는 그대로 가져갈 수 있도록 래핑했습니다.

 

특정 벤더에 종속되지 않고, NestJS의 이슈에서 메인테이너가 제안했던 NestJS 아키텍처 패턴을 반영했다고 보시면 돼요. Pipe 기반 검증, OpenAPI의 통합, Response Serialization(Interceptor 응답 필터링)이 이에 해당해요.

 

 

트레이드오프 및 고려사항

장점만 있는건 당연히 아니겠죠. 도입 전 고려해야 할 점들이 있습니다.

 

 

자주 바꾸지 않는 Validator

우선 이 오픈소스 자체가 커뮤니티의 니즈이지, 제 니즈는 아니었어요. 풀어서 써보자면,

저는 하나의 프로젝트를 만들 때, Validator을 고르면 바꾼 적이 없어요. cv/cf, typia 등 하나의 Validator을 그대로 가져갔었어요.

그렇기 때문에 cv/cf 외에 다른 라이브러리를 선택해서 NestJS 아키텍처와 맞추기 위해 여러 커스터마이징 작업을 하더라도 한 번 구축하면 거의 손 볼 일이 없어요.

 

하나의 프로젝트에서 여러 Validator을 사용하거나, 다른 라이브러리로의 전환을 고려하고 있지 않다면 굳이 사용하지 않을 것 같아요.

 

 

기존 프로젝트 마이그레이션 비용

위의 연장선으로, NestJS를 사용하신다면 이미 cv(class-validator)로 구축된 프로젝트가 많을 것이라고 생각되는데요, DTO를 스키마 기반으로 전환해야해요.

// Before: class-validator
class CreateUserDto {
  @IsString()
  name: string;
}

// After: 스키마 기반
const CreateUserSchema = z.object({ name: z.string() });
class CreateUserDto extends createStandardDto(CreateUserSchema) {}

 

 

학습 곡선

cv의 데코레이터 방식에 익숙한 개발자라면, 스키마 기반 패턴이 낯설 수 있어요.

 

 

OpenAPI 자동 생성의 한계

Zod v4 이전 버전이나 다른 Validator을 사용한다면 OpenAPI 스키마를 직접 정의해야하는 불편함이 남아있어요.

// 수동 OpenAPI 메타데이터
class UserDto extends createStandardDto(ValibotSchema, {
  openapi: {
    name: { type: 'string', example: 'John' },
    email: { type: 'string', format: 'email' },
  },
}) {}

 

 

레퍼런스의 부재

아무래도 새로 만든 오픈소스다보니 레퍼런스가 부재해요.

저도 노력하고, 커뮤니티의 선택도 더러 받는다면 좋은 레퍼런스들이 많이 생기지 않을까 생각합니다.

 

 

 

마치며

커뮤니티의 작은 니즈에서 시작한 프로젝트로 비슷한 고민을 하시는 분들께 도움이 되길 바랍니다.

 

피드백이나 기여는 언제든 환영해요. 이를테면 ArkType, TypeBox등은 standard-schema 스펙을 구현하므로 이론적으로 호환되지만, 직접 테스트되지는 않았습니다.

 

https://github.com/mag123c/nestjs-stdschema

https://www.npmjs.com/package/@mag123c/nestjs-stdschema

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

방명록