BackEnd/Nest.js

[Nest.js] 백엔드 개발 기본 지식

Grace 2023. 4. 27. 17:30

Node.js

Nest는 Node.js를 기반으로 동작합니다. 정확히는 Nest로 작성한 소스 코드를 Node.js 기반 프레임워크인 Express나 Fastify에서 실행 가능한 자바스크립트 소스 코드로 컴파일 하는 역할을 합니다. 따라서 Node.js의 동작 원리를 이해하면 개발할 때 도움이 됩니다.

Node.js의 등장으로 자바스크립트를 이용하여 서버를 구동할 수 있게 되었습니다. 프론트엔드와 백엔드에서 같은 언어를 사용한다는 것은 큰 장점입니다. 구글은 크롬 브라우저를 출시하면서 자체 개발한 자바스크립트 엔진인 v8 엔진을 크롬에 내장했고 v8의 뛰어난 성능은 자바스크립트는 느리다는 인식을 바꾸어주었습니다.

단일 스레드에서 구동되는 논블로킹 I/O 이벤트 기반 비동기 방식

여러 작업 요청이 한꺼번에 들어올 때, 각 작업을 처리하기 위한 스레드를 만들고 할당하는 방식을 멀티스레딩이라고 합니다. 멀티스레딩 방식은 여러 작업을 동시에 처리하므로 작업 처리 속도가 빠르다는 장점이 있지만, 공유 자원을 관리하는 노력이 많이 들고 동기화를 잘못 작성하면 락에서 빠져나오지 못하는 경우가 발생할 수 있습니다. 스레드가 늘어날 때마다 메모리를 소모하게 되므로 메모리 관리 역시 중요합니다.

이에 비해 Node.js는 하나의 스레드에서 작업을 처리합니다. 사실 애플리케이션 단에서는 단일 스레드이지만 백그라운드에서는 스레드 풀을 구성해 작업을 처리합니다. 개발자 대신 플랫폼이 스레드 풀을 관리하기 때문에 개발자는 단일 스레드에서 동작하는 것처럼 이해하기 쉬운 코드를 작성할 수 있습니다. 웹 서버를 운용할 때는 CPU 코어를 분산해서 관리하므로 실제 작업은 여러 개의 코어에서 별개로 처리됩니다.

Node.js는 이렇게 들어온 작업을 앞의 작업이 끝날 때까지 기다리지 않고(논블로킹 방식) 비동기로 처리합니다. 입력은 하나의 스레드에서 받지만 순서대로 처리하지 않고 먼저 처리된 결과를 이벤트로 반환해주는 방식이 바로 Node.js가 사용하는 단일 스레드 논블로킹 이벤트 기반 비동기 방식입니다.

Node.js의 장단점

이러한 단일 스레드 이벤트 기반 비동기 방식은 서버의 자원에 크게 부하를 가하지 않습니다. 이는 대규모 네트워크 애플리케이션을 개발하기에 적합합니다. 물론 스레드를 하나만 사용하기 때문에 하나의 스레드에 문제가 생기게 되면 애플리케이션 전체가 오류를 일으킬 위험이 있습니다.

하나의 스레드로 동작하는 것처럼 코드를 작성할 수 있다는 점은 개발자에게 큰 장점입니다. 멀티스레딩을 직접 만들고 관리하다 deadlock에 빠져본 분들은 그 어려움을 공감할 것입니다.

Node.js의 단점은 컴파일러 언어의 처리 속도에 비해 성능이 떨어진다는 점입니다. 하지만 서버의 성능은 꾸준히 발저하고 있고, 엔진의 성능도 계속 향상되고 있어 왠만한 웹 애플리케이션을 만들기에는 손색이 없습니다.

이벤트 루프

이벤트 루프는 시스템 커널에서 가능한 작업이 있다면 그 작업을 커널에 이관합니다. 자바스크립트가 단일 스레드 기반임에도 불구하고 Node.js가 논블로킹 I/O 작업을 수행할 수 있도록 해주는 핵심 기능입니다.

이벤트 루프에는 6개의 단계가 있습니다. 다음 그림의 루프를 이루고 있는 왼쪽 부분에서 6개의 네모로 표시된 것들입니다. 각 단계는 단계마다 처리해야 하는 콜백 함수를 담기 위한 큐를 가지고 있습니다. 화살표는 각 단계가 전이되는 방향을 뜻합니다. 이후에 설명하겠지만 반드시 다음 단계로 넘어가는 것은 아닙니다. 각 단계에는 해당 단계에서 실행되는 작업을 저장하는 큐가 있습니다. 또한 이벤트 루프의 구성 요소는 아니지만 nextTickQueue와 microTaskQueue가 존재합니다. nextTickQueue와 microTaskQueue에 들어 있는 작업은 이벤트 루프가 어느 단계에 있든지 실행될 수 있습니다.

 

자바스크립트 코드는 유휴, 준비 단계를 제외한 어느 단계에서나 실행될 수 있습니다. nextTickQueue와 microTaskQueue는 이벤트 루프의 구성 요소는 아니고, 이 큐에 들어 있는 작업 역시 이벤트 루프가 어느 단계에 잇든지 실행될 수 있습니다. node main.js 명령어로 Node.js 애플리케이션을 콘솔에서 실행하면 Node.js는 먼저 이벤트 루프를 생성한 다음 메인 모듈인 main.js를 실행합니다. 이 과정에서 생성된 콜백들이 각 단계에 존재하는 큐에 들어가게 되는데, 메인 모듈의 실행을 완료한 다음 이벤트 루프를 계속 실행할지 결정합니다. 큐가 모두 비어서 더 이상 수행할 작업이 없다면 Node.js는 루프를 빠져나가고 프로세스를 종료합니다.

타이머 단계

이벤트 루프는 타이머 단계에서 시작합니다. 타이머 단계의 큐에는 setTimeout이나 setInterval과 같은 함수를 통해 만들어진 타이머들을 큐에 넣고 실행합니다. now - registerTime >= delta인 타이머들이 큐에 들어갑니다. 여기서 deltasetTimeout(()=>{}, delta)와 같이 타이머가 등록된 시각에서 얼만큼 시간이 흐른 후 동작해야 하는지를 나타내는 값입니다. 즉, 대상 타이머들은 이미 실행할 시간이 지났다는 뜻입니다. 타이머들은 최소 힙으로 관리됩니다. 힙을 구성할 때 기준으로 실행할 시각이 가장 적게 남은 타이머가 힙의 루트가 됩니다. 이 단계에서는 최소 힙이 들어있는 타이머들을 순차적으로 찾아 실행한 후 힙을 재구성합니다.

대기 콜백 단계

대기 단계의 큐에 들어 있는 콜백들은 현재 돌고 있는 루프 이전의 작업에서 큐에 들어온 콜백입니다. 예를 들어 TCP 핸들러 내에서 비동기의 쓰기 작업을 한다면, TCP 통신과 쓰기 작업이 끝난 후 해당 작업의 콜백이 큐에 들어옵니다. 또 에러 핸들러 콜백도 pending_queue로 들어오게 됩니다.

타이머 단계를 거쳐 대기 콜백 단계에 들어오면, 이전 작업들의 콜백이 pending_queue에서 대기 중인지를 검사합니다. 만약 실행 대기 중이라면 시스템 실행 한도에 도달할 때까지 꺼내어 실행합니다.

유휴, 준비 단계

유휴 단계는 틱마다 실행됩니다. 준비 단계는 매 폴링 직전에 실행됩니다. 이 두 단계는 Node.js의 내부 동작을 위한 것이라고만 알고 있으면 됩니다.

폴 단계

이벤트 루프 중 가장 중요한 단계가 폴 단계입니다. 폴 단계에서는 새로운 I/O 이벤트를 가져와서 관련 콜백을 수행합니다. 예를 들어 소켓 연결과 같은 새로운 커넥션을 맺거나 파일 읽기와 같이 데이터 처리를 받아들이게 됩니다. 이 단계가 가지고 있는 큐는 watch_queue입니다. 이 단계에 진입한 후 watch_queue가 비어 있지 않다면 큐가 비거나 시스템 실행 한도에 다다를 때까지 동기적으로 모든 콜백을 실행합니다. 만약 큐가 비게 되면 Node.js는 곧바로 다음 단계로 이동하지 않고 check_queue, pending_queue, closing_callbacks_queue에 남은 작업이 있는지 검사한 다음 작업이 있다면 다음 단계로 이동합니다. 만약 큐가 모두 비어서 해야 할 작업이 없다면 잠시 대기를 하게 됩니다. 이렇게 하는 이유는 바로 타이머 단계로 넘어간다고 해도 어차피 첫 번째 타이머를 수행할 시간이 되지 않았기 때문에 이벤트 루프를 한 번 더 돌아야 하기 때문입니다.

체크 단계

체크 단계는 setImmediate의 콜백만을 위한 단계입니다. 역시 큐가 비거나 시스템 실행 한도에 도달할 때까지 콜백을 수행합니다.

종료 콜백 단계

종료 콜백 단계에서는 socket.on('close', (()=>{})과 같은 closedestroy 이벤트 타입의 콜백이 처리됩니다. 이벤트 루프는 종료 콜백 단계를 마치고 나면 다음 루프에서 처리해야 하는 작업이 남아 있는지 검사합니다. 만약 작업이 남아 있다면 타이머 단계부터 한 번 더 루프를 돌게 되고, 아니라면 루프를 종료합니다.

nextTickQueueprocess.nextTick() API의 콜백들을 가지고 있으며 microTaskQueue는 resolve된 Promise의 콜백을 가지고 있습니다. 이 두개의 큐는 기술적으로 libuv 라이브러리에 포함된 것이 아니라 Node.js에 포함된 기술입니다. 이 두 큐에 들어 있는 콜백은 단계를 넘어가는 과정에서 먼저 실행됩니다. nextTickQueuemicroTaskQueue보다 높은 우선순위를 가지고 있습니다.

데커레이터

Nest는 데커레이터를 적극 활용합니다. 데커레이터를 잘 활용하면 횡단 관심사를 분리하여 관점 지향 프로그래밍을 적용한 코드를 작성할 수 있습니다. 타입스크립트의 데커레이터는 파이썬의 데커레이터나 자바의 애너테이션과 유사한 기능을 합니다. 클래스, 메서드, 접근자, 프로퍼티, 매개벼수에 적용 가능합니다. 각 요소의 선언부 앞에 @로 시작하는 데커레이터를 선언하면 데커레이터로 구현된 코드를 함께 실행합니다. 예를 들어 다음 코드는 유저 생성 요청의 몬문을 데이터 전송 객체(DTO)를 표현한 클래스입니다.

class CreateUserDto {
  @IsEmail()
  @MaxLength(60)
  readonly email: string;
  
  @IsString()
  @Matches(/^[A-Za-z\d!@#$%^&*()]{8,30}$/)
  readonly password: string;
}

사용자는 얼마든지 요청을 잘못 보낼 수 있기 때문에 데커레이터를 이용하여 애플리케이션이 허용하는 값으로 제대로 요청을 보냈는지 검사하고 있습니다. email은 이메일 형식을 가진 문자열이어야 하고(@IsEmail()) 그 길이는 최대 60자이어야 합니다(@MaxLength(60)). password는 문자열이어야 하고(@IsString()) 주어진 정규 표현식에 적합해야 합니다.(@Matches(…)).

password의 유효성 검사 규칙에는 @Matches()를 통해 정규 표현식을 이용했습니다. 정규 표현식은 문자열 규칙을 판단하는 강력한 도구입니다.

초기화한 프로젝트의 루트에 있는 tsconfig.json 파일은 타입스크립트의 빌드 환경을 정의한 파일입니다.

{
  "compilerOptions": {
    ...
    "experimentalDecorators": true,
    ...
  }
}

experimentalDecorators 옵션이 true로 설정되어 있습니다. 이 옵션을 켜야 데커레이터를 사용할 수 있습니다. 비록 실험적인 기능이지만 매우 안정적이며 수많은 프로젝트에서 이미 사용하고 있습니다.

데커레이터는 위에서 봤던 것처럼 @expression과 같은 형식으로 사용합니다. 여기서 expression은 데커레이팅된 선언에 대한 정보와 함께 런타임에 호출되는 함수여야 합니다.

function deco(target: nay, propertyKey: string, descriptor: PropertyDescriptor) {
  console.log('데커레이터가 평가됨')
}

class TestClass {
  @deco
  test() {
    console.log('함수 호출됨')
  }
}

const t = new TestClass();
t.test();

만약 데커레이터에 인수를 넘겨서 데커레이터의 동작을 변경하고 싶다면 데커레이터 팩토리, 즉 데커레이터를 리턴하는 함수를 만들면 됩니다.

function deco(value: string) {
  console.log('데커레이터가 평가됨')
  function (target: nay, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log(value)
  }
}

class TestClass {
  @deco('Hello')
  test() {
    console.log('함수 호출됨')
  }
}

데커레이터 합성

여러 개의 데커레이터를 사용한다면 수학의 함수 합성과 같이 데커레이터를 합성하면 됩니다.

@f
@g
test

여러 데커레이터를 사용할 때는 다음과 같은 단계가 수행됩니다.

  1. 각 데커레이터의 표현은 위에서 아래로 평가됩니다.
  2. 그런 다음 결과는 아래에서 위로 함수로 호출됩니다.

다음 예의 출력 결과를 보면 합성 순서에 대해 이해를 높일 수 있을 것입니다.

function first() {
  console.log('first(): factory evaluated');
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log('first(): called')
  }
}

function second() {
console.log('second(): factory evaluated');
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log('second(): called')
  }
}

class ExampleClass {
  @first()
  @second()
  method() {
    console.log('method is called')
  }
}

클래스 데커레이터

클래스 데커레이터는 이름 그대로 클래스 바로 앞에 선언됩니다. 클래스 데커레이터는 클래스의 생성자에 적용되어 클래스 정의를 읽거나 수정할 수 있습니다. 선언 파일과 선언 클래스 내에서는 사용할 수 없습니다.

다음 코드는 클래스에 reportingURL 속성을 추가하는 클래스 데커레이터의 예입니다.

/* 클래스 데코레이터 팩토리
  생성자 타입을 상속받는 제네릭 타입 T를 가지는 생성자를 팩토리 메서드의 인수로 전달 */
function reportableClassDecorator(constructor: T) {
  return class extends constructor { // 클래스 데코레이터는 생성자를 리턴하는 함수
    reportingURL = 'http://www.example.com'; /* 클래스 데코레이터가 적용되는 클래스에 새로운
                                              reportingURL이라는 속성 추가 */
  }
}

@reportableClassDecorator
class BugReport {
  type = 'report'
  title: string
  constructor(t: string) {
    this.title = t;
  }
}

const bug = new BugReport("Needs dark mode");
console.log(bug)

BugReport 클래스에 선언되지 않았던 새로운 속성이 추가되었습니다.

클래스의 타입이 변경되는 것은 아닙니다. 타입 시스템은 reportingURL을 인식하지 못하기 때문에 bug.reportingURL과 같이 직접 사용할 수 없습니다.

메서드 데커레이터

메서드 데커레이터는 메서드 바로 앞에 선언됩니다. 메서드의 속성 설명자에 적용되고 메서드의 정의를 읽거나 수정할 수 있습니다. 선언 파일, 오버로드, 메서드, 선언 클래스에 사용할 수 없습니다.

앞서 deco 메서드 데커레이터에서 봤던 것처럼 메서드 데커레이터는 다음 세 개의 인수를 가집니다.

  1. 정적 멤버가 속한 클래스의 생성자 함수이거나 인스턴스 멤버에 대한 클래스의 프로토타입
  2. 멤버의 이름
  3. 멤버의 속성 설명자. PropertyDescriptor 타입을 가짐

만약 메서드 데커레이터가 값을 반환한다면 이는 해당 메서드의 속성 설명자가 됩니다.
함수를 실행하는 과정에서 에러가 발생했을 때 이 에러를 잡아서 처리하는 로직을 구현합니다.

interface PropertyDescriptor {
  configurable?: boolean; // 속성의 정의를 수정할 수 있는지 여부
  enumerable?: boolean;   // 열거형인지 여부
  value?: any;            // 속성 값
  writable?: boolean;     // 수정 가능 여부
  get?(): any;            // getter
  set?(v: any): void;     // setter
}

function HandleError(){
  /* 메서드 데커레이터가 가져야 하는 3개의 인수
    이 중 propertyDescriptor는 객체 속성의 특성을 기술하는 객체로서 enumerable 외에도 여러 가지
    속성을 가지고 있습니다. enumerable이 true가 되면 이 속성은 열거형이라는 뜻이 됩니다 */
  return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log(target); /* 출력 결과는 {constructor: ƒ, hello: ƒ}입니다.
                          데커레이터가 선언된 메서드 hello가 속해있는 클래스의 생성자와 프로토타입을
                          가지는 객체임을 알 수 있습니다. */
    console.log(propertyKey) // 함수 이름 hello가 출력됩니다.
    console.log(descriptor) /* hello 함수가 처음 가지고 있던 설명자가 출력됩니다. 출력 결과는
                              {value: ƒ, writable: true, enumerable: false, 
                              configurable: true}입니다. */
    
    const method = descriptor.value // 설명자의 value 속성으로 원래 정의된 메서드를 저장
    
    descriptor.value = function () {
      try {
        method(); // 원래 메서드를 호출
      } catch(error) {
        // 에러 핸들링 로직 구현
        console.log(error); // Error: 테스트 에러
      }
    }
  }
}

class Greeter {
  @HandleError()
  hello() {
    throw new Error('테스트 에러')
  }
}

const r = new Greeter();
t.hello();

접근자 데커레이터

접근자 데커레이터는 접근자 바로 앞에 선언합니다. 접근자의 속성 설명자에 적용되고 접근자의 정의를 읽거나 수정할 수 있습니다. 역시 선언 파일과 선언 클래스에는 사용할 수 없습니다. 접근자 데커레이터가 반환하는 값은 해당 멤버의 속성 설명자가 됩니다.

function Enumerable(enumerable: boolean) {
  return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    descriptor.enumerable = enumerable // 설명자의 enumerable 속성을 데커레이터 인수로 결정
  }
}

class Person {
  constructor(private name: string) {} // name은 외부에서 접근하지 못하는 private 멤버
  
  @Enumerable(true) // getter 함수는 열거 가능
  get getName() {
    return this.name
  }
  
  @Enumerable(false) // setter 함수는 열거 불가
  set setName(name: string) {
    this.name = name
  }
}

const person = new Person('Dextor')
for(let key in person){
  console.log(`${key}: ${person[key]}`) /* 결과를 출력하면 getter는 출력되지만
                                        setter는 열거 불가이기 때문에 key로 받을 수 없음 */
}

속성 데커레이터

속성 데커레이터는 클래스의 속성 바로 앞에 선언됩니다. 역시 선언 파일, 선언 클래스에서는 사용하지 못합니다. 속성 데커레이터는 다음 두 개의 인수를 가지는 함수입니다.

  1. 정적 멤버가 속한 클래스의 생성자 함수이거나 인스턴스 멤버에 대한 클래스의 프로토타입
  2. 멤버의 이름

메서드 데커레이터나 접근자 데커레이터와 비교해보면, 세 번쨰 인수인 속성 설명자가 존재하지 않습니다. 공식 문서에 따르면 반환값도 무시되는데, 이는 현재 프로토타입의 멤버를 정의할 때 인스턴스 속성을 설명하는 메커니즘이 없고 초기화 과정을 관찰하거나 수정할 방법이 없기 때문이라고 합니다.

function format(formatString: string) {
  return fuction(target: any, propertyKey: string): any {
    let value = target[propertyKey]
    
    function getter() {
      return `${formatString} ${value}` /* getter에서 데코레이터 인수로 들어온 
                                        formatString을 원래 속성과 조합한 스트링으로 변경 */
    }
    
    function setter(newVal: string){
      value = newVal
    }
    
    return {
      get: getter,
      set: setter,
      enumerable: true,
      configurable: true
    }
  }
}

class Gretter {
  @format('Hello') // 데코레이터에서 formatString을 전달
  greeting: string;
}

const t = new Greeter()
t.greeting = 'World'
console.log(t.greeting) // Hello World

매개변수 데커레이터

매개변수 데커레이터는 생성자 또는 메서드의 매개변수에 선언되어 적용됩니다. 역시 선언 파일, 선언 클래스에서는 사용할 수 없습니다. 매개변수 데커레이터는 호출될 때 3가지 인수와 함께 호출됩니다. 반환값은 무시됩니다

  1. 정적 멤버가 속한 클래스의 생성자 함수이거나 인스턴스 멤버에 대한 클래스의 프로토타입
  2. 멤버의 이름
  3. 매개변수가 함수에서 몇 번째 위치에 선언되었는지를 나타내는 인덱스

매개변수가 제대로 된 값으로 전달되었는지 검사하는 데커레이터를 만들어보겠습니다. 매개변수 데커레이터는 단독으로 사용하는 것보다 함수 데커레이터와 함께 사용할 때 유용하게 쓰입니다. Nest에서 API 요청 매개변수에 대해 유효성 검사를 할 때 이와 유사한 데커레이터를 많이 사용합니다.

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

function MinLength(min: number) { // 매개변수의 최솟값을 검사하는 매개변수 데코레이터
  return function(target: any, propertyKey: string, parameterIndex: number) {
    target.validators = { // target 클래스의 validators 속성에 유효성을 검사하는 함수 할당
      minLength: function (args: string[]) { 
        return args[parameterIndex].length >= min; // 유효성 검사
      }
    }
  }
}

// 함께 사용할 메서드 데코레이터
function Validate(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const method = descriptor.value; // 메서드 데코레이터가 선언된 메서드를 method 변수에 임시 저장
  
  descriptor.value = runction(...args) { // 설명자의 value에 유효성 검사 로직이 추가된 함수 할당
    Object.keys(target.validators).forEach(key => { 
      /* target에 저장해둔 validators를 모두 수행. 원래 메서드에 전달된 인수들을 
        각 validator에 전달 */
      if(!target.validators[key](args)) { // 인수들을 validator에 전달하여 유효성 검사
        throw new BadRequestException()
      }
    })
    method.apply(this, args) // 함수 실행
  }
}

class User {
  private name: string;
  
  @Validate
  setName(@MinLength(3) name: string) {
    this.name = name;
  }
}

const r = new User()
t.setName('Dextor') // 통과
console.log('----------------')
t.setName('De') // 에러

'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.05.08
[Nest.js] Get Started  (0) 2023.04.27