NestJS에 대하여

심화 데브캠프 1일차

Devcamp 1일차

NestJS

Nest는 테스트가 수월하고 확장성이 좋으며 유지보수가 편리한 Node.js 프레임워크 중 하나이다.

Typescript

Nest는 Typescript를 지원하는 것이 가장 큰 특징이다. 따라서 Interface를 사용할 수 있으며 Static/strong typing을 지원하기 때문에 type에 관해서, 혹은 property나 이름 오류도 코드 작성 단계에서 감지된다. 서버를 매번 실행하는 번거로움 없이 오타나 Type 설정, 디버깅을 할 수 있다는 것은 개발에 들어가는 시간과 노력을 줄여주는 큰 장점이다.

성질

객체지향 프로그래밍, 자료 처리를 수학적 함수의 계산으로 취급하는 FP(Functional Programming), 그리고 비동기적 데이터 흐름의 요소를 갖추고 있다. 기본적으로 Express hTTP 프레임워크를 사용하지만, Fastify를 포함하여 다양한 Node HTTP 프레임워크도 사용할 수 있고, Express에서 지원되는 것들은 Nest에서도 사용할 수 있어서 사용이 편리하다.

기능

Dependency Injection, Exception filters과 같은 기능들도 지원하여 개발에 용이하다. Dependency Injection은 모듈이 독립적으로 존재하는 상황에서, 다른 모듈의 서비스 내 메서드를 사용하기 위해 종속성을 추가해주는 것을 의미한다.

Decorator

Decorator를 적극적으로 활용한다. method, property 앞에 @decorator 의 형태로 선언하여 함수처럼 사용된다. decorator는 class, 내부의 method, property 까지 적용할 수 있다. decorator를 사용하여 코드가 명확해지고, 재사용성과 확장성이 높아진다. decorator를 사용하지 않을 때는, 필요한 로직을 직접 구현해야 한다. 예를 들어, @IsEmail 같은 decorator는 email 형식을 받는지 확인해주는데, decorator를 사용하지 않으면 직접 내부에 검증하는 코드를 만들어야 해서 번거로워진다.

Node.js의 Express와 비교

구조

Express는 제한없이 자유로 형식 덕분에 코드가 읽기 쉽지만, 반대로 애플리케이션이 복잡해지고 기능이 추가될수록 관리가 힘들어진다. 반면 Nest는 개발자가 정해진 방식으로 기본적인 툴과 코드를 사용하도록 하였기 때문에 함께 협업하며 쓰기에 좋고, 기능을 추가하며 애플리케이션을 확장시키기에 좋다. 관련된 컨트롤러와 서비스를 하나의 모듈로 묶어 독립적으로 관리할 수 있기 때문이다.

성능

Express는 여러 오퍼레이션을 독립적으로(비동기적으로) 실행한다. Nest는 기본적으로는 Express를 사용하지만 Fastify로 프레임워크를 변경하여 성능을 높일 수 있다.

테스트

Nest CLI는 Jest를 기반으로한 테스트 환경을 지원하지만 Express는 유닛 테스트를 위해 별도의 코드를 짜야한다.

Module

목적

관련된 코드, 기능들을 하나로 묶는 역할을 한다. 모듈은 컨트롤러, 서비스, Repository 등으로 구성된다. 애플리케이션은 App Module이라고 하는 커다란 하나의 모듈로 구성되어 있으며 내부에는 다양한 모듈로 구성되고 여러 계층으로 존재할 수도 있다. 예를 들어, App 모듈 안에 유저와 관련된 User Module, 상품과 관련된 Product Module, 결제와 관련된 Payment Moduel이 있고, Product Module 내에는 상품 종류별로 Food Module, Vehicle Module 등 이 존재할 수 있다.

구체적인 사용 방법

애플리케이션 전체를 하나의 App 모듈로 설정하고, 내부를 로직과 관련성으로 분류하여 다양하게 모듈을 구성한다. App 모듈 안에 유저와 관련된 User Module, 상품과 관련된 Product Module, 결제와 관련된 Payment Moduel이 있고, Product Module 내에는 상품 종류별로 Food Module, Vehicle Module 등 이 존재할 수 있다.

모듈에는 providers, controllers, import, exports properties가 나타나야 한다. src 디렉토리 내에는 전체를 나타내는 app.module.ts가 존재하고, src내에 모듈마다 디렉토리를 만들고 그 내부에 모듈 파일을 만들어야 한다.

전체를 감싸는 App 모듈과 내부에 정의된 cat 모듈이 존재한다면 다음과 같이 코드를 작성하여 사용한다.

1
2
3
4
5
6
7
8
9
import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

@Module({
  controllers: [CatsController], // 이 모듈(cat 모듈) 내에 정의된 컨트롤러
  providers: [CatsService], // 이 모듈이 제공하는 종속성
})
export class CatsModule {}
1
2
3
4
5
6
7
import { Module } from '@nestjs/common';
import { CatsModule } from './cats/cats.module';

@Module({
  imports: [CatsModule], // App module 내부에 정의된 cat module을 import 함
})
export class AppModule {}

주의사항

각 모듈은 독립적으로 코드와 기능을 분리하기 때문에 구조가 분명해진다는 장점이 있지만, 모듈간의 종속성 주입(Dependency Injection)에 주의해야 한다. 한 모듈 내에서 Service 내에 정의된 method를 다른 모듈에서 사용하기 위해선 사용할 모듈에 종속성을 주입해줘야 한다. 종속성 주입은 3단계로 이루어진다.

1단계 : 다른곳으로 주입될 서비스에 @injectable() decorator 추가하기

2단계 : 옮겨질 서비스의 모듈 export 리스트에 service 추가하기

3단계 : 주입받을 서비스에 constructor 생성자를 정의하고 종속성 주입하기

Provider

목적

독립되어 있는 객체는 서로 관계가 있을 수 있다. 어떤 객체는 실행되기 위해 다른 객체가 필요할 수도 있고, 두 객체가 모두 실행되야 한 객체가 실행될 준비가 완료될 수 도 있다. 이러한 객체간의 “연결”을 위해 Provider가 존재한다. providers list에 있는 것들은 모듈 외부로 DI를 통해 공유될 수 있다. 모듈파일 내에 작성된다.

구체적인 사용법

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { Injectable } from '@nestjs/common';
import { Cat } from './interfaces/cat.interface';

@Injectable() // 이 decorator를 통해 다른 곳(ex. CatsContorller)에 주입될 수 있다.
export class CatsService {
  private readonly cats: Cat[] = [];

  create(cat: Cat) { // 관련된 method들
    this.cats.push(cat);
  }

  findAll(): Cat[] { // 이러한 method들이 다른 module에서 사용하려면 DI이 필요하다.
    return this.cats;
  }
}

위는 cat 모듈 내에 정의된 서비스 CatsServcie이다. 다음과 같이 Controller에게 제공할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { Controller, Get, Post, Body } from '@nestjs/common';
import { CreateCatDto } from './dto/create-cat.dto';
import { CatsService } from './cats.service'; // 주입하기 위해선 import부터 한다.
import { Cat } from './interfaces/cat.interface';

@Controller('cats')
export class CatsController {
  constructor(private catsService: CatsService) {} // 생성자를 통해 주

  @Post()
  async create(@Body() createCatDto: CreateCatDto) {
    this.catsService.create(createCatDto);
  }

  @Get()
  async findAll(): Promise<Cat[]> {
    return this.catsService.findAll();
  }
}

위에는 나와있지 않지만 cats.module.ts 파일의 provider 리스트에 catsService가 추가되어있어야 한다.

주의사항

모듈간의 Provide는 종속성 주입이 필요하다. 또한 다른 곳에서 사용되기 위해서 @injectable decorator 설정이 필요하고, module의 provider 리스트에도 추가해줘야 한다.

Middleware

목적

Middleware는 request와 response 객체, request-response cycle내 next() 미들웨어 함수에 접근하여 로직을 추가하기 위해 사용된다.

middleware는 다음을 수행할 수 있다.

  • 어떤 코드든 실행한다.
  • request와 response 객체를 수정한다.
  • request-response cycle을 종료한다.
  • stack에서 다음 middleware function을 호출한다.
  • 현재 middleware 함수가 request-response cycle을 종료하지 않았다면, 제어권을 다음 middleware 함수로 넘기기 위해 next() 함수를 호출한다. 호출하지 않으면 요청이 중단된다.

적용하고자 하는 모듈의 디렉토리 내에 middleware를 작성한다.

구체적인 사용법

1
2
3
4
5
6
7
8
9
10
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

@Injectable() 
export class LoggerMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    console.log('Request...');
    next();
  }
}

@Injectable() decorator로 LoggerMiddleware는 다른 곳으로 inject될 수 있다. 이 미들웨어는 use라는 메서드를 수행하게 되는데, use함수는 ‘Request…’를 콘솔창에 출력하고 next 함수를 호출한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { LoggerMiddleware } from './common/middleware/logger.middleware';
import { CatsModule } from './cats/cats.module';

@Module({
  imports: [CatsModule],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware)
      .forRoutes('cats');
  }
}

서비스 전체를 담고 있는 최상위 모듈은 app.module의 코드이다. 이처럼 module 클래스의 configure() 메서드를 사용하여 적용한다.

주의사항

middleware를 포함하는 모듈은 반드시 NestModule 인터페이스를 implement해야 한다. interceptor와 달리 route handler 이전에만(response가 아니라 requests때만) 호출된다.

interceptor와 guard는 ExecutionContext를 통해 이후 호출되는 과정을 알지만 middleware는 알지 못한다.

Exception Filter

목적

application에서 처리되지 않은 모든 예외를 다룬다. 예외들 중 application에서 처리되지 않은 것들은 모두 filter가 포착하여 적절한 response로 보낸다. logging을 추가하거나 동적인 요소들을 기반으로 한 JSON 스키마를 사용하기 위해서도 사용한다.

구체적인 사용법

기본적으로 Nest에는 내장된 예외들이 있다. NotFoundException 이나, BadRequestException 등이 그 예시이다. 이러한 예외들은 다음과 같이 설정한다.

1
2
3
4
5
6
7
8
9
async changeApproval(id: string, approved: boolean) {
    const report = await this.repo.findOne({ where: { id: parseInt(id) } });
    if (!report) {
      throw new NotFoundException('report not found');
    }

    report.approved = approved;
    return this.repo.save(report);
  }

메서드를 정의하는 서비스 파일의 예시이다. 찾아야 할 report를 찾지 못한다면 내장된 예외처리인 NotFoundException을 던진다. 이외에도 사용자가 정의한 예외를 던질 수도 있다.

Exception Filter는 다음과 같이 작성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Request, Response } from 'express';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus();

    response
      .status(status)
      .json({
        statusCode: status,
        timestamp: new Date().toISOString(),
        path: request.url,
      });
  }
}

주의사항

모든 exceptionfilter는 ExceptionFilter<T> interface를 implement 해야 한다. T는 예외의 type을 나타낸다. response가 들어와 respond를 내보내는 일련의 과정에서 가장 마지막에 위치한다.

Guard

목적

특정 조건에 따라 route handler가 들어온 request를 처리하게 할지 말지를 결정한다. authorization, 해당 요청이 접근할(처리될) 권한이 있는지를 검사하기 위한 class이다.

구체적인 사용법

1
2
3
4
5
6
7
8
9
import { CanActivate, ExecutionContext } from '@nestjs/common';

export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext) {
    const request = context.switchToHttp().getRequest();

    return request.session.userId;
  }
}

이 guard는 요청에 담긴 session에서 userId를 추출해낸다. 어떤 보고서(게시글 같은)를 수정하거나 삭제할 때 그 레코드의 작성자의 usedId와 위 가드를 통해 반환된 userId의 비교를 통해 같다면 route handler에게 request를 넘겨 처리되도록 하고 다르다면 거부한다.

주의사항

가드는 interceptor와 pipe 이전에, 모든 미들웨어보단 늦게 수행되기 때문에, 미들웨어에 대한 권한은 검사하지 못한다. 그리고 interceptor와 pipe보다는 이전에 수행된다.

Interceptor

목적

인터셉터는 앞서 middleware와 비슷한듯 하지만 전체 컨트롤러에 적용할 수도 있고 각 root handler에도 적용할 수도 있다.

interceptor를 통해 다음이 가능하다.

  • route handler 전에 로직을 추가한다.
  • 들어오는 request, 나가는 response 둘 다 적용 가능하다.

구체적인 사용법

들어오는 요청에서 특정 property를 제거하려면 entity에서 해당 property는 @Exclude() decorator를 적용해주고 컨트롤러에서 @UseInterceptors decorator에 interceptor를 인자로 전달한다.

1
2
3
4
5
6
7
8
9
10
import { UseInterceptors, ClassSerializerInterceptor } from '@nestjs/common';
  @UseInterceptors(ClassSerializerInterceptor)
  @Get('/:id')
  async findUser(@Param('id') id: string) {
    const user = await this.usersService.findOne(parseInt(id));
    if (!user) {
      throw new NotFoundException('user not found');
    }
    return user;
  }

혹은 custom interceptor를 만들 수도 있다. NestInterceptor 를 implement 하고 요청에 대한 처리 로직과 응답에 대한 처리 로직을 추가하면 된다. DTO를 통해 특정 속성이 있는지를 확인해주는 등의 코드를 추가하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import {
  UseInterceptors,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { plainToClass } from 'class-transformer';

export class SerializeInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, handler: CallHandler): Observable<any> {
    // 들어오는 request 처리해주는 로직을 추가
    console.log('Im running before the handler', context);

    return handler.handle().pipe(
      map((data: any) => {
        // 나가는 response 처리해주는 로직을 추가
        console.log('Im running before response is sent out', data);
      })
    );
  }
}

주의사항

DI를 사용하려면 constructor를 사용해야 한다. Interceptor의 적용은 위에서 말했듯 컨트롤러, 전체 global(전역) 등 다양하다. 이에 따라 다양한 효과를 낼 수 있기 때문에 어디에 어떤 순서로 적용할 지 명확히 해야 한다.