컨트롤러, 서비스, 모듈 구현하기

컨트롤러, 서비스, 모듈 생성

1
2
3
nest g mo #module
nest g co #controller
nest g s #service

generate 명령어로 컨트롤러와 서비스, 모듈을 같은 이름으로 생성한다. 그러면 그 이름으로 된 폴더가 src 폴더에 만들어지고 그 안에 3개의 파일과 .spec.ts의 테스트 파일이 생성된다.

모듈 다루기

모듈은 같은 이름으로 생성한 컨트롤러와 서비스를 import해서 한 역할을 하는 모듈로 만든다. 그 모듈들이 루트 모듈(AppModule)에서 import되고, 객체지향 프로그래밍을 할 수 있게 된다.

1
2
3
4
5
6
7
8
9
10
import { Module } from '@nestjs/common';
import { MoviesController } from './movies.controller';
import { MoviesService } from './movies.service';

@Module({
controllers: [MoviesController],
providers: [MoviesService],
})
export class MoviesModule {}

위처럼 기본적인 구조를 nest에서 알아서 잡아주기에 필요시 controllers와 providers(서비스)에 넣어주면 된다.

컨트롤러 다루기

컨트롤러는 클라이언트로부터 uri를 통해 특정 작업을 요청받고, 서비스로 데이터 처리를 요청하고 클라이언트에게 반환하는 역할을 한다. 따라서 Nest의 OOP 철학을 준수하기 위해서는 비즈니스 로직을 서비스에 구현하여 컨트롤러와 분리시켜야 한다.

GET 다루기

1
2
3
4
5
6
7
8
9
import { Movie } from './entities/movie.entity';
@Controller('movies')
export class MoviesController {
constructor(private readonly moviesService: MoviesService) {}

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

이렇게 GET 요청을 받고, 서비스에 넘겨줄 뿐이다. 신기한 것은 서비스를 따로 import하지 않고 생성자(constructor)에서 불러온다는 것이다. @Get으로 GET 요청을 받고 있는데, REST API에서 사용하는 @Post @Put @Patch @Delete 등 모든 메소드를 데코레이터로 사용할 수 있다. (이 점에서 상당히 스프링부트와 유사하다)

또한 기본적으로 타입스크립트 기반이기 때문에 Type을 준수해야 한다. 당연히 기본적인 타입으로는 정확한 타입을 명시하지 못하는 경우가 많은데, entities 폴더를 따로 만들어 별도의 타입을 명시할 수 있다.

1
2
3
4
5
6
7
// src/movies/entities/movie.entity.ts
export class Movie {
id: number;
title: string;
year: number;
genres: string[];
}

이제 이 entity를 타입스크립트 리턴 타입, 파라미터 타입으로 사용하면 된다. (이 부분은 Nest보다는 타입스크립트에 가까우니 더 자세한 설명은 생략하겠다)

본론으로 넘어와서, /movies에 GET 요청을 받으면

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

이렇게 getAll 함수가 호출되고, 이 컨트롤러에서 생성자에서 정의한 서비스의 메소드를 호출한다.

POST 다루기

1
2
3
4
@Post()
create(@Body() movieData: CreateMovieDto): void {
return this.moviesService.create(movieData);
}

POST 요청도 GET과 마찬가지다. Body의 내용은 @Body() 변수이름: (타입)으로 받는다. Body로 넘어온 인자에 대한 유효성 검사는 나중에 DTO 개념에 대해서 설명하겠다. (이걸 아는 순간 신세계가 열린다)

다른 DELETE, PATCH 등의 요청도 똑같이 @Delete(), @Patch() 등의 데코레이터로 알맞게 받아주면 된다.

파라미터, 쿼리 받기

파라미터든 쿼리든 컨트롤러에서 자유롭게 받을 수 있다.

1
2
3
4
@Get('search')
search(@Query('year') searchingYear: number): Movie[] {
return this.moviesService.search(searchingYear);
}

쿼리는 위처럼 @Query('value 이름') 변수명: (타입)으로 받는다.

1
2
3
4
@Get(':id')
getOne(@Param('id') movieId: number): Movie {
return this.moviesService.getOne(movieId);
}

파라미터는 @Param('value 이름') 변수명: (타입)으로 받는다.

Req, Res를 사용하지 않는 이유

그런데 Express에서는 Request 객체로 쿼리와 파라미터를 다뤘었다. 그래서 보통 req.params, req.query처럼 request 객체를 직접 인자로 받아 핸들링했다. Nest에서도 @Req, @Res처럼 Express 응답 객체를 다룰 수 있지만, Nest는 Express 프레임워크만이 아닌 Fastify 프레임워크도 지원한다. 따라서 프로젝트를 무조건 Express로 고정할 것이 아니라면 @Param @Query 등 공통적으로 사용할 수 있는 Nest의 방식을 준수하는 것이 낫다.

참고로 Fastify는 Express보다 벤치마킹 결과가 2배정도 빠르다고 한다

서비스 다루기

서비스는 비즈니스 로직의 전반이 구현된 클래스이다. 컨트롤러에서 받은 요청을 처리하는데, 이 비즈니스 로직은 데이터 처리 등의 모든 로직이 포함되어 있다.

1
2
3
4
5
6
7
@Injectable()
export class MoviesService {
private movies: Movie[] = [];

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

위 컨트롤러에서 GET 요청을 받고 서비스의 getAll()을 호출한다. 여기서 받은 데이터를 그대로 컨트롤러에 넘겨주고, 컨트롤러는 클라이언트에 넘겨준다.

여기서는 데이터베이스 없이 메모리상의 배열 데이터를 DB로 가정했지만, 실제로는 Sequelize같은 ORM을 쓰던, Mysql 라이브러리를 쓰던지 해서 데이터를 처리하면 된다.

파라미터가 있는 메소드 처리

파라미터가 있는 메소드도 마찬가지다.

1
2
3
search(year: number): Movie[] {
return this.movies.filter((movie) => movie.year >= year);
}

컨트롤러에서 받은 타입을 그대로 서비스 메소드의 파라미터 타입에도 그대로 명시하고 처리하면 된다. 타입스크립트기에 타입을 정확하게 다룰 수 있어야 한다.

서비스는 전반적으로 이렇게 구현된다. 이러면 OOP 개념은 거의 끝났다. 이제 신세계를 느꼈던 DTO에 대해서 알아볼까 한다.

DTO

DTO(Data Transfer Object)는 데이터 교환을 위한 객체이다. POST, PUT 등 Body에 데이터를 실어서 보낼 때 사용한다. 일반적으로 Express에서는 검증 미들웨어를 별도로 구현하던지, 실제 비즈니스 로직에서 파라미터의 데이터에 대한 유효성 처리를 구현해야 한다.

Nest에서는 DTO를 정의하고, global pipe를 연결해주면 정말 쉽게 클라이언트가 보낸 데이터에 대한 검증을 해결할 수 있다.

class-validator 사용하기

우선 class-validator를 npm 패키지로 설치한다.

1
$ yarn add class-validator # npm i class-validator

그리고 같은 폴더에 dto라는 폴더를 만들고, ~.dto.ts 파일을 만든다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/movies/dto/create-movie.dto.ts
import { IsNumber, IsOptional, IsString } from 'class-validator';

export class CreateMovieDto {
@IsString()
readonly title: string;

@IsNumber()
readonly year: number;

@IsString({ each: true })
@IsOptional()
readonly genres: string[];
}

이제 POST 요청 시 받을 데이터를 위에서 엔터티 만들던 방식대로 만든다. 그리고 @IsString() 등의 데코레이터를 달아준다. class-validator에는 정말 많은 검증 데코레이터가 있다. NPM 공식 문서에서 Validation decorators를 보고 알맞게 데코레이터를 붙여주면 된다. 참고로 {each: true}를 넣은 것은 배열 하위의 데이터 각자가 string이어야 한다고 명시한 것이다. @IsOptinal은 선택사항이고 없어도 된다는 데코레이터이다.

useGlobalPipes 추가하기

이제 이 Validator가 돌아갈 수 있게 미들웨어 형식으로 메인 app에 달아준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.listen(3000);
}
bootstrap();

마법이 시작되는 공간이다. ValidationPipe를 미들웨어로 달아줌으로써 위의 DTO에서 구현해놓은 데코레이터가 작동하며 클라이언트로부터 전달받은 파라미터의 데이터를 검증한다. Exception을 뱉어줄 경우 친절하게 왜 뱉었는지도 알려준다. whitelist, forbidNonWhitelisted, transfrom 옵션에 대해서 알아보자.

transform 옵션

아직 마법까진 아니라고 생각할 수도 있다. 자, 이제 transform에 대한 기능이다. 잠시 DTO 생각은 잊고, 일반적으로 파라미터 넘어온 값은 숫자로 보내도 벡엔드에서는 string 형식으로 받는다. 그래서 대부분 parseInt() 메소드나 +number 등의 방식으로 데이터 타입을 한번 변경한 후 사용한다.

예를 들면,

1
2
3
4
@Get(':id')
getOne(@Param('id') movieId: string): Movie {
return this.moviesService.getOne(movieId);
}

여기서 id는 number형으로 넘겨받고 싶지만 어쩔 수 없이 string으로 받고 서비스나 컨트롤러에서 데이터 타입을 변환해야 한다. 하지만 위 global pipe에서 transform 옵션을 넣었기 때문에 이제 number로 받아도 된다. 알아서 데이터 타입을 변경해준다. 결론적으로 transform은 클라이언트가 보낸 값을 서버가 원하는 값의 타입으로 변환한다. 그러면 만약에 abc 처럼 number로 변환할 수 없는 값을 받았을 때는? NaN으로 변환되기 때문에 로직에서 문제될 것이 없다.

whitelist 옵션

whitelist: true는 유효하지 않은 데이터가 포함되어 넘어왔을 때 Validator에 도달하지 않게 한다.

예를 들어서 CreateMovieDto에서는 title, year, genres를 받았지만 만약 클라이언트가 악의적으로 hello 개체를 데이터에 포함해서 넘겼다면, whitelist에서 자동으로 hello 개체는 빼고 보낸다.

forbidNonWhitelisted 옵션

forbidNonWhitelisted: true는 유효하지 않은 개체일 경우 리퀘스트 자체를 막는다. 이 3개의 옵션뿐만 아니라 공식 문서에 useGlobalPipes에 들어가는 여러가지 옵션이 있다.

마치며

타입스크립트와 Express만 어느정도 다룰 줄 안다면 Nest를 적용시키는 것은 일도 아니다. 오히려 너무 편하다. 분명히 내가 모르는 다른 유용한 기능도 많을 것이고, 그런 것들은 공식문서나 검색으로 넣어주면 된다. 다음에는 테스트에 대해서 알아볼 것인데, 사실 테스트에 큰 흥미가 없었는데 이번 기회에 Jest를 사용한 유닛 테스트, E2E 테스트에 많은 재미를 느꼈다. 아무튼 난 토이프로젝트가 아닌 이상 Pure Express App이 아닌 Nest 프레임워크를 사용할 것 같다.