Lsiron

Nest.js에서 DTO를 통해 유효성 검사 처리하기 본문

백엔드/Nest.js

Nest.js에서 DTO를 통해 유효성 검사 처리하기

Lsiron 2024. 8. 23. 04:11

movies.service.ts

import { Injectable, NotFoundException } from '@nestjs/common';
import { Movie } from './entities/movie.entity';

@Injectable()
export class MoviesService {
    private movies: Movie[] = [];

    getAll(): Movie[] {
        return this.movies;
    }

    getOne(id:string): Movie {
        const movie = this.movies.find(movie => movie.id === parseInt(id));
        if(!movie) {
            throw new NotFoundException(`Movie with ID ${id} not found`)
        }
        
        return movie;
    }

    delete(id: string) {
        this.getOne(id)
        this.movies = this.movies.filter(movie => movie.id !== parseInt(id));
    }

    create(movieData){
        this.movies.push({
            id: this.movies.length + 1,
            ...movieData
        })
    }

    update(id: string, updateData) {
        const movie = this.getOne(id);
        this.delete(id);
        this.movies.push({...movie, ...updateData })
    }
}

movies.controller.ts

import { Controller, Get, Post, Delete, Patch, Param, Body } from '@nestjs/common';
import { MoviesService } from './movies.service';
import { Movie } from './entities/movie.entity';

@Controller('movies')
export class MoviesController {
    constructor(private readonly moviesService: MoviesService) {}

    @Get()
    getAll(): Movie[] {
        return this.moviesService.getAll();
    }

    @Get('/:id')
    getOne(@Param('id') movieId: string): Movie {
        return this.moviesService.getOne(movieId);
    }

    @Post()
    create(@Body() movieData) {
        return this.moviesService.create(movieData);
    }

    @Delete('/:id')
    remove(@Param('id') movieId: string) {
        return this.moviesService.delete(movieId);
    }

    @Patch('/:id')
    patch(@Param('id') movieId: string, @Body() updateData) {
        return this.moviesService.update(movieId, updateData)
    }
}

 

이 전에 만들었던 movieData와 updateData에 타입 부여를 하지 않았었다.

 

이에 타입을 부여하기 위해서 우리는 service와 controller에서 DTO(Data Transfer Object) 즉, 데이터 전송 객체를 만들어야 한다.

 

위를 위해 먼저 dto 라는 폴더를 만들고 그 안에 create-movie.dto.ts 파일을 만들어 주자.

 

이 후, create-movie.dto.ts 파일로 가서 아래와 같이 작성을 해 주자.

export class CreateMovieDto {
    readonly title: string;
    readonly year: number;
    readonly genres: string[];
}

 

entities 폴더의 movie,entity.ts 파일을 기반으로 유저가 보낼 수 있는 객체에 타입을 부여했다.

 

이제 movies.controller.ts 와 movies.service.ts 에서 타입을 지정 해 주지 않았던 movieData와 updateData에 타입을 부여 해 주자.

 

movies.service.ts

import { Injectable, NotFoundException } from '@nestjs/common';
import { Movie } from './entities/movie.entity';
import { CreateAxiosDefaults } from 'axios';
import { CreateMovieDto } from './dto/create-movie.dto';

@Injectable()
export class MoviesService {
    private movies: Movie[] = [];

    getAll(): Movie[] {
        return this.movies;
    }

    getOne(id:string): Movie {
        const movie = this.movies.find(movie => movie.id === parseInt(id));
        if(!movie) {
            throw new NotFoundException(`Movie with ID ${id} not found`)
        }
        
        return movie;
    }

    delete(id: string) {
        this.getOne(id)
        this.movies = this.movies.filter(movie => movie.id !== parseInt(id));
    }

    create(movieData: CreateMovieDto){ // 타입 지정
        this.movies.push({
            id: this.movies.length + 1,
            ...movieData
        })
    }

    update(id: string, updateData: CreateMovieDto) { // 타입 지정
        const movie = this.getOne(id);
        this.delete(id);
        this.movies.push({...movie, ...updateData })
    }
}

movies.controller.ts

import { Controller, Get, Post, Delete, Patch, Param, Body } from '@nestjs/common';
import { MoviesService } from './movies.service';
import { Movie } from './entities/movie.entity';
import { CreateMovieDto } from './dto/create-movie.dto';

@Controller('movies')
export class MoviesController {
    constructor(private readonly moviesService: MoviesService) {}

    @Get()
    getAll(): Movie[] {
        return this.moviesService.getAll();
    }

    @Get('/:id')
    getOne(@Param('id') movieId: string): Movie {
        return this.moviesService.getOne(movieId);
    }

    @Post()
    create(@Body() movieData: CreateMovieDto) { // 타입 지정
        return this.moviesService.create(movieData);
    }

    @Delete('/:id')
    remove(@Param('id') movieId: string) {
        return this.moviesService.delete(movieId);
    }

    @Patch('/:id')
    patch(@Param('id') movieId: string, @Body() updateData: CreateMovieDto) { // 타입 지정
        return this.moviesService.update(movieId, updateData)
    }
}

  

DTO를 사용하는 이유는 프로그래머로서 코드를 더 간결하게 만들고 구조화 할 수 있도록 돕기 위함이다.

 

또한 가장 중요한 것은 Nest.js가 들어오는 요청 데이터에 대해 유효성을 검사 할 수 있도록 해 주는 것 이다.

 

이 유효성 검사를 만들어주기 위해 main.ts 파일로 가서 파이프를 설정 해 보자.

 

파이프란? 요청이 처리되는 중간에 데이터를 가공하거나, 유효성을 검사하는 역할을 한다.

 

즉, 코드는 이 유효성 검사를 위한 파이프를 통과하면서 잘못된 데이터에 대해 필더링이나 오류 처리가 가능하도록 해 준다.

( 이는 express.js에서의 미들웨어와 유사한 개념이다. )

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe()); // 유효성 검사 파이프
  await app.listen(3000);
}
bootstrap();

 

validationPipe가 바로 유효성 검사를 수행하는 역할을 하며, 이러한 유효성 검사에 여러 옵션을 추가 할 수 있도록 해 준다. ( 옵션을 추가하는 기능은 후에 다루도록 하겠다. )

 

이를 위해, 아래와 같이 명령어를 입력하여 class-validator와 class-transformer 패키지를 설치 해야한다.

$ npm install class-validator class-transformer

 

class-validator는 데이터가 유효한지 검사하기 위한 검증 규칙을 설정하는 데 도움을 준다. 예를 들어, 요청 데이터에서 숫자여야 할 값이 실제로 숫자인지, 문자열은 특정 형식을 따르는지 등을 검사한다.

 

class-transformer는 DTO로 들어오는 JSON 데이터가 DTO 클래스의 인스턴스로 올바르게 변환되도록 도와준다. 이 라이브러리 덕분에 요청 데이터가 특정 클래스 타입으로 변환되어 더 구조적인 코드를 작성할 수 있게 된다.

 

즉, 두 라이브러리는 유효성 검증과 데이터변환을 보다 쉽게 관리 할 수 있게 해 준다.

 

이를 통해 보다 안전하고 일관된 방식으로 유효성 검사를 수행할 수 있으며, DTO를 통해 구조적인 코드를 작성할 수 있게 된다.

 

설치가 끝났다면 create-movie.dto.ts 파일로 가서 decorator를 사용 해 주자.

import { IsString, IsNumber } from 'class-validator';

export class CreateMovieDto {
    @IsString()
    readonly title: string;
    @IsNumber()
    readonly year: number;
    @IsString({ each: true }) // 모든 요소를 하나씩 검사 한다는 뜻
    readonly genres: string[];
}

 

차례대로 역할에 대해 알아보자.

 

@IsString(): 해당 속성이 문자열이어야 함을 검사하며, 만약 이 속성이 문자열이 아니면, 유효성 검사에서 오류를 발생시킨다.

예시: @IsString()이 적용된 title 속성에 숫자나 배열이 들어오면 Nest.js는 유효성 검사를 실패시키고 오류 응답을 보낸다.

 

@IsNumber(): 해당 속성이 숫자여야 함을 검사한다. 만약 이 속성이 숫자가 아니면, 유효성 검사에서 오류가 발생한다.

예시: @IsNumber()가 적용된 year 속성에 문자열이나 배열이 들어오면 Nest.js는 유효성 검사를 실패시키고 오류 응답을 보낸다.

 

@IsString({ each: true }): 이 데코레이터는 배열을 대상으로 동작하며, 배열의 모든 요소가 문자열인지 검사한다.

each: true 옵션이 있으면 배열의 각 요소를 개별적으로 검사한다.

예시: @IsString({ each: true })가 적용된 genres 속성은 문자열 배열이어야 하며, 배열 안의 각 요소가 문자열이어야 한다. 만약 배열 안에 숫자나 다른 타입이 포함되어 있으면 Nest.js는 유효성 검사를 실패시키고 오류 응답을 보낸다.

 

즉, @IsString(), @IsNumber() 등의 유효성 검사 데코레이터는 각 속성에 대해 원하는 데이터 타입이나 형식을 강제하기 위한 도구이다.

 

한번 데이터 생성을 통해 유효성 검사 파이프가 제대로 작동하는지 확인해보러 Postman으로 가보자.

 

 

DTO로 설정 해 놓았던 형식을 지켜주지 않으니 바로 400에러인 Bad Request가 발생하면서, 어떠한 오류가 있는지 자세하게 다 알려준다.

 

이로 인해 우리는 input 값 마저도 유효성을 체크하고 있으며, TS를 통해 실시간으로 코드의 유효성을 체크하고 있다.

 

이제 main.ts로 가서 앞서 말했던 유효성 검사에 옵션을 추가하는 기능에 대해 알아보자.

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(
    new ValidationPipe({   
      whitelist: true, // 아무 decorator도 없는 object를 걸러준다.
      forbidNonWhitelisted:true // 잘못된 데이터를 요청하면, request를 막아준다.
    }),
  );
  await app.listen(3000);
}
bootstrap();

 

위와 같이 whitelist와 forbidNonWhitelisted 에 true 옵션을 추가하여 보안을 더 강화 시킬 수 있다.

 

이제 movies.controller.ts로 가보자.

import { Controller, Get, Post, Delete, Patch, Param, Body } from '@nestjs/common';
import { MoviesService } from './movies.service';
import { Movie } from './entities/movie.entity';
import { CreateMovieDto } from './dto/create-movie.dto';

@Controller('movies')
export class MoviesController {
    constructor(private readonly moviesService: MoviesService) {}

    @Get()
    getAll(): Movie[] {
        return this.moviesService.getAll();
    }

    @Get('/:id')
    getOne(@Param('id') movieId: string): Movie {
        return this.moviesService.getOne(movieId);
    }

    @Post()
    create(@Body() movieData: CreateMovieDto) {
        return this.moviesService.create(movieData);
    }

    @Delete('/:id')
    remove(@Param('id') movieId: string) {
        return this.moviesService.delete(movieId);
    }

    @Patch('/:id')
    patch(@Param('id') movieId: string, @Body() updateData: CreateMovieDto) {
        return this.moviesService.update(movieId, updateData)
    }
}

 

Nest.js에서 컨트롤러와 서비스 간의 타입 불일치 문제는 간혹 발생할 수 있다.

 

예를 들어, 우리가 앞서 movieId의 타입을 컨트롤러에서는 string으로 정의하고, entity의 id 필드는 number로 정의했을 때 발생하는 상황이다.

 

//movie.entity.ts
export class Movie {
    id: number;
    title: string;
    year: number;
    genres: string[];
}

 

왜 movieId를 string으로 받아와서 타입 불일치가 발생했을까?

 

이 문제는 URL로 전달되는 값은 기본적으로 문자열(string) 타입이라는 점에서 시작된다.

 

따라서 getOne을 예시로 들자면, 컨트롤러에서는 @Param('id')로 URL 파라미터를 받아 movieId라는 변수를 생성한다.

이때, 파라미터는 문자열로 전달되므로 타입을 string으로 정의한다.

//movies.controller.ts
   @Get('/:id')
    getOne(@Param('id') movieId: string): Movie {
        return this.moviesService.getOne(movieId);
    }

 

이 후, service로 movieId를 넘겨준다.

// movie.service.ts
import { Movie } from './entities/movie.entity';

    getOne(id:string): Movie {
        const movie = this.movies.find(movie => movie.id === parseInt(id));
        if(!movie) {
            throw new NotFoundException(`Movie with ID ${id} not found`)
        }
        
        return movie;
    }

 

서비스 로직에서 id는 엔터티에서 number 타입으로 정의되어 있다.

 

따라서 컨트롤러에서 받은 string 타입의 movieId를 number 타입으로 변환해야 한다.

( parseInt(id)로 parseInt 함수 말고 그냥 +id 로 + 연산자를 사용해서 타입을 number로 변환 시킬 수 있다. )

 

바로 entity와 controller 간 타입불일치를 해결하기 위해 service 로직에서 id의 타입을 number로 다시 변환 시킨 것.

 

이는 좋은 방법이 아니다. 따라서 ValidationPipe에 옵션을 하나 더 추가 할 것이다.

 

main.ts 파일로 가서, transfrom 옵션을 추가 해주자.

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(
    new ValidationPipe({   
      whitelist: true, 
      forbidNonWhitelisted:true, 
      transform: true, // 요청으로 들어오는 데이터를 우리가 기대하는 DTO의 타입으로 자동 변환 해줌
    }),
  );
  await app.listen(3000);
}
bootstrap();

 

주석으로 넣어놨듯이 transform 옵션은 요청으로 들어오는 데이터를 우리가 기대하는 DTO의 타입으로 자동 변환해주는 기능을 한다. 

 

transform: true가 설정된 경우, 다음과 같은 변환 작업이 자동으로 이루어진다.

 

문자열을 숫자, 날짜 등으로 변환: 예를 들어, 클라이언트가 year라는 숫자 값을 전송했지만 그 값이 "2024"처럼 문자열로 들어온 경우, 이 값을 자동으로 number 타입으로 변환해준다.

 

이 말은 즉? 번거롭게 controller에서는 id를 string 타입으로 지정해주고 다시 service에서 타입을 number로 변환하는 번거로운 행동을 하지 않아도 되는 것이다.

 

movie.controller.ts 파일로 가서 string 타입으로 지정된 movieId 들을 모두 number로 바꿔주자.

import { Controller, Get, Post, Delete, Patch, Param, Body } from '@nestjs/common';
import { MoviesService } from './movies.service';
import { Movie } from './entities/movie.entity';
import { CreateMovieDto } from './dto/create-movie.dto';

@Controller('movies')
export class MoviesController {
    constructor(private readonly moviesService: MoviesService) {}

    @Get()
    getAll(): Movie[] {
        return this.moviesService.getAll();
    }

    @Get('/:id')
    getOne(@Param('id') movieId: number): Movie {
        return this.moviesService.getOne(movieId);
    }

    @Post()
    create(@Body() movieData: CreateMovieDto) {
        return this.moviesService.create(movieData);
    }

    @Delete('/:id')
    remove(@Param('id') movieId: number) {
        return this.moviesService.delete(movieId);
    }

    @Patch('/:id')
    patch(@Param('id') movieId: number, @Body() updateData: CreateMovieDto) {
        return this.moviesService.update(movieId, updateData)
    }
}

 

마찬가지로 movies.service.ts 파일로 가서 id 타입을 number로 바꿔주고 parseInt() 함수도 제거 해 주자.

import { Injectable, NotFoundException } from '@nestjs/common';
import { Movie } from './entities/movie.entity';
import { CreateMovieDto } from './dto/create-movie.dto';

@Injectable()
export class MoviesService {
    private movies: Movie[] = [];

    getAll(): Movie[] {
        return this.movies;
    }

    getOne(id:number): Movie {
        const movie = this.movies.find(movie => movie.id === id);
        if(!movie) {
            throw new NotFoundException(`Movie with ID ${id} not found`)
        }
        
        return movie;
    }

    delete(id: number) {
        this.getOne(id)
        this.movies = this.movies.filter(movie => movie.id !== id);
    }

    create(movieData: CreateMovieDto){
        this.movies.push({
            id: this.movies.length + 1,
            ...movieData
        })
    }

    update(id: number, updateData: CreateMovieDto) {
        const movie = this.getOne(id);
        this.delete(id);
        this.movies.push({...movie, ...updateData })
    }
}

 

 

제대로 작동하는지 테스트를 해보자.

 

movies.controller.ts 파일의 getOne에 타입을 확인하는 console.log를 찍어보자.

    @Get('/:id')
    getOne(@Param('id') movieId: number): Movie {
        console.log(typeof movieId)
        return this.moviesService.getOne(movieId);
    }

 

Postman으로 가서 Post 요청으로 id를 하나 생성한 후, Get 요청을 날려보자.

 

 

id 생성이 잘 되며 typeof로 찍었던 console.log도 movieId의 타입이 number임을 명시해주고 있다.

 

이런식으로 validationPipe의 transform 옵션을 통해 Nest.js는 타입을 받아서 넘겨 줄 때 자동으로 타입 변환을 해 준다.

( express.js에서는 모든걸 직접 전환해야했다. )

 

이제까지 우리는 CreateMovieDto를 만들었고 이를 적용 해 주었다. DTO를 하나만 더 만들어보자.

 

바로 updateData에 적용할 UpdateMovieDto를 만들 것 이다.

 

dto 폴더에 update-movie.dto.ts 파일을 만들고 create-movie.dto.ts 파일에 있는 내용을 그대로 가져오자.

//update-movie.dto.ts

import { IsString, IsNumber } from 'class-validator';

export class CreateMovieDto {
    @IsString()
    readonly title: string;
    @IsNumber()
    readonly year: number;
    @IsString({ each: true })
    readonly genres: string[];
}

 

이제 읽기전용이자 필수는 아니도록 설정 해 주자. ( ? 추가 )

import { IsString, IsNumber } from 'class-validator';

export class UpdateMovieDto {
    @IsString()
    readonly title?: string;
    @IsNumber()
    readonly year?: number;
    @IsString({ each: true })
    readonly genres?: string[];
}

 

왜 필수가 아니냐면, UpdateMovieDto를 쓸 때 title만 수정하거나, year이나 genres만 수정할 때가 있기 때문이다.

 

이제 controller와 service에 updateData 타입을 UpdateMovieDto로 바꿔주자.

movies.controller.ts

//movies.controller.ts
import { Controller, Get, Post, Delete, Patch, Param, Body } from '@nestjs/common';
import { MoviesService } from './movies.service';
import { Movie } from './entities/movie.entity';
import { CreateMovieDto } from './dto/create-movie.dto';
import { UpdateMovieDto } from './dto/update-movie.dto';

@Controller('movies')
export class MoviesController {
    constructor(private readonly moviesService: MoviesService) {}

    @Get()
    getAll(): Movie[] {
        return this.moviesService.getAll();
    }

    @Get('/:id')
    getOne(@Param('id') movieId: number): Movie {
        console.log(typeof movieId)
        return this.moviesService.getOne(movieId);
    }

    @Post()
    create(@Body() movieData: CreateMovieDto) {
        return this.moviesService.create(movieData);
    }

    @Delete('/:id')
    remove(@Param('id') movieId: number) {
        return this.moviesService.delete(movieId);
    }

    @Patch('/:id')
    patch(@Param('id') movieId: number, @Body() updateData: UpdateMovieDto) {
        return this.moviesService.update(movieId, updateData)
    }
}

movies.service.ts

//movie.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { Movie } from './entities/movie.entity';
import { CreateMovieDto } from './dto/create-movie.dto';
import { UpdateMovieDto } from './dto/update-movie.dto';

@Injectable()
export class MoviesService {
    private movies: Movie[] = [];

    getAll(): Movie[] {
        return this.movies;
    }

    getOne(id:number): Movie {
        const movie = this.movies.find(movie => movie.id === id);
        if(!movie) {
            throw new NotFoundException(`Movie with ID ${id} not found`)
        }
        
        return movie;
    }

    delete(id: number) {
        this.getOne(id)
        this.movies = this.movies.filter(movie => movie.id !== id);
    }

    create(movieData: CreateMovieDto){
        this.movies.push({
            id: this.movies.length + 1,
            ...movieData
        })
    }

    update(id: number, updateData: UpdateMovieDto) {
        const movie = this.getOne(id);
        this.delete(id);
        this.movies.push({...movie, ...updateData })
    }
}

 

CreateMovieDto와의 차이점은 그저 필수사항이 아니도록 지정한 점이다.

 

허나, 우리는 Nest.js 기능중의 하나인 partial types를 사용할 것이다. 먼저 아래 명령어를 입력하여 패키지를 하나 설치 해 주자.

$ npm i @nestjs/mapped-types

 

mapped-types 패키지는 타입을 변환시키고 사용할 수 있도록 하는 기능을 한다.

 

이제 update-movie.dto.ts 파일로 가서 아래와 같이 작성 해 주자.

import { PartialType } from '@nestjs/mapped-types';
import { CreateMovieDto } from './create-movie.dto';

export class UpdateMovieDto extends PartialType(CreateMovieDto) {}

 

PartialType은 basetype이 필요한데 이 basetype을 CreateMovieDto로 해 주었다.

 

앞서 필수사항이 아니도록 하기위해 길게 써 주었던 코드가 간결하게 바뀌었다.

 

이제 Postman으로 가서 테스트를 해 보자.

 

 

업데이트가 잘 반영된 것을 확인 할 수 있다.

 

이를 통해 Nest.js와 TS를 사용함으로써 얻는 장점이 무엇인지 직접 확인 해 보았다.

 

특히, Nest.js 를 사용함으로 인해 TS의 보안도 이용하고, 유효성 검사를 자동으로 처리 해 주는 점이 인상적이다.

 

참조: 노마드코더