NestJS 유효성 검사 동작원리 딥다이브 : 왜 interface 대신 class를 사용할까?

분석 · 2025. 6. 22.

Nestjs란?

NestJS는 TypeScript를 기반으로 강력하고 확장 가능한 서버 애플리케이션을 구축할 수 있게 해주는 Node.js 프레임워크다. 특히 Spring Framework와 유사한 의존성 주입(DI), 모듈 시스템 등은 대규모 프로젝트의 구조를 체계적으로 관리하는 데 큰 강점이 있다.

의존성 주입(DI)이란?

의존성 주입이란 클래스가 필요로 하는 의존성(다른 클래스의 인스턴스 등)을 내부에서 직접 생성하는 것이 아니라, 외부(NestJS 모듈)에서 주입받아 사용하는 디자인 패턴이다. 이를 통해 코드 간의 결합도를 낮추고 테스트 용이성과 재사용성을 높일 수 있다.

Pipe를 통한 유효성 검사

NestJS의 여러 기능 중, 파이프는 컨트롤러의 핸들러로 들어오는 요청 데이터를 처리하는 역할을 하며, 주로 유효성 검사(Validation)와 데이터 변환(Transformation)에 사용된다.

일반적으로 NestJS에서는 아래와 같이 classclass-validator 라이브러리의 데코레이터를 이용해 유효성 검사 로직을 구현한다.

//src/cats/dto/create-cat.dto.ts
//1. DTO (Data Transfer Object) 정의
import { IsInt, IsString } from 'class-validator';

export class CreateCatDto {
  @IsString()
  readonly name: string;

  @IsInt()
  readonly age: number;

  @IsString()
  readonly breed: string;
}

//src/cats/cats.controller.ts
//2. 컨트롤러에서 DTO 사용
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { Roles } from '../common/decorators/roles.decorator';
import { RolesGuard } from '../common/guards/roles.guard';
import { ParseIntPipe } from '../common/pipes/parse-int.pipe';
import { CatsService } from './cats.service';
import { CreateCatDto } from './dto/create-cat.dto';
import { Cat } from './interfaces/cat.interface';

@UseGuards(RolesGuard)
@Controller('cats')
export class CatsController {
  constructor(private readonly catsService: CatsService) {}

  @Post()
  @Roles(['admin'])
  async create(@Body() createCatDto: CreateCatDto) {
    this.catsService.create(createCatDto);
  }

  @Get()
  async findAll(): Promise<Cat[]> {
    return this.catsService.findAll();
  }

  @Get(':id')
  findOne(
    @Param('id', new ParseIntPipe())
    id: number,
  ) {
    // Retrieve a Cat instance by ID
    console.log(id);
  }
}

//src/main.ts
//3. 전역 ValidationPipe 설정
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());

  await app.listen(3000);
  console.log(`Application is running on: ${await app.getUrl()}`);
}
bootstrap();
{
  "message": [
    "age must be an integer number"
  ],
  "error": "Bad Request",
  "statusCode": 400
}

의문점

여기서 나는 한가지 의문이 들었다.

"어차피 타입스크립트를 쓰는데, class 대신 interfacetype으로 유효성 검사를 하면 안 될까?"

결론부터 말하면, interfacetype은 NestJS의 런타임 유효성 검사에 사용할 수 없다. 이 글에서는 그 이유를 타입스크립트의 동작 원리를 통해 알아보고, NestJS가 이 문제를 어떻게 해결하는지 살펴보겠다.

타입스크립트의 타입 소거(Type Erasure)

근본적인 이유는 타입스크립트가 자바스크립트로 컴파일되는 방식에 있다. .ts 파일은 브라우저나 Node.js 환경에서 실행 가능한 .js 파일로 변환되는 과정을 거친다.

이 과정에서 string, number, interface 등 타입스크립트의 모든 타입 관련 코드는 최종 결과물인 자바스크립트 파일에서 제거된다. 이를 타입 소거(Type Erasure)라고 한다.

예를 들어, 아래 타입스크립트 함수는

function createUser(name: string, age: number) {
  // ...
}

컴파일 후 다음과 같은 자바스크립트 코드가 된다.

function createUser(name, age) {
  // ...
}

string, number 타입 정보가 모두 사라진 것을 확인할 수 있습니다. 타입스크립트의 타입은 개발 단계의 편의성과 안정성을 위한 도구일 뿐, 코드가 실제로 실행되는 런타임 환경에는 존재하지 않는다.

런타임에서 이루어지는 유효성 검사

유효성 검사는 클라이언트로부터 잘못된 요청이 들어오는 런타임 시점에 이루어져야 한다. 하지만 타입 정보는 이미 컴파일 과정에서 사라졌기 때문에, 런타임에서 TypeScript의 interfacetype을 기준으로 요청 데이터의 유효성을 검증할 방법이 없다.

이는 프론트엔드에서 폼 데이터를 검증할 때 TypeScript 타입 대신 Zod 같은 런타임 유효성 검사 라이브러리를 사용하는 것과 정확히 같은 원리이다.

NestJS의 솔루션 : Class, 데코레이터, 그리고 메타데이터

그렇다면 NestJS는 어떻게 런타임 유효성 검사를 수행할까? 이는 class, decorators 문법과 reflect-metadata, class-validator , class-transformer 라이브러리들의 유기적인 연동을 통해 가능하다.

1. class와 decorators

interface와 달리, class는 ECMAScript 표준 사양이므로 컴파일 후에도 JavaScript 런타임 환경에 실제 '함수(생성자)' 형태로 존재한다. NestJS에서는 이 class의 속성에 @IsString(), @IsInt() 같은 데코레이터를 붙여 유효성 검사 규칙에 대한 메타데이터(추가 정보)를 정의한다.

데코레이터란?

데코레이터란 타입스크립트 문법으로 @ 기호를 사용하여 클래스, 메서드, 속성, 파라미터 등에 붙여 추가적인 기능이나 정보를 부여하는 함수다. 이름처럼 대상 코드를 '장식'하여 기능을 확장하거나 수정하는 역할을 한다. NestJS에서 흔히 보는 @Controller(), @Post(), @Body() 등이 모두 데코레이터다. 이들은 클래스나 메서드가 라우팅과 요청 처리에서 어떤 역할을 하는지 NestJS 프레임워크에 알려주는 표식이다. 유효성 검사에 사용된 @IsString() 역시 "이 속성은 문자열이어야 한다"는 규칙(메타데이터)을 부여하는 데코레이터다.

2. TypeScript의 emitDecoratorMetadata 옵션

TypeScript 컴파일러는 tsconfig.json의 특정 옵션을 통해, 데코레이터가 사용된 코드의 타입 정보를 메타데이터 형태로 JavaScript 파일에 남길 수 있다.

// tsconfig.json
{
  "compilerOptions": {
    // ...
    "emitDecoratorMetadata": true, // 데코레이터가 선언된 곳에 타입 메타데이터를 저장
    "experimentalDecorators": true // 데코레이터 문법을 사용하기 위한 실험적 기능 활성화
  }
}

이 옵션이 활성화되면, 컴파일러는 타입 정보를 지우는 대신, 다음과 같은 메타데이터를 JavaScript 코드 안에 심어놓는다.

//dist/cats/cats.controller.js
__decorate([
    (0, common_1.Post)(),
    (0, roles_decorator_1.Roles)(['admin']),
    __param(0, (0, common_1.Body)()),
    __metadata("design:type", Function),
    __metadata("design:paramtypes", [create_cat_dto_1.CreateCatDto]), // <- 타입 정보가 여기에 저장됨
    __metadata("design:returntype", Promise)
], CatsController.prototype, "create", null);

만약 class 대신 interface를 사용했다면, 타입 정보가 소거되어 Object로 기록된다. 이 경우 ValidationPipe는 어떤 구체적인 규칙으로 검증해야 할지 알 수 없게 된다.

//dist/cats/cats.controller.js
__decorate([
    (0, common_1.Post)(),
    (0, roles_decorator_1.Roles)(['admin']),
    __param(0, (0, common_1.Body)()),
    __metadata("design:type", Function),
    __metadata("design:paramtypes", [Object]), // <- DTO 타입 정보가 사라짐
    __metadata("design:returntype", Promise)
], CatsController.prototype, "create", null);

3. ValidationPipe의 동작 과정

모든 준비가 완료되면, ValidationPipe는 다음과 같은 순서로 동작한다.

  1. 클라이언트로부터 API 요청이 들어오면 ValidationPipe가 이를 가로챈다.
  2. reflect-metadata 라이브러리를 사용해 TypeScript 컴파일러가 남겨둔 design:paramtypes 메타데이터(CreateCatDto)를 읽는다.
  3. class-transformer 라이브러리가 요청 본문(plain object)을 CreateCatDto 클래스의 인스턴스로 변환한다.
  4. class-validator 라이브러리가 변환된 클래스 인스턴스를 순회하며 @IsString, @IsInt 등 데코레이터에 정의된 규칙에 따라 유효성을 검사한다.
  5. 하나라도 규칙에 어긋나는 데이터가 있으면 BadRequestException 예외를 발생시킵니다. 모두 유효하다면 요청을 다음 단계(컨트롤러 핸들러)로 전달한다.

아래는 NestJS의 ValidationPipe 내부 코드의 일부로, 이러한 과정을 잘 보여준다.

//dist/common/pipes/validation.pipe.js
const class_transformer_1 = require("class-transformer");
const class_validator_1 = require("class-validator");
let ValidationPipe = class ValidationPipe {
    async transform(value, metadata) {
        const { metatype } = metadata;
        if (!metatype || !this.toValidate(metatype)) {
            return value;
        }
        // 1. 요청 데이터를 DTO 클래스의 인스턴스로 변환
        const object = (0, class_transformer_1.plainToInstance)(metatype, value);
        // 2. class-validator를 사용해 유효성 검사 수행
        const errors = await (0, class_validator_1.validate)(object);
        // 3. 에러 발생 시 예외 처리
        if (errors.length > 0) {
            throw new common_1.BadRequestException('Validation failed');
        }
        return value;
    }
    toValidate(metatype) {
        const types = [String, Boolean, Number, Array, Object];
        return !types.find(type => metatype === type);
    }
};

정리

NestJS에서 유효성 검사를 위해 interfacetype 대신 class를 사용하는 이유는 명확하다.

런타임에 실행되어야 하는 유효성 검사는 컴파일 시점에 사라지는 TypeScript 타입 정보(interface, type)를 활용할 수 없기 때문이다.

대신 NestJS는 런타임에 존재하는 class를 기반으로, TypeScript 컴파일러가 제공하는 메타데이터 기능을 활용해 타입 정보를 런타임까지 전달하고, reflect-metadata, class-validator , class-transformer 라이브러리들의 유기적인 연동을 통해 유효성 검사 로직을 구현했다. 반복되는 로직을 손쉽게 구현하기 위해 정교하게 짜여져있다는 것이 정말 놀랍다.