NestJS Overview - Controllers

Min Su Kwon·2021년 10월 17일
2

컨트롤러는 요청을 핸들링하고, 클라이언트에게 응답을 반환하는 책임을 가지고 있다.

컨트롤러의 목적은 애플리케이션을 위해 구체적인 요청들을 받는 것이다. 라우팅 메커니즘이 어떤 컨트롤러가 어떤 요청을 받을지 통제하게 된다. 대부분의 경우에 각각의 컨트롤러는 여러개의 라우트를 가지며, 각각의 라우트는 모두 다른 액션을 취할 수 있다.

기본적인 컨트롤러를 만들기 위해서, 클래스와 데코레이터를 사용한다. 데코레이터들은 클래스를 필요한 메타데이터와 연결시켜주며, Nest로 하여금 라우팅 맵을 만들 수 있도록 도와준다.

빌트인 Validation 과정을 포함하는 간단한 CRUD 컨트롤러를 만드려면, Nest CLI의 CRUD 제너레이터를 사용할 수 있다 - nest g resource [name]

Routing

아래의 예시에서 @Controller() 데코레이터를 사용해서 기본적인 컨트롤러를 정의한다. @Controller 데코레이터에게 path prefix를 인자로 넘길 수 있으며(필수는 아님), 이를 이용해 연관된 여러 라우트를 한데 묶을 수 있게된다.

import { Controller, Get } from '@nestjs/common';

@Controller('cats')
export class CatsController {
  @Get()
  findAll(): string {
    return 'This action returns all cats';
  }
}

CLI를 이용해 컨트롤러르 생성하려면, nest g controller [name] 커맨드를 사용한다

위의 예시에서 @Get() 데코레이터는 해당 메서드가 어떤 HTTP 요청을 받게될지 알게해준다. 여기서 인자로 넘겨진 path 값과 컨트롤러에 이미 존재하는 path prefix를 합친 결과가 실제 어떤 URL/HTTP Method로 들어오는 요청을 받을지 결정하게 된다.

위의 예시에서는 /cats 로 들어오는 GET 요청을 findAll 메서드가 담당하게된다. 정확히는 해당 라우트로 들어오는 요청을 받은 Nest가 findAll 메서드로 요청을 넘겨주게된다. 여기서 메서드 이름은 실제 애플리케이션의 행동에 아무런 영향도 주지않는다.

findAll 메서드는 200 응답 코드와 함께 간단한 문자열 응답을 뱉을 것이다. 이를 설명하려면 먼저 Nest가 응답 조작을 위해서 두가지 옵션을 제공한다는 점을 알아야 한다.

  • Standard
    이 빌트인 메서드를 사용하면, 요청 핸들러가 JS 객체 또는 배열을 반환할 시, 자동으로 JSON 형식으로 직렬화된다. 만약 JS 원시 타입을 반환할 경우, 직렬화시도를 하지 않고 값 그대로를 넘기게 된다. 이로 인해서 응답 핸들링이 쉬워지는 효과가 있다. 그냥 값을 반환하면, Nest가 알아서 다 하니깐.
  • Library-specific
    특정 라이브러리에 종속되는 응답 객체를 사용할 수도 있다. 이는 @Res() 데코레이터를 이용해서 주입될 수 있으며, 해당 객체가 노출하는 네이티브 응답 핸들링 메서드를 사용할 수 있게된다. 예를 들어, Express의 경우 다음과 같은 코드로 응답 객체를 만들 수 있다 : response.status(200).send()

Nest는 핸들러의 @Res() 또는 @Next() 데코레이터를 통해서 어떤 라이브러리에 종속되는 응답을 사용하고 있는지 탐지한다. 만약 둘이 동시에 사용되고 있다면, Standard 접근법은 자동으로 비활성화되고, 동작하던대로 동작하지 않게된다. 만약 둘을 동시에 사용하고 싶다면, passthrough 옵션을 true로 설정해줘야 한다 : @Res({ passthrough: true })

Request object

핸들러들은 종종 클라이언트 요청에 대한 정보를 필요로 한다. Nest는 요청 객체(선택한 플랫폼에 해당하는 - 디폴트는 Express)에 대한 접근을 제공하며, @Req() 데코레이터를 통해서 요청 객체를 핸들러에서 사용할 수 있다.

import { Controller, Get, Req } from '@nestjs/common';
import { Request } from 'express';

@Controller('cats')
export class CatsController {
  @Get()
  findAll(@Req() request: Request): string {
    return 'This action returns all cats';
  }
}

위의 예시처럼 express 타입이 필요하다면, @types/express 패키지를 설치해야한다.

여기서 요청 객체는 HTTP 요청을 대변하게 되며, 쿼리 스트링, 파라미터, HTTP 헤더, 바디 등 다양한 프로퍼티를 포함하게 된다. 대부분의 경우에 이 프로퍼티들을 수동으로 끌어올 필요는 없고, 좀 더 구체적인 데코레이터를 사용할 수 있다.

  • @Request(), @Req() : req
  • @Response(), @Res() : res
  • @Next() : next
  • @Session() : req.session
  • @Param(key?: string) : req.params / req.params[key]
  • @Body(key?: string) : req.body / req.body[key]
  • @Query(key?: string) : req.query / req.query[key]
  • @Headers(name?: string) : req.headers / req.headers[name]
  • @Ip() : req.ip
  • @HostParam() : req.hosts

다른 HTTP 플랫폼들과의 타입 호환성을 위해서, Nest는 @Res@Response 데코레이터를 제공한다. @Res 데코레이터는 단순히 @Response의 별칭이다. 둘은 모두 네이티브 플랫폼의 repsonse 객체 인터페이스를 노출한다. 이들을 사용할때는 HTTP 플랫폼에 해당하는 타입을 import 해와야 제대로 사용할 수 있게된다.

참고로 @Res 데코레이터를 사용하는 핸들러는 곧 Nest에게 이 핸들러는 라이브러리에 종속적인 모드를 사용할 것이라고 알리는 것과 같으며, 따라서 응답 객체는 개발자가 알아서 하겠다고 선언하겠다고 하는 것과 같다. 따라서 직접 응답 객체를 만들어야 한다. res.json(...), res.send(...) 과 같은 메서드를 사용해서.

Resources

위에서는 고양이 리소스를 받아오기 위해 GET 라우트를 정의했다. 이제 고양이 리소스를 생성하기 위한 POST 라우트를 정의해보자.

import { Controller, Get, Post } from '@nestjs/common';

@Controller('cats')
export class CatsController {
  @Post()
  create(): string {
    return 'This action adds a new cat';
  }

  @Get()
  findAll(): string {
    return 'This action returns all cats';
  }
}

이렇게 간단하게 만들 수 있다. Nest는 각 HTTP 메서드에 해당하는 데코레이터를 제공하며, @All() 데코레이터를 사용해서 모든 메서드를 핸들링하게 할 수도 있다.

Route wildcards

패턴을 이용한 라우트를 정의하는 것도 가능하다. 예를들어, * 문자를 사용해보면 (어떤 문자든 허용), abcd, ab_cd, abecd에 해당하는 요청들을 처리하게된다. 추가로 ?, +, () 등을 사용할 수 있으며, 정규식처럼 사용할 수 있다. 참고로 -.는 모두 문자열로 인식된다.

Status Code

위에서 언급했듯이, 디폴트로 반환되는 상태코드는 200이며, POST 핸들러의 경우에만 201이다. 이는 @HttpCode() 데코레이터를 사용해서 손쉽게 변경할 수 있다.

@Post()
@HttpCode(204)
create() {
  return 'This action adds a new cat';
}

다양한 변수로 인해서 한 핸들러 내에서도 다양한 코드를 반환해야할 수도 있는데, 이럴때는 @Res() 데코레이터를 이용해서 res 객체를 직접 핸들링 하는 등의 액션이 필요하다.

Headers

커스텀 헤더를 지정하기 위해서, @Header() 데코레이터 또는 @Res() 데코레이터를 이용해서 직접 핸들링할 수 있다.

@Post()
@Header('Cache-Control', 'none')
create() {
  return 'This action adds a new cat';
}

Redirection

리다이렉션을 하려면 @Redirect() 데코레이터 또는 res 객체를 사용할 수 있다. @Redirect() 데코레이터는 두가지 인자(url, statusCode)를 받으며, 둘다 옵셔널하다. 상태 코드의 디폴트 값은 302다.

@Get()
@Redirect('https://nestjs.com', 301)

url 또는 statusCode를 동적으로 변경하고 싶으면, 라우트 핸들러 메서드에서 다음과 같은 객체를 반환하면 리다이렉트하도록 할 수 있다.

{
  "url": string,
  "statusCode": number
}

반환된 값들은 @Redirect() 데코레이터로 넘겨진 인자를 오버라이드 하게된다.

@Get('docs')
@Redirect('https://docs.nestjs.com', 302)
getDocs(@Query('version') version) {
  if (version && version === '5') {
    return { url: 'https://docs.nestjs.com/v5/' };
  }
}

Rotue parameters

요청의 일부분으로 동적인 데이터를 받아야 하는 경우 정적인 경로가 워킹하지 않을 수 있다. 파라미터와 함께 라우트를 정의하려면, 라우트 파라미터 토큰을 경로 중간에 추가해서 동적인 값을 캐치할 수 있다. @Get() 데코레이터 내부에 있는 파라미터 토큰은 @Param() 데코레이터를 통해서 접근 가능하며, 메서드 시그니처에 추가해주면 된다.

@Get(':id')
findOne(@Param() params): string {
  console.log(params.id);
  return `This action returns a #${params.id} cat`;
}

@Param() 데코레이터를 통해서 라우트 파라미터 값들 전체를 받아올 수 있으며, 파라미터 이름을 인자로 넘겨서 특정 프로퍼티만 파라미터만 받아오도록 할 수도 있다.

@Get(':id')
findOne(@Param('id') id: string): string {
  return `This action returns a #${id} cat`;
}

Sub-Domain Routing

@Controller() 데코레이터는 host 옵션을 인자로 받을 수 있는데, 이를 통해서 요청의 HTTP 호스트가 특정 값과 일치하는지 확인하고 요청을 핸들링하도록 할 수 있다.

@Controller({ host: 'admin.example.com' })
export class AdminController {
  @Get()
  index(): string {
    return 'Admin page';
  }
}

Fastify는 네스팅된 라우트를 지원하지 않기 때문에, 서브도메인 라우팅을 위해서는 Express 어댑터를 사용해야한다.

라우트 path와 유사하게, hosts 옵션 또한 토큰을 통해서 동적인 값을 체크할 수 있다. 이는 @HostParam() 데코레이터를 통해서 핸들러 내에서 받아오고 사용할 수 있다.

@Controller({ host: ':account.example.com' })
export class AccountController {
  @Get()
  getInfo(@HostParam('account') account: string) {
    return account;
  }
}

Scopes

다양한 프로그래밍 언어 배경을 가진 사람들에게는, 네스트에서는 거의 모든 것들이 요청에 공유된다는 것이 놀라울 수 있다. DB로의 커넥션 풀, 전역 상태를 가진 싱글턴 서비스 등 모든 것이. Node js는 요청/응답에 대해 멀티 스레드 모델을 사용하지 않고, 각각의 요청을 각각 다른 스레드가 처리하도록 하기 때문에, 싱글턴 인스턴스를 사용하는 것은 앱 입장에서 안전하다고 볼 수 있다.

하지만, 요청 기반의 컨트롤러 라이프타임이 필요한 동작일 때도 있다. 예를 들면, GraphQL 애플리케이션에서의 per-request 캐싱이나, 요청 트래킹, multi-tenancy의 경우가 있을 수 있다. 스코프 핸들링은 문서를 참고한다.

Asynchronicity

모던 자바스크립트에서 데이터 추출은 대부분 비동기 작업이기 때문에, Nest는 비동기 함수와 잘 동작하고, 많은 것을 지원해준다.

각각의 비동기 함수는 Promise를 반환해야한다. 이는 곧 pending 중인 Promise 값을 반환해도 된다는 뜻이다.

@Get()
async findAll(): Promise<any[]> {
  return [];
}

위의 코드는 유효하며, Nest 라우트 핸들러들은 RxJS의 Observable 스트림을 반환할 수 있다는 점에서 더욱 강력하다. 이를 통해 Nest는 아랫단의 소스에 subscribe하고, 마지막으로 emit된 값을 가져올 수 있다.

@Get()
findAll(): Observable<any[]> {
  return of([]);
}

어떤 쪽을 선택해도 동작하기 때문에, 요구사항에 맞춰서 결정하면된다.

Request payloads

@Body() 데코레이터를 통해서 핸들러에게 요청 객체에 포함된 바디 파라미터를 넘길 수 있다.

하지만 그 전에 먼저, DTO 스키마를 정해야한다. DTO는 네트워크를 통해서 데이터가 어떤 형식으로 전해질지 정의해놓은 객체다. 타입스크립트 인터페이스 또는 간단한 클래스로 DTO 스키마를 정의할 수 있다.

둘중에서는 클래스를 사용하는 것이 권장된다. 이유는 클래스가 ES6에 포함되어 자바스크립트 표준으로 취급되고, 타입스크립트 코드가 자바스크립트 코드로 트랜스파일링 된 후에도 남아있기 때문이다. 반면에, 타입스크립트 인터페이스는 트랜스파일링 과정에서 사라지기 때문에, 런타임에 이들의 타입에 관해서 Nest가 알 길이 없다. 또한 파이프와 같은 기능이 런타임에 타입에 대한 정보를 알고 모르고가 차이를 보여주기 때문에, 클래스 사용이 권장된다.

CreateCatDto 클래스를 만들어보자

export class CreateCatDto {
  name: string;
  age: number;
  breed: string;
}

3가지 프로퍼티를 가지고 있으며, 이 Dto를 CatsController 내부에서 사용할 수 있다.

@Post()
async create(@Body() createCatDto: CreateCatDto) {
  return 'This action adds a new cat';
}

ValidationPipe를 통해서 필요없는 프로퍼티를 걸러낼 수 있다. 이를 사용하면 받을 수 있는 프로퍼티들을 화이트리스팅하고, 화이트리스트에 포함되지 않은 프로퍼티들은 모두 걸러낸 뒤 핸들러에게 넘겨주는 등의 동작을하게 할 수 있다.

Handling errors

에러 핸들링 챕터를 참고한다.

Getting up and running

컨트롤러만 정의해놓는다고 해서, Nest가 바로 컨트롤러의 존재여부를 인지하고 인스턴스를 만들 순 없다.

컨트롤러는 항상 모듈에 속하며, 이 때문에 @Module() 데코레이터에 controllers 배열이 포함된다.

import { Module } from '@nestjs/common';
import { CatsController } from './cats/cats.controller';

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

@Module 데코레이터를 이용해서 모듈을 등록한 덕분에, 어떤 컨트롤러를 mount해야하는지 알아낼 수 있다.

Library-specific approach

위에서는 Nest 표준으로 응답을 조작하는 방법을 다뤘다. 응답을 조작하는 두번째 방법은 특정 라이브러리의 response 객체를 사용하는 것이다. 특정 response 객체를 주입하기 위해서, @Res() 데코레이터를 사용한다.

import { Controller, Get, Post, Res, HttpStatus } from '@nestjs/common';
import { Response } from 'express';

@Controller('cats')
export class CatsController {
  @Post()
  create(@Res() res: Response) {
    res.status(HttpStatus.CREATED).send();
  }

  @Get()
  findAll(@Res() res: Response) {
     res.status(HttpStatus.OK).json([]);
  }
}

위 접근법이 동작하긴 하고, 응답 객체를 더욱 유연하게 다룰 수 있는 방법이긴 하지만, 주의해서 사용해야한다. 일반적으로, 위 접근법은 덜 명확하고 몇가지 단점이 있다. 가장 큰 단점은 코드가 플랫폼에 의존하게 되고, 테스트하기도 어려워진다는 점이다.

추가로, 다른 Nest 기능들도 사용할 수 없게 될 수 있다. 예를들면 Nest 표준 응답 핸들링, 인터셉터, @HttpCode() 데코레이터 같은 기능들을 사용할 수 없게된다. 이를 막으려면, @Res() 데코레이터에게 passthrough 옵션을 줘야한다.

@Get()
findAll(@Res({ passthrough: true }) res: Response) {
  res.status(HttpStatus.OK);
  return [];
}

이제 네이티브 응답 객체와 상호작용하면서 나머지 프레임워크의 기능도 활용할 수 있다.

느낀점

데코레이터를 활용해서 가독성 좋은 코드를 만들 수 있는 점이 굉장히 좋은 것 같다. 핸들러 메서드가 핸들링 해야하는 route url과 HTTP 메서드를 떡하니 메서드 위에 써놓으니 메서드의 역할이 명확해지는 느낌. 들어온 요청에 대한 정보를 메서드 시그니처에서 데코레이터를 활용해 인자처럼 할당할 수 있는 것도 굉장히 좋은데, 메서드 바디 부분에서 req 객체로부터 직접 다 꺼내써야하는 불편함도 없고, 역시 가독성도 훌륭한 것 같다.

Library-specific한 것들은 가능하면 최대한 자제해야겠다는 생각이든다. Nest가 유연성에 굉장히 신경쓰고 있는 것 같고, 이런 것들을 너무 자주 사용하면 유연성을 떨어트리는 것이 될테니.

Observable은 몇번 보기는 했지만 뭔지 모르는 친군데, 시간 날때 공부좀 해봐야겠다. 정확히 뭔지 모르겠으니.. 😅

Reference

profile
이제 막 커리어를 시작한 소프트웨어 엔지니어입니다. 배운 것을 정리하면서 조금 더 깊이 이해하려는 습관을 들이려고 합니다. 피드백은 언제나 환영입니다.

0개의 댓글