icon
5장 : REST API 개발

DTO와 유효성 검사


안녕하세요! 지난 절에서는 RESTful API의 설계 원칙과 NestJS에서 이를 구현하는 기본적인 방법에 대해 살펴보았습니다. 이번 절에서는 REST API 개발의 핵심적인 부분인 DTO(Data Transfer Object)와 유효성 검사(Validation) 에 대해 심층적으로 알아보겠습니다.

클라이언트가 서버로 데이터를 보낼 때, 이 데이터가 서버의 비즈니스 로직에 맞게 올바른 형식과 내용을 가지고 있는지 확인하는 것은 매우 중요합니다. 잘못된 데이터는 애플리케이션 오류, 데이터베이스 손상, 심지어 보안 취약점으로 이어질 수 있기 때문입니다. NestJS는 이를 위해 DTO를 활용한 타입 안전성과 강력한 유효성 검사 기능을 제공합니다.


DTO란 무엇인가?

DTO(Data Transfer Object) 는 클라이언트와 서버 간에 데이터를 전송하기 위해 사용되는 객체 형태의 데이터 구조입니다. 즉, 네트워크를 통해 전송될 데이터의 형식(Shape) 을 정의하는 데 사용됩니다.

DTO 사용의 주요 이점

  • 타입 안전성(Type Safety): TypeScript와 함께 사용될 때, DTO는 API 요청 및 응답 데이터의 타입을 명확하게 정의하여 컴파일 시점에 오류를 방지하고 코드의 안정성을 높입니다.
  • 유효성 검사(Validation): DTO 클래스에 @class-validator 라이브러리의 데코레이터를 사용하여 데이터의 유효성 규칙을 선언적으로 정의할 수 있습니다.
  • 코드 가독성 및 유지보수성: API의 입력 및 출력 데이터 구조를 한눈에 파악할 수 있게 하여 코드의 가독성을 높이고, 변경 사항이 발생했을 때 영향을 받는 부분을 쉽게 파악할 수 있게 합니다.
  • 문서화: DTO는 API의 입력 및 출력 데이터에 대한 훌륭한 자체 문서 역할을 합니다. Swagger와 같은 API 문서화 도구와 연동될 때 더욱 빛을 발합니다.
  • 강력한 자동 완성: IDE에서 DTO 속성에 대한 자동 완성을 제공하여 개발 생산성을 향상시킵니다.

예시 (User 생성 DTO)

// src/users/dto/create-user.dto.ts
export class CreateUserDto {
  name: string;
  email: string;
  age: number;
}

위 DTO는 사용자를 생성할 때 name(문자열), email(문자열), age(숫자) 필드가 필요함을 명시합니다.


NestJS의 유효성 검사

NestJS는 DTO를 이용한 유효성 검사를 위해 두 가지 강력한 라이브러리를 사용합니다. class-validatorclass-transformer입니다. 이 두 라이브러리는 NestJS에서 DTO를 정의하고 유효성 검사를 수행하는 데 필수적인 역할을 합니다.

  • class-validator: 클래스 속성에 데코레이터를 붙여 유효성 규칙을 정의하는 데 사용됩니다.
  • class-transformer: 일반 객체를 클래스 인스턴스로 변환하거나, 클래스 인스턴스를 일반 객체로 변환하는 데 사용됩니다. 특히 요청 본문(plain JavaScript object)을 DTO 클래스의 인스턴스로 자동 변환하여 @class-validator가 작동할 수 있도록 해줍니다.

단계 1: 필요한 패키지 설치

npm install class-validator class-transformer

단계 2: DTO에 유효성 검사 데코레이터 적용

지난 절에서 만들었던 CreateUserDtoUpdateUserDto에 유효성 검사 규칙을 추가해 보겠습니다.

// src/users/dto/create-user.dto.ts
import { IsString, IsEmail, IsNotEmpty, IsInt, Min, Max } from 'class-validator';

export class CreateUserDto {
  @IsString({ message: '이름은 문자열이어야 합니다.' })
  @IsNotEmpty({ message: '이름은 필수 항목입니다.' })
  name: string;

  @IsEmail({}, { message: '유효한 이메일 형식이 아닙니다.' })
  @IsNotEmpty({ message: '이메일은 필수 항목입니다.' })
  email: string;

  @IsInt({ message: '나이는 정수여야 합니다.' })
  @Min(0, { message: '나이는 0보다 커야 합니다.' })
  @Max(150, { message: '나이는 150보다 작거나 같아야 합니다.' })
  age: number; // 새로운 필드 추가
}
  • @IsString(): 해당 필드가 문자열인지 확인합니다.
  • @IsEmail(): 해당 필드가 유효한 이메일 형식인지 확인합니다.
  • @IsNotEmpty(): 해당 필드가 비어있지 않은지 (null, undefined, 빈 문자열) 확인합니다.
  • @IsInt(), @Min(), @Max(): 숫자 필드에 대한 유효성 검사 규칙입니다.
  • { message: '...' }: 유효성 검사 실패 시 반환될 사용자 지정 오류 메시지를 설정할 수 있습니다.

부분 업데이트를 위한 UpdateUserDto

// src/users/dto/update-user.dto.ts
import { PartialType } from '@nestjs/mapped-types'; // npm install @nestjs/mapped-types
import { CreateUserDto } from './create-user.dto';

// PartialType은 CreateUserDto의 모든 필드를 선택적(optional)으로 만듭니다.
// 즉, name?: string; email?: string; age?: number; 와 동일합니다.
export class UpdateUserDto extends PartialType(CreateUserDto) {}

단계 3: 전역 ValidationPipe 설정

main.ts 파일에서 NestJS 애플리케이션 전체에 ValidationPipe를 전역적으로 적용하는 것이 가장 효율적인 방법입니다. 이 파이프는 들어오는 모든 요청 본문을 자동으로 유효성 검사하고 DTO 인스턴스로 변환합니다.

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common'; // ValidationPipe 임포트

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

  // 전역 유효성 검사 파이프 설정
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true, // DTO에 정의되지 않은 속성은 요청 본문에서 자동으로 제거합니다.
                     // 클라이언트가 불필요하거나 악의적인 속성을 보낼 때 유용합니다.
    forbidNonWhitelisted: true, // DTO에 정의되지 않은 속성이 존재하면 요청을 거부하고 400 Bad Request 에러를 발생시킵니다.
    transform: true, // 요청 본문을 해당 DTO 클래스의 인스턴스로 자동 변환합니다.
                     // (예: 문자열 '123'을 숫자 123으로, "true"를 boolean true로)
                     // 이 옵션이 있어야 class-transformer가 작동하고, @class-validator가 올바른 타입에서 유효성 검사를 수행합니다.
    disableErrorMessages: process.env.NODE_ENV === 'production', // 프로덕션 환경에서 상세 에러 메시지 비활성화 (보안)
  }));

  await app.listen(3000);
}
bootstrap();

ValidationPipe 옵션 설명

  • whitelist: true: DTO에 정의되지 않은 속성(필드)이 요청 본문에 포함되어 있으면, 해당 속성을 자동으로 제거합니다. 이는 예상치 못한 데이터나 잠재적으로 악의적인 데이터가 데이터베이스에 저장되는 것을 방지하는 데 유용합니다.
  • forbidNonWhitelisted: true: whitelist 옵션과 함께 사용될 때, DTO에 정의되지 않은 속성이 요청 본문에 포함되어 있으면 아예 요청을 거부하고 400 Bad Request 응답을 반환합니다. 이는 API의 입력 스키마를 엄격하게 강제하고자 할 때 유용합니다.
  • transform: true: 수신된 요청 본문(일반 JavaScript 객체)을 해당 DTO 클래스의 인스턴스로 자동 변환합니다. 예를 들어, CreateUserDtoage: number 필드에 클라이언트가 "25"라는 문자열을 보내도, ValidationPipe는 이를 25라는 숫자로 변환하여 유효성 검사를 수행하고 컨트롤러에 전달합니다. 이 기능은 @class-transformer에 의해 제공됩니다.
  • disableErrorMessages: 프로덕션 환경에서는 상세한 에러 메시지가 잠재적인 공격자에게 정보를 노출할 수 있으므로, 이를 비활성화하여 일반적인 "Bad Request" 메시지만 반환하도록 설정할 수 있습니다.

단계 4: 컨트롤러에서 DTO 사용

컨트롤러 메서드의 @Body() 데코레이터와 함께 DTO 클래스를 타입 힌트로 사용합니다.

// src/users/users.controller.ts (업데이트)
import { Controller, Get, Post, Body, Param, Patch, Delete, HttpCode, HttpStatus, NotFoundException, UsePipes } from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
// import { ValidationPipe } from '@nestjs/common'; // 개별 파이프 적용 시 임포트

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  // ... (findAll, findOne 등 기존 메서드는 동일)

  @Post()
  @HttpCode(HttpStatus.CREATED)
  // @UsePipes(new ValidationPipe()) // 전역 파이프를 사용하므로 이 부분은 필요 없습니다.
  create(@Body() createUserDto: CreateUserDto) {
    // ValidationPipe가 자동으로 createUserDto의 유효성을 검사하고,
    // 유효하면 CreateUserDto 인스턴스로 변환하여 여기에 주입합니다.
    // 유효하지 않으면 400 Bad Request 응답을 자동으로 보냅니다.
    return this.usersService.create(createUserDto);
  }

  @Patch(':id')
  update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
    const updatedUser = this.usersService.update(+id, updateUserDto);
    if (!updatedUser) {
      throw new NotFoundException(`User with ID ${id} not found`);
    }
    return updatedUser;
  }

  // ... (remove 등 기존 메서드는 동일)
}

컨트롤러 코드 자체는 DTO를 타입 힌트로 사용하는 것 외에는 크게 변하지 않습니다. ValidationPipe가 모든 마법을 수행하여 요청이 컨트롤러에 도달하기 전에 유효성 검사를 처리합니다.


유효성 검사 테스트 및 에러 응답 확인

애플리케이션을 실행하고(npm run start:dev), Postman 등으로 테스트해봅니다.

새로운 사용자 생성 (유효한 데이터)

  • POST http://localhost:3000/users
  • Body: {"name": "David", "email": "david@example.com", "age": 30}
  • 응답: 201 Created와 생성된 사용자 정보.
  • (정상 동작)

새로운 사용자 생성 (유효하지 않은 데이터 - name 누락, email 형식 오류, age 범위 오류):

  • POST http://localhost:3000/users
  • Body: {"email": "invalid-email", "age": 200}
  • 응답: 400 Bad Request와 함께 다음과 유사한 에러 메시지 배열을 받게 됩니다.
    {
        "statusCode": 400,
        "message": [
            "이름은 문자열이어야 합니다.",
            "이름은 필수 항목입니다.",
            "유효한 이메일 형식이 아닙니다.",
            "나이는 150보다 작거나 같아야 합니다."
        ],
        "error": "Bad Request"
    }
  • (각 필드에 정의된 에러 메시지가 반환됨을 확인)

새로운 사용자 생성 (DTO에 정의되지 않은 필드 포함)

  • POST http://localhost:3000/users
  • Body: {"name": "Eve", "email": "eve@example.com", "age": 25, "extraField": "malicious data"}
  • forbidNonWhitelisted: true 일 경우: 400 Bad Request 응답 (정의되지 않은 필드 extraField 때문에 요청 거부).
  • forbidNonWhitelisted: false & whitelist: true 일 경우: 201 Created 응답과 함께 extraField가 자동으로 제거된 사용자 정보가 생성됨.

DTO와 유효성 검사는 REST API의 견고성과 신뢰성을 크게 향상시키는 필수적인 요소입니다. NestJS는 class-validatorclass-transformer를 통한 강력한 통합으로 개발자가 최소한의 노력으로 최대의 유효성 검사 효과를 얻을 수 있도록 돕습니다. 이는 API의 안정성을 보장하고, 클라이언트와 서버 간의 데이터 계약을 명확하게 정의하는 데 기여합니다.

다음 절에서는 API 문서화를 위한 Swagger 통합에 대해 알아보겠습니다.