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

분석 · 2025. 6. 22.

Nestjs란?

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

의존성 주입(DI)이란?

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

Pipe를 통한 유효성 검사

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

일반적으로 NestJS에서는 아래와 같이 classclass-validator 라이브러리의 데코레이터를 이용해 유효성 검사 로직(DTO, Data Transfer Object)을 구현합니다

//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 라이브러리들의 유기적인 연동을 통해 유효성 검사 로직을 구현했습니다. 반복되는 로직을 손쉽게 구현하기 위해 정교하게 짜여져있다는 것이 정말 놀랍습니다.