DTO와 유효성 검사
DTO 클래스로 요청 계약을 선언하고 ValidationPipe로 변환·허용 필드·검증 오류를 제어합니다.
지난 절에서는 URI와 HTTP 메서드, controller와 service의 책임 경계를 정리했습니다.
이번 절에서는 그 경계로 들어오는 값을 DTO(Data Transfer Object)로 선언하고, ValidationPipe로 런타임에 검사하는 방법을 다룹니다.
HTTP 요청으로 받은 JSON은 신뢰할 수 없는 JavaScript 값입니다. TypeScript의 타입 표기는 컴파일 뒤 지워지므로 @Body() dto: CreateUserDto라는 표기만으로 요청이 검사되지는 않습니다. Nest가 런타임에 참조할 수 있는 구체적인 DTO 클래스, class-validator 데코레이터, 그리고 연결된 pipe가 함께 있어야 입력 계약이 실제로 강제됩니다.
DTO는 전송 경계의 계약입니다
DTO는 controller가 받을 요청 표현의 필드와 제약을 선언하는 클래스입니다. 데이터베이스 entity를 그대로 입력 DTO로 노출하지 않고, 생성·수정·응답에 필요한 계약을 각각 분리하면 클라이언트가 내부 필드나 서버가 관리하는 값을 덮어쓰는 일을 막기 쉽습니다.
DTO가 제공하는 이점은 다음과 같습니다.
- 개발 시점 타입 정보: controller와 service 코드에서 자동 완성과 정적 검사를 제공합니다.
- 런타임 입력 검증: DTO 클래스의 validation 데코레이터를
ValidationPipe가 실행합니다. - 허용 필드 경계: whitelist 정책으로 decorated field만 통과시키거나 알 수 없는 필드를 거부할 수 있습니다.
- 문서화 기반: 다음 절의 Swagger 통합처럼 같은 클래스를 API schema 생성에 활용할 수 있습니다.
DTO 검증은 인증·인가, 데이터베이스 고유성, 재고나 상태 전이 같은 domain 규칙을 대신하지 않습니다. 이런 규칙은 guard와 service·저장소 경계에서 계속 강제해야 합니다.
DTO 클래스에 검증 규칙 선언하기
먼저 Nest의 ValidationPipe가 사용하는 패키지를 설치합니다.
npm install class-validator class-transformer아래 DTO는 age 검증을 덧붙인 입력 설명용 발췌입니다. 5-1의 사용자 API를 이어 만든다면 기존 name·email의 trim·길이 규칙을 유지하세요. 현재 UserRecord, Alice/Bob seed, UserResponseDto는 id·name·email만 정의합니다. DTO에 age를 추가하는 것만으로 저장·조회·응답까지 확장된 계약이 생기지는 않습니다. 나이를 실제 자원 필드로 제공하려면 그 경계도 함께 설계해야 합니다.
생성 요청 DTO를 구체적인 클래스로 정의하고 각 필드에 런타임 규칙을 붙입니다.
import {
IsEmail,
IsInt,
IsNotEmpty,
IsString,
Max,
Min,
} 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()과@IsInt()는 런타임 값의 타입을 확인합니다.@IsNotEmpty()는undefined,null, 빈 문자열을 허용하지 않습니다.@IsEmail()은 이메일 형식을 검사합니다.@Min(0)과@Max(150)은 두 끝값을 포함한 숫자 범위를 검사합니다.
필드의 string·number 표기는 TypeScript 개발 도구를 위한 정보입니다. 요청을 실제로 거부하는 것은 validation 데코레이터와 pipe입니다. 또한 DTO를 interface로 만들거나 import type { CreateUserDto }로 가져오면 런타임 클래스 참조가 사라져 ValidationPipe가 필요한 metatype을 사용할 수 없습니다.
PATCH용 DTO 파생하기
부분 수정에서는 생성 DTO의 규칙을 복사한 뒤 모든 필드를 선택적으로 만드는 PartialType()을 사용할 수 있습니다.
npm install @nestjs/mapped-typesimport { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
export class UpdateUserDto extends PartialType(CreateUserDto, {
skipNullProperties: false,
}) {}PartialType()은 생성 DTO의 validation metadata를 이어받고 누락된 필드를 선택적으로 다룹니다. 기본 skipNullProperties 값은 true라서 undefined뿐 아니라 null도 validation에서 건너뜁니다. 위 예제는 이를 false로 바꿔 누락된 필드만 선택적으로 두고, 전달된 null에는 상속한 validator를 실행합니다. null이 “값 제거”를 뜻해야 한다면 그 의미와 변경 불가 필드를 별도 DTO·domain 규칙으로 명시합니다.
Swagger나 GraphQL mapped type을 사용할 때는 해당 통합 패키지가 제공하는 PartialType을 사용해야 schema metadata가 함께 유지됩니다. 다음 절에서 Swagger를 연결할 때 이 import 경계도 다시 확인합니다.
전역 ValidationPipe 설정
여러 controller에 같은 입력 정책을 적용하려면 bootstrap에서 전역 pipe를 등록합니다. 누적 API에서는 아래 useGlobalPipes() 옵션을 기존 main.ts에 합치고, 5-1의 URI version 설정은 유지합니다.
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({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
disableErrorMessages: process.env.NODE_ENV === 'production',
}),
);
await app.listen(3000);
}
bootstrap();옵션의 역할을 정확히 구분해야 합니다.
transform: true: plain payload를 handler의 DTO 클래스 인스턴스로 변환합니다. path·query에서 추출한 원시 값은 handler parameter metatype을 바탕으로 변환을 시도할 수 있습니다.whitelist: true: DTO에 단순히 “선언된 필드”가 아니라 validation 데코레이터가 붙은 필드만 남깁니다. 검증 없이 허용할 필드는@Allow()처럼 whitelist metadata를 명시해야 합니다.forbidNonWhitelisted: true:whitelist와 함께 사용할 때 알 수 없는 필드를 제거하는 대신 기본400 Bad Request로 거부합니다.disableErrorMessages: 상세 validation message를 응답에서 숨깁니다. 공개할 오류 형식과 로그에 남길 진단 정보는 별도로 설계합니다.
transform: true가 DTO 속성의 모든 문자열을 TypeScript 표기대로 자동 변환한다는 뜻은 아닙니다. 위 DTO의 JSON body에 "age": "25"를 보내면 명시적인 property transformer가 없으므로 @IsInt()가 거부합니다. body coercion이 API 계약이라면 @Type()·@Transform() 등을 명시하고 경계값을 테스트하세요. path와 query처럼 문자열로 들어오는 단일 값은 ParseIntPipe, ParseBoolPipe, ParseUUIDPipe를 붙이면 변환 규칙과 실패 조건을 더 분명하게 드러낼 수 있습니다.
중첩 객체도 타입 표기만으로 재귀 검증되지 않습니다. 중첩 DTO에는 @ValidateNested()와 @Type(() => NestedDto) 같은 런타임 metadata가 필요합니다.
아래 흐름은 요청 값과 DTO class metadata가 ValidationPipe에서 만나 controller 실행 또는 오류 응답으로 나뉘는 경계를 보여줍니다.
Nest DTO 런타임 검증 경계
pipe가 plain 요청과 runtime class metatype에 변환/whitelist/검증을 적용해 handler 실행 또는 400 응답으로 이어지게 한다.
Nest DTO · 런타임 입력 계약
실제 DTO class와 validation metadata가 입력을 검사합니다
요청 값과 class metatype을 ValidationPipe에 함께 전달합니다. 본문의 transform·whitelist·forbidNonWhitelisted 설정에서는 허용하지 않는 입력을 거부하고 통과한 값만 handler에 전달합니다.
1 · 요청 값
JSON parser를 거친 body는 아직 신뢰할 수 없는 plain JavaScript 값입니다.
TypeScript property 표기만으로는 런타임 검사가 생기지 않습니다.
2 · 런타임 DTO 계약
CreateUserDto class의 metatype과 validation decorator가 허용 필드·타입·형식·범위를 선언합니다.
interface와 type-only import는 런타임 class 참조를 남기지 않습니다.
3 · ValidationPipe
transform은 plain 값을 DTO instance로 만들고, whitelist와 decorator 검증은 입력 정책을 적용합니다.
forbidNonWhitelisted를 함께 쓰면 알 수 없는 필드를 제거하지 않고 거부합니다.
PASS · Controller
선언한 계약을 통과한 DTO만 handler에 전달됩니다.
고유성·권한·상태 전이는 service와 저장소에서 계속 검사합니다.
FAIL · 400
필수 값, 타입, 형식, 범위 또는 허용 필드 정책을 위반하면 handler와 service를 실행하지 않습니다.
기본 상태는 400 Bad Request이며 옵션으로 오류 형식을 바꿀 수 있습니다.
whitelist의 기준은 TypeScript field 선언이 아니라 validation metadata입니다. DTO 검증은 transport 입력을 좁히지만 인증·인가와 domain·database 불변식까지 증명하지는 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 클라이언트의 plain JavaScript 요청 값과 CreateUserDto class의 runtime metatype 및 validation decorator가 ValidationPipe에 입력된다. Pipe는 설정에 따라 class instance 변환, whitelist, decorator 검증을 적용하고, 계약을 통과하면 controller를 실행하며 실패하면 handler를 실행하지 않고 기본 400 Bad Request로 끝낸다.
- 요청의 plain 값과 DTO class/metatype/decorator 정보는 서로 다른 입력으로 ValidationPipe에 모인다. 변환/허용 필드/검증 규칙을 적용하고 통과한 값만 controller로 전달한다. 실패는 기본 400이며 handler/service를 실행하지 않는다. DTO class에서 요청 값을 만들거나 검증 통과가 domain 고유성을 보장한다는 뜻은 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | Nest DTO 런타임 검증 경계 |
| 24 | 요청의 plain 값과 DTO class/metatype/decorator 정보는 서로 다른 입력으로 ValidationPipe에 모인다. 변환/허용 필드/검증 규칙을 적용하고 통과한 값만 controller로 전달한다. 실패는 기본 400이며 handler/service를 실행하지 않는다. DTO class에서 요청 값을 만들거나 검증 통과가 domain 고유성을 보장한다는 뜻은 아니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| plain 값과 metatype plain 요청 값과 DTO class·decorator를 Pipe에 전달합니다. | 값: plain JavaScript · metatype: DTO class | runtime 검증에 필요한 두 입력이 모입니다. |
| 설정된 Pipe 처리 설정에 따라 instance 변환·whitelist·검증을 합니다. | 작업: 설정된 변환·검증 · interface: runtime 대체 아님 | 원래 입력이 handler 계약으로 좁혀집니다. |
| handler 또는 400 통과하면 controller, 실패하면 기본 400입니다. | 성공: controller · 실패: 400·handler 없음 | 검증 결과가 실제 진입을 결정합니다. |
Nest DTO 런타임 검증 경계
pipe가 plain 요청과 runtime class metatype에 변환/whitelist/검증을 적용해 handler 실행 또는 400 응답으로 이어지게 한다.
Nest DTO · 런타임 입력 계약
실제 DTO class와 validation metadata가 입력을 검사합니다
요청 값과 class metatype을 ValidationPipe에 함께 전달합니다. 본문의 transform·whitelist·forbidNonWhitelisted 설정에서는 허용하지 않는 입력을 거부하고 통과한 값만 handler에 전달합니다.
1 · 요청 값
JSON parser를 거친 body는 아직 신뢰할 수 없는 plain JavaScript 값입니다.
TypeScript property 표기만으로는 런타임 검사가 생기지 않습니다.
2 · 런타임 DTO 계약
CreateUserDto class의 metatype과 validation decorator가 허용 필드·타입·형식·범위를 선언합니다.
interface와 type-only import는 런타임 class 참조를 남기지 않습니다.
3 · ValidationPipe
transform은 plain 값을 DTO instance로 만들고, whitelist와 decorator 검증은 입력 정책을 적용합니다.
forbidNonWhitelisted를 함께 쓰면 알 수 없는 필드를 제거하지 않고 거부합니다.
PASS · Controller
선언한 계약을 통과한 DTO만 handler에 전달됩니다.
고유성·권한·상태 전이는 service와 저장소에서 계속 검사합니다.
FAIL · 400
필수 값, 타입, 형식, 범위 또는 허용 필드 정책을 위반하면 handler와 service를 실행하지 않습니다.
기본 상태는 400 Bad Request이며 옵션으로 오류 형식을 바꿀 수 있습니다.
whitelist의 기준은 TypeScript field 선언이 아니라 validation metadata입니다. DTO 검증은 transport 입력을 좁히지만 인증·인가와 domain·database 불변식까지 증명하지는 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 클라이언트의 plain JavaScript 요청 값과 CreateUserDto class의 runtime metatype 및 validation decorator가 ValidationPipe에 입력된다. Pipe는 설정에 따라 class instance 변환, whitelist, decorator 검증을 적용하고, 계약을 통과하면 controller를 실행하며 실패하면 handler를 실행하지 않고 기본 400 Bad Request로 끝낸다.
- 요청의 plain 값과 DTO class/metatype/decorator 정보는 서로 다른 입력으로 ValidationPipe에 모인다. 변환/허용 필드/검증 규칙을 적용하고 통과한 값만 controller로 전달한다. 실패는 기본 400이며 handler/service를 실행하지 않는다. DTO class에서 요청 값을 만들거나 검증 통과가 domain 고유성을 보장한다는 뜻은 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | Nest DTO 런타임 검증 경계 |
| 24 | 요청의 plain 값과 DTO class/metatype/decorator 정보는 서로 다른 입력으로 ValidationPipe에 모인다. 변환/허용 필드/검증 규칙을 적용하고 통과한 값만 controller로 전달한다. 실패는 기본 400이며 handler/service를 실행하지 않는다. DTO class에서 요청 값을 만들거나 검증 통과가 domain 고유성을 보장한다는 뜻은 아니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| plain 값과 metatype plain 요청 값과 DTO class·decorator를 Pipe에 전달합니다. | 값: plain JavaScript · metatype: DTO class | runtime 검증에 필요한 두 입력이 모입니다. |
| 설정된 Pipe 처리 설정에 따라 instance 변환·whitelist·검증을 합니다. | 작업: 설정된 변환·검증 · interface: runtime 대체 아님 | 원래 입력이 handler 계약으로 좁혀집니다. |
| handler 또는 400 통과하면 controller, 실패하면 기본 400입니다. | 성공: controller · 실패: 400·handler 없음 | 검증 결과가 실제 진입을 결정합니다. |
Controller에서 검증된 DTO 사용하기
전역 pipe가 등록되어 있으면 controller는 DTO 클래스를 handler parameter의 런타임 metatype으로 제공합니다. DTO를 type-only import하지 않는 이유도 여기에 있습니다.
아래 코드는 검증된 DTO가 service로 넘어가는 위치를 보여주는 발췌입니다. 5-1의 UsersController를 계속 쓰는 경우 기존 version 1, 출력 DTO, 생성 Location, 404·409 변환을 유지하면서 이 입력 경계만 대조합니다. 다음의 버전 접두사가 없는 /users 요청 예시는 이 검증 설명의 기준이며, 누적 API에서는 /v1/users로 요청합니다.
import {
Body,
Controller,
NotFoundException,
Param,
ParseIntPipe,
Patch,
Post,
} from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
@Patch(':id')
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateUserDto: UpdateUserDto,
) {
const updatedUser = this.usersService.update(id, updateUserDto);
if (!updatedUser) {
throw new NotFoundException(`User with ID ${id} not found`);
}
return updatedUser;
}
}POST handler의 기본 성공 상태는 201 Created입니다. DTO 또는 parameter pipe가 실패하면 handler와 service는 실행되지 않고 기본적으로 400 Bad Request가 반환됩니다. errorHttpStatusCode나 exceptionFactory를 설정하면 상태와 응답 envelope를 바꿀 수 있으므로, 클라이언트가 의존할 오류 계약은 테스트로 고정해야 합니다.
Controller에 도달한 DTO는 transport 형식과 선언한 validation 규칙을 통과한 값입니다. 이메일 중복, 수정 권한, 존재하지 않는 사용자, 트랜잭션 같은 판단은 service와 저장소가 계속 담당합니다.
유효성 검사 테스트
애플리케이션을 실행하고 HTTP 요청으로 경계를 확인합니다.
POSThttp://localhost:3000/users- body:
{"name":"David","email":"[email protected]","age":30} - 결과: handler와 service가 실행되고 기본
201 Created응답을 반환합니다.
- body:
{"email":"invalid-email","age":200} - 결과:
name누락, 이메일 형식, 나이 상한 오류가 기본400 Bad Request의 message 목록에 포함됩니다. - 검증 오류의 순서와 최종 JSON envelope는 Nest 버전과
exceptionFactory정책에 의존할 수 있으므로 상태와 필요한 field/message를 기준으로 검증합니다.
- body:
{"name":"Eve","email":"[email protected]","age":25,"role":"admin"} whitelist: true,forbidNonWhitelisted: true: handler 전에400 Bad Request로 거부합니다.whitelist: true,forbidNonWhitelisted: false:role을 제거한 뒤 handler에 전달합니다.
- body:
{"name":"Eve","email":"[email protected]","age":"25"} - 위 DTO처럼 property transformer를 선언하지 않았다면
@IsInt()가 문자열을 거부합니다. - 문자열 숫자를 지원해야 한다면 명시적인 변환 규칙과 빈 문자열·
NaN·범위 밖 값 테스트를 함께 둡니다.
현재 DTO가 실제로 거부하는 값 찾기
이 절에 보인 trim 없는 설명용 DTO와 전역 pipe를 그대로 사용하고, 별도 property transformer는 없다고 가정합니다. 유효한 이메일과 함께 name: " ", age: 25를 보내면 이름 검증이 실패할까요? age를 문자열 "25"로 바꾸면 어떤 차이가 생길까요? 5-1의 trim과 @Length(1, 80) 규칙을 유지했을 때의 이름 결과도 비교하세요.
빈 문자열·공백·숫자 타입을 구분하기
이 절의 @IsNotEmpty()는 빈 문자열을 거부하지만 공백 한 글자는 빈 문자열이 아니므로 이름 검증을 통과합니다. 숫자 25는 정수·범위를 만족하고, 문자열 "25"는 @IsInt()를 통과하지 못합니다. transform: true만으로 DTO 속성의 문자열 숫자를 모두 바꾸지는 않습니다. 5-1 규칙에서는 trim으로 공백 이름이 빈 문자열이 되고 최소 길이 1에 실패합니다. 이름의 의미와 입력 정규화 정책을 정한 뒤 그 규칙을 DTO에 명시해야 합니다.
DTO 클래스는 TypeScript 개발 경험을 제공하고, validation 데코레이터와 ValidationPipe는 네트워크 경계의 런타임 계약을 강제합니다. transform, whitelist, forbidNonWhitelisted를 서로 다른 정책으로 이해하고, DTO 검증과 인증·domain·저장소 규칙을 분리해야 예측 가능한 API가 됩니다.
다음 절에서는 이 입력 계약을 Swagger schema와 예제로 노출하고 문서와 구현이 함께 변경되도록 구성합니다.