NestJS Pipes와 Validation
클라이언트가 보내는 데이터를 신뢰할 수 없다. "매장 이름"에 빈 문자열이 올 수도 있고, "수량"에 음수가 올 수도 있다. NestJS는 Pipe라는 메커니즘으로 데이터를 검증하고 변환한다. class-validator와 결합하면 데코레이터만으로 유효성 검사를 선언적으로 처리할 수 있다.
Pipe란
Pipe는 Controller 핸들러가 실행되기 직전에 데이터를 가로채서 변환하거나 검증하는 계층이다. 두 가지 역할을 수행한다.
- 변환(transformation): 문자열
"42"를 숫자42로 바꾸는 것처럼 타입을 변환한다. - 검증(validation): 데이터가 규칙에 맞는지 확인하고, 맞지 않으면 예외를 던진다.
Client Request → Pipe (검증/변환) → Controller Handler
↓ (실패 시)
400 Bad Request
Pipe에서 검증이 실패하면 Controller 핸들러는 실행되지 않는다. 잘못된 데이터가 비즈니스 로직에 도달하는 것을 사전에 차단한다.
ValidationPipe
NestJS에 내장된 ValidationPipe는 class-validator 라이브러리와 연동해서 DTO의 데코레이터 기반으로 자동 검증을 수행한다.
전역으로 설정하면 모든 Controller에 자동 적용된다.
// main.ts
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.listen(3000);
}
| 옵션 | 설명 |
|---|---|
whitelist: true | DTO에 정의되지 않은 속성을 자동으로 제거한다 |
forbidNonWhitelisted: true | 정의되지 않은 속성이 있으면 400 에러를 던진다 |
transform: true | 요청 데이터를 DTO 클래스의 인스턴스로 자동 변환한다 |
whitelist가 없으면 클라이언트가 { "name": "강남점", "isAdmin": true } 같은 데이터를 보내도 그대로 통과한다. isAdmin이 DTO에 정의되지 않은 속성인데도 Service에 전달되는 것이다. whitelist: true로 이런 위험을 방지한다.
class-validator 데코레이터
DTO 클래스의 속성에 데코레이터를 붙여서 유효성 규칙을 정의한다.
기본 검증
export class CreateStoreDto {
@IsNotEmpty()
@IsString()
@Length(1, 255)
name!: string;
@IsNotEmpty()
@IsString()
address!: string;
@IsOptional()
@IsString()
@Length(1, 50)
phone?: string;
}
| 데코레이터 | 검증 내용 |
|---|---|
@IsString() | 문자열인지 확인 |
@IsNotEmpty() | 빈 문자열, null, undefined가 아닌지 확인 |
@IsOptional() | 값이 없으면 나머지 검증을 건너뜀 |
@Length(1, 255) | 문자열 길이가 1~255자인지 확인 |
@IsOptional()과 @IsNotEmpty()는 반대 개념이다. 필수 필드에는 @IsNotEmpty(), 선택 필드에는 @IsOptional()을 붙인다. @IsOptional()이 붙은 속성은 값이 없으면 그 아래의 @IsString(), @Length() 검증을 수행하지 않는다.
타입별 검증
export class InitiatePaymentDto {
@IsInt()
@Min(2)
@Max(10)
quantity!: number;
@IsOptional()
@IsString()
@MaxLength(50)
couponCode?: string;
}
| 데코레이터 | 검증 내용 |
|---|---|
@IsInt() | 정수인지 확인 (소수점 불가) |
@IsNumber() | 숫자인지 확인 (소수점 허용) |
@IsBoolean() | boolean인지 확인 |
@IsUUID() | UUID 형식인지 확인 |
@IsDateString() | ISO 8601 날짜 문자열인지 확인 |
@Min(n) / @Max(n) | 최소값 / 최대값 |
@MaxLength(n) | 문자열 최대 길이 |
UUID 검증
API에서 ID를 받을 때 UUID 형식인지 확인하는 것이 중요하다. 잘못된 형식의 ID가 DB 쿼리까지 도달하면 에러가 발생한다.
export class CreateSessionDto {
@IsUUID()
@IsNotEmpty()
conceptId!: string;
@IsOptional()
@IsUUID()
frameThemeId?: string;
@IsUUID()
@IsNotEmpty()
frameId!: string;
}
@IsUUID()는 "550e8400-e29b-41d4-a716-446655440000" 같은 UUID v4 형식을 검증한다. 이 검증이 없으면 "abc" 같은 값이 들어와서 DB 에러가 발생할 수 있다.
중첩 객체 검증
DTO 안에 다른 DTO가 포함된 경우, @ValidateNested()와 @Type()을 함께 사용한다.
export class CreateFrameCutImageDto {
@IsInt()
@Min(1)
@Max(4)
slotIndex!: number;
@IsUUID()
assetId!: string;
}
export class CreateFrameDto {
@IsString()
@MaxLength(100)
name!: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateFrameCutImageDto)
cutImages?: CreateFrameCutImageDto[];
}
@ValidateNested({ each: true })는 배열의 각 요소에 대해 CreateFrameCutImageDto의 검증 규칙을 적용한다. @Type(() => CreateFrameCutImageDto)는 class-transformer가 JSON 데이터를 해당 DTO 클래스의 인스턴스로 변환하도록 한다. 이 변환이 있어야 중첩 객체의 데코레이터가 인식된다.
class-transformer
class-transformer는 plain object를 클래스 인스턴스로 변환한다. ValidationPipe의 transform: true 옵션과 함께 동작한다.
대표적인 활용이 Query Parameter의 타입 변환이다. HTTP 요청에서 쿼리 파라미터는 항상 문자열로 들어온다.
export class MonthlySalesQueryDto {
@IsInt()
@Min(2020)
@Max(2100)
@Type(() => Number)
year!: number;
@IsInt()
@Min(1)
@Max(12)
@Type(() => Number)
month!: number;
}
GET /sales?year=2026&month=2 요청이 오면 year와 month는 문자열 "2026", "2"로 들어온다. @Type(() => Number)가 이를 숫자로 변환한 후, @IsInt(), @Min(), @Max() 검증이 수행된다. @Type 없이 @IsInt()만 붙이면 문자열이 정수가 아니라는 이유로 검증이 실패한다.
PartialType
Update DTO를 만들 때 Create DTO의 모든 필드를 선택적으로 만드는 유틸리티다.
export class CreateFrameDto {
@IsString()
@MaxLength(100)
name!: string;
@IsUUID()
frameTypeId!: string;
@IsNumber()
@Min(0)
price!: number;
}
export class UpdateFrameDto extends PartialType(CreateFrameDto) {
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
PartialType(CreateFrameDto)는 CreateFrameDto의 모든 필드를 optional로 만든 새 클래스를 반환한다. UpdateFrameDto에서는 추가 필드(isActive)만 정의하면 된다. Create와 Update의 검증 규칙을 중복 작성할 필요가 없다.
PartialType은 @nestjs/swagger 패키지에서 가져와야 Swagger 문서에도 반영된다. @nestjs/mapped-types의 PartialType은 Swagger 메타데이터를 복사하지 않는다.
검증 에러 응답
ValidationPipe가 검증 실패를 감지하면 자동으로 400 Bad Request 응답을 반환한다.
{
"statusCode": 400,
"message": [
"name should not be empty",
"name must be a string",
"address should not be empty"
],
"error": "Bad Request"
}
어떤 필드가 어떤 규칙을 위반했는지 배열로 알려준다. 클라이언트 개발자가 어떤 데이터를 고쳐야 하는지 바로 알 수 있다.
Swagger 데코레이터와의 연동
class-validator 데코레이터와 Swagger 데코레이터는 별개이지만, 함께 사용하면 "검증 규칙이 곧 API 문서"가 된다.
@ApiProperty({
description: '출력 장수',
minimum: 2,
maximum: 10,
example: 2,
})
@IsInt()
@Min(2)
@Max(10)
quantity!: number;
@ApiProperty()는 Swagger UI에서 이 필드의 설명, 범위, 예시를 보여준다. @IsInt(), @Min(), @Max()는 실제 런타임 검증을 수행한다. 두 데코레이터의 정보가 일치하도록 유지하는 것이 중요하다. Swagger 문서에는 maximum: 10이라고 써놓고 실제 검증은 @Max(20)이면 혼란이 생긴다.
왜 ValidationPipe인가
Pipe 없이 Controller에서 직접 검증하는 방식과 비교하면 차이가 명확하다. 직접 if (!body.name) 같은 조건문을 나열하면 검증 로직이 비즈니스 로직과 섞이고, DTO마다 같은 패턴을 반복하게 된다. Joi나 Yup 같은 스키마 라이브러리를 쓰면 검증은 분리되지만 타입과 스키마가 이중 관리된다. class-validator + ValidationPipe 조합은 DTO 클래스 하나로 타입, 검증 규칙, Swagger 문서를 모두 관리할 수 있어 NestJS의 데코레이터 중심 설계와 자연스럽게 맞는다.
정리
- Controller에 검증 코드를 넣지 않고 DTO 데코레이터로 선언하면 관심사가 분리되고, 같은 DTO를 여러 핸들러에서 재사용할 수 있다
- whitelist + forbidNonWhitelisted 조합으로 정의하지 않은 속성 유입을 차단하고, transform + @Type으로 쿼리 파라미터 등 문자열 자동 변환을 처리한다
- PartialType으로 Create/Update DTO 중복을 제거하고, @ValidateNested + @Type으로 중첩 객체까지 검증 범위를 확장한다