[Nest.js] Controller 란?

정지현·2022년 10월 26일
0

본 포스팅은 [Nest.js 공식 도큐먼트 - Controller] 를 정리한 글입니다. Nest.js 내부적으로 Express 프레임워크로 동작하고 있을 때를 가정한 글입니다.

정리하려고 했는데 원문 번역을 한 것 같은 느낌이 드네요..

Controller 란?

  • Controller(이하 컨트롤러) 는 클라이언트로부터 들어오는 Request(이하 요청) 와 Response(이하 응답) 를 담당한다.

  • 컨트롤러의 목적은 어플리케이션에서 특정한 요청을 전달받는 것이다. 이는 Nest 의 라우팅 매커니즘을 통해 특정 요청에 대하여 어떤 컨트롤러가 역할을 담당할지 정한다.

  • 보통, 각 컨트롤러는 요청을 전달받을 수 있는 하나 이상의 라우트를 지니고 있으며, 정의된 라우트는 각각 다른 동작을 한다.

  • 컨트롤러를 정의하기 위해서, 클래스데코레이터(Decorator) 를 사용한다.

  • 데코레이터는 클래스의 특정 동작을 위해 필요한 메타데이터로서 클래스에 부착되어서 사용되며, 또한 Nest 가 이를 바탕으로 라우팅 맵(Routing Map)을 생성하여 특정 요청이 클라이언트로부터 들어올 경우 이에 매핑되는 라우트로 요청이 전달될 수 있도록 한다.

라우팅 (Routing)

  • 말 그대로 특정 요청이 해당 요청을 처리할 수 있는 적합한 컨트롤러로 길을 찾아가는 것을 의미한다.

  • 컨트롤러를 정의하기 위해서는 @Controller 데코레이터가 필요하다.

  • @Controller 데코레이터의 인자에는 라우트 경로를 위한 접두사를 붙일 수 있다. 예를 들어, @Controller('cats') 일 경우, 기본 라우트 경로는 /cats 가 될 것이다. 아무런 인자값도 없다면, / 로 동작한다. 하지만 각 컨트롤러에 기본 라우트 경로에 대한 접두사를 지정하는 게 좋다. 이는 요청을 실제로 전달받는 주체인 컨트롤러 내의 각 메소드의 라우트를 지정하는 것에 대하여 반복적인 작업을 줄여줄 수 있고, 컨트롤러 내부의 각 요청을 담당하는 메소드를 그룹화하는 효과도 있기 때문이다! 예를 들어, 검은 고양이들을 조회하기 위한 라우트 경로가 /cats/dark 이고, 흰색 고양이들을 조회하기 위한 라우트 경로가 /cats/white 로 정했다고 치자. 만일 @Controller 데코레이터에 /cats 기본 라우트 경로를 지정해주지 않았다면, 두 엔드포인트에 대하여 동작을 처리하는 메소드의 라우트 경로를 각각 /cats/dark, /cats/white 로 지정해야한다. 그러나 기본 라우트 경로를 /cats 로 지정해주었다면, 단순히 두 메소드에 대하여 /dark, /white 로만 지정해주면 된다. 만약 이러한 기본 라우트 경로를 지정하지 않았을 때, 개발자의 실수로 두 메소드 중 한 메소드에 대하여 cats 에서 영어 복수를 표현하기 위한 s 를 빠뜨렸다면, 즉 /cat/dark/cats/white 로 지정하였다면... 그리고 클라이언트는 /cats/dark 로 계속 호출을 시도하였다면... 404 Not Found 가 아주 이쁘게 뜰 것이고... 제때 찾지 못 한다면... 무사히 잘 찾도록 건투를 빌어야 한다... 아무튼 이러한 역할을 하는 컨트롤러 코드는 다음과 같다.

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

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

참고

Nest CLI 를 통해 컨트롤러를 생성할 수 있는 커맨드는 $ nest g controller cats 이다.

  • findAll() 메소드 상단의 @Get() 데코레이터는 컨트롤러 내에서 HTTP 요청을 전달받는 메소드의 HTTP 요청 메소드(POST, GET, PUT ,DELETE 등)를 나타낸다. 이때, @GET() 데코레이터의 인자로 아무 것도 쓰이지 않았으므로, GET /cats 로 들어오는 요청에 대하여 Nest 에 의해 매핑될 것이다! 만일, @GET('profile') 이었다면, 이를 호출하기 위한 엔드포인트는 GET /cats/profile 이 될 것이다.

  • 상기 코드에서 findAll() 메소드는 리턴값으로 상태코드 200(OK)를 보낼 것이고, 'This action returns all cats' 라는 문자열을 반환할 것이다. 단순히 문자열만 반환하는데, 상태코드와 리턴 값이 어떻게 응답으로 클라이언트에게 전달될까? 이를 이해하기 위해서는 클라이언트에 응답하기 위해 Nest 가 어떤 옵션을 사용하는지 이해할 필요가 있다!

옵션설명
Standard(표준 및 권장)자바스크립트 객체 혹은 배열을 리턴하면, 데이터는 자동으로 JSON 형태로 직렬화된다. 그러나 자바스크립트 원시 자료형(string, number, boolean 등)을 반환하면, Nest 는 직렬화 없이 값을 클라이언트에게 반환한다. 즉, 그냥 값만 반환하도록 하던가, Nest 가 JSON 직렬화 등을 자동으로 수행하여 전달하도록 할 수 있다.
Library-specificNest.js 에 기본 내장된 Express 등을 통해 직접 Response 의 형태를 제어할 수 있는 방법이다. 요청을 전달받는 메소드에 @Res() 데코레이터를 주입하여 사용할 수 있다 (ex. findAll(@Res() response)). 이러한 방법을 통해 클라이언트에게 반환되는 Response 를 직접 제어할 수 있다. 예를 들면, 201 상태코드를 반환하기 위해 response.status(201).send() 와 같이 사용할 수 있다. 후술하겠지만, 상태코드를 변경하기 위해 이러한 방법 말고 @HttpCode() 라고 하는 데코레이터를 사용하면 된다.

주의할 점

Nest 는 @Res() 또는 @Next() 를 사용할 경우, library-specific 옵션을 선택했다고 판단한다. 만일 두 옵션(Standard, Library-specific) 을 사용하려고 의도하였더라도, Standard 옵션은 자동으로 해제된다. 만일 Response 객체를 쿠키 또는 헤더 등의 조작을 위해 사용하려고 했고, 나머지 동작은 Standard 로 동작하게끔 하고 싶다면 passthrough 라는 프로퍼티를 true 로 사용하면 된다. 즉, @Res({ passthrough: true }) 로 지정하여 사용한다.

요청 객체 (Request Object)

  • 종종 요청받는 메소드(핸들러)는 클라이언트의 요청 객체에 접근해야할 필요가 있다.

  • Nest 는 이러한 요청 객체의 접근하기 위해 핸들러에 @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';
  }
}

참고

Typescript 에서 Express 에 대한 타입 지원을 위해서는(상기 request: Request 처럼) $ npm install @types/express 로 설치 후 사용하면 된다.

  • 이러한 요청 객체는 HTTP 요청(HTTP Request)에 대한 정보를 담고 있으며, 쿼리스트링, 파라미터, HTTP 헤더, 바디와 같은 속성을 지니고 있다. 보다 자세한 설명은 여기를 참고하면 된다.

  • 대부분의 경우, 상술한 요청 객체의 속성들을 직접 뽑아다가 쓰지는 않아도 된다. Nest 가 이와 관련하여 아주 편리하게 @Body(), @Query() 와 같은 데코레이터를 제공해주기 때문이다! 다음 표는 이와 관련하여 지원되는 데코레이터의 목록을 보여준다.

요청 객체 관련 데코레이터대응되는 속성
@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

주의할 점

상술했지만 주의할 점은, @Res() 또는 @Response() 데코레이터를 사용하면 Nest 가 Library-specific 모드로 동작한다는 점이다! 이는 반환되는 Response 를 개발자가 직접 책임져야한다는 것을 의미한다.

이러한 방식으로 사용할 경우, 반드시 Response 로 res.json(...) 또는 res.send(...) 를 사용하여 응답을 반환해야한다. 그렇지 않으면 HTTP 서버가 뻗는다.

자원 (Resources)

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 메소드를 모두 제공한다. 지원하는 메소드는 @Get(), @Post(), @Put(), @Delete(), @Patch(), @Options(), @Head() 이다.

  • 모든 HTTP 메소드를 핸들링하고 싶다면, @All() 을 사용하면 된다.

라우트 와일드카드 (Route Wildcard)

  • 라우트 경로를 패턴을 통해 지정할 수 있다. 가령, 별표(*) 를 사용하여 와일드카드로 나타낼 수 있다.
@Get('ab*cd')
findAll() {
  return 'This route uses a wildcard';
}
  • 'ab*cd'abcd, ab_cd, abecd 등등과 매칭된다.

  • ?, +, *, () 등이 라우트 경로에서 사용될 수 있으며, 정규표현식에 대응된다.

  • 하이픈(-) 과, 점(.) 은 특별한 동작은 하지 않으며, 문자로 취급된다.

상태 코드 (Status Code)

  • 상태코드는 언제나 200(OK) 를 반환하며, 예외로 POST HTTP 메소드를 갖는 엔드포인트의 경우 201(Created) 를 반환한다.

  • 개발자가 직접 상태코드를 지정할 수 있다. @HttpCode(...) 데코레이터를 사용하면 된다.

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

참고

HttpCode@nestjs/common 패키지에서 임포트할 수 있다.

헤더 (Headers)

  • 커스텀 응답 헤더를 설정하고 싶다면, @Header() 데코레이터를 사용하거나, Library-specific 반환 모드를 통해 Response 객체(res.header() 호출하여 조작)를 사용한다.

참고

Header@nestjs/common 패키지에서 임포트할 수 있다.

리다이렉션 (Redirection)

  • 특정 URL 로 리다이렉트 시키기 위해서는 @Redirect() 데코레이터 또는 Library-specific 반환 모드를 사용하여 Response 객체(res.redirect() 호출)를 사용한다.

  • @Redirect() 데코레이터는 인자로 urlstatusCode 를 전달받는다. 필수 인자는 아니다.

  • statusCode 가 생략되었을 경우, 기본값은 302번 상태코드를 갖는다.

@Get()
@Redirect('https://nestjs.com', 301)
  • HTTP 상태 코드 혹은 리다이렉트 될 URL 을 동적으로 제어하고 싶을 때는 다음과 같이 객체를 구성하여 컨트롤러에서 반환하면 된다. 해당 객체에 담기는 값은 @Decorator() 에 전달한 인자를 오버라이드한다.
{
  "url": string,
  "statusCode": number
}
@Get('docs')
@Redirect('https://docs.nestjs.com', 302)
getDocs(@Query('version') version) {
  if (version && version === '5') {
    return { url: 'https://docs.nestjs.com/v5/' };
  }
}

라우트 파라미터 (Route Parameters)

  • 스프링의 @PathVariable 어노테이션과 동일한 역할이다.

  • 정적인 라우드 경로는 요청 시 동적인 데이터를 전달받지 못 한다. 가령, 1번 아이디를 지닌 고양이를 조회하기 위한 GET /cats/1 와 같이 동적으로 1 을 전달받지 못 한다. 클라이언트는 2번 고양이, 3번 고양이도 조회하고 싶을 것이다.

  • 라우트 파라미터를 엔드포인트 메소드가 접근하기 위해서는 @Param() 데코레이터를 사용한다.

@Get(':id') // 라우트 파라미터 적용
findOne(@Param() params): string { // 모든 라우트 파라미터를 가져온다.
  console.log(params.id);
  return `This action returns a #${params.id} cat`;
}
  • @Param() 데코레이터가 인자 없이 단독으로 사용되면 현재 라우트 경로에 작성된 모든 라우트 파라미터를 포함하는 객체로 동작한다. 상기 예제에서는 params.id 를 통해 id 값을 가져올 수 있다.

  • @Param() 데코레이터에 라우트 경로에 포함된 라우트 파라미터명을 적어주면 해당 파라미터 값만 가져올 수 있다.

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

참고

Param 데코레이터는 @nestjs/common 패키지에서 임포트할 수 있다.

서브 도메인 라우팅 (Sub-Domain Routing)

  • @Controller 데코레이터에 host 옵션을 통하여 특정 HTTP 호스트에 해당하는 요청만 받도록 할 수 있다.
@Controller({ host: 'admin.example.com' })
export class AdminController {
  @Get()
  index(): string {
    return 'Admin page';
  }
}
  • 라우터 경로와 동일하게, hosts 옵션 또한 동적인 값을 전달받을 수 있으며, 이러한 호스트 파라미터에 접근하기 위해서는 @HostParam() 데코레이터를 사용한다.
@Controller({ host: ':account.example.com' })
export class AccountController {
  @Get()
  getInfo(@HostParam('account') account: string) {
    return account;
  }
}

스코프 (Scopes)

  • Node.js 는 싱글 쓰레드다. 즉, 모든 요청에 대하여 멀티 쓰레드로 동작하지 않는다는 것이다. 따라서 Nest 에서 싱글톤 패턴을 사용하는 것은 안전하다고 공식 문서에 나와있다.. Thread-safe 하다는 의미로 보인다.

  • GraphQL 의 요청 별 캐싱, 요청 추적(Request Tracking) 또는 멀티 테넌시(Multi-tenancy) 와 같이 컨트롤러의 요청 생명주기에 따라 특정 동작을 원할 수 있다. 자세한 사항은 여기를 참고하면 된다.

비동기 (Asynchronicity)

  • 데이터 처리와 관련해서는 대부분 비동기로 처리하는게 일반적이다.

참고

async / await 에 대해서 더 자세히 알고 싶다면, 여기를 참고하면 된다.

  • 모든 async 함수는 Promise 를 반환한다. 이를 통해서 deferred value(?) 를 반환할 수 있다고 한다.
@Get()
async findAll(): Promise<any[]> {
  return [];
}
  • Nest 의 라우트 핸들러는 RxJS Observable stream 을 반환할 수 있다.
@Get()
findAll(): Observable<any[]> {
  return of([]);
}

요청 페이로드 (Request Payload)

  • 컨트롤러의 라우트 핸들러를 통해 요청 바디(Request Body)를 전달받을 수 있다.

  • 요청 바디는 DTO(Data Transfer Object) 를 구현하여 사용할 수 있다.

  • Nest 는 Typescript 인터페이스로도 DTO 를 구현할 수는 있지만, 클래스를 사용하는 것을 추천한다고 한다. 클래스는 ES6 표준이기도 하고, 컴파일된 자바스크립트 상에서 그 자체로 형태가 보존되기 때문이라고 한다. 반면, Typescript 의 인터페이스로 DTO 를 구현하면 자바스크립트로 트랜스컴파일 될 때 해당 인터페이스가 사라진다. 즉, Nest 가 런타임 시점에 이러한 DTO 를 참조할 수 없다고 한다.

  • DTO 는 다음과 같이 구현할 수 있다.

export class CreateCatDto {
  name: string;
  age: number;
  breed: string;
}
  • 구현된 DTO 를 컨트롤러 내 라우트 핸들러에 다음과 같이 추가할 수 있다.
@Post()
async create(@Body() createCatDto: CreateCatDto) { // DTO 추가
  return 'This action adds a new cat';
}

참고

ValidationPipe 를 통해 핸들러로 들어오는 요청 바디에 대한 검증을 수행할 수 있다. 자세한건 여기를 참고하면 된다.

예외 핸들링

전체 컨트롤러 구현 예

import { Controller, Get, Query, Post, Body, Put, Param, Delete } from '@nestjs/common';
import { CreateCatDto, UpdateCatDto, ListAllEntities } from './dto';

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

  @Get()
  findAll(@Query() query: ListAllEntities) {
    return `This action returns all cats (limit: ${query.limit} items)`;
  }

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

  @Put(':id')
  update(@Param('id') id: string, @Body() updateCatDto: UpdateCatDto) {
    return `This action updates a #${id} cat`;
  }

  @Delete(':id')
  remove(@Param('id') id: string) {
    return `This action removes a #${id} cat`;
  }
}

참고

Nest CLI 를 사용하여 Controller 를 생성하면 상기 코드와 같은 보일러플레이트를 자동으로 생성해준다. 이와 관련한 참고는 여기를 확인하면 된다.

이제 컨트롤러를 써보자!

  • 하지만 아직이다. 컨트롤러를 열심히 만들어보았지만, 아직 Nest 는 CatsController 의 존재를 모르기 때문이다. 따라서 아직은 컨트롤러 클래스를 인스턴스로 만들 수 없다.

  • 컨트롤러는 언제나 모듈에 포함되어 있어야 한다. 모듈 클래스 내의 @Module() 데코레이터에 controllers 속성이 있다. 여기에 배열의 형태로 컨트롤러를 넣어주면 된다.

  • 아직 Cats Module 을 생성하지 않았기 때문에 기본 생성된 AppModule 에 지금까지 만든 CatsController 를 넣어주면 된다. 그러면 Nest 에 해당 컨트롤러가 반영된다.

app.module.ts

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

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

Library-specific 방식

  • 지금까지는 Nest 표준 응답 방식에 대하여 알아보았다. 응답을 가공할 수 있는 두 번째 방법은 Library-specific 을 통하여 응답 객체를 조작하는 것이다.

  • 해당 방식을 사용하기 위해서는 @Res() 데코레이터를 사용해야한다. 차이점을 확인하기 위해, CatsController 에 다음과 같이 작성한다.

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([]);
  }
}
  • Library-specific 방식은 응답 객체 전체를 제어할 수 있다는 점에서 보다 유연하게 Response 를 가공할 수 있다. 예를 들면, Header 제어라던가, Express 에서 제공하는 다양한 기능이라던가 등등...

  • 일반적으로 표준 응답 방식에 비해서 깔끔하지는 않고, 단점도 있긴 하다. 대표적으로, 플랫폼 의존도(platform-dependent)가 높아진다고 한다. 예를 들어, Express 의 경우 응답 객체에 대하여 사용하는 API 가 다를 수도 있다고 한다. 또한, 테스트 하기가 어렵다. 테스트를 위해서는 Mock Response 객체를 생성해서 사용해야한다고 한다.

  • Nest 에서 기본으로 제공하는 표준 응답 방식에서 사용하는 다양한 기능들을 사용하지 못 한다. 인터셉터라던가, @HttpCode(), @Header() 등..

  • 이를 해결하기 위해서는 다음과 같이 passthrough 옵션을 true 로 설정하면 된다.

@Get()
findAll(@Res({ passthrough: true }) res: Response) {
  res.status(HttpStatus.OK);
  return [];
}
  • 이를 통해 쿠키 사용한다던가, 헤더를 조작할 수 있다. 물론 이를 제외한 나머지는 Nest 프레임워크가 알아서 처리한다.

끗.

profile
나를 성장시키는 좌절에 감사하고 즐기려고 노력 중

0개의 댓글