NestJS | API 만들기(3) - Controller, Service, Module, DTO

Sua·2021년 3월 22일
1

NestJS

목록 보기
3/9
post-thumbnail

노마드코더강의를 들으면서 NestJS로 영화 API를 만들고 있다.

controller

src 폴더에 main.ts와 app.module.ts 파일만 남겨두고, 나머지는 다 지워버리자.

app.module.ts 파일에서도 필요없는 것들을 삭제한다.

// app.module.ts
import { Module } from '@nestjs/common';

@Module({
  imports: [],
  controllers: [],
  providers: [],
})
export class AppModule {}

nest g co 커맨드로 새로운 controller를 생성한다. 이름은 movies로 한다.

movies.controller.ts, movies.controller.spec.ts와 그걸 담고 있는 폴더가 생성되었다.
(movies.controller.spec.ts 파일은 테스트 파일인데, 우선 지운다.)

또한, app.module.ts에는 movies 컨트롤러가 자동적으로 import되어 있다.

// app.module.ts
import { Module } from '@nestjs/common';
import { MoviesController } from './movies/movies.controller';

@Module({
  imports: [],
  controllers: [MoviesController],
  providers: [],
})
export class AppModule {}

이제 movies 컨트롤러로 가서 첫 번째 api 라우터를 만들어보자.

url

// movies.controller.ts
import { Controller, Delete, Get, Param, Patch, Post, Body, Query } from '@nestjs/common';

@Controller('movies')
export class MoviesController {

  @Get()
  getAll() {
    return 'This will return all movies';
  }

http://localhost:3000했을 때 경로를 찾지 못하는 이유는 @Controller() 데코레이터의 인자로 'movies'를 받고 있기 때문이다. 이 컨트롤러가 movies 컨트롤러이기 때문에, NestJS에서 자동적으로 생성해줬다. 이 부분이 url의 entry point를 컨트롤하고 있다.

http://localhost:3000/movies로 해야 경로를 찾을 수 있다.

path paramter

  @Get(':id')
  getOne(@Param('id') movieId: string) {
    return `This will return one movie with the id: ${movieId}`;
  }

NestJS에서는 무언가 필요하면 요청해야만 한다. getOne()이 url에서 id라는 parameter를 원하기 떄문에 @Param() 데코레이터로 요청한 것이다.

path parameter와 @Param이 받는 인자의 이름은 동일해야 한다. 여기서는 id.
하지만 url에서 id를 가져오고 나서는 movieId라는 string 변수로 저장해서 사용할 수 있다.

  @Delete(':id')
  remove(@Param('id') movieId: string) {
    return `This will delete a movie with the id: ${movieId}`;
  }

body

  @Post()
  create(@Body() movieData) {
    return movieData;
  }

Post에서 리퀘스트의 body를 가져오고 싶다고 하자.

path paramter + body

  @Patch(':id') // Put 리소스 전체를 업데이트, Patch 리소스의 일부분만 업데이트
  patch(@Param('id') movieId: string, @Body() updateData) {
    return {
      updateMovie:movieId,
      ...updateData
    }; // 업데이트할 movie의 id와 우리가 보낼 데이터의 오브젝트를 리턴
  } // 리턴값으로 json을 받았다는 것. express.js에서 body를 json으로 리턴하려면 설정을 해줬어야 함. 

query parameter

쿼리 파라미터는 @Query() 데코레이터를 사용한다.

  @Get('search')
  search(@Query('year') searchingYear: string) {
    return `We are searching for a movie made after: ${searchingYear};`
  } 

하지만 출력된 결과가 우리가 의도한 결과가 아니다.
그 이유는 그냥 Get이 path paramter를 가진 Get보다 아래에 있으면 경로를 path paramter로 인식하기 때문이다. 여기에서는 search를 id로 판단했다. (express.js에서도 마찬가지)

@Get('search') @Get(':id')보다 위로 올리면 정상적으로 동작한다.

  @Get('search')
  search(@Query('year') searchingYear: string) {
    return `We are searching for a movie made after: ${searchingYear};`
  }

  @Get(':id')
  getOne(@Param('id') movieId: string) {
    return `This will return one movie with the id: ${movieId}`;
  } // :id가 위에 있으면 다른 Get이 의도대로 동작하지 않는다. 

DTOs and Validation

movieData와 updateData에 타입을 부여하기 위해서 서비스와 컨트롤러에 DTO를 만들어야 한다.
DTO는 데이터 전송 객체(Data Transfer Object)를 말한다.

movies 폴더 안에 dto 폴더를 만들고 create-movie.dto.ts 파일을 생성한다.


create-movie.dto.ts 파일에 movie를 만들기 위해 필요한 것들을 나열한다.

// create-movie.dto.ts
export class CreateMovieDto {
  readonly title: string;
  readonly year: number;
  readonly genres: string[]; 
}

controller와 service에서 movieData 타입을 CreateMovieDto로 수정한다.

// movie.controller.ts
  @Post()
  create(@Body() movieData: CreateMovieDto) {  
    return this.moviesService.create(movieData);
  }
// movie.service.ts
  create(movieData: CreateMovieDto) {
    this.movies.push({
      id: this.movies.length + 1,
      ...movieData
    })
  }

그런데 여기서 dto로 타입을 지정해줬는데도 지정되지 않는 키 값이 들어왔을 때 그대로 작동한다.

그럼 왜 DTO를 쓰는 걸까?

  • 코드를 더 간결하게 만들 수 있도록 하기 위해
  • NestJS가 들어오는 쿼리에 대해 유효성 검사를 할 수 있게 하기 위해

이를 위해 main.ts에 유효성 검사를 위한 '파이프'를 만들어 줄 것이다.
(파이프를 미들웨어라고 생각할 수 있다.)

main.ts에 app.useGlobalPipes(new ValidationPipe())를 추가한다. ValidationPipe()가 유효성 검사를 해준다.

// main.ts
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());
  await app.listen(3000);
}
bootstrap();

여기서 npm i class-validator class-transformer 커맨드로 class-validator와 class-transformer를 설치한다.

이제 CreateMovieDto를 위한 데코레이터를 사용할 것이다.

// create-movie.dto.ts
import { IsNumber, IsString } from "class-validator";

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

이제 다시 Post 요청을 보내서 테스해보자.

설정되있지 않은 키값을 보냈을 경우 에러가 발생하는 것 확인할 수 있다. 즉, input 값마저도 유효성 체크를 하고 있다. 다시 말해, 디버깅 코드가 아니어도 Typescript로 실시간으로 코드의 유효성을 체크하고 있다.

자 다시 main.ts에 가보자.
ValidationPipe는 여러 옵션들을 가지고 있다.

  1. whitelist를 true로 설정하면 데코레이터로 꾸며지지 않은 property를 거르게 된다. 다시 말해, "hacked": "by me"는 Validator에 도달하지 않을 것이란 말이다.
  2. 보안을 한 단계 더 업그레이드할 수 있다. forbidNonWhitelisted를 true로 설정하고, 다시 "hacked": "by me"를 보내면 "property hacked should not exist"라는 메시지를 받을 수 있다.

  1. movie를 하나를 가져올 때 url에서 id라는 parameter를 가져온다. 문제는 이게 string이라는 것이다. url로 보낸 값은 뭐든지 string이기 때문이다. 그래서 service의 getOne() 함수에서 id를 number로 변경해야 했던 것이다.
    그래서 transform 옵션을 true로 하면, 바디에서 받아온 값을 실제 타입으로 변환시켜준다. 이제 id를 string 타입으로 지정할 필요가 없다. 컨트롤러와 서비스로 가서 number로 바꿔주자. string을 number로 바꿔주는 로직로 삭제하자.
// main.ts
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
  }));
  await app.listen(3000);
}
bootstrap();

console.log로 movieId의 타입을 찍어보면 number로 나오는 것을 확인할 수 있다.

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

이번에는 update-movie.dto.ts 파일을 생성하자.
controller와 service에서 updateData 타입을 UpdateMovieDto로 수정한다.

?를 붙여서 각 키들을 받지 않아도 되는 상태로 지정해준다.

// update-movie.dto.ts
import { IsNumber, IsString } from "class-validator";

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

  @IsNumber()
  readonly year?: number;

  @IsOptional() // 필수로 받지 않고 싶을 때 사용
  @IsString({ each: true })
  readonly genres?: string[];
}

사실 ?를 사용하는 것보다 PartialType을 사용하는 게 더 좋다.

PartialType를 사용하기 위해 mapped-types을 설치하자. mapped-types는 타입을 변환시키고 사용할 수 있게 하는 패키지이다. npm i @nestjs/mapped-types 커맨드로 설치한다.

https://www.npmjs.com/package/@nestjs/mapped-types

extends로 PartialType을 가져온다. 그리고 PartialType은 베이스 타입이 필요한데, CreateMovieDto로 설정해줄 것이다. 이제 UpdateMovieDto는 모든 값이 필수 사항이 아니라는 것만 빼면 CreateMovieDto와 똑같다.

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

export class UpdateMovieDto extends PartialType(CreateMovieDto) {}

(https://github.com/typestack/class-validator 사이트에서 class-validator가 가진 다양한 기능들을 확인할 수 있다.)

바뀐 코드가 정상적으로 동작하는지 테스트해보자.

Good!

Modules

모듈을 좀 더 좋은 구조로 만들어보자.

app.module은 controller와 provider를 가지고 있다.
그런데 사실 app.module은 AppController와 AppProvider만 가지고 있어야 한다.
NestJS에서 앱은 여러 개의 모듈로 구성이 되기 때문이다.

// app.module.ts
import { Module } from '@nestjs/common';
import { MoviesController } from './movies/movies.controller';
import { MoviesService } from './movies/movies.service';

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

그래서 여기 있는 MovieController와 MovieService를 movie.module로 옮길 것이다.

nest g mo 커맨드로 movies라는 이름의 module을 생성하자.

movies.module.ts 파일을 생성했고, app.module.ts 파일을 업데이트한 것을 알 수 있다.

app.module의 MovieController와 MovieService를 삭제한다.

// app.module.ts
import { Module } from '@nestjs/common';
import { MoviesModule } from './movies/movies.module';

@Module({
  imports: [MoviesModule],
  controllers: [],
  providers: [],
})
export class AppModule {}

movie.module에 MovieController와 MovieService를 추가한다.

// movie.module.ts
import { Module } from '@nestjs/common';
import { MoviesController } from './movies.controller';
import { MoviesService } from './movies.service';

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

그렇다면 app.module에는 언제 controller와 provider를 만들면 될까?

app.controller에는 그냥 홈페이지를 가져오는 역할을 시켜보자.

// app.controller.ts
import { Controller, Get } from '@nestjs/common';

@Controller('')
export class AppController {
  @Get()
  home() {
    return 'Welcome to my Movie API';
  }
}

Dependency Injection

movie.controller를 보면 this.moviesService.getAll()를 사용한 것을 볼 수 있다. 이게 작동하는 이유는 moviesService: MoviesService와 같이 moviceService라 물리는 property를 만들고 타입을 지정해줘서 그렇다. 타입스크립트가 아니었다면 타입만 지정해줘서는 안 되고, 무언가를 import해줬어야 했을 것이다. 여기서는 타입만 import, 그러니까 MovieService라는 클래스만 import하고 있다.

// movie.controller.ts
@Controller('movies')
export class MoviesController {

  constructor(private readonly moviesService: MoviesService) {} 

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

또한, movie.module을 보면 controller와 provider를 import하고 있는데, 여기서 모든 것이 일어나게 된다. 바로 NestJS가 providers에 있는 것을 import하고 controllers에 inject하고 있다. 즉, providers의 배열에 있는 MovieService를 알아서 import했기 때문에 타입을 추가하는 것만으로 잘 작동하는 것이다. 이것을 Dependency Injection라고 한다.

// movie.module.ts
import { Module } from '@nestjs/common';
import { MoviesController } from './movies.controller';
import { MoviesService } from './movies.service';

@Module({
  controllers: [MoviesController],
  providers: [MoviesService]
})
export class MoviesModule {}
profile
Leave your comfort zone

2개의 댓글

comment-user-thumbnail
2021년 8월 1일

글 잘봤습니다! 혹시 nest에 dist폴더 생성되셨나요??

1개의 답글