BackEnd/Nest.js

[Nest.js] 인터페이스

Grace 2023. 5. 8. 12:45

컨트롤러

Nest의 컨트롤러는 MVC 패턴에서 말하는 그 컨트롤러를 말합니다. 컨트롤러는 들어오는 요청을 받고 처리된 결과를 응답으로 돌려주는 인터페이스 역할을 합니다.

 

컨트롤러는 엔드포인트 라우팅 메커니즘을 통해 각 컨트롤러가 받을 수 있는 요청을 분류합니다. 컨트롤러를 사용 목적에 따라 구분하면 구조적이고 모듈화된 소프트웨어를 작성할 수 있습니다.

다음과 같이 컨트롤러를 생성합니다.

$ cd [project folder]
$ nest g controller [controller name]

AppModule에는 방금 생성한 UserController와 프로젝트를 생성할 때 만들어진 AppService를 임포트해서 사용하고 있습니다.

그 밖의 Nest 구성 요소에 대한 약어는 nest -h 명령어로 확인할 수 있습니다.

namealiasdesciption

application application Generate a new application workspace
class cl Generate a new class
configuration config Generate a CLI configuration file
controller co Generate a congroller declaration
decorator d Generate a custom decorator
filter f Generate a filter declaration
gateway ga Generate a gateway declaration
guard gu Generate a guard declaration
interceptor in Generate an interceptor declaration
interface interface Generate an interface
middleware mi Generate a middleware declararion
module mo Generate a module declaration
pipe pi Generate a pipe declaration
provider pr Generate a provider declaration
resolver r Generate a GraphQL resolver declaration
service s Generate a service declaration
library lib Generate a new library within a monorepo
sub-app app Generate a new application within a monorepo
resource res Generate a new CRUD resource

만들고자 하는 리소스의 CRUD 보일러플레이트 코드를 한 번에 생성할 수도 있습니다.

$ nest g resource [name]

라우팅

현재 localhost의 루트 경로로 요청 처리가 되고 있습니다.

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }
}

서버가 수행해야 하는 많은 귀찮은 작업을 데코레이터로 기술하여, 애플리케이션의 핵심 로직에 집중할 수 있도록 도와줍니다. @controller 데코레이터를 클래스에 선언하는 것으로 해당 클래스는 컨트롤러 역할을 하게 됩니다.

getHello 함수는 @Get 데코레이터를 가지고 있습니다. 따라서 루트 경로로 들어오는 요청을 처리할 수 있게 되었습니다. 라우팅 경로는 @Get 데코레이터의 인수로 관리할 수 있습니다. 경로를 루트 경로가 아니라 ‘/hello’로 변경하면 /hello로 접속해야 에러가 발생하지 않습니다.

  @Get('/hello')
  getHello(): string {
    return this.appService.getHello();
  }

@Controller 데코레이터에도 인수를 전달할 수 있습니다. 이를 통해 라우팅 경로의 접두어를 지정합니다. 예를 들어 @Controller('app')이라고 헀다면 이제 /app/hello 경로로 접근해야 합니다. 보통 컨트롤러가 맡은 리소스의 이름을 지정하는 경우가 많습니다.

와일드 카드 사용

라우팅 패스는 와일드 카드를 이용하여 작성할 수 있습니다. 예를 들어 별표(*) 문자를 사용하면 문자열 가운데 어떤 문자가 와도 상관없이 라우팅 패스를 구성하겠다는 뜻입니다.

  @Get('he*lo')
  getHello(): string {
    return this.appService.getHello();
  }

이 코드는 helo, hello, he__lo 같은 경로로 요청을 받을 수 있습니다. * 외에 ?, +, () 문자 역시 정규 표현식에서의 와일드 카드와 동일하게 동작합니다. 단, 하이픈(-)과 점(.)은 문자열로 취급합니다. 즉, @Get('he.lo')는 hello로 요청할 수 없습니다. 와일드 카드는 컨트롤러의 패스를 정할 때만 사용하는 것이 아닙니다. 앞으로 배우게 될 많은 컴포넌트에서 이름을 정할 때 사용할 수 있습니다.

요청 객체

클라이언트는 요청을 보내면서 종종 서버가 원하는 정보를 함께 전송합니다. Nest는 요청과 함께 전달되는 데이터를 핸들러가 다룰 수 있는 객체로 변환합니다. 이렇게 변환된 객체는 @Req 데코레이터를 이용하여 다룰 수 있습니다.

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

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get('he*lo')
  getHello(@Req() req: Request): string {
    console.log(req);
    return this.appService.getHello();
  }
}

요청 객체는 HTTP 요청을 나타냅니다. 요청 객체(req)는 쿼리 스트링, 매개변수, 헤더와 본문 외 많은 정보를 가지고 있습니다.

API를 작성 할 때 요청 객체를 직접 다루는 경우는 드뭅니다. Nest는 @Query(), @Param(key?: string), @Body() 데커레이터를 이용해서 요청에 포함된 쿼리 매개변수, 패스 매개변수, 본문을 쉽게 받을 수 있게 해줍니다.

응답

nest g resource Users 명령어로 USERS 리소스에 대한 CRUD API를 만들었습니다. 서버를 실행하면 어떤 라우팅 패스를 통해 요청을 받을 수 있는지 콘솔 로그를 통해 확인할 수 있습니다.

...
[Nest] 81903  - 2023. 04. 27. 오후 6:34:03     LOG [RoutesResolver] UsersController {/users}: +0ms
[Nest] 81903  - 2023. 04. 27. 오후 6:34:03     LOG [RouterExplorer] Mapped {/users, POST} route +0ms
[Nest] 81903  - 2023. 04. 27. 오후 6:34:03     LOG [RouterExplorer] Mapped {/users, GET} route +0ms
[Nest] 81903  - 2023. 04. 27. 오후 6:34:03     LOG [RouterExplorer] Mapped {/users/:id, GET} route +1ms
[Nest] 81903  - 2023. 04. 27. 오후 6:34:03     LOG [RouterExplorer] Mapped {/users/:id, PATCH} route +0ms
[Nest] 81903  - 2023. 04. 27. 오후 6:34:03     LOG [RouterExplorer] Mapped {/users/:id, DELETE} route +0ms
...

Users 리소스에 대한 CRUD 요청 결과를 표로 정리하면 다음과 같습니다.

경로HTTP 메서드응답 상태 코드본문

/users POST 201 This action adds a new User
/users GET 200 This action returns all users
/users/1 GET 200 This action returns a #1 user
/users1 PATCH 200 This action updates a #1 user
/users/1 DELETE 200 This action removes a #1 user

CLI로 자동 생성된 업데이트는 PATCH 메서드를 사용하는 것을 볼 수 있습니다. HTTP 메서드에는 업데이트 동작을 기술하는 메서드가 2가지 있습니다. PUT은 리소스 전체를 교체할 때 쓰고, PATCH는 리소스의 일부를 업데이트 할 때 사용합니다. 실제 구현 시에는 이를 엄격하게 지키지 않고 보통 PUT을 사용하긴 합니다.

각 요청의 성공 응답 코드는 POST일 경우에만 201이고, 나머지는 200인 것을 볼 수 있습니다. 또한 응답 본문은 스트링 값을 가지고 있는데 이는 UsersController의 각 메서드가 리턴하는 값입니다. Nest는 이렇게 응답을 어떤 방식으로 처리할지 미리 정의해뒀습니다. string, number, boolean과 같이 자바스크립트 원시 타입을 리턴할 경우 직렬화 없이 바로 보내지만, 객체를 리턴한다면 직렬화를 통해 JSON으로 자동 변환해줍니다. 이 방법이 권장하는 방법이긴 하지만 라이브러리별 응답 객체를 직접 다룰 수도 있습니다.

  @Get()
  findAll(@Res() res) {
    const users = this.usersService.findAll();
    return res.status(200).send(users);
  }

Nest는 CRUD에 대해 성공 응답으로 POST는 201, 그 외에는 200을 보낸다고 했습니다. 만약 이 상태 코드를 다른 값으로 바꾸길 원한다면 @HttpCode 데코레이터를 사용하면 됩니다.

  @HttpCode(202)
  @Patch(':id')
  update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
    return this.usersService.update(+id, updateUserDto);
  }

HTTP 202(Accepted)

요청이 성공적으로 접수되었으나, 아직 해당 요청에 대해 처리 중이거나 처리 시작 전임을 의미합니다. 요청이 처리 중 실패할 수도 있기 때문에 요청은 실행될 수도 실행되지 않을 수도 있습니다. 이 상태 코드는 비확약적, 즉 HTTP가 나중에 요청 처리 결과를 나타내는 비동기 응답을 보낼 방법이 없다는 것을 의미합니다.

요청을 처리하는 도중 에러가 발생하거나 예외를 던져야 한다면 400 Bad Request 예외를 던져야 합니다.

  @Get(':id')
  findOne(@Param('id') id: string) {
    if (+id < 1) {
      throw new BadRequestException('id는 0보다 큰 값이어야 합니다');
    }
    return this.usersService.findOne(+id);
  }

NotFoundException 객체의 생성자로 전달한 메세지와 함께 상태 코드가 400인 에러가 발생합니다.

터미널에서 그냥 curl 명령어로 실행하면 JSON 결과가 읽기 편하게 출력되지는 않습니다. 출력을 예쁘게 보려면 jq에 파이프로 전달하면 됩니다. $ curl -X GET http://localhost:3000/users/0 | jq

헤더

Nest는 응답 헤더 역시 자동 구성해줍니다.

만약 응답에 커스텀 헤더를 추가하고 싶다면 @Header 데커레이터를 사용하면 됩니다. 인수로 헤더 이름과 값을 받습니다. 물론 라이브러리에서 제공하는 응답 객체를 사용해서 res.header() 메서드로 직접 설정도 가능합니다.

import { Header } from '@nestjs/common';

@Header('Custom', 'Test Header')
@Get(':id')
findOneWithHeader(@param('id') id: string){
  return this.usersService.findOne(+id)
}

curl 명령어를 사용할 때 자세한 정보를 얻으려면 -v 옵션(또는 --verbose 옵션)을 지정하면 됩니다. -X 옵션을 생략하면 GET으로 동작하므로 옵션이 굳이 필요없다는 설명이 추가된 것을 볼 수 있습니다. 또한 -v 옵션을 이용하면 헤더를 확인할 수도 있습니다.

리디렉션

종종 서버가 요청을 처리한 후, 요청을 보낸 클라이언트를 다른 페이지로 이동하고 싶은 경우가 있습니다. 이를 리디렉션이라 합니다. 응답 본문에 리디렉션할 URL을 포함해서 클라이언트가 스스로 페이지를 이동하게 해도 되지만, @Redirect 데코레이터를 사용하면 쉽게 구현이 가능합니다. 데코레이터의 두 번째 인수는 상태 코드입니다. 301 Moved Permanently는 요청한 리소스가 헤더에 주어진 리소스로 완전히 이동됐다는 뜻입니다. 이 상태 코드를 200과 같이 다른 것으로 바꾸어 응답할 수 있습니다. 아지만 301, 307, 308과 같이 Redirect로 정해진 응답 코드가 아닐 경우 브라우저가 제대로 반응하지 않을 수 있습니다.

import { Redirct } from '@nestjs/common';

@Redirect('https://nestjs.com', 301)
@Get(':id')
findOne(@param('id') id:string) {
  return this.usersService.findOne(+id);
}

요청 처리 결과에 따라 동적으로 리디렉트하고자 한다면 응답으로 다음과 같은 객체를 리턴하면 됩니다.

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

예를 들어 쿼리 매개변수로 버전 숫자를 전달받아 해당 버전의 페이지로 이동한다고 하면 다음처럼 구현할 수 있습니다.

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

Nest는 자바스크립트 객체를 리턴하면 JSON 스트링으로 직렬화를 해서 보내줍니다.

라우트 매개변수

라우트 매개변수는 패스 매개변수라고도 합니다. 경로를 구성하는 매개변수가 됩니다.전달받은 매개변수는 함수 인수에 @Param 데코레이터로 주입받을 수 있습니다.

라우트 매개변수를 전달받는 방법은 2가지가 있습니다. 먼저 매개변수가 여러 개 전달될 경우 객체로 한 번에 받는 방법입니다. 이 방법은 params의 타입이 any가 되어 권장하지 않습니다. 물론 라우트 매개변수는 타입이 항상 string이기 때문에 명시적으로 { [key: string]: string } 타입을 지정해도 됩니다.

@Delete(':userId/memo/:memoId')
deleteUserMemo(@Param() param: {[key:string]: string}) {
  return `userId: ${params.userId}, memoId: ${params.memoId}`
}

더 일반적인 방법은 라우팅 매개변수를 따로 받는 것입니다. REST API를 구성할 때는 라우팅 매개변수의 개수가 너무 많아지지 않게 설계하는 것이 좋기 때문에 따로 받아도 코드가 많이 길어지지는 않습니다.

@Delete(':userId/memo/:memoId')
deleteUserMemo(
  @Param('userId') userId: string,
  @Param('memoId') memoId: string,
) {
  return `userId: ${params.userId}, memoId: ${params.memoId}`
}

하위 도메인

서버에서 제공하는 기능을 API로 외부에 공개하기로 했다고 가정했을 때, 현재 사용하는 도메인과 api 요청에 사용할 도메인을 다르게 사용하고, 다르게 처리하려고 하고 하위 도메인에서 처리하지 못하는 요청은 원래의 도메인에서 처리하고 싶을 경우 하위 도메인 라우팅 기법을 쓸 수 있습니다.

먼저 새로운 컨트롤러를 생성합니다.

nest g co Api

app.controller.ts에 이미 루트 라우팅 경로를 가진 엔드포인트가 존재합니다. ApiController에서도 같은 엔드포인트를 받을 수 있도록 하기 위해 ApiController가 먼저 처리되도록 순서를 수정합니다.

@Module({
  controllers: [ApiController, AppController],
  ...
})
export class AppModule { }

@Controller 데코레이터는 ControllerOptions 객체를 인수로 뱓는데, host 속성에 하위 도메인을 기술하면 됩니다.

@Controller({ host: 'api.example.com' }) // 하위 도메인 요청 처리 설정
export class ApiController {
  @Get() // 같은 루트 경로
  index(): string {
    return 'Hello, API'; // 다른 응답
  }
}

로컬에서 테스트를 하기 위해 하위 도메인을 api.localhost로 지정하면 curl 명령어가 제대로 동작하지 않습니다. 이는 api.localhost가 로컬 요청을 받을 수 있도록 설정되어 있지 않기 때문입니다. 이를 해결하려면 /etc/hosts 파일의 마지막에 127.0.0.1 api.localhost를 추가하고 서버를 다시 구동하면 됩니다.

요청 패스를 @Param 데코레이터로 받아 동적으로 처리할 수 있지만, 유사하게 @HostParam 데코레이터를 이용하면 서브 도메인을 변수로 받을 수 있습니다. API 버저닝을 하는 방법은 여러 가지 있지만 하위 도메인을 이용하는 방법을 많이 사용합니다. 하위 도메인 라우팅으로 쉽게 API 버전별로 분리할 수 있습니다.

@Controller({ host: ':version.api.localhost'})
export class ApiController {
  @Get()
  index(@HostParam('version') version: string): string {
    return `Hello, API ${version}`
  }
}

host param이 없는 host로 요청을 하면 기존 도메인으로 요청이 처리되는 것을 볼 수 있습니다.

페이로드 다루기

POST, PUT, PATCH 요청은 보통 처리에 필요한 데이터를 함께 실어 보냅니다. 이 데이터 덩어리, 즉 페이로드를 본문이라고 합니다. NestJS에는 데이터 전송 객체(data transfer object, DTO)가 구현되어 있어 본문을 쉽게 다룰 수 있습니다.

앞서 생성한 Users 리소스를 생성하기 위해 POST /users로 들어오는 본문을 CreateUserDto로 받았습니다. 이제 회원 가입을 처리하기 위해 이름과 이메일을 추가해봅시다.

export class CreateUserDto {
  name: string;
  email: string;
}

@Post()
crate(@Body() crateUserDto: CreateUserDto) {
  const { name, email } = createUserDto;
  
  return `유저를 생성했습니다. 이름: ${name}, 이메일: ${email}`
}

GET 요청에서 서버에 전달할 데이터를 포함할 때는 일반적으로 요청 주소에 포함시킵니다. 예를 들어 유저 목록을 가져오는 요청은 GET /user?offset=0&limit=10과 같이 페이징 옵션이 포함되도록 구성할 수 있습니다. offset은 데이터 목록 중 건너뛸 개수를 의미하고 limit은 offset 이후 몇 개의 데이터를 가져올지 지정합니다. 이 두 쿼리 매개변수를 @Query DTO로 묶어 처리할 수 있습니다.

export class GetUsersDto{
  offset: number;
  limit: number;
}

지금까지 백엔드 애플리케이션의 관문이라 할 수 있는 컨트롤러를 Nest에서 어떻게 사용하는지 살펴봤습니다. 정리하자면, 컨트롤러는 서버로 들어오는 요청을 처리하고 응답을 가공합니다. 서버에서 제공하는 기능을 어떻게 클라이언트와 주고받을지에 대한 인터페이스를 정의하고 데이터의 구조를 기술합니다.

유저 서비스의 인터페이스

유저 서비스는 외부에서 4가지의 요청을 받아 처리하며, 각 요청에 대한 인터페이스는 다음과 같이 정의합니다.

기능엔드포인트본문 데이터 예 (JSON)패스 매개변수응답

회원 가입 POST /users { “name”: “YOUR_NAME”, “email”: “YOUR_EMAIL@gmail.com”, “passowrd”: “PASSWORD” }
201
이메일 인증 POST /users/email-verify { “signupVerifyToken”: “임의의 문자열” }
201 액세스 토큰
로그인 POST /users/login { “email”: “YOUR_EMAIL@gmail.com”, “password”: “PASSWORD” }
201 액세스 토큰
회원 정보 조회 GET /users/id
id: 유저 생성 시 만들어진 유저 ID, email이 아니라 임의의 문자열 200 회원 정보

이제 앞에서 정의한 인터페이스를 컨트롤러에 만들어봅니다. AppControllerAppService는 불필요하므로 삭제 후 컴파일 에러가 발생하는 부분을 수정해줍니다. 그리고 UserController를 생성합니다.

nest g co Users

먼저 회원 가입 인터페이스를 UsersController에 구현해보겠습니다.

import { Body, Controller, Post } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';

@Controller('users')
export class UsersController {
  @Post()
  async createUser(@Body() dto: CrateUserDto): Promise<void>{
    console.log(dto)
  }
}

회원 가입 요청의 본문을 CreateUserDto 클래스로 받습니다. src/users/dto/create-user.dto.ts 파일을 만들고 이름, 이메일, 패스워드 데이터를 정의하는 DTO를 정의합니다.

export class CreateUserDto {
  readonly name: string;
  readonly email: string;
  readonly password: string;
}

마찬가지로 나머지 인터페이스들도 컨트롤러에 구현합니다.

현업에서는 이렇게 계층별로 구현하는 것보다는 일명 ‘김밥 썰기’라고 부르는 기능별 구현 방식을 추천합니다. 업무를 진행할 때는 여러 개발자가 본인이 맡은 기능을 여러 레이어에 걸쳐 구현하는 것이 보통입니다. 또한 전체 레이어를 관통하여 구현해야 요청 처리가 전체 레이어에 걸쳐 문제없이 동작하는지 확인할 수 있습니다. 지금은 컨트롤러, 서비스, 데이터베이스 등의 컴포넌트를 설명한 후 예시를 들기 때문에 레이어별로 구현하는 방식으로 진행합니다.

import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { CreateUserDto } from './dto/crate-user.dto';
import { UserLoginDto } from './dto/user-login.dto';
import { VerifyEmailDto } from './dto/verify-email.dto';
import { UserInfo } from './UserInfo';

@Controller('users')
export class UsersController {
  @Post()
  async createUser(@Body() dto: CreateUserDto): Promise<void> {
    cionsole.log(dto)
  }
  
  @Post('/email-verify')
  async verifyEmail(@Query() dto: VerifyEmailDto): Promise<string> {
    /* 이메일 인증 시 URL에 포함되어 전달되는 쿼리 매개변수를 @Query 데코레이터와
      함께 선언한 DTO로 받습니다 */
    console.log(dto)
    return
  }
  
  @Post('/login')
  async login(@Body() dto: UserLoginDto): Promise<string>{
    // 로그인 할 떄 유저가 입력한 데이터는 본문으로 전달되도록 합니다.
    console.log(dto)
    return 
  }
  
  @Get('/:id') /* 유저 정보 조회 시 유저 아이디를 패스 매개변수 id로 받습니다. @Get 데코레이터의
    인수에 있는 id와 @Param 데코레이터의 인수로 있는 id는 이름이 같아야 합니다. */
  async getUserInfo(@Param('id') userId: string): Promise<UserInfo> {
    console.log(userId)
    return
  }
}
// dto/verify-email.dto
export class VerifyEmailDto {
  signupVerifyToken: string;
}

// dto/user-login.dto
export class UserLoginDto {
  email: string;
  password: string;
}

// UserInfo
export interface UserInfo {
  id: string;
  name: string;
  email: string;
}

'BackEnd > Nest.js' 카테고리의 다른 글

[Nest.js] 파이프와 유효성 검사  (0) 2023.05.16
[Nest.js] Config 패키지  (0) 2023.05.16
[Nest.js] 프로바이더  (0) 2023.05.16
[Nest.js] 백엔드 개발 기본 지식  (0) 2023.04.27
[Nest.js] Get Started  (0) 2023.04.27