[Node.js] 웹 프레임워크와 Node.js

bolee·2023년 1월 8일
0

NestJS

목록 보기
2/3
post-thumbnail

웹 프레임워크(Web Framework)

초기 인터넷은 정적 이미지와 텍스트로만 이루어져 있어 HTML만 있다면 웹페이지를 만드는데 충분했다. 이후 인터넷은 발전하여 많은 일을 처리할 수 있게 되었고, 필요한 애플리케이션 역시 웹과 모바일 앱 양쪽으로 작성되고 있다.
이를 위해 자바스크립트는 화면을 동적으로 구성하기 위한 필수적인 도구로 떠올랐고 웹 기반 애플리케이션을 작성하기 위해 반드시 배워야하는 언어가 되었다.

이전에는 웹 페이지에서 구동되는 애플리케이션은 모두 SSR(server-side rendering) 방식으로 동작했다. 즉, 서버가 요청을 처리하고 HTML과 자바스크립트 응답을 브라우저에 전송하면 브라우저는 서버가 전송한 HTML 코드를 화면에 그려주고 동적으로 구성되는 부분은 함께 전달된 자바스크립트를 파싱하여 화면을 구성하였다.

하지만 시간이 지나면서 웹 기술은 복잡해지며 발전하였고, 웹 앱을 만드는데 필요한 기술들을 기존의 방식으로 개발하기에는 시간과 노력이 많이 들었다. 이에 따라웹 개발에 필요한 요소들을 묶어 개발자들이 쉽게 사용할 수 있도록 하고자하는 시도가 생겨났으며, 이러한 결과물들을 웹 프레임워크(web framework)라고 한다.

이러한 웹 프레임워크들은 다양하게 존재하기 때문에 선택에 어려움이 존재한다. 이 때 다음 사항들을 고려하면 좋다.

  • 개발 문서
    • 쉽게 이해할 수 있게 잘 쓰인 개발 문서는 생산성을 올려준다.
  • 사용자 수
    • 사용자 수가 많다는 것은 안정적으로 운용되고 있다는 것에 증거이다.
    • 궁금한 점을 커뮤니티를 통해 쉽게 찾을 수 있다.
  • 활성 커뮤니티
  • 깃허브 스타 수와 이슈 대응

Node.js

Node.js는 2009년에 릴리스되었다. 그 전까지 자바스크립트는 프런트엔드에서만 사용하는 언어라는 인식이 강했고 실제로도 그랬다.
웹 시장이 커지자 동적 페이지를 다루기 위해 HTML 내 <script> 태그로 삽입하여 주로 사용하였다. 그러나 브라우저에 삽입된 소스 코드가 그대로 노출되므로 보안에 취약하다는 인식이 있었고, 사용은 많이 하지만 '어디까지나 스크립트 언어'라는 괄시를 받기도 하였다.

이후 Node.js의 등장으로 자바스크립트를 이용해 서버를 구동할 수 있게 되었으며, 자바스크립트는 하나의 언어로 인정받게 되었다. 또 2008년 구글은 크롬 브라우저를 출시하면서 자체 개발한 자바스크립트 엔진인 V8을 크롬에 내장했고, 이 V8 엔진의 뛰어난 성능을 '자바스크립트는 느리다'라는 인식을 바꾸었다.

Node.js는 npm이라는 패키지 관리 시스템을 가지고 있어 누구나 자신이 만든 Node.js 기반 라이브러리를 등록해 다른 사람들이 사용할 수 있도록 공개할 수 있다. npm에 따르면 2022년 3월 기준 등록된 패키지 수가 약 190만개에 다다른다.이런 많은 패키지로 인해 많은 커뮤니티가 생성되고 자바스크립트와 Node.js의 입지는 증가하였다.
이러한 영향으로 인해 npm에 공개되어 있는 대부분의 소스 코드가 깃허브에 공개되어 있는데 깃허브는 2020년 3월에 npm을 인수하였다. 깃허브는 마이크로소프트가 인수하였으므로 사실상 npm은 마이크로소프트가 주도하게 되었다.

이러한 Node.js의 특징은 다음과 같다.

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

여러 작업 요청이 한번에 많이 들어올 때, 각 작업을 처리하기 위한 스레드를 만들고 할당하는 방식을 멀티스레딩(multi-threading)이라고 한다. 멀티스레딩 방식은 여러 작업을 동시에 처리하기 때문에 처리 속도가 빠르기만, 공유 자원을 관리해야하고 잘 못하면 교착 상태(dead lock, 데드락)이 발생할 수 있다. 또한 스레드가 늘어날 때마다 메모리를 소모하기 때문에 메모리 관리 또한 중요하다.

이에 비해 Node.js는 단일 스레드에서 작업을 처리한다. 사실 애플리케이션 단에서는 단일 스레드이지만 백그라운드에서는 스레드 풀을 구성해 작업을 처리한다.
Node.js는 개발자 대신 Node.js에 포함된 비동기 I/O 라이브러리인 libuv에서 스레드 풀을 관리하기 때문에 개발자는 단일 스레드에서 동작하는 것처럼 쉬운 코드를 작성할 수 있다. 웹 서버를 운용할 때는 CPU 코어를 분산해 관리하기 때문에 실제 작업은 여러 개의 코어에서 별개로 처리된다.

libuv
Node.js에서 사용하는 비동기 I/O 라이브러리이다.
커널을 사용해 처리할 수 있는 비동기 작업을 발견하면 바로 커널로 넘기고, 이후 해당 작업들이 종료되어 커널로부터 시스템 콜을 받으면 이벤트 루프에 콜백을 등록한다.
커널이 지원하지 않는 작업일 경우 별도의 스레드에서 작업을 처리한다.

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

Node.js의 장단점

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

하나의 스레드로 동작하는 것처럼 코드를 작성할 수 있다는 것은 개발자에게 큰 장점이다. 멀티 스레드를 직적 만들고 관리하며, 데드락을 생각할 필요가 없기 때문이다.

Node.js의 단점은 컴파일러 언어의 처리 속도에 비해 성능이 떨어진다는 점이다. 하지만 서버의 성능과 자바스크립트 엔진의 성능도 계속 늘어나고 있거 웬만한 웹 애플리케이션을 만들기에 충분하다.
다만 이벤트 기반 비동기 방식으로 복잡한 기능을 구현하다 보면 여러 이벤트를 동시에 처리하는 경우 '콜백 지옥'에 빠져 가독성이 떨어지는 코드가 만들어 질 수 있다.

하지만 ECMAScript 2016(ES6)에서 프로미스(Promis)가 도입되면서 간결한 표현으로 작성할 수 있게 되었고, ECMAScript 2017(ES7)에서는 async/await가 추가되면서 비동기 코드를 마치 동기적으로 처리하는 것처럼 코드를 작성할 수 있게 되었다.

이벤트 루프(Event Loop)

이벤트 루프(event loop)는 시스템 커널에서 가능한 작업이 있다면 그 작업을 커널에 이관하는 역할을 한다. 자바스크립트가 단일 스레드 기반이지만 Node.js가 논블로킹 I/O 작업을 수행할 수 있도록 해주는 핵심 기능이다.

이벤트 루프에는 6 단계로 이루어져 있다. 아래 그림은 위 그림의 이벤트 루프 동작 방식을 좀 더 단순화한 것이다. 각 단계는 처리해야 하는 콜백 함수를 담기 위한 큐(queue)를 가지고 있으며, 화살표는 각 단계가 전이되는 방향을 뜻한다. 꼭 반드시 다음 단계로 넘어가는 것은 아니다.

각 단계에는 해당 단계에서 실행되는 작업을 저장하는 큐(queue)가 있다. 또한 이벤트 루프의 구성 요소는 아니지만 nextTickQueuemicroTaskQueue가 존재한다. nextTickQueuemicroTaskQueue에 들어 있는 작업은 이벤트 루프가 어느 단계에 있든지 실행될 수 있다.

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

각 단계를 차례대로 살펴보자.

타이머(Timer) 단계

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

최소 힙(min heap)
최솟값을 찾기 위해 완전 이진 트리를 사용하는 자료구조

실행할 시간이 지난 타이머들의 콜백이 무한정 실행되는 것은 아니고 시스템의 실행 한도(hardl limit)에 도달하면 다음 단계로 넘어간다.

대기 콜백(Pending I/O callbacks) 단계

대기 단계(pending i/o callbacks phase)의 큐(pending_queue)에 들어 있는 콜백들은 현재 돌고 있는 루프 이전의 작업에서 큐에 들어온 콜백이다.
예를 들어 TCP 핸들러 내에서 비동기의 쓰기 작업을 한다면, TCP 통신과 쓰기 작업이 끝난 후 해당 작업의 콜백이 큐에 들어오며, 에러 핸들러 콜백도 pending_queue에 들어 온다.

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

유휴, 준비(Idle, prepare) 단계

유휴 단계(idle phase)는 틱(tick)마다 실행되며, 준비 단계(prepare phase)는 매 폴링 직전에 실행된다.
이 두 단계는 Node.js의 내부 동작을 위한 것이다.

틱(tick)
프로그램 실행 시 최소 간격을 말하며 '틱 카운트'라고도 한다.
이벤트 루프에서는 각 단계가 이동하는 것을 뜻한다.

폴(Poll) 단계

폴 단계(poll phase)는 이벤트 루프 중 가장 중요한 단계로 새로운 I/O 이벤트를 가져와 관련 콜백을 수행한다.
예를 들어 소켓 연결과 같은 새로운 커넥션을 맺거나 파일 읽기와 같이 데이터 처리를 받아들인다.

이 단계가 가지고 있는 큐는 watch_queue이다. 이 단계에 진입한 후 watch_queue가 비어 있지 않다면, 큐가 비거나 시스템 실행 한도에 다다를 때까지 동기적으로 모든 콜백을 실행한다.
만약 큐가 비게 되면 Node.js는 바로 다음 단계로 이동하지 않고 check_queue(체크 단계의 큐), pending_queue(대기 콜백 단계의 큐), closing_queue(종료 콜백 단계의 큐)에 남은 작업이 있는지 검사한 후 작업이 있다면 다음 단계로 이동한다.
만약 큐가 모두 비어 해야할 작업이 없다면, 잠시 대기를 한다(타이머 최소 힙의 첫 번째 타이머를 꺼내 지금 실행할 수 있는 상태라면 그 시간만큼 대기 후 다음 단계로 이동한다.).
이렇게 하는 이유는 타이머 단계로 넘어가도 어짜피 첫 번째 타이머를 수행할 시간이 되지 않았기 때문에 이벤트 루프를 한 번 더 돌아야 하기 때문이다.

체크(Check) 단계

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

종료 콜백(Close callbacks) 단계

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

nextTickQueue, microTaskQueue

한편 nextTickQueueprocess.nextTick() API의 콜백들을 가지고 있으며, microTaskQueueresolvePromise의 콜백을 가지고 있다. 즉, libuv 라이브러리에 포함된 것이 아닌 Node.js에 포함된 기술이다.
이 두 큐에 들어 있는 콜백은 단계를 넘어가는 과정에서 먼저 실행되며, nextTickQueuemicroTaskQueue보다 높은 우선순위를 가지고 있다.

패키지 의존성 관리

package.json

Node.js 관련 프로젝트를 생성하게 되면 package.json 파일이 존재하게 된다. 생성된 package.json 파일의 역할은 다음과 같다.

  • 애플리케이션이 필요 하는 패키지 목록 나열
  • 각 패키지는 시멘틱 버저닝(semantic versioning) 규칙으로 필요한 버전을 기술한다.
  • 다른 개발자와 같은 빌드 환경을 구성할 수 있어 버전이 달라 발생하는 문제를 예방할 수 있다.

유의적 버전(semantic versioning)은 패키지의 버전명을 숫자로 관리하는 방법으로 많이 사용되고 있으며 규칙은 다음과 같다.

[Major].[Minor].[Patch]-[label]

Major, Minor, Patch는 각각 숫자를 사용한다. 규칙에 따라 1.2.3-beta와 같이 사용한다.

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

유의적 버전을 사용할 때 완전히 동일한 버전만을 정의해야하는 것은 아니다. 다음과 같은 규칙을 사용해 의존성을 해치지 않는 다른 버전을 설치할 수 있다.

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

package-lock.json

프로젝트 루트 디렉토리에서 npm install 명령을 수행하면 node_modules 디렉토리와 package-lock.json 파일이 생성된다. node_modules는 프로젝트가 필요한 패키지들이 실제로 설치되는 장소이다. 애플리케이션은 런타임에 여기에 설치된 패키지들을 참조한다. package-lock.json 파일은 package.json에 선언된 패키지들이 설치될 때의 정확한 버전과 서로 간의 의존성을 표현한다.

package-lock.json 파일은 node_modulespackage.json 파일의 내용이 바뀌면 npm install 명령을 수행할 때 자동 수정된다. 만약 소스 코드 내에 package-log.json 파일이 이미 존재한다면 npm install 명령을 수행할 때 이 파일을 기준으로 패키지들을 설치하게 된다.
따라서 이를 이용해 팀원들 간의 정확한 개발 환경을 공유할 수 있기 때문에 소스 코드 저장소에서 관리해야 한다.

package.json 파일 분석

NestJS로 생성된 프로젝트의 package.json 파일을 바탕으로 package.json에 기술된 내용을 살펴볼 것이다.

{
  "name": "test",			// 1
  "version": "0.0.1",		// 2
  "description": "",		// 3
  "author": "",				
  "private": true,			// 4
  "license": "UNLICENSED",	// 5
  "scripts": {				// 6
    "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": {			// 7
    "@nestjs/common": "^9.0.0",
    "@nestjs/core": "^9.0.0",
    "@nestjs/platform-express": "^9.0.0",
    "reflect-metadata": "^0.1.13",
    "rimraf": "^3.0.2",
    "rxjs": "^7.2.0"
  },
  "devDependencies": {		// 8
    "@nestjs/cli": "^9.0.0",
    "@nestjs/schematics": "^9.0.0",
    "@nestjs/testing": "^9.0.0",
    "@types/express": "^4.17.13",
    "@types/jest": "28.1.8",
    "@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.1.3",
    "prettier": "^2.3.2",
    "source-map-support": "^0.5.20",
    "supertest": "^6.1.3",
    "ts-jest": "28.0.8",
    "ts-loader": "^9.2.3",
    "ts-node": "^10.0.0",
    "tsconfig-paths": "4.1.0",
    "typescript": "^4.7.4"
  },
  "jest": {					// 9
    "moduleFileExtensions": [
      "js",
      "json",
      "ts"
    ],
    "rootDir": "src",
    "testRegex": ".*\\.spec\\.ts$",
    "transform": {
      "^.+\\.(t|j)s$": "ts-jest"
    },
    "collectCoverageFrom": [
      "**/*.(t|j)s"
    ],
    "coverageDirectory": "../coverage",
    "testEnvironment": "node"
  }
}
  1. name: 패키지 이름이다. version과 함께 고유한 식별자가 된다. 패키지를 npm에 공개하지 않는다면 선택 사항이다.
  2. version: 패키지의 버전이다.
  3. description: 패키지에 대한 설명이다.
  4. private: true로 설정할 경우 공개되지 않는다.
  5. license: 패키지의 라이센스이다. 공개된 패키지를 사용할 때 참고해야 한다.
  6. scripts: npm run 명령과 함께 수행할 수 있는 스크립트이다. 스크립트를 이용해 다양한 기능을 구현할 수 있다.
  7. dependencies: 패키지가 의존하는 다른 패키지들이 표기되어 있다. 프로덕션 환경에서 필요한 주요 패키지를 선언한다.
  8. devDependencies: dependencies와 같은 기능을 하지만 개발 환경 또는 테스트 환경에서만 필요한 패키지를 여기서 선언한다. 실 사용 서비스에서는 불필요한 패키지를 설치하지 않도록 해야 한다.
  9. jest: 테스팅 라이브러리 Jest를 위한 환경 구성 옵션이다. NestJS는 기본으로 Jest를 이용한 테스트를 제공한다.

참고 자료

0개의 댓글