Lsiron
Nest.js에서 DTO를 통해 유효성 검사 처리하기 본문
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의 보안도 이용하고, 유효성 검사를 자동으로 처리 해 주는 점이 인상적이다.
참조: 노마드코더
'백엔드 > Nest.js' 카테고리의 다른 글
Nest.js에서 Jest로 유닛 테스트와 E2E 테스트 시작하기 (5) | 2024.08.28 |
---|---|
Nest.js에서 모듈과 의존성 주입 이해하기 (0) | 2024.08.25 |
Nest.js에서 Controller와 Service 다루기 (0) | 2024.08.22 |
Nest.js 에서 API 만들기 (get, post, patch, delete) (0) | 2024.08.22 |
Nest.js 시작하기 (0) | 2024.07.21 |