[Nest]

[Nest] [클린코드 3편] DTO와 전역 pipe로 요청 데이터 깔끔하게 검증하기

Juyear 2025. 11. 17. 16:22
728x90

 

[Nest] [클린코드 2편] Guard와 Decorator로 요청 처리 깔끔하게 구현하기

 

[Nest] [클린코드 2편] Guard와 Decorator로 요청 처리 깔끔하게 구현하기

[Nest] [클린코드 1편] JWT 발급 및 인증 구조 깔끔하게 구현하기 (Postman API 테스트) [Nest] [클린코드 1편] JWT 발급 및 인증 구조 깔끔하게 구현하기 (Postman API 테스트)👋 소개안녕하세요! 대학생 개발

blog.juyear.dev

이전 글 읽으러 가기!

 

👋 소개

안녕하세요! 대학생 개발자 주이어입니다.

오늘은 Nest 클린코드 3편으로, DTO전역 pipe로 요청 데이터를 검증하는 방법에 대해서 정리해보려고 합니다.

 

1편과 2편에서 진행했던 내용에 이어서 진행할 예정이니, 이전 글 부터 보고 오시는 것을 추천드립니다.

 

그럼 오늘의 본론으로 바로 들어가도록 하겠습니다.


💡 DTO 란?

DTO는 요청으로 들어온 데이터가 유효한지 검증하는 역할을 합니다.

저번 시간에 배웠던 Decorator와 다른 점이라면,

Decorator는 필요한 데이터를 추출하는 역할만 할 뿐, 검증하는 역할을 하지는 않습니다.

이렇게 추출된 데이터를 검증하는 것이 DTO라고 생각하시면 됩니다.

 

DTO는 UserDto, BookDto와 같이 여러개로 나눠지며, UserDto도 UserRequestDto, UserResponseDto 등으로 또 나눠질 수 있습니다.

즉, DTO는 특정 데이터 묶음을 검증해야할 경우 어디서든 사용될 수 있습니다.

 

이러한 DTO의 특성은 도메인과 분리해야 하는 이유 중 하나이기도 합니다.
(이 부분에 대해서는 다음에 다룰 예정입니다.)


⚙️ Request DTO 구현하기

DTO가 무엇인지에 대해서 어느정도 이해했으니 이제 구현을 해보도록 하겠습니다.

먼저 Request(요청) DTO를 만들어보도록 하겠습니다.

Request DTO는 이름에서 알 수 있듯이 클라이언트가 보낸 값을 검증하는 DTO 입니다.

@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@UserDecorator() request: { id: number; username: string }) {
  console.log(request);
  const user = this.userService.findById(request.id);
  return {
    message: '유저 프로필',
    user,
  };
}

기존에 작성했던 GET/ profile 코드에 Request DTO를 적용해보도록 하겠습니다.

npm install class-validator class-transformer

먼저 nest에서 DTO를 구현하기 위해서는 class-validator와 class-transformer 2개의 라이브러리가 필요합니다.

import { Expose } from 'class-transformer';
import { IsNotEmpty, IsString } from 'class-validator';

export class GetUserRequest {
  @Expose()
  @IsNotEmpty({ message: 'id가 존재하지 않습니다.' })
  id: number;

  @Expose()
  @IsString()
  @IsNotEmpty({ message: 'username이 존재하지 않습니다.' })
  username: string;

  constructor(partial: Partial<GetUserRequest>) {
    Object.assign(this, partial);
  }
}

 

라이브러리를 다운받았다면, 위와 같이 간단하게 구현하실 수 있습니다.

class-validator 라이브러리에는 값을 검증할 수 있는 여러가지 기능이 들어있고, 적절하게 사용할 수 있습니다.

 

위 코드의 경우,

  • IsNotEmpty() : 값이 비어있을 수 없음.
  • IsString() : 값이 문자열임.
  • Expose() : class-validator의 기능이 아닌, class-transformer의 기능으로, 객체를 클래스 인스턴스로 변환할 때, 해당 속성을 포함시킬지 여부를 결정하는데 사용됨.

id와 username에 대한 값을 검증하는 부분입니다.

 

그 밑에 생성자는 DTO를 생성할 때 전달된 객체의 값을 DTO 클래스 필드에 자동으로 넣어주는 부분입니다.

const dto = new GetUserRequest({ id: 1, username: 'juyear' });

예를 들어, 위와 같은 경우에서 id=1, username='juyear' 로 클래스 인스턴스가 자동으로 생성됩니다.

@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@UserDecorator() user: GetUserRequest) {
  const res = this.userService.findById(user.id);
  return {
    message: '유저 프로필',
    res,
  };
}

이렇게 만든 Request DTO를 controller 적용할 수 있습니다.

이제 Decorator가 추출한 데이터를 검증하는 구조까지 만들어졌습니다.

 

만약 { id: number; username: string } 방식으로 한다면, controller 내부에서 데이터 검증까지 진행해야 하므로, 단일 책임에 대한 클린 코드 규칙이 깨지고, 가독성이나 유지보수성도 떨어지게 됩니다.

하지만, DTO를 사용함으로써 책임을 나누고, controller 접근 전에 요청 데이터를 검증할 수 있게 됩니다.

 

추가 (중요)

Guard,DecoratorDTO를 같이 사용하는 경우에 DTO 검증은 적용되지만, DTO로 변환이 제대로 이루어지지 않을 수 있습니다.

ValidationPipe는 Body(), Query(), Param() 등에서만 적용이 되기 때문입니다.

즉, Guard에서 직접 추가한 request.user 값에는 자동으로 적용되지 않습니다.

여기서 말하는 ValidationPipe가 적용되지 않는다는건 다음 목차에서 설명할 설정들이 제대로 적용되지 않는다는 뜻 입니다.

(DTO 속성 이외의 값이 자동으로 제거되야 하지만 제거되지 않는 문제 등등..이 발생할 수 있다는 뜻으로 이해하시면 됩니다.)

export const UserDecorator = createParamDecorator(
  async (data: unknown, context: ExecutionContext) => {
    const request = context.switchToHttp().getRequest<Request>();
    const dto = plainToInstance(GetUserRequest, request.user, {
      excludeExtraneousValues: true,
    });

    const errors = await validate(dto);
    if (errors.length > 0) {
      throw new BadRequestException(errors);
    }
    return dto;
  },
);

이런 경우 위 처럼 Decorator 코드를 조금 수정해줘야 합니다. 

기존에는 return request.user로 그냥 값을 반환했지만, DTO를 적용시키기 위해서는 여기서 변환 및 검증 작업을 해줘야 합니다.

plainToInstance는 임의의 객체를 DTO 클래스로 변환시켜주는 역할을 하며, excludeExtraneousValues 옵션은 DTO에 정의되지 않은 속성을 제거해주는 역할을 합니다. (ValidationPipe의 whitelist 기능)

검증의 경우 validate()를 사용하시면 됩니다. 해당 DTO에 설정된 검증을 진행합니다. (IsString, IsNotEmpty 등)

이렇게 해주면 DTO 변환이 제대로 이루어지게 됩니다.

(결론적으로, JWT 기반 사용자 인증과 같이 특수한 경우에 Decorator에서 직접 검증합니다. 일반적인 경우에는 ValidationPipe로 바로 DTO 검증이 가능합니다.)


⚙️ ValidationPipe 적용하기

위에서 만든 DTO의 경우, Decorator에서 변환 및 검증 작업을 하고 있지만,

일반적으로는 제대로 작동하기 위해서 ValidationPipe를 적용시켜야 합니다.

ValidationPipe는 DTO가 적용되어 있는 controller에 대해서 DTO가 데이터를 검증할 수 있도록 설정해주는 역할을 합니다.

또한, DTO의 판단 여부 등을 설정할 수 있습니다.

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

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    }),
  );

  await app.listen(process.env.PORT ?? 3000);
}

ValidationPipe는 main.ts에서 적용할 수 있습니다.

위와 같이 useGloablPipes를 사용하여 ValidationPipe를 생성할 수 있으며, 이렇게 생성된 pipe는 DTO에 적힌 class-validator 규칙을 기반으로 요청 데이터를 검증하게 됩니다.

 

위에서 설정된 각각의 기능에 대해서 설명하면,

  • whitelist : DTO에 명시된 속성만 남기고, 나머지는 모두 제거합니다. (true일 경우)
    만약, DTO에 username 속성만 있다면, 나머지 속성은 제거됨.
  • forbidNonWhitelisted : DTO에 없는 속성이 들어오면 아예 에러를 발생시킵니다. (true일 경우)
  • transform : DTO에 들어온 데이터를 자동으로 DTO 클래스 인스턴스로 변환합니다. (true일 경우)
    id : "10"으로 들어오더라도 DTO에 id : number로 되어있다면 자동으로 number로 변환됨.

이렇게 pipe까지 적용해주면, DTO가 제대로 작동하게 됩니다.


Request DTO 테스트 (Postman)

DTO 테스트 1

DTO의 변환 및 검증이 적용되기 전에는 iat와 exp등 request.user의 모든 정보가 출력되는 것을 알 수 있습니다.

하지만 DTO가 적용된 이후에는 검증 절차가 이루어져, id와 username만 출력되는 것을 알 수 있습니다.

request.user = { id: 1, username: 33 };

DTO 검증 절차가 제대로 이루어지는 확인해보기 위해, 임의로 username 값을 숫자로 변경해 보았습니다.

DTO 테스트 2

그 후 postman으로 요청을 보내보면, 위와 같이 검증 절차에서 오류를 반환하는 것을 확인할 수 있습니다.

오류를 확인해보면, isString에서 username이 문자열이어야 한다는 오류를 확인할 수 있습니다.

 

이렇게 DTO가 잘 적용되는 것을 테스트 해보았습니다.

현재 경우 ValidationPipe로 검증을 하는 것이 아닌, Decorator에서 수동으로 검증을 진행하고 있기 때문에 결과가 조금 다를 수 있습니다.


⚙️ Response DTO 구현하기

Response DTO의 경우 Request DTO 보다 간단하게 제작할 수 있습니다.

기본적으로 Response DTO는 ValidationPipe의 영향을 받지 않고, Guard, Decorator와 복잡하게 연결되어 있지 않기 때문입니다.

import { Exclude, Expose } from 'class-transformer';

export class GetUserResponse {
  @Expose()
  id: number;

  @Expose()
  username: string;

  @Exclude()
  password: string;
}

Response DTO의 경우 어떤 값을 포함시키고 포함시키지 않을지 정도만 정해주면 됩니다.

(DB에 저장할 때, 이미 데이터 검증이 이루어졌기 때문에 포함 여부만 설정하면 됩니다.)

  • Expose() : 반환 값에 해당 속성을 포함시킴.
  • Exclude() : 반환 값에 해당 속성을 포함시키지 않음.

password와 같이 민감한 정보는 Exclude로 포함시키지 않을 수 있습니다.

@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @UseGuards(JwtAuthGuard)
  @Get('profile')
  getProfile(@UserDecorator() user: GetUserRequest): GetUserResponse {
    const res = this.userService.findById(user.id);
    const dto = plainToInstance(GetUserResponse, res, {
      excludeExtraneousValues: true,
    });
    return dto;
  }
}

 

그 후 controller에 바로 적용시킬 수 있습니다.

service로부터 반환받은 user 데이터를 검증해야 하니, plainToInstance와 excludeExtraneousValues 옵션을 사용해줍니다.

이렇게 하면, Expose() 속성만 포함시킨 DTO 인스턴스를 반환하게 됩니다.


 Response DTO 테스트 (Postman)

const res = this.userService.findById(user.id);
console.log(res);
const dto = plainToInstance(GetUserResponse, res, {
  excludeExtraneousValues: true,
});
console.log(dto);

먼저 res와 dto값을 각각 출력시켜서 비교해보도록 하겠습니다.

Response DTO 테스트 1

보이시는 것과 같이 res의 경우 password를 포함시키고 있지만, 

dto의 경우 DTO로 변환되면서 password 값을 제외시킨 것을 확인할 수 있습니다.

반환 값

실제로 반환된 데이터에는 password 속성 자체가 제거되어 나오는 것을 확인할 수 있습니다.


🛠️ 현재 폴더 구조


😊 마무리

이렇게 오늘 DTOValidationPipe에 대해서 정리해보았습니다.

지금까지 진행했던 내용들 중에 가장 복잡했던 부분인 것 같습니다.

 

일반적인 ValidationPipe를 사용한 DTO의 경우 많이 복잡하지 않았지만, ValidationPipe가 적용되지 않는 부분에 대해서

직접 변환 및 검증을 해주는 것이 살짝 복잡했던 것 같습니다.

 

사실 이번 편은 작성하기 전까지 분량이 짧을 것이라고 예상했었는데, 생각보다 많이 길어진 것 같습니다...

 

지금은 굉장히 단순한 기능에 적용시키고 있기 때문에 오버 엔지니어링이라고 생각하실 수 있지만,

기능이 복잡해질수록, 이러한 시스템 설계는 분명 프로젝트 제작에 큰 도움이 될 것이라고 생각합니다.

 

현재까지 진행된 정도를 확인해보면,

로그인 구현(JWT 발급) -> API 요청 -> Guard로 JWT 검증 -> Decorator로 데이터 추출 -> DTO로 데이터 검증

까지 진행이 되었습니다.

 

다음 글에서는 현재 진행에서 살짝 벗어나, service와 repository를 분리하여 유지보수성을 높이는 방법에 대해서 정리해볼 예정입니다.

 

그럼 지금까지 읽어주셔서 감사드리며, 다음에 더 유익한 글로 찾아오도록 하겠습니다.

https://discord.gg/8Hh8WgM4zp

 

KYT CODING COMMUNITY Discord 서버에 가입하세요!

Discord에서 KYT CODING COMMUNITY 커뮤니티를 확인하세요. 25명과 어울리며 무료 음성 및 텍스트 채팅을 즐기세요.

discord.com

KYT CODING COMMUNITY 가입하기!

728x90