새소식

반응형
Back-end/NestJS

[NestJS | Docs] Controller 개념정리

2024.01.31
  • -
반응형

1. Controller

Controller는 요청을 받아들이고 클라이언트에게 응답을 반환하는 역할을 합니다.

 

controller의 목적은 애플리케이션의 구체적인 요청을 받는 것입니다. routing 이라는 메커니즘은 어느 controller가 어느 요청을 받을지 관리하는 역할을 수행합니다. 대부분의 경우, 각 controller는 하나 이상의 라우트를 갖고 각각의 라우트는 모두 다른 기능을 수행합니다.

 

기본 controller를 만들기 위해 Nest에서는 클래스와 데코레이터를 사용합니다. 데코레이터는 클래스를 필요한 메타데이터와 연결시켜주고 Nest가 라우팅 맵(routing map)을 생성할 수 있도록 합니다.

 

1-1. Routing

다음 예시에 @Controller() 데코레이터가 사용되는데, 이 데코레이터는 가장 기본적인 controller를 정의하는데 반드시 필요한 요소입니다.

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

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

 

위와 같이 `cats`라는 선택적으로 라우트 경로 접두사(prefix)를 지정할 수 있습니다. @Controller() 데코레이터에서 경로 접두사를 명시함으로써 관련된 라우트 세트를 쉽게 그룹화하고 반복적인 코드를 최소화할 수 있습니다.

 

예를 들어, cat 엔티티와의 상호작용을 관리하는 라우트 세트를 /cats 경로 아래에 그룹화할 수 있을 것입니다. 이러한 경우, @Controller 데코레이터에 경로 접두사 cats를 지정하면, 그 파일에서 각 라우트에 대해 /cats를 적는 것과 같이 path의 해당 부분을 반복하는 작업을 하지 않아도 됩니다.

 

CLI로 controller를 만드려면, 터미널 창에 `$nest g controller [name]`을 입력합니다.

 

findAll() 메서드 위에 붙은 HTTP 요청 메서드 데코레이터인 @Get()는 Nest가 HTTP 요청의 특정 엔드포인트에 대한 핸들러를 생성하도록 지시하는 역할입니다. 그 엔드포인트는 HTTP 요청 메서드와 라우트 경로가 매핑됩니다.

 

그렇다면 라우트 경로(route path)는 무엇일까요? 핸들러의 라우트 경로는 컨트롤러에서 선언된 (선택적인) 접두사와 메서드의 데코레이터로 지정된 모든 경로를 연결하여 결정됩니다.

 

모든 라우트에 대한 접두사(위 예제에서 cats)를 선언했고 메서드 데코레이터에서 어떠한 경로 정보를 추가하지 않았기 때문에 Nest는 `GET /cats` 요청을 이 핸들러에 매핑시킬 것입니다. 앞서 말했듯, 경로에는 선택적인 경로 접두사(path prefix)와 요청 메서드 데코레이터(위 예제에서 @Get)에 선언된 경로 문자열(path string)이 모두 포함됩니다.

 

예를 들어, @Get('breed') 데코레이터와 결합된 경로 접두사 cats는 `GET /cats/breed`와 같은 요청에 대한 라우트 매핑을 만들어냅니다.

 

위의 예시에서 GET 요청이 해당 엔드포인트로 들어오면 Nest는 사용자 정의 메서드인 findAll() 메서드로 그 요청을 라우팅합니다. findAll 이라는 메서드 이름은 완전히 임의의 것임을 아셔야 합니다. 라우트를 바인딩할 메서드를 선언해야 해서 이름을 짓는 것이지 사실 Nest는 우리가 지은 메서드 이름에 어떤 의미도 부여하지 않습니다.

 

이 메서드는 200번의 status code와 그에 맞는 응답(위 경우 문자열)을 반환할 것입니다. 왜 이런 일이 벌어질까요? 이를 설명하기 위해선, 먼저 Nest가 응답을 조작하는 2가지 다른 옵션을 사용하는 개념을 소개하겠습니다:

 

  • Standard(recommended)
    • Nest 내장 메서드를 사용해서 요청 핸들러가 JavaScript 객체 혹은 배열을 반환할 때, 해당 객체 혹은 배열이 자동으로 JSON 형태로 직렬화(serialize)됩니다.
    • 하지만 JavaScript 원시 타입(primitive type)을 반환할 때는 Nest가 그 객체나 배열을 직렬화하려 하지 않고 그냥 값 그대로를 보낼 것입니다.
    • 이는 응답 처리를 간단히 하는 작업입니다:
      • 값을 그냥 보내고 Nest가 다 처리하도록 하기 위함
    • 게다가 응답의 status code는 201번을 사용하는 POST 요청을 제외하면 항상 기본값 200을 갖습니다. 그치만, 핸들러 레벨에서 @HttpCode() 데코레이터를 추가하여 status를 쉽게 바꾸는 것이 가능합니다.
  • Library-specific
    • 우리는 메서드 핸들러 시그니처에 @Res() 데코레이터를 사용하여 주입될 수 있는 라이브러리별(library-specific) 응답 객체를 사용할 수도 있습니다. (여기서 라이브러리는 express와 같은 것을 의미합니다.)
      • e.g., `findAll(@Res() response)`
    • 이러한 접근에서, 해당 객체가 제공하던 네이티브 응답 핸들링 메서드를 사용할 수 있습니다. 
      • 예를 들어, Express에서 `response.status(200).send()`와 같은 코드를 작성하여 응답을 만들어낼 수 있는 것입니다.
Nest는 핸들러가 @Res()나 @Nest()를 사용 중인 경우를 감지하여 우리가 라이브러리별 옵션을 선택했음을 인식합니다. 그런데 만약 두 가지 접근을 동시에 사용한다면, Standard 접근이 자동으로 단일 라우트에 대해 비활성화되고 더 이상 작동이 예상대로 되지 않게됩니다. 그러나 두 방식을 동시에 사용하고 싶다면, @Res({ passthrough: ture }) 데코레이터에 passthrough 옵션을 true로 설정해야 합니다.

 

1-2. Request object (요청 객체)

핸들러들은 종종 클라이언트 요청의 세부 사항을 필요로 하는 경우가 있습니다. Nest는 기반 플랫폼(Express by default)의 요청 객체로의 접근을 제공합니다. @Req() 데코레이터를 핸들러의 시그니처에 추가하여 Nest가 요청 객체를 주입하도록 지시함으로써 요청 객체에 접근할 수 있습니다.

// cats.controller.ts
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 타이핑(위 예시에서 request: Request와 같은)을 사용하기 위해서는 @types/express 패키지를 설치해야 합니다.

 

요청 객체는 HTTP 요청을 나타내고 요청 query string, parameter, HTTP header, body에 대한 속성을 갖습니다.

 

대부분의 경우, 우리는 이러한 속성들을 직접 다룰 필요가 없습니다. @Body()나 @Query()와 같이 한 번에 사용 가능한 전용 데코레이터를 대신 사용할 수 있습니다. 아래는 Nest가 제공하는 데코레이터와 해당 데코레이터가 나타내는 plain 플랫폼별 객체 목록입니다.

Nest 데코레이터 express 객체
@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 플랫폼(ex. Express, Fastify)에서의 타이핑과의 호환성을 위해 Nest는 @Res() 및 @Response() 데코레이터를 제공합니다.

  • @Res()는 @Response()에 대한 별칭입니다.

둘 다 기반 네이티브 플랫폼의 response 객체 인터페이스를 직접 제공합니다. 이러한 기능을 사용할 때는 기반 라이브러리의 타이핑도 가져와야 그 기능을 최대한 활용할 수 있습니다.(ex. @types/express)

 

매서드 핸들러에 @Res()나 @Response()를 주입하면 해당 핸들러에 대해 Nest를 라이브러리별 모드로 전환하고 응답 관리를 담당하게 됩니다. 이 경우 응답 객체( e.g., res.json() or res.send() )를 호출하여 어떠한 종류의 응답을 반드시 발행해야 하며, 그렇지 않은 경우 HTTP 서버가 중단될 것입니다.

 

1-3. Resources

앞서 cats 리소스(GET 라우트)를 가져오는 엔드포인트를 정의해 봤습니다. 이제 우리는 일반적으로 새 레코드를 생성하는 엔드포인트도 제공해야 합니다. 이를 위해 POST 핸들러를 만들어 봅시다.

// cats.controller.ts
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()

또한 @All()을 통해 위 메서드를 모두 처리하는 엔드포인트를 정의할 수도 있습니다.

 

1-4. Route wildcards

패턴 기반 라우트도 지원됩니다. 예를 들어, asterick(*)는 와일드카드로 사용되며 어떠한 문자 조합과도 일치합니다.

@Get('ab*cd')
findAll() {
  return 'This route uses a wildcard';
}

 

'ab*cd' 라우트 경로는 'abcd, 'ab_cd', 'abecd' 등등과 모두 매치될 수 있습니다. ?, +, *, () 와 같은 문자들은 라우트 경로에 사용될 수 있고, 이 정규 표현식 대응 문자의 부분집합입니다. hyphen(-)와 dot(.)은 문자 그대로 문자열 기반 경로로 해석됩니다.

 

경로 중간에 표현되는 wildcard는 express에서만 지원됩니다.

 

 

1-5. Status code (상태 코드)

앞서 말했듯, 응답 status code는 항상 기본값으로 200을 갖습니다.(201번인 POST 요청을 제외하고)

 

우리는 @HttpCode() 데코레이터를 핸들러 레벨에 추가하여 status code를 쉽게 설정해줄 수 있습니다. 

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

 

많은 경우에 status code는 고정적이지 않고 다양한 요소에 의해 바뀔 수 있습니다. 이 경우, 라이브러리별 응답 객체(@Res()를 사용하여 주입된)를 사용할 수 있습니다.(혹은 에러 발생 시 예외를 던짐)

 

 1-6. Headers

custom 응답 헤더를 지정하기 위해선, @Header() 데코레이터를 사용하거나 라이브러리별 응답 객체를 사용할 수 있습니다.(res.header())

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

 

1-7. Redirection

특정 URL에 대한 응답을 리다이렉트 하려면 @Redirect() 데코레이터를 사용하거나 라이브러리별 응답 객체를 사용할 수 있습니다.(res.redirect())

 

@Redirect()는 2개의 인자, url과 statusCode를 취하고 둘 다 선택적입니다. statusCode의 기본값은 생략될 경우 302(Found)입니다. 사용 예시는 다음과 같습니다.

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

 

특별한 경우에 HTTP status code 또는 redirect URL을 동적으로 결정하고 싶은 경우가 있을 수 있습니다. 그러한 경우 HttpRedirectResponse 인터페이스를 따르는 객체를 반환하면 됩니다.

 

반환된 값은 @Redirect() 데코레이터로 전달된 모든 인자를 override합니다. 아래는 그 예시입니다:

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

 

1-8. Route parameters

요청의 일부로 동적인 데이터를 받아들여야 하는 경우에는 정적 경로가 있는 경로가 동작하지 않습니다.(ex. ID가 1인 고양이를 가져오기 위한 `GET /cats/1`)

 

매개 변수가 있는 라우트를 정의하려면 라우트 경로에 라우트 매개변수 토큰(ex. ':id')을 추가하여 요청 URL의 해당 위치에서 동적인 값들을 가져올 수 있습니다. 아래 @Get() 데코레이터 예제의 라우트 매개변수 토큰은 이러한 방식의 사용법을 보여줍니다.

 

이렇게 선언되는 매개변수는 메서드 시그니처에 추가해야 하는 @Param() 데코레이터를 사용하여 접근할 수 있습니다.

 

따라서 매개 변수를 갖는 라우트는 반드시 모든 정적인 경로 이후에 선언되어야 합니다.
@Get(':id')
findOne(@Param() params: any): string {
  console.log(params.id);
  return `This action returns a #${params.id} cat`;
}

 

@Param()은 메서드의 매개변수를 데코레이팅할 때 사용되고 라우트 매개 변수를 메서드 body(본문) 내에서 데코레이팅된 메서드 매개변수의 속성으로 사용할 수 있게 합니다.

 

위 코드에서 보면 params.id를 레퍼런싱함으로써 id 매개변수에 접근할 수 있습니다. 그러나 특정 매개변수 토큰을 데코레이터로 전달한 다음 메서드 본문에서 이름으로 라우트 매개변수에 그냥 바로 참조하는 방식도 가능합니다.

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

 

1-9. Sub-Domain Routing

@Controller 데코레이터는 host 옵션을 사용하여 들어오는 요청의 HTTP host가 몇몇 특정 값에 매치하도록 요구할 수 있습니다.

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

 

라우트의 path와 마찬가지로 host 옵션도 토큰을 사용하여 host 이름에서 해당 위치의 동적인 값을 가져올 수도 있습니다.

 

아래 @Controller 데코레이터 예제는  host 매개변수 토큰을 사용하는 방식을 보여줍니다.

 

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

이러한 방식으로 선언된 host 매개변수는 메서드 시그니처에 추가해야 하는 @HostParam() 데코레이터를 사용하여 접근할 수 있습니다.

 

1-10. Scopes

다른 프로그래밍 언어를 사용하다 오신 분들은, Nest에서 거의 모든 것이 들어오는 요청 전반에 걸쳐 공유된다는 사실을 알게되면 의외로 놀랄 수 있습니다.

 

Nest는 데이터베이스에 대한 connection pool, 전역 상태의 singleton 서비스 등을 갖고있습니다. Node.js는 모든 요청이 별도의 스레드에서 처리되는 요청/응답 Multi-Threaded Stateless 모델을 따르지 않는다는 점을 기억해야 합니다. 그렇기 때문에 싱글톤 인스턴스를 사용하는 것이 애플리케이션에 가장 안전한 방식이 될 수 있습니다.

 

그러나 예를 들어, GraphQL 애플리케이션에서의 요청별 캐싱, 요청 추적 또는 multi-tenancy 등 컨트롤러의 요청 기반 수명등과 같이 바람직한 동작일 수 있는 예외적인 경우가 있습니다. 

 

1-11. Asynchronicity (비동기성)

Nest는 모던 자바스크립트를 좋아하고 data extraction(데이터 추출)이 대부분 비동기라는 것을 알고 있습니다. 이것이 Nest가 async 함수를 지원하고 잘 동작하는 이유입니다.

 

async/await을 참고하려면 이곳을 참고하세요.

 

모든 async 함수는 Promise를 반환해야 합니다. 이는 Nest가 자체적으로 resolve할 수 있는 지연된 값을 반환할 수 있다는 것을 의미합니다. 이에 대한 예는 아래와 같습니다.

// cats.controller.ts
@Get()
async findAll(): Promise<any[]> {
  return [];
}

 

위의 코드는 완전히 유효한 코드입니다. 또한 Nest 라우트 핸들러는 RxJS의 Observable stream을 반환할 수 있기 때문에 훨씬 더 강력합니다. Nest는 그 아래의 소스를 자동으로 구독(subscribe)하고 stream이 완료되면 마지막으로 방출되었던 값을 가져올 것입니다.

// cats.controller.ts
@Get()
findAll(): Observable<any[]> {
  return of([]);
}

 

위 두 방식 모두 동작하고 요구사항에 맞게 무엇이든 사용할 수 있습니다.

 

1-12. Request payloads

앞선 POST 라우트 핸들러의 예시에서는 어떠한 클라이언트 매개변수도 받지 않았습니다. @Body() 데코레이터를 추가하는 법을 알아보겠습니다.

 

하지만 이를 위해 먼저 DTO 스키마를 결정해주어야 합니다. DTO는 데이터가 네트워크를 통해 전송되는 방식을 정의한 객체입니다. 타입스크립트의 인터페이스를 사용하거나 간단한 클래스를 사용하여  DTO 스키마를 결정할 수 있습니다.

 

재밌게도 Nest는 클래스를 사용하는 것을 권장하고 있습니다. 왜 그럴까요? 

 

클래스는 JavaScript ES6 표준의 일부이기 때문에 컴파일된 자바스크립트에서 실제 엔티티로써 보존되기 때문입니다. 반면에 타입스크립트의 인터페이스는 트랜스파일링(transpiling) 과정에서 제거되기 때문에 런타임에 Nest가 참조할 수 없다는 문제가 있습니다.

 

이는 중요한데, 왜냐하면 Pipes 기능은 런타임에서 변수의 해당 메타타입에 접근할 수 있을 때 추가적인 기능들을 사용가능하도록 열어주기 때문입니다.

 

아래는 CreateCatDto 클래스입니다.

// create-cat.dto.ts
export class CreateCatDto {
  name: string;
  age: number;
  breed: string;
}

이 DTO에서는 3개의 기본 속성(properties)을 갖습니다. 그러면 이제 새로 생성된 DTO를 CatsController 내에서 사용할 수 있는 것입니다.

 

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

 

Nest의 ValidationPipe는 메서드 핸들러가 받으면 안되는 속성을 필터링하는 기능을 할 수 있습니다. 그러한 경우에 받아들일 수 있는 속성에 대한 리스트(whitelist)를 먼저 만들고 그 리스트 안에 들지 않은 속성을 자동으로 결과 객체로부터 제거시킵니다.

 

CreateCatDto 예제에서 whitelist는 name, age, breed 속성인 것입니다.

 

1-13. Full resource sample (전체 예시)

아래 예시는 기본 controller를 생성하는데 가능한 몇 가지 데코레이터들을 이용한 예시입니다.

// cats.controller.ts
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`;
  }
}

 

1-14. Getting up and running

위에서 controller를 완전히 정의했으나, Nest는 여전히 CatsController의 존재를 모르고 결과적으로 이 클래스의 인스턴스를 생성하지 않을 것입니다.

 

controller는 항상 모듈에 속해야 하기 때문에 Nest의 @Module() 데코레이터 내에는 controller들을 포함하는 배열이 존재합니다. 아직 루트의 AppModule을 제외하고 어떠한 모듈도 정의하지 않았기 때문에 AppModule에 CatsController를 등록하겠습니다.

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

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

 

@Module() 데코레이터를 사용하여 모듈 클래스에게 메타데이터를 부여하였습니다. 그렇게 함으로써 Nest는 어느 controller를 마운트해야 할지를 이제 쉽게 반영할 수 있게 됩니다.

 

1-15. Library-specific(라이브러리별) 방식: Express

지금까지는 Nest의 표준 응답 조작 방식에 대해 설명했습니다.

 

응답을 조작하는 두 번째 방식은 라이브러리별 응답 객체를 사용하는 것입니다. 특정 응답 객체를 주입하기 위해서 @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([]);
  }
}

 

물론 이 접근 방식은 잘 동작하고, 실제로도 응답 객체에 대한 완전한 제어를 제공하기 때문에 어떤 면에서는 더 많은 유연성을 제공한다고 할 수 있지만, 다소 신중하게 사용해야 할 필요가 있습니다.

 

일반적으로 이 접근 방식은 훨씬 덜 명확하며 몇 가지 단점이 존재합니다.

 

가장 큰 단점은 코드가 플랫폼에 따라 달라질 수 있다는 것이고 테스트하기가 더 어렵다는 것입니다.

  • 기반 라이브러리가 응답 객체에 대해 서로 다른 API를 사용하기 때문.
  • 응답 객체를 mocking해야 함.

 

즉, 위 예제에서의 interceptor@HttpCode() / @Header() 데코레이터와 같이 Nest 표준 응답 처리에 의존하는 Nest 기능과의 호환성을 잃게 됩니다. 앞서 말했듯, 이러한 문제 해결을 위해선, passthrough 옵션을 true로 설정하는 것입니다.

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

이렇게 하면 기본 응답 객체를 사용할 수 있지만 나머지는 프레임워크에 맡길 수 있게 됩니다.

 

 

이렇게 이번 포스팅에서는 controller에 대해 알아보는 시간을 가져보았습니다. 감사합니다

 

반응형
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.