새소식

반응형
Back-end/NestJS

[NestJS] NestJS 구조 살펴보기

2024.01.29
  • -
반응형

이전 포스팅들을 통해 NestJS와 관련된 많은 용어와 개념들에 대해서 정리를 해보았습니다. 이번에는 그러한 개념들을 NestJS에서 실제 사용하는 방식에 대해서 알아보려고 합니다.

 

해당 포스팅을 잘 이해하기 위해서는 이 글을 먼저 읽고 보시는 것을 추천드립니다!

 

 

1. Controller

NestJS로 프로그래밍을 시작하는 가장 간단한 방법은 모든 일을 다 처리하는 Controller를 하나 만드는 것 입니다.

  • validation부터 데이터베이스와 연동하여 비지니스 로직을 다루는 요청 처리까지 말이죠.

1계층 (하나의 컨트롤러가 모든 것을 담당)

 

코드는 다음과 같습니다: 수행하는 기능은 인자로 받은 숫자에 80을 곱한 값으로 변환하여 해당 값을 응답하는 것입니다.

// simpel.controller.ts
/*
	GET /simple/convert/12
*/
import { Controller, Get, Param } from '@nestjs/common';
@Controller('simple')
export class SimpleController {
  @Get('/convert/:inr')
  convert(@Param('inr') inr: number) {
    return inr * 80;
  }
}

 

이렇게 생긴 컨트롤러는 수많은 백엔드 아키텍처에서 전혀 권장되지 않는 방식입니다. 그러한 이유로 이렇게 많은 기능을 가진 Controller를 흔히 FAT UGLY Controller라고들 부릅니다.

 

그럼 어떻게 해야 좀 더 통용될 수 있는 구조를 만들어낼 수 있을까요?

 

2. Services

규칙 1: 비지니스 로직은 service라는 별도의 개체에 위임되어야 한다.

 

비지니스 로직을 서비스에 위임한 컨트롤러 모습

 

service의 모습은 다음과 같습니다.

// converter.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class ConverterService {
  convert(inr: number): number {
    return inr * 80;
  }
}

 

Service는 컨트롤러에서 사용될 수 있어야 하기 때문에, 즉 "Injectable(주입가능한)" 해야하므로 클래스 이름 위에  @Injectable() 데코레이터가 붙여진 것이고, 이를 모듈의 provider 부분에 등록도 해주어야 합니다. 아래와 같이 말이죠.

providers: [ConverterService],

 

 

NestJS는 SOLID 원칙의 의존성 역전 원칙을 따릅니다. 우리는 보통 컨트롤러에서 사용되는 service를 컨트롤러 내부에서 직접 인스턴스화(instantiate) 시키고 싶지 않아합니다.

 

그래서 Nest의 의존성 주입 기능이 다른 곳에 존재하는 service에 대한 컨트롤러의 의존성을 줄여줍니다. NestJS는 모든 그러한 요구사항에 대해 service를 직접 인스턴스화 하는 책임을 대신 갖고 있습니다.

 

그러면 우리는 컨트롤러 내부에서 service 주입하기 위해서 생성자를 이용할 수 있습니다. Nest가 대신 생성한 인스턴스를 생성자로부터 주입 받아 편하게 사용하는 것이죠.

 

// service.controller.ts
import { Controller, Get, Param } from '@nestjs/common';
import { ConverterService } from './converter.service';
@Controller('service')
export class ServiceController {
  /* ConverterService는 Injectable 합니다. 그렇기에 NestJS가 대신 인스턴스화 작업을 처리해줍니다.*/
  constructor(private readonly converterService: ConverterService) {}

  @Get('/convert/:inr')
  convert(@Param('inr') inr: number): number {
    return this.converterService.convert(inr);
  }
}

생성자 부분을 보시면 인자에 CounterService라는 서비스를 명시했는데, 이는 NestJS가 ConverterService 객체를 사용가능하도록 대신 인스턴스화 시켜줌으로써 이 컨트롤러에서 ConverterService를 직접 생성하지 않고도 사용할 수 있게 되는 것을 의미합니다.

 

물론 모듈(CounterModule 혹은 AppModule)에는 이 컨트롤러가 그러한 Service를 사용한다고 providers에 명시해 주어야 하겠지만 말이죠.

 

 

이러한 분리만으로도 꽤 좋은 애플리케이션을 만들 수 있습니다. 그런데 Validation(검증)을 해야하는 경우는 어떨까요?

 

예를 들어, http://127.0.0.1:3000/service/convert/12의 요청은 잘 작동하겠지만, http://127.0.0.1:3000/service/convert/aaa는 Param이 숫자가 아니기 때문에 제대로 작동하지 않을 것입니다. 아마 NaN 값을 반환하겠죠(Nan: Not a Number). 그래서 이런 경우 중간에 Validation Layer가 하나 필요한 것입니다. 

 

물론 이러한 검증(validation) 로직을 컨트롤러 내부에도 구현할 수 있겠지만, 이는 결국 다시 FAT UGLY 컨트롤러(😣)를 만드는 꼴이 되어버립니다.

 

그래서 NestJS에서는 validation을 위해 Pipe 개념을 활용합니다.

 

3. Pipes

규칙 2: Validatoin 로직은 pipe라는 별도의 개체에 위임해야 한다.

Service와 Pipe를 사용하는 컨트롤러

 

Nest에서 Pipe는 주로 transformation(변환)과 validation(검증)을 위해 사용됩니다. NestJS에는 내장 Pipe 클래스를 지원하지만 한 번 우리만의 똑똑한 custom pipe 클래스를 만들어봅시다. 

  • 변환(transformation)은 string에서 integer로 변환시켜주거나, 빠진 데이터 필드에 대해 default 값으로 적용해줄 때 유용합니다.
  • 또한 네트워크를 통해 들어오는 payload의 값은 plain JavaScript object인데, 이를 DTO 클래스에 맞는 타입형 객체로 바꿔줄 때도 유용하게 사용됩니다.

 

그 예시로, 입력값이 숫자인지에 대한 검증은 물론, 콤마(,)를 포함하는지도 검증해야 한다고 해봅시다. 만약 그 입력 값이 "abc"라면 Pipe는 그 값을 허용하지 않을 것이지만 "1,000"은 허용할 것입니다. 그리고 그 값을 컨트롤러에 값을 전달하기 전에 해당 값으로부터 콤마를 제거하는 작업을 수행할 것입니다.

 

  • /smart/convert/12 (O)
  • /smart/convert/1,000 (O)
  • /smart/convert/abc (X)

1,000은 Pipe에 의해 숫자 1000으로 처리되지만 abc는 허용되지 않아 422 에러를 발생시켜야 합니다.
(422: UnprocessableEntityException)

 

// comma.pipe.ts
import {
  ArgumentMetadata,
  Injectable,
  PipeTransform,
  UnprocessableEntityException,
} from '@nestjs/common';

@Injectable()
export class CommaPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    /* remove comma from input */
    var output = value.replaceAll(',', '');
    /* If input is Not a Number, raise 422 error  */
    if (isNaN(output)) {
      throw new UnprocessableEntityException(
        ['Non numeric input'],
        'Incorrect Parameter',
      );
    }
    return output;
  }
}
  • value: 현재 처리 중인 메소드 인자(라우트 핸들링 메소드에 의해 받기 전의 값)
  • metadata: 현재 처리 중인 메소드의 메타데이터( ArgumentMetadata: { type, metadata?, data? } )
    • type의 경우 @Body, @Query, @Param, or custom 중 하나입니다.

 

파이프를 만들었으니 컨트롤러에서 이 파이프를 사용해줍니다. 아래는 @Param 데코레이터의 인자에 우리가 만든 CommaPipe의 인스턴스를 직접 넣어준 방식의 예시입니다.

// smart.controller.ts
import { Controller, Get, Param } from '@nestjs/common';
import { ConverterService } from './converter.service';
import { CommaPipe } from './comma.pipe';

@Controller('smart')
export class SmartController {
  constructor(private readonly converterService: ConverterService) {}
  /*
    We have used CommaPipe to validate "inr" path parameter
  */
  @Get('/convert/:inr')
  convert(@Param('inr', new CommaPipe()) inr: number): number {
    return this.converterService.convert(inr);
  }
}

 

 

혹은, 다음과 같이 @UsePipes() 데코레이터를 사용하여 우리가 만든 Pipe를 사용하도록 지정할 수도 있습니다.

  ...
  @Get('/convert/:inr')
  @UsePipes(new CommaPipe())
  convert(@Param('inr') inr: number): number {
    return this.converterService.convert(inr);
  }
}

이러면 이 GET 메소드는 inr이라는 Param을 우리가 만든 CommaPipe의 transform 메소드의 value로 넘겨주게 되어 변환과정을 모두 거친 후에 service로 넘어가게 됩니다.

 

 

혹은, 만약 inr이 빠져서 요청이 오는 경우를 대비해 아래와 같이 그것의 default 값을 지정해 줄 수도 있습니다.

// smart.controller.ts
import { Controller, Get, Param } from '@nestjs/common';
import { ConverterService } from './converter.service';
import { CommaPipe } from './comma.pipe';

@Controller('smart')
export class SmartController {
  constructor(private readonly converterService: ConverterService) {}
  /*
    We have used CommaPipe to validate "inr" path parameter
  */
  @Get('/convert/:inr')
  convert(@Param('inr', new DefaultValuePipe(0), new CommaPipe()) inr: number): number {
    return this.converterService.convert(inr);
  }
}

 

4. Interceptor

규칙 3: 응답 변환(response transformation)은 interceptor라는 별도의 개체로 위임되어야 한다.

Service와 Pipes, Interceptor를 사용하는 Controller

 

만약 우리가 더 가독성있는 형태의 출력을 원한다면 어떻게 해줘야 할까요? 예를 들어, 사용자에게 가독성을 제공하기 위해 계산되어 나온 값인 24400000와 같은 숫자를 2,400,000와 같은 형태로 변환하여 응답하고 싶다고 합시다. 

 

이 때, Interceptor를 사용할 수 있습니다.

 

Interceptor는 custom 로직을 적용하고 응답을 변환하는 데 사용됩니다.

 

import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
  UnprocessableEntityException,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Injectable()
export class CommaInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    return next.handle().pipe(
      map((data) => {
        /* adding comma every 3 digits */
        data = data.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
        return data;
      }),
    );
  }
}

구현된 로직은 위와 같습니다.

 

컨트롤러에서는 아래와 같이 Interceptor를 사용하는 데코레이터를 추가해줍니다:

import { Controller, Get, Param, UseInterceptors } from '@nestjs/common';
import { ConverterService } from './converter.service';
import { CommaPipe } from './comma.pipe';
import { CommaInterceptor } from './comma.interceptor';

@Controller('intercept')
export class InterceptController {
  constructor(private readonly converterService: ConverterService) {}

  @Get('/convert/:inr')
  /* Interceptor */
  @UseInterceptors(CommaInterceptor) 
  /* Pipe for Param */
  convert(@Param('inr', new CommaPipe()) inr: number): number {
    return this.converterService.convert(inr);
  }
}

 

 5. Repository

규칙 3: Data Layer(데이터 계층)은 고립되어야 한다. 데이터 접근과 조작 로직은 Repository라는 별도의 개체로 위임되어야 한다.

Service와 Pipes, Intercpetor, Repository를 사용하는 컨트롤러

 

 

NestJS App을 MongoDB나 MySQL 또는 API를 통해 외부 데이터에 접근하여 연결해야 하는 것은 중요한 작업 중 하나입니다.

 

 

6. 전체 흐름도

NestJS는 Middleware와 Guards도 제공하는데요. 아래 그림은 이러한 모든 요소를 모두 이용하여 구축한 완벽한 NestJS App의 예시를 보여줍니다.

 

 

 

 

반응형
Contents

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

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