[02장] NestJS를 배우기 전에

Kim Seohyun·2022년 8월 5일
1
post-thumbnail

✏️ NestJS로 배우는 백엔드 프로그래밍 를 공부하며 정리하는 글입니다 ✏️
⚙️ 관련 코드는 Github 에서 보다 자세히 보실 수 있습니다 ⚙️

2.1 웹 프레임워크

웹 프레임워크의 등장 배경

예전에는 웹 페이지에서 구동되는 애플리케이션은 모두 SSR(Server Side Rendering) 방식으로 동작했다. 서버에서 요청을 처리하고 응답으로 웹 브라우저가 그려야 하는 HTML과 자바스크립트를 그대로 전송하고 브라우저는 서버에 전달되는 코드를 화면에 뿌려주기만 하면 됐다. 물론 이후 동적 구성되는 부분은 함께 전달된 자바스크립트를 파싱하여 화면을 구성했다. 하지만 시간이 지날수록 웹 기술은 고도의 복잡도를 가지도록 발전하였고 웹 앱을 만들기 위해 필수로 적용해야 하는 기술들을 기존 방식으로 작성하기에는 개발자의 시간과 노력이 너무 많이 들게 되었다. 자연스럽게 웹 개발에 필수적인 요소들을 묶어 개발자들이 쉽게 쓸 수 있게 하고자 하는 시도가 생겼고 이를 웹 프레임워크라고 부른다.

예를 들어 웹 프레임워크는 데이터베이스에 연결을 설정하고 데이터를 관리하거나, 세션을 맺고 유지하는 등의 동작을 정해진 방법과 추상화된 인터페이스로 제공한다. 이 방법들이 프레임워크 사용자의 자유도를 제약한다고 생각할 수도 있지만 프레임워크에서 표준으로 제시하는 방법을 이용하면 쉽고 빠르게 안정적인 애플리케이션을 구축할 수 있다. 프레임워크가 뼈대, 골조라는 뜻을 가지고 있는데 미리 만들어 둔 뼈대를 세우고 살을 붙여가는 작업을 하기만 되기 때문에 붙여진 이름인 것이다.

웹 프레임워크의 종류

이름언어특징
ReactJS, TSSPA(Single Page Application) 나 모바일 앱 개발에 사용, DOM(Virtual Document Object Model)을 사용
Vue.jsJS, TSSPA 구축 가능, MVVM 패턴에서 VM 에 해당, 속도가 빠름
ExpressJS, TSNode.js 기반 백엔드 프레임워크
SpringJava, KotlinIoC, DI, AOP 와 같은 객체 지향 프로그래밍 기법을 쉽게 적용 가능
DjangoPythonMVC 패턴을 사용
Ruby on RailsRubyActiveRecord 를 이용하여 쿼리를 쉽게 다룰수 있게 함

SPA는 SSR 방식과 다르게 서버로부터 매 요청에 대해 최소한의 데이터만 응답받고 화면 구성 로직을 프론트엔드에서 구성한다. 페이지 이동 시 화면이 깜빡거리는 것과 같이 어색한 화면이 줄어들 수 있다는 장점이 있지만 초기 로딩 속도가 오래 걸린다는 단점을 갖는다.

2.2 Node.js

Node.js 란

Nest 는 Node.js 를 기반으로 동작한다. 정확히는 Nest로 작성한 소스코드를 Node.js 기반 프레임워크인 Express나 Fastify에서 실행 가능한 자바스크립트 소스코드로 컴파일해 주는 역할을 한다. 따라서 Node.js의 동작원리를 이해하면 개발할 때 도움이 된다.
Node.js의 등장으로 자바스크립트를 이용하여 서버를 구동할 수 있게 되었다. 프론트엔드와 백엔드에서 같은 언어를 사용한다는 것은 큰 장점이다. 같은 개발자가 풀스택2으로 개발할 경우 생산성을 향상시켜주고, 프론트엔드/백엔드가 분리되어 있다 하더라도 커뮤니케이션 비용을 줄여준다.
Node.js는 NPM(Node Package Manager) 이라고 하는 패키지(또는 라이브러리) 관리 시스템을 가지고 있다. 누구나 자신이 만든 Node.js 기반 라이브러리를 등록하여 다른 사람들이 사용하게 공개할 수 있다. 예를 들어, 1장에서 설치한 @nestjs/cli 패키지는 installation 하면 dependencies 를 일일이 설치할 필요 없이 해당 기능을 모두 사용 수 있다. 공개하기는 싫지만 NPM을 이용하여 사내에서 패키지를 관리하고자 한다면 돈을 지불하고 비공개(Private)로 등록도 가능하다.

Node.js 의 특징

단일 쓰레드에서 구동되는 non-blocking I/O 이벤트 기반 비동기 방식

멀티 쓰레드 방식이란 작업 요청이 한꺼번에 들어올 때 각 작업을 처리하기 위한 쓰레드를 만들고 할당하는 방식이다. 멀티 쓰레드 방식은 여러 작업을 동시에 처리하므로 작업 처리속도가 빠른 장점이 있지만, 공유 자원을 관리하는 노력이 많이 들고 잘못 작성된 동기화로 인해 락에서 빠져나오지 못하는 경우가 발생하기 쉽다. 쓰레드가 늘어날 때 마다 메모리를 소모하게 되므로 메모리 관리 역시 중요하다.
Node.js는 하나의 쓰레드에서 작업을 처리한다. 애플리케이션 단에서는 단일 쓰레드이지만 백그라운에서는 Node.js에 포함된 libuv5가 쓰레드 풀을 구성해 작업을 처리한다. 그래서 개발자는 단일 쓰레드에서 동작하는 것처럼 이해하기 쉬운 코드를 작성할 수 있다. 웹 서버를 운용할 때는 코어(CPU)를 분산해서 관리하므로 실제 작업은 여러개의 코어에서 별개로 처리된다.
Node.js는 이렇게 들어온 작업을 앞의 작업이 끝날때까지 기다리지 않고(non-blocking) 비동기로 처리한다. 책에서는 이 개념을 다음과 같이 쉽게 설명한다.
푸드코트의 경우 주문은 한 곳에서 받지만 음식은 입점된 각 식당에서 만든다. 음식이 완성된 순서대로 각 식당에서 호출벨을 통해 손님을 부르고 손님은 음식을 픽업한다. 여기서 계산을 담당하는 작업은 단일 쓰레드이고 각 요리를 완성해 벨을 호출하는 식당들은 비동기로 요리를 준비한다. 입력은 하나의 쓰레드에서 받지만 순서대로 처리 하지 않고 먼저 처리된 결과를 이벤트로 반환해주는 방식이 바로 Node.js 가 사용하는 단일 쓰레드 non-blocking 이벤트 기반 비동기 방식이다.
ECMAScript 에 새로운 기능이 추가 되면서 비동기 방식으로 복잡한 기능을 구현하는 것이 간편해지고 있다. ECMAScript 2015(ES6)에서 Promise가 도입되면서 간결한 표현으로 작성할 수 있게 되었다. ECMAScript 2017에서는 async/await 기능이 추가되면서 비동기 동작을 마치 동기로 처리하는 것처럼 코드를 작성할 수 있게 되었다.

Node.js의 장단점

하나의 쓰레드로 동작하는 것처럼 코드를 작성할 수 있다는 장점

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

컴파일러 언어의 처리속도에 비해 그 성능이 떨어진다는 단점

하지만 서버의 성능은 꾸준히 발전하고 있고, V8엔진의 성능이 계속 향상되고 있어 왠만한 웹 애플리케이션을 만들기에는 손색이 없다.

2.3 이벤트 루프

이벤트 루프는 시스템 커널에서 가능한 작업이 있다면 그 작업을 커널에 이관한다. 자바스크립트가 단일 쓰레드 기반임에도 불구하고 Node.js 가 non-blocking I/O 작업을 수행할 수 있도록 해주는 핵심 기능이다.
이벤트 루프에는 6개의 단계(Phase)가 있다. 아래 그림의 루프를 이루고 있는 부분에서 네모박스로 표시된 것들이다. 각 단계는 단계마다 처리해야 하는 콜백 함수를 담기 위한 큐를 가지고 있다. 화살표는 각 단계가 전이되는 방향을 뜻하지만 이후에 설명하듯 반드시 다음 단계로 넘어가는 것은 아니다. 자바스크립트 코드는 idle & prepare 단계를 제외한 어느 단계에서나 실행될 수 있다. nextTickQueuemicroTaskQueue 는 이벤트 루프의 구성요소는 아니고 이 큐에 들어 있는 작업 역시 이벤트 루프가 어느 단계에 있든지 실행될 수 있다. node main.js 명령어로 Node.js 애플리케이션을 콘솔에서 실행하면 Node.js 는 먼저 이벤트 루프를 생성한 다음 메인 모듈인 main.js 를 실행한다. 이 과정에서 생성된 콜백들이 각 단계에 존재하는 큐에 들어가게 되는데 메인 모듈의 실행을 완료한 다음 이벤트 루프를 계속 실행할 지 결정한다. 만약 큐가 모두 비어서 더 이상 수행할 작업이 없다면 Node.js는 루프를 빠져나가고 프로세스를 종료한다.

아래에서 각 단계에 대해 더 구체적으로 알아보자. 보다 자세한 설명이 궁금하다면 Paul Shan 의 블로그를 읽어보자.

Timer 단계

이벤트 루프는 Timer 단계에서 시작한다. Timer 단계의 큐에는 setTimeout 이나 setInterval 과 같은 함수를 통해 만들어진 타이머들을 큐에 넣고 실행한다. now - registeredTime ≥ delta 인 값을 가지는 타이머들이 큐에 들어간다. 여기서 deltasetTimeout(() => {}, delta) 과 같이 타이머가 등록된 시각에서 얼마만큼 시간이 흐른후에 동작해야 하는 지를 나타내는 값이다. 즉, 대상 타이머들은 이미 실행할 시간이 같거나 지났다는 뜻이다. 타이머들은 최소 힙(Min Heap, 최솟값을 찾기 위해 완전 이진 트리를 사용하는 자료 구조)으로 관리된다. 힙을 구성할 때 기준으로 실행할 시각이 가장 적게 남은 타이머가 힙의 루트가 된다. Timer 단계에서 최소 힙에 들어 있는 타이머들을 순차적으로 찾아 실행한 후 힙을 재구성한다.
예를 들어보자. 딜레이 값이 100, 200, 300, 400인 4개의 타이머 A, B, C, D 를 특정 시간 t에 힙에 등록했다고 가정해보자. 최소 힙은 A → B → C → D 의 순으로 순회할 수 있도록 구성된다. 이제 이벤트 루프가 t+250 시각에 Timer 단계에 진입했다고 한다면, 힙에서 순차적으로 A, B, C, D 순으로 꺼내어 시간을 비교한다. A, B 는 이미 250 만큼의 시간이 지났기 때문에 둘의 콜백은 수행이 되지만, C 는 아직 시간이 지나지 않았기 때문에 실행되지 않는다. D 는 최소 힙의 특성상 C 를 이미 실행하지 않기로 했기 때문에 비교할 필요가 없다. 그리고 시간이 지난 타이머들의 콜백이 무한정 실행되는 것은 아니고 시스템의 실행 한도(Hard Limit)에 도달하면 다음 단계로 넘어간다.

Pending (I/O) 콜백 단계

이 단계의 큐에 들어있는 콜백들은 현재 돌고 있는 루프 이전의 작업에서 큐에 들어온 콜백이다. 예를 들어 TCP 핸들러 내에서 비동기의 쓰기 작업을 한다면, TCP 통신과 쓰기 작업이 끝난 후 해당 작업의 콜백이 큐에 들어온다. 또 에러 핸들러 콜백도 pending_queue 로 들어오게 된다.
Timer 단계를 거쳐 pending 콜백 단계에 들어오면 이전 작업들의 콜백이 pending_queue 에서 대기중인지를 검사한다. 만약 실행 대기 중이라면 시스템 실행 한도에 도달할 때까지 꺼내어 실행한다.

Idle, Prepare 단계

Idle 단계는 매 틱(Tick, 매 단계가 이동하는 것을 의미함)마다 실행된다. Prepare 단계는 매 폴링마다 그 전에 실행된다. 이 두 단계는 Node.js 의 내부 동작을 위한 것이다.

Poll 단계

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

Check 단계

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

Close 콜백 단계

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

nextTickQueue과 microTaskQueue

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

정리하자면 다음과 같은 워크플로우를 가진다.

2.4 패키지 의존성 관리

package.json

package.json 파일의 역할은 다음과 같다.

  • 애플리케이션이 필요로 하는 패키지 목록을 나열
  • 각 패키지는 시맨틱 버저닝 규칙으로 필요한 버전을 기술
  • 다른 개발자와 같은 빌드환경을 구성하여 의존성이 달라 발생하는 문제를 예방

시맨틱 버저닝

Node.js 에서 사용되는 시맨틱 버저닝 규칙(Semantic Version Rules, SemVer)은 패키지의 버전명을 숫자로 관리하는 방법이다. 버저닝 규칙은 다음과 같다.

<Major>.<Minor>.<Patch>-<label>
  • Major, Minor, Patch는 각각 숫자를 사용
  • Major: 이전 버전과 호환이 불가능할 때 숫자를 하나 증가, Major 버전이 바뀐 패키지를 사용하고자 한다면 반드시 breaking change(하위 호환성이 깨진 기능) 목록을 확인하고 이전 기능을 사용하는 코드를 수정
  • Minor: 기능이 추가되는 경우 숫자를 증가, 기능이 추가되었다고 해서 이전 버전의 하위 호환성을 깨뜨리지는 않음
  • Patch: 버그 수정 패치를 적용할 때 사용
  • label(선택사항): pre, alpha, beta와 같이 버전에 대해 부가 설명을 붙이고자 할 때 문자열로 작성

시맨틱 버저닝을 사용할 때 완전히 동일한 버전만을 정의하지 않는다. 다음과 같은 규칙으로 기술하여 의존성이 깨지지 않는 다른 버전을 설치할 수 있다.

  • ver: 완전히 일치하는 버전
  • =ver: 완전히 일치하는 버전
  • >ver: 큰 버전
  • >=ver: 크거나 같은 버전
  • <ver: 작은 버전
  • <=ver: 작거나 같은 버전
  • ~ver: 버전범위
    • ~1.0, 1.0.x: 1.0 이상 1.1 미만의 버전
  • ^ver: SemVer 규약을 따른다는 가정에서 동작하는 규칙
    • ^1.0.2: 1.0.2 이상 2.0 미만의 버전
    • ^1.0: 1.0.0 이상 2.0 미만의 버전
    • ^1: 1.0.0 이상 2.0 미만의 버전

파일 분석

1장에서 생성한 프로젝트의 package.json 파일을 바탕으로 package.json에 기술된 내용을 살펴 보자. 설치되는 시점에 따라 조금 차이가 있을 수 있다.

{
  "name": "study-nestjs",
  "version": "0.0.1",
  "description": "",
  "author": "",
  "private": true,
  "license": "UNLICENSED",
  "scripts": {
    "prebuild": "rimraf dist",
    "build": "nest build",
    "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
    "start": "nest start",
    "start:dev": "nest start --watch",
    "start:debug": "nest start --debug --watch",
    "start:prod": "node dist/main",
    "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:cov": "jest --coverage",
    "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
    "test:e2e": "jest --config ./test/jest-e2e.json"
  },
  "dependencies": {
    "@nestjs/common": "^8.0.0",
    "@nestjs/core": "^8.0.0",
    "@nestjs/platform-express": "^8.0.0",
    "reflect-metadata": "^0.1.13",
    "rimraf": "^3.0.2",
    "rxjs": "^7.2.0"
  },
  "devDependencies": {
    "@nestjs/cli": "^8.0.0",
    "@nestjs/schematics": "^8.0.0",
    "@nestjs/testing": "^8.0.0",
    "@types/express": "^4.17.13",
    "@types/jest": "27.5.0",
    "@types/node": "^16.0.0",
    "@types/supertest": "^2.0.11",
    "@typescript-eslint/eslint-plugin": "^5.0.0",
    "@typescript-eslint/parser": "^5.0.0",
    "eslint": "^8.0.1",
    "eslint-config-prettier": "^8.3.0",
    "eslint-plugin-prettier": "^4.0.0",
    "jest": "28.0.3",
    "prettier": "^2.3.2",
    "source-map-support": "^0.5.20",
    "supertest": "^6.1.3",
    "ts-jest": "28.0.1",
    "ts-loader": "^9.2.3",
    "ts-node": "^10.0.0",
    "tsconfig-paths": "4.0.0",
    "typescript": "^4.3.5"
  },
  "jest": {
    "moduleFileExtensions": [
      "js",
      "json",
      "ts"
    ],
    "rootDir": "src",
    "testRegex": ".*\\.spec\\.ts$",
    "transform": {
      "^.+\\.(t|j)s$": "ts-jest"
    },
    "collectCoverageFrom": [
      "**/*.(t|j)s"
    ],
    "coverageDirectory": "../coverage",
    "testEnvironment": "node"
  }
}
  • name: 패키지의 이름, version과 함께 고유한 식별자(패키지를 NPM에 공개하지 않는다면 선택사항)
  • private: true로 설정할 경우 공개되지 않음
  • version: 패키지의 버전(공개할 패키지를 만들고 있다면 버전명에 신경을 써야함)
  • description: 패키지에 대한 설명을 기술
  • license: 패키지의 라이선스를 기술, 공개된 패키지를 사용할 때 참고
  • scripts: npm run 명령과 함께 수행할 수 있는 스크립트
    • ex) npm run build 를 수행하면 nest build 명령이 수행 됨
  • dependencies: 패키지가 의존하고 있는 다른 패키지를 기술
  • devDependencies: dependencies 와 같은 기능이지만 개발 환경 또는 테스트 환경에만 필요한 패키지는 devDependencies에 선언(실 사용 서비스에서는 불필요한 패키지를 설치하지 않도록 해야 함)
  • jest: 테스팅 라이브러리인 Jest 사용을 위한 환경 구성 옵션(NestJS 는 기본으로 Jest 를 이용한 테스트를 제공)

package-lock.json

프로젝트 루트 디렉토리에서 npm install 명령을 수행하면 node_modules 디렉토리와 package-lock.json 파일이 생성된다. 그리고 package-lock.json 파일은 node_modules 나 package.json 파일의 내용이 바뀌면 npm install 명령을 수행할 때 자동 수정된다. node_modules 는 프로젝트가 필요로 하는 패키지들이 실제로 설치되는 장소이다. 애플리케이션은 런타임에 여기에 설치된 패키지들을 참조한다.
package-lock.json 파일은 package.json 에 선언된 패키지들이 설치될 때의 정확한 버전과 서로간의 의존성을 표현한다. 팀원들 간에 정확한 개발 환경을 공유할 수 있다. 같은 패키지 설치를 위해 node_modules 를 소스 리포지토리에 공유하지 않아도 되는 것이다.
만약 소스코드 내에 package-lock.json 파일이 이미 존재한다면 npm install 명령을 수행할 때 이 파일을 기준으로 패키지들을 설치하게 된다. package-lock.json 파일을 소스코드 리포지토리에서 관리해야 하는 이유이다.

2.5 Typescript

typescript 는 마이크로소프트에서 개발한 언어다. javascript 코드에 타입 시스템을 도입하여 런타임에 에러가 발생할 가능성이 있는 코드를 정적 분석으로 찾아준다. typescript 는 자바스크립트에 구문을 추가하여 만들어졌다.
typescript 가 제공하는 타입 추론은 타입 오류로 인해 런타임에 발생하는 오류를 컴파일 타임에 잡아준다. VSCode 와 같은 IDE 에서는 소스코드에 에러를 표시해 주므로 일일히 컴파일 명령을 실행하지 않아도 된다.

tsc 명령으로 컴파일하여 javascript 코드로 변환이 가능하다. 컴파일 후 생성된 javascript 는 타입이 없다. javascript 에 원래 타입이 없기 때문이다.

변수 선언

타입스크립트에서 변수를 선언하는 방식은 다음과 같다.

<선언 키워드> <변수명>: <타입>;
  • 선언 키워드: const, let 또는 var 로 선언
    • const 는 선언 후 재할당이 불가능, letvar 는 재할당이 가능하여 값을 바꿀 수 있음
    • letvar 의 차이는 hoisting 여부인데, var 는 변수를 사용한 후에 선언이 가능하지만 let 은 그렇지 못함

Typescript에서 지원하는 타입

타입스크립트는 자바스크립트가 가지고 있는 자료형을 모두 포함한다. 자바스크립트의 타입은 기본 타입(primitive value)과 객체형(object), 함수형(function)이 있다. 아래와 같이 typeof 키워드를 이용하여 인스턴스의 타입을 알 수 있습니다.

typeof instance === "undefined"

primitive 타입

typeof설명할당 가능한 값
boolean참, 거짓을 나타내는 논리값true, false
null유효하지 않음null
undefined변수는 선언 됐으나 값이 존재하지 않음undefined
number부동 소수 값의 숫자16진수, 10진수, 2진수, 8진수
bigintNumber의 범위를 넘어서는 정수정수 끝에 n을 추가한 값
string변경 불가능한 문자열홑따옴표 또는 쌍따옴표로 둘러싸인 문자열
symbol유일하고 변경 불가능한 기본 타입 객체 속성의 키-

object 타입

객체 타입은 속성(property)을 가지고 있는 데이터 컬렉션이다. 속성은 키와 값으로 표현 되는데 값은 다시 javascript 의 타입을 가지고 있다. 따라서 다음 예와 같이 데이터를 구조적으로 표현할 수 있다.

const dexter = {
  name: 'Dexter Han',
  age: 21,
  hobby: ['Movie', 'Billiards'],
}

javascript 에는 개발할 때 유용한 내장 객체들이 있다.

  • Date: 1970년 1월 1일 UTC 자정과의 시간 차이를 밀리초 단위로 나타낸 것으로 시간을 다룰 때 사용
  • 배열(Array): 정수를 키로 가지는 일련의 값을 가진 객체, 대괄호([])로 표현
  • 키를 가진 컬렉션: 키와 값을 가지는 객체 타입 map 과 weakmap, 유일값들로 이루어진 컬렉션 객체 타입 set 과 weakset
  • JSON: javascript 에서 파생된 경량 데이터 교환 형식으로 범용 데이터 구조를 구축

이 외 표준 라이브러리에는 더 많은 내장 객체가 있다. 궁금하다면 MDN 문서를 읽어보자.

function 타입

javascript 는 함수를 변수에 할당하거나 다른 함수의 인자로 전달할 수 있다. 함수의 결과로 반환할 수도 있다. 언어의 이러한 특징을 일급 함수라고 한다. 임의의 함수 func 의 타입을 검사하면 "function"이 된다.

typeof func === "function"

any/unknown/never

typescript 에는 any, unknown, never 라는 특수한 타입들이 있다.
any 는 javascript 와 같이 어떠한 타입의 값도 받을 수 있는 타입이다. any 타입의 객체 역시 어떤 타입의 변수에도 할당이 가능하다. 이 특성때문에 런타임에 오류를 일으킬 가능성이 있다. unknown 타입은 any타입과 마찬가지로 어떤 타입도 할당 가능하지만 다른 변수에 할당 또는 사용할 때 타입을 강제하도록 하여 any가 일으키는 오류를 줄여 준다.
never 타입의 변수에는 어떤 값도 할당할 수 없다. 함수의 리턴 타입으로 지정하면 함수가 어떤 값도 반환하지 않는다는 것을 뜻하고, 다음과 같이 특정 타입의 값을 할당받지 못하도록 하는데 사용할 수도 있다.

타입 정의하기

typescript 는 타입을 정의해서 사용할 수 있다. 기본 타입과 같은 타입을 정의한다는 뜻은 아니고, 위에서 설명한 타입들을 조합하여 타입에 이름을 붙여 사용한다.
vscode 는 아래와 같이 마우스를 변수 위로 가져가면 추론된 타입을 표시해준다.

변수에 객체를 바로 할당하지 않고 interface 로 정의할 수 있다. 아래는 인터페이스를 이용하여 User 타입을 선언하는 것입니다.

interface User {
    name: string;
    age: number;
}

const user: User = {
    name: 'Dexter',
    age: 21,
}

interface 는 class 로 선언할 수도 있다.

class User {
  constructor(name: string, age: number) { }
}

const user: User = new User('Dexter', 21);

또 타입은 type 키워드로 새로운 타입을 만들 수 있다. 예를 들어 아래의 MyUser 타입은 기존 User 타입을 그대로 사용하지만 내가 사용하는 도메인에 맞는 이름으로 바꾼 것이다.

type MyUser = User;

타입 구성하기

javascript 는 변수에 어떠한 타입의 값도 할당할 수 있다. typescript 도 여러 타입의 값을 할당할 수 있다. 여러 타입을 조합한 새로운 타입을 가지는 것이다.

union 타입

여러 타입을 조합한 타입이다. 다음 코드에서 getLength 함수의 인자인 obj 는 string 또는 string 배열 타입을 가질 수 있다.

function getLength(obj: string | string[]) {
  return obj.length;
}

union 타입을 활용하면 변수가 가질 수 있는 값을 제한할 수도 있다.

enum Status {
  READY = "Ready",
  WAITING = "Waiting",
}

generic 타입

어떠한 타입이든 정의될 수 있지만 호출되는 시점에 타입이 결정된다. 다음과 같이 인자를 그대로 리턴하는 함수가 있다고 하자.

function identity(arg: any): any {
  return arg;
}

이 함수의 반환값은 any 로 되어 있기 때문에 arg 에 'test'를 인자로 전달할 경우 전달한 인자의 string 타입이 반환할 때 any가 되어 버린다.
반면 다음과 같이 generic 타입을 사용하게 되면 리턴되는 값의 타입은 함수를 호출하는 시점의 인자로 넣은 타입으로 결정되도록 할 수 있다. 제네릭을 선언할 때는 보통 대문자 한글자를 사용한다.

function identity<T>(arg: T): T {
  return arg;
}

2.6 데코레이터

Nest 는 데코레이터를 적극 활용한다. 데코레이터를 잘 사용하면 횡단 관심사를 분리하여 관점 지향 프로그래밍을 적용한 코드를 작성할 수 있다. typescript 의 데코레이터는 파이썬의 데코레이터나 자바의 어노테이션과 유사한 기능을 한다. 클래스, 메서드, 접근자, 프로퍼티, 매개변수에 적용 가능하다. 각 요소의 선언부 앞에 @로 시작하는 데코레이터를 선언하면 데코레이터로 구현된 코드를 함께 실행한다.

🧐 관점 지향 프로그래밍(AOP, Aspect Oriented Programming)
기능별로 class를 분리했음에도 불구하고 생기는 중복코드의 단점을 해결하고자 나온 방식, 공통기능과 핵심부분을 분리하여, 필요할 때에만 공통기능을 핵심부위에 넣어주는 방식이다.
횡단 분할 기법(seperation of cross-cutting concerns)을 사용함으로써 모듈성을 증가시키는 것이 목적인 프로그래밍 패러다임이다.
횡단 분할 기법이란 OOP 와 같은 모듈화가 뛰어난 방법을 사용하더라도 결코 쉽게 분리된 모듈로 작성하기 힘든 요구사항이 실제 어플리케이션 설계와 개발에서 자주 발견된다는 점이다. 이를 AOP에서는 횡단 관심이라고 한다.
기능을 핵심 비지니스 로직과 공통 모듈로 구분하고, 핵심 로직에 영향을 미치지 않고 사이사이에 공통 모듈을 효과적으로 잘 끼워넣도록 하는 개발 방법이다.
더 자세한 것은 여기에서 읽어보자.

예를 들어 다음코드는 유저 생성 요청의 본문을 DTO 로 표현한 클래스이다.

class CreateUserDto {
  @IsEmail()
  @MaxLength(60)
  readonly email: string;

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

사용자는 얼마든지 요청을 잘못 보낼 수 있기 때문에 데코레이터를 이용하여 애플리케이션이 허용하는 값으로 제대로 요청을 보냈는지 아래와 같은 조건으로 검사하고 있다.

  • @IsEmail(): email 은 이메일 형식을 가진 문자열
  • @MaxLength(60): email 길이는 최대 60자
  • @IsString(): password 는 문자열
  • @Matches(...): password 는 주어진 정규 표현식에 적합해야 함

데코레이터는 typescript 스펙에서 아직 실험적인 기능이다. 프로젝트의 루트에 있는 tsconfig.json 파일을 보면 experimentalDecorators 옵션이 true 로 설정되어 있다. 이 옵션을 켜야 데코레이터를 사용할 수 있다. 비록 실험적인 기능이지만 매우 안정적이며 수많은 프로젝트에서 사용하고 있다.

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

데코레이터는 위 예시와 같이 @expression 과 같은 형식으로 사용한다. 여기서 expression 은 데코레이팅 된 선언(데코레이터가 선언되는 클래스, 메서드 등)에 대한 정보와 함께 런타임에 호출되는 함수여야 한다. 다음과 같은 메서드 데코레이터가 있고 이 데코레이터를 test 라는 메서드에 선언했다. 여기서 deco 함수에 인자들이 있는데 메서드 데코레이터로 사용하기 위해서는 아래와 같이 정의하면 된다.

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

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

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

이제 TestClass 를 생성하고 test 메서드를 호출하면 다음과 같은 결과가 콘솔에 출력된다. 데코레이팅 된 선언에 대한 정보와 함께 런타임에 호출되는 함수임을 알 수 있는 것이다.

데코레이터가 평가됨
함수 호출됨

만약 데코레이터에 인자를 넘겨서 데코레이터의 동작을 변경하고 싶다면 데코레이터 팩토리, 즉 데코레이터를 리턴하는 함수를 만들면 된다. 위의 예시를 다음과 같이 value라는 인자를 받도록 바꿔보자.

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

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

결과는 다음과 같다.

데코레이터가 평가됨
HELLO
함수 호출됨

데코레이터 합성

만약 여러개의 데코레이터를 사용한다면 수학에서의 함수 합성과 같이 적용된다. 다음 데코레이터 선언의 합성 결과는 f(g(x))와 같다.

@f
@g
test

여러 데코레이터를 사용할 때 다음 단계가 수행된다.

  1. 각 데코레이터의 표현은 위에서 아래로 평가(evaluate) 됨
  2. 결과는 아래에서 위로 함수로 호출(call) 됨

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

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');
  }
}
first(): factory evaluated
second(): factory evaluated
second(): called
first(): called
method is called

typescript 가 지원하는 데코레이터

typescript 는 5가지 데코레이터를 지원한다.

데코레이터역할호출 시 전달되는 인자선언 불가능한 위치
classclass 정의를 읽거나 수정constructord.ts 파일, declare 클래스
methodmethod 정의를 읽거나 수정target, propertyKey, propertyDescriptord.ts 파일, declare class, overload method
accessoraccessor 정의를 읽거나 수정target, propertyKey, propertyDescriptord.ts 파일, declare 클래스
propertyproperty 정의를 읽음target, propertyKeyd.ts 파일, declare 클래스
parameterparameter 정의를 읽음target, propertyKey, parameterIndexd.ts 파일, declare 클래스

각각에 대하여 더 자세히 알아보자.

class 데코레이터

class 바로 앞에 선언된다. class 데코레이터 는 class 의 생성자에 적용되어 class 정의(definition)를 읽거나 수정할 수 있다. 선언 파일(typescript 소스 코드를 컴파일 할 때 생성되는 파일로 typescript 의 타입 추론을 돕는 코드가 포함되어 있음, 파일 이름은 d.ts 로 끝남)과 선언 클래스(declare class)내에서는 사용할 수 없다.
다음 코드는 class 에 reportingURL 속성을 추가하는 클래스 데코레이터의 예다.

function reportableClassDecorator<T extends { new (...args: any[]): {} }>(constructor: T) {
  return class extends constructor {
    reportingURL = "http://www.example.com";
  };
}

@reportableClassDecorator
class BugReport {
  type = "report";
  title: string;

  constructor(t: string) {
    this.title = t;
  }
}

const bug = new BugReport("Needs dark mode");
console.log(bug);
  • L1: class 데코레이터 팩토리, 생성자 타입(new (...args: any[]): {}. new 키워드와 함께 어떠한 형식의 인자들도 받을 수 있는 타입)을 상속받는 제네릭 타입 T 를 가지는 생성자(constructor)를 팩토리 메서스의 인자로 전달
  • L2: class 데코레이터는 생성자를 리턴하는 함수
  • L3: 클래스 데코레이터가 적용되는 클래스에 새로운 reportingURL 이라는 새로운 속성을 추가

위 코드의 출력결과는 다음과 같다.

{type: 'report', title: 'Needs dark mode', reportingURL: 'http://www.example.com'}
  • BugReport 클래스에 선언되어 있지 않은 새로운 속성이 추가 됨
  • class 의 타입이 변경되는 것은 아님(타입 시스템은 reportingURL 을 인식하지 못하기 때문에 bug.reportingURL과 같이 직접 사용할 수 없음)

method 데코레이터

method 데코레이터는 method 바로 앞에 선언된다. method 의 속성 디스크립터(속성의 특성을 설명하는 객체)에 적용되고 method 의 정의를 읽거나 수정할 수 있다. 선언 파일, 오버로드 메서드, 선언 클래스에 사용할 수 없다.
앞서 deco method 데코레이터에서 보았던 것처럼 method 데코레이터는 다음 세 개의 인수를 가집니다.

  • 정적 멤버가 속한 클래스의 생성자 함수이거나 인스턴스 멤버에 대한 클래스의 프로토타입
  • 멤버의 이름
  • 멤버의 속성 디스크립터(PropertyDescriptor 타입을 가짐)

만약 method 데코레이터가 값을 반환한다면 이는 해당 method 의 속성 디스크립터가 된다.
메서드 데코레이터의 예를 보자. 함수를 실행하는 과정에서 에러가 발생했을 때 이 에러를 잡아서 처리하는 로직을 구현하고 있다.

function HandleError() {
  return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log(target)
    console.log(propertyKey)
    console.log(descriptor)

    const method = descriptor.value;

    descriptor.value = function() {
      try {
        method();
      } catch (e) {
        console.log(e);
      }
    }
  };
}

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

const t = new Greeter();
t.hello();
  • L2: 메서드 데코레이터가 가져야 하는 3개의 인자
    interface PropertyDescriptor {
      configurable?: boolean;  // 속성의 정의를 수정할 수 있는지 여부
      enumerable?: boolean;    // 열거형인지 여부
      value?: any;             // 속성 값
      writable?: boolean;      // 수정 가능 여부
      get?(): any;             // getter
      set?(v: any): void;      // setter
    }
    • PropertyDescriptor 는 객체 속성의 특성을 기술하고 있는 객체
    • enumerable 이 true 면 해당 속성은 열거형이라는 뜻
  • L3: 출력결과는 {constructor: ƒ, greet: ƒ} 인데 이때 데코레이터가 선언된 메서드 hello 가 속해있는 클래스의 생성자와 프로토타입을 가지는 객체임을 알 수 있음
  • L4: 함수 이름 'hello' 가 출력
  • L5: hello 함수가 처음 가지고 있던 디스크립터가 출력(출력 결과: {value: ƒ, writable: true, enumerable: false, configurable: true})
  • L7: 디스크립터의 value 속성으로 원래 정의된 메서드를 저장
  • L10: 원래의 메서드를 호출
  • L12: 만약 원래의 메서드를 수행하는 과정에서 발생한 에러를 핸들링하는 로직을 이 곳에 구현
  • L13: 'Error: 테스트 에러'가 출력

accessor 데코레이터

accessor 데코레이터는 접근자 바로 옆에 선언한다. accessor 의 속성 디스크립터에 적용되고 접근자의 정의를 읽거나 수정할 수 있다. 선언 파일과 선언 클래스에 사용할 수 없다. accessor 데코레이터가 반환하는 값은 해당 멤버의 속성 디스크립터가 된다.
특정 멤버를 열거가 가능한 지 결정하는 데코레이터의 예를 보자.

function Enumerable(enumerable: boolean) {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    descriptor.enumerable = enumerable;
  }
}

class Person {
  constructor(private name: string) {}

  @Enumerable(true)
  get getName() {
    return this.name;
  }

  @Enumerable(false)
  set setName(name: string) {
    this.name = name;
  }
}

const person = new Person('Dexter');
for (let key in person) {
  console.log(`${key}: ${person[key]}`);
}
  • L3: 디스크립터의 enumerable 속성을 데코레이터의 인자로 결정
  • L8: name 은 외부에서 접근하지 못하는 private 멤버
  • L10 ~ 13: getter getName 함수는 열거가 가능하도록 함
  • L15 ~ 16: setter setName 함수는 열거가 불가능하도록 함
  • L21 ~ 24: 결과를 출력하면 getName 은 출력되지만 setName 은 열거하지 못하게 되었기 때문에 for 문에서 key 로 받을 수가 없음
    name: Dexter
    getName: Dexter

property 데코레이터

property 데코레이터는 클래스의 property 바로 앞에 선언된다. 파일, 선언 클래스에서 사용하지 못한다. property 데코레이터는 다음 두 개의 인수를 가지는 함수입니다.

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

method 데코레이터나 accessor 데코레이터와 비교했을 때 세 번째 인자인 속성 디스크립터가 존재하지 않는다. 공식 문서에 따르면 반환값도 무시되고, 이는 현재 프로토타입(prototype)의 멤버를 정의할 때 인스턴스 속성을 설명하는 메커니즘이 없고 속성의 초기화 과정을 관찰하거나 수정할 수 있는 방법이 없기 때문이라고 한다.

function format(formatString: string) {
  return function (target: any, propertyKey: string): any {
    let value = target[propertyKey];

    function getter() {
      return `${formatString} ${value}`;
    }

    function setter(newVal: string) {
      value = newVal;
    }

    return {
      get: getter,
      set: setter,
      enumerable: true,
      configurable: true,
    }
  }
}

class Greeter {
  @format('Hello')
  greeting: string;
}

const t = new Greeter();
t.greeting = 'World';
console.log(t.greeting);
  • L6: getter 에서 데코레이터 인자로 들어온 formatString 을 원래의 속성과 조합한 스트링으로 바꿈
  • L23: 데코레이터에 formatString 을 전달
  • L29: 속성을 읽을 때 게터가 호출되면서 'Hello World'가 출력

parameter 데코레이터

생성자 또는 메서드의 파라미터에 선언되어 적용된다. 선언 파일, 선언 클래스에서 사용할 수 없다. parameter 데코레이터는 호출 될 때 3가지의 인자와 함께 호출된다. 반환값은 무시된다.

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

파라미터가 제대로 된 값으로 전달되었는지 검사하는 데코레이터를 만들어 보자. 매개변수 데코레이터는 단독으로 사용하는 것보다 함수 데코레이터와 함께 사용할 때 유용하게 쓰인다.

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

function MinLength(min: number) {
  return function (target: any, propertyKey: string, parameterIndex: number) {
    target.validators = {
      minLength: function (args: string[]) {
        return args[parameterIndex].length >= min;
      }
    }
  }
}

function Validate(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const method = descriptor.value;

  descriptor.value = function(...args) {
    Object.keys(target.validators).forEach(key => {
      if (!target.validators[key](args)) {
        throw new BadRequestException();
      }
    })
    method.apply(this, args);
  }
}

class User {
  private name: string;

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

const t = new User();
t.setName('Dexter');
console.log('----------')
t.setName('De');
  • L3: 파라미터의 최소값을 검사하는 파라미터 데코레이터
  • L5 ~ 9: target 클래스(여기서는 User)의 validators 속성에 유효성을 검사하는 함수를 할당
  • L6: args 인자는 18번 라인에서 넘겨받은 메서드의 인자
  • L7: 유효성 검사를 위한 로직으로 parameterIndex 에 위치한 인자의 길이가 최소값보다 같거나 큰지 검사
  • L13: 함께 사용할 메서드 데코레이터
  • L14: 메서드 데코레이터가 선언된 메서드를 method 변수에 임시 저장
  • L16: 디스크립터의 value 에 유효성 검사 로직이 추가된 함수를 할당
  • L17 ~ 21: target(User 클래스) 에 저장해 둔 validators 를 모두 수행합니다. 이때 원래 메서드에 전달된 인자(args)들을 각 validator에 전달
  • L22: 원래의 함수를 실행
  • L36: 파라미터 name 의 길이가 5 이기 때문에 문제가 없음
  • L38: 파라미터 name 의 길이가 3 보다 작기 때문에 BadRequestException 이 발생
profile
EWHA Cyber Security 19

0개의 댓글