Basic Node.js

유웅조·2020년 1월 13일
0

Basic

목록 보기
7/13

Node.js

Node.js는 무엇일까?

개요

node.js는 오픈 소스, 크로스 플랫폼, 자바스크립트 런타임 환경으로서 자바스크립트 코드를 브라우저 밖에서 실행시키도록 해준다.

이전까지는 브라우저에 종속되어 있었던 자바스크립트가 node.js 환경을 만나면서 서버-사이드에서도 자바스크립트를 활용할 수 있게 되었다.

자연스럽게 "Javascript Everywhere" 페러다임을 대표하게 되었고, 웹-어플리케이션 개발에 있어서 더 이상 여러개의 언어가 아닌 언어 통일의 기회를 제공했다.

node.js는 이벤트 기반의 싱글 스레드로 비동기 I/O로 작동한다. 따라서 input/output 작업이 많거나, 실시간 웹어플리케이션에 유용하게 사용될 수 있다.

탄생

node.js를 만든 Ryan Dahl은 그때 당시에 가장 많이 활용되었던 웹-서버인 Apache HTTP Server가 대량의 동시 접속을 해결하는 방식을 비판해왔다.

코드가 실행되는 절차 속에서 대량의 동시 접속이 발생할 경우, 특정 코드가 전체의 프로세스를 BLOCK 하거나 많은 실행 스텍을 막는 등의 제한적인 면모를 비판해왔다.

이에 2009년 11월 8일, Common JS 기반, 그리고 구글의 V8엔진과 이벤트 루프, 그리고 low-level의 I/O API로 이루어진 node.js를 발표했다.

이후 2010년 Node.js의 환경을 위한 Package Manager인 NPM이 등장했다. 개발자들은 npm을 이용해 node.js 라이브러리를 더욱 쉽게 공유하고 설치하고 사용할 수 있게 되었다.

Node.js로 할 수 있는 일

node.js를 활용하면 Javascript와 "Modules"를 이용해 웹 서버와 네트워킹 시스템을 만들 수 있다.

Modules는 다양한 핵심적인 기능을 제공하는 도구로서 file system I/O, Networking(DNS, HTTP, TCP, TLS//SSL, UDP), Buffers, Cryptography, Data streams 등과 같은 기능을 복잡하지 않도록 사용할 수 있도록 해준다.

node.js는 자바스크립트만을 기반으로 돌아간다. 그러나 compile-to-JS 가 가능한 언어라면 node.js로 구동이 가능하다. 예를 들어, Coffesccript, Dart, Typesciprt, Clojurescript 등.

node.js를 활용하면 "Event-Driven-Programming"을 활용한 빠른 웹 서버를 구축할 수 있다.

개발자는 이러한 "Event-Driven-Programming"의 단순화된 모델을 사용함으로써, Callback을 활용해 "Threading"[1]을 하지 않고도 스케일링이 가능한 서버를 만들 수 있다.

Node.js 작동 방식

node.js는 싱글 스레드, 이벤트 루프 기반의 Non-Blocking I/O로 작동한다.

그렇기에 수만 개의 동시 접속에도 Thread Context Switch(스레드 문맥 교환)[2]을 발생시키지 않아도 된다.

Observer Pattern[3]을 사용하여 I/O 작업이 있는 함수를 Callback으로 작동시키는 하나의 스레드를 모든 요청이 함께 공유하게 되면 굉장히 높은 효율로 동기적인 어플리케이션이 작동할 수 있다.

node.js의 기본적인 시스템 구조를 살펴보면 다음과 같다.

node.js는 libuv라는 라이브러리를 사용해 이벤트 루프를 제어한다.

libuv란 C 언어로 개발된, 비동기 I/O에 집중하는 멀티 플랫폼 라이브러리이다.

libuv에는 thread pool 이라는 것이 존재해서, 여기에 있는 thread가 동기적인 입출력 작업을 event-loop 대신 처리를 해준다. 또한

만약 시스템적으로 Non-Blocking IO를 지원하지 않는 IO 호출이 있는 경우, 이를 비동기 처리 하기 위해, 내부의 Thread Pool(libio)를 별도로 이용해 처리한다.

Event-Loop는 가능하다면 최대한 시스템 커널에 작업을 넘겨 Non-Blocking I/O 작업을 수행할 수 있도록 한다. 이것을 풀어서 설명하자면,

1. 메인 스레드는 테스크 큐에 테스크를 추가한다.
2. 비동기로 작동하는 함수라면 kernel-side non-blocking sockets로 이전된다.
3. 만약 동기로 작동한다면 해당 스레드에서 작동한다.
4. 스레드 풀에 있던 테스크가 완료되면 메인 스레드에게 알리고 지정되어 있던 콜백 함수가 작동하게 된다.

이러한 싱글 스레드의 단점은 cluster, StrongLoop Process Manager, pm2와 같은 외부 모듈 없이는, 가동 중인 CPU 코어를 늘려 스레드의 갯수를 늘리는 것과 같은 scale up을 지원하지 않는다는 것이다.

하지만 개발자는 libuv 스레드 풀에 기본 스레드 수를 증가시킬 수는 있다.

다른 한가지 문제점은 굉장히 복잡한 컴퓨팅이나, CPU를 많이 써야하는 작업의 경우, 그것이 완료될 때까지 모든 event-loop가 멈춰있을 수 있다는 단점이 있다.

이 문제는 node.js 10 버전으로 들어오면서 Worker_thread 라는 모듈을 통해 thread 생성이 가능하게 되면서 어느 정도 해소할 수 있어 보인다.

그러나 내부적으로 프로세스를 나누는 것 자체도 CPU에게는 하나의 일이기 때문에 정말 하드한 CPU 작업이 아니라면 스레드를 나누는 것이 오히려 퍼포먼스를 더 저하시킬 수도 있다.

require() 함수는 어떻게 사용하는가

Node.js는 Common.js의 모듈 시스템을 따른다. Common.js는 웹 브라우저 밖의 자바스크립트 모듈 생테계의 규칙을 설립하기 위한 프로젝트이다. 원래는 2009년 모질라의 개발자에 의해 ServerJS라는 이름으로 시작되었다. 후에 API의 더 넓은 적용 가능성을 나타내기 위해 CommonJS라는 이름으로 변경되었다. require() 함수는 CommonJS에서 모듈을 호출하기 위한 함수로서 사용된다.

require() 함수의 내부는 대략적으로 다음과 같이 작동한다. 우선 불러올 소스의 디렉토리를 인자로 받아, 해당 디렉토리의 파일을 읽고, 내부 변수에 저장한다. 그리고 module.exports 라는 빈 객체를 생성하고 거기에 읽어두었던 파일의 내용을 복사한다. 그리고 최종적으로 module.exports를 반환한다.

import...from과는 어떻게 다를까?

import는 ES6에서 지원하는 모듈 시스템이다. ES6 문법이기 때문에 바벨을 사용하지 않으면 브라우저 환경에서는 제약이 있다.

자바스크립트는 사실 브라우저에 의존적이었기 때문에 모듈 관리가 힘들었다. <script>를 활용해서 HTML에 삽입하는 방식으로 여러 개의 자바스크립트 파일을 사용할 수 있었는데, 이렇게 할 경우 모든 자바스크립트 소스도 결국 window 객체에 종속되기 때문에 중복되는 변수 등이 있는지 항상 확인해야하는 번거로움이 존재했다.

그래서 Node.js에서는 CommonJS를 이용해 require()를 이용해 모듈 관리를 쉽게 할 수 있다.

후에 ES6에서 import, export 와 같은 문법을 지원하기 시작하면서 자바스크립트 자체에서도 모듈을 활용할 수 있게 되었다.

require()import...from의 가장 큰 차이점은 이 둘의 기본적인 작동 방식일 것이다.
require()는 특정 모듈을 모두 사용하기 때문에 만약 모듈의 속성이 모두 필요한 것이 아니라면 메모리의 낭비가 있을 수 있다. 하지만 import...from은 특정 속성만을 호출해서 사용할 수 있기 때문에 메모리의 이점이 있을 수 있다.

module.exportsexports는 어떻게 다를까

결국 require 함수는 객체를 반환하는 함수이다. 그렇다면 이렇게 함수를 호출해서 사용하기 위해 어떻게 모듈을 export 할 수 있을까.

우선 exports와 module.exports의 관계에 대해서, exports는 module.exports의 일종의 alias이다.

한마디로 exports와 module.exports는 call by reference로 같은 메모리 주소의 객체를 바라보고 있고, 최종적으로 반환되는 객체는 module.exports이다.

Node Package Manager

npm은 node.js 서버 플랫폼을 위한 패키지 매니저이다. node.js 프로그램의 서드 파티를 설치하는 것을 관리하는 도구이다. 기본적으로 node.js를 설치하면 자동적으로 설치된다.

npm을 이용해 설치한다는 것은 설치하고자 하는 패키지를 현재 디렉토리 기준의 node 환경을 관리하는 package.json에 설치 된다. 만약 npm installl .... -g로 패키지를 설치하게 될 경우, 해당 패키지를 디렉토리 기준이 아닌 로컬의 node 저장소에 패키지가 다운 받아진다.

따라서 해당 패키지명과 관련하 CLI를 사용할 수도 있으며, 로컬 전역에서 해당 패키지를 사용할 수 있다.

V8 엔진에 대하여

자바스크립트는 여러 개의 엔진을 갖고 있다. 그중 가장 많이 사용되는 것이 V8 엔진이다. V8 엔진은 구글의 크롬 브라우저와 node.js에 사용된다.

V8 엔진은 크게 두 부분으로 이루어져있다. Memory Heap (메모리 할당이 이루어지는 곳), Stack (코드가 실행되면서 스텍 콜이 쌓이는 곳)[4]

V8 엔진은 인터프리터를 사용하는 대신에 자바스크립트 코드를 머신 코드로 번역한다. Just In Time[5] 컴파일러를 구현함으로써 코드를 실행 시에 자바스크립트 코드를 머신 코드로 컴파일하는데 이 과정에서 바이트코드와 같은 중간 코드를 생산하지 않는다.

V8엔진은 내부적으로 여러 개의 스레드를 사용한다. 우선 메인 스레드는 코드를 가져와 컴파일하고 실행한다. 이때 컴파일을 위한 별도의 스레드가 있어서 컴파일을하며 최적화하는 동안 메인 스레드는 다른 일을 할 수 있다. 또 다른 한편으로 프로파일러 스레드가 작동하면서 사용자가 무슨 메소드에서 오랜 시간을 보내는지 런타임에게 알려주어 크랭크샤프트가 이를 최적화할 수 있도록 해준다. 이외에도 가비지 컬렉터를 위한 몇 개의 스레드가 있다.

사실 대부분의 최적화는 크랭크샤프트가 자바스크립트의 추상 구문을 그래프 형태로, 또 최적화하는 과정을 거친다.

하지만 2017년 V8 version 5.9 이후로는 Ignition(Interpreter), TurboFan(Optimizing Compiler) 기반으로 새로운 컴파일 파이프라인을 구축하면서 크랭크샤프트는 더이상 쓰이지 않고 TurboFan이 그 자리를 대신한다.

5.9 버전 이후의 V8 엔진은 자체 Parser를 이용해 먼저 Abstract Syntax Tree를 만든다. 그리고 Ignition, 인터프리터가 그로부터 바이트코드를 생성한다. TurboFan은 이 바이트코드로부터 머신 코드를 만들어낸다. 한마디로 V8은 실행 바로 전에 JIT 컴파일을 이용해 곧바로 머신 코드로 컴파일하는 셈이다.

컴파일된 코드는 런타임에서 다양하게 최적화되는 과정이 이루어진다. 대표적으로 "인라이닝"이 있다.

Inlining(인라이닝)은 말 그대로 최대한 많은 코드를 인라인으로 바꾸는 것을 말한다. 예를 들어, 다른 곳에 있는 함수를 호출하는 코드가 있다면 그 구문을 해당 함수의 내용으로 바꾸는 것과 같은 작업을 말한다.

인라이닝에 이어서 자바스크립트에서 자바나 C# 등과는 다르게 컴파일하는 방식은 히든 클래스이다.

자바스크립트는 프로토타입 언어이다. 즉 클래스라는 것은 없고 객체는 프로토타입 체이닝을 통한 복제로 생성된다. 또한 자바스크립트는 동적 언어이기 때문에 객체가 생성된 이후에도 속성을 쉽게 추가하거나 삭제할 수 있어야 한다.

대부분의 자바스크립트 인터프리터는 해쉬 함수를 이용해 객체의 속성 값을 저장한다. 동일한 방식을 사용하는 자바나 C#의 경우에는 런타임에 기존에 생성된 객체의 속성값을 추가하거나 삭제하는 것이 불가능하다.

따라서 자바스크립트는 런타임에서의 자유로움을 위해 "히든 클래스"를 사용한다. 히든 클래스는 자바나 C#에서 사용하는 고정 객체 레이아웃과 매우 비슷하지만 런타임에 생성된다는 차이점을 갖고 있다.

예를 들어,

function Point(x, y) {
    this.x = x;
    this.y = y;
}

let p = new Point(1, 2);

위와 같은 객체를 생성했다고 가정하면 V8은 런타임에 C0이라는 히든 클래스를 만든다.

첫번째 구문인 this.x = x;가 실행되면 V8은 C0을 기반으로 C1이라는 히든 클래스를 만든다. 여기서 C1에는 Point의 x 속성을 찾을 수 있는 메모리 상의 위치에 대한 정보가 들어있다.

위의 경우에는 오프셋0에 저장되는데 이는 연속된 buffer로서 해당 메모리의 Point 객체를 읽을 때 첫번째 오프셋이 x속성에 대응한다는 것을 의미한다.

그리고 만약 x 속성이 Point 객체에 추가된다면 V8은 C0에서 C1으로 히든 클래스를 전환한다.

다시 말해 객체에 새로운 속성이 추가될 때마다 V8은 자동으로 히든 클래스를 업데이트 하면서 최적화를 진행핸다는 의미이다.

한가지 생각해볼 것은, 여기서의 히든 클래스를 업데이트하는 과정은 런타임에서의 동적 할당의 순서와 매우 밀접한 관련이 있다는 것이다. 예를 들어, 특정한 객체에 같은 속성을 추가하는 두 가지의 다른 경우가 순차적으로 벌어진다고 할 때, 추가되는 속성의 해쉬값은 같지만 순서가 다르기 때문에 서로 다른 히든 클래스를 생성하게 된다. 그러므로 불필요한 메모리 누수가 발생할 수 있다. 예시:

function Point(x, y) {
    this.x = x;
    this.y = y;
}

let p1 = new Point(1, 2);
let p2 = new Point(3, 4);

p1.a = 1;
p1.b = 2;

p2.a = 3;
p2.b = 4;

V8은 인라인 캐싱이라는 것도 활용한다.

만약 동일한 히든 클래스에서 동이한 메소드가 두번 이상 호출될 경우, V8은 해당 객체 포인터에 속성 오프셋을 더한다. 이후 해당 메소드가 호출되면 V8은 히든 클래스를 찾아 떠나지 않고, 포인터에 더해져있는 속성 오프셋을 통해 직접 메모리 주소로 이동한다.

Node.js "http" module

node.js에서 HTTP를 사용하기 위해서는 http 모듈을 사용해야 한다. 해당 모듈은 기존에 사용하기 까다롭고 어려웠던 http에 관한 프로토콜들을(예를 들어, 큰 스트림 데이터, chunk-encoded-data) 손쉽게 사용할 수 있도록 여러 기능을 지원한다.

  • http의 기본 생김새는 이러하다

    Properties

    • http.METHODS
    • http.STATUS_CODES
    • http.globalAgent

    Methods

    • http.createServer()
    • http.request() * http.get()

    Classes

    • http.Agent
    • http.ClientRequest
    • http.Server
    • http.ServerResponse * http.IncomingMessage
  • http.ClientRequest

    http.request(), http.get()가 호출될 때 반환되는 객체이다.

    이 겍체는 큐에 들어가 현재 진행 중인 요청을 나타낸다. 헤더는 setHeader(name, value), getHeader(name), removeHeader(name)API를 이용해서 변경이 가능하다. 실제 헤더는 첫번째 data-chunk, 혹은request.end()가 호출될 때 전송된다.

    응답을 받기 위해 request 객체에 "listener"를 추가해야 한다. "response"의 headers를 받게 되면 response 이벤트가 발생한다. response 이벤트는 http.IncomingMessage의 인스턴스를 인자로 받아 실행된다.

    response 이벤트가 발생되면 data 이벤트를 수신하기 위해 "response" 객체에 "listener"를 추가할 수 있다.

    만약 event handler가 추가되지 않았으면 "response"는 버려진다. 하지만 "response"에 event handler가 달리면 "response" 객체로부터의 데이터를 읽을 수 있다. 데이터를 읽어들이지 않는다면 메모리를 지속적으로 소비하게 되어 process out of memory 에러가 발생할 수 있다.

    request 객체와는 달리 "response"가 불안정하게 종료될 경우, "response" 객체는 "error"를 반환하지 않고 "aborted" 이벤트가 발생된다.

  • http.ServerResponse

    이 객체는 HTTP server에 의해 내부적으로 생성된다. request 이벤트의 두번째 인자로 전달된다.

HTTP Mehod

HTTP는 주어진 리소스에 대해 어떠한 행동을 할 것인지를 가리키기 위한 요청 메소드를 갖고 있다.

명사도 있지만 REST에서는 주로 동사로서 HTTP의 액션을 정의하는데 사용된다.

  • GET

    우선 GET은 데이터를 읽는데 사용되는 요청이다. 요청을 보낼 때 body가 없으며 만약 성공적으로 응답이 올 경우 body에 데이터가 들어있다. 캐시가 가능하다.

  • POST

    POST는 서버로 데이터를 전송할 때 사용된다. body의 타입은 요청의 헤더에 Content-Type에 명시되어 있다.

    POST와 PUT의 차이점은 PUT은 멱등성을 가진다는 것이다. 쉽게 말해 연산을 PUT 요청을 여러번 보내더라도 그 결과가 달라지지 않는 다는 의미이다. 그러나 POST의 경우에는 일종의 Side Effect가 일어날 수도 있다.

    POST 요청은 기본적으로 HTML form을 통해 전송되고, 서버에서 응답을 받는 구조이다. 이럴 경우 <form> 태그의 enctype에 따라 content-type이 결정된다.

    Content-Type은 여러가지가 있지만 우선 몇 가지만 살펴보면,

    application/x-www-form-urlencoded : key, value가 &로 구분되고, =로 나뉘어진 key-value 튜플로 인코딩된다. 만약 문자나 숫자가 아닐 경우, key-value는 모두 percent-encoding 된다. 그래서 바이너리 데이터에 해당 타입을 적용하면 안 되는데, 바이너리 데이터의 경우, multipart/form-data를 사용해야 한다.

    multipart/form-data : 각각의 value는 user-agent에서 정한 경계 기호로 나뉘어진 데이터의 블록으로 전달된다. 해당 키는 Content-Disposition에서 지정한다.

  • Other HTTP Methods

    • PATCH: Patch는 해당 리소스에 대한 부분적인 변화를 의미한다.
    • PUT: PUT은 새로운 리소스를 생성하거나, 기존에 있던 타켓 리소스를 request의 payload로 교체하는 것을 말한다.
    • DELETE: DELETE은 지정된 리소스를 삭제하는데 사용되는 메소드이다.
    • CONNECT: CONNECT는 양방향 연결에 사용된다. 이를 이용해서 SSL(HTTPS)에 접속할 때 사용할 수 있다. 예를 들어 클라이언트가 HTTP Proxy 서버로 원하는 목적지로 TCP 터널을 연결해달라는 요청을 보낼 수 있다.
    • OPTIONS: OPTIONS는 타겟 리소스에 대한 커뮤니케이션 옵션들을 나타낼 때 사용된다.

Content-Type

Content-Type는 리소스의 미디어 타입이 무엇인지 알려주는 역할을 한다.

response에서 Content-Type는 클라이언트에게 응답해주는 콘텐츠의 미디어 타입이 무엇인지 알려준다. 브라우저는 특정 경우에 MIME sniffing을 하고, 반드시 해당 헤더 값을 따르지는 않는다. 만약 이 작동을 막고 싶다면 헤더의 X-Content-Type-Options 값에 nosniff로 설정해두면 된다.

request(예: POST, PUT)에서는 클라이언트가 서버에게 전송하는 데이터의 타입을 명시해둔다.

Content-Type에 사용되는 주요 MIME은 다음과 같다.

text/plain
text/html
image/jpeg
image/png
audio/mpeg
audio/ogg
audio/*
video/mp4
application/octet-stream

기본적인 구분은 다음과 같다.

text: 모든 종류의 텍스트를 포함하는 모든 문서
image: 모든 종류의 이미지(gif와 같은 애니매이션 이미지는 포함)
audio: 모든 종류의 오디오 파일
video: 모든 종류의 비디오 파일
application: 모든 종류의 이진 파일

Postman에서 요청을 보낼 때, body에 어떠한 데이터를 담아서 전송할 경우 해당 데이터의 타입을 명시해주어야 한다. form-data, x-www-form-urlencoded, raw, binary와 같은 것들은 그에 대한 명시이다.

form-data는 multipart/form-data를 말한다. --를 구분으로 여러 개의 다양한 타입의 데이터를 함께 전송할 수 있다.
x-www-form-urlencoded는 key와 value 형태로 이루어져 &로 구분된 하나의 긴 텍스트 형태이다.
raw는 텍스트의 형태로 작성할 수 있는 모든 형태의 데이터를 말한다. 예를 들어, json, html, javascript, xml
binary는 파일을 전송할 때 사용된다.


[1] Thread: 프로그램 내, 프로세스 내에서 실행되는 흐름의 단위를 말한다. 일반적으로 한 프로그램은 하나의 스레드를 가지고 있다. 하지만 프로그램 환경에 따라 둘 이상의 스레드를 동시에 실행할 수 있다. 이러한 실행 방식을 멀티스레드라고 한다.

[2] Context Switch: 문맥 교환, 하나의 프로세스가 CPU를 사용 중인 상태에서, 다른 프로세스가 CPU를 사용하도록 하기 위해, 이전의 프로세스의 상태(문맥)을 보관하고 새로운 프로세스의 상태를 적재하는 작업. 한 프로세스의 문맥은 그 프로세스의 프로세스 제어 블록에 기록되어 있다. 문맥 교환이 일어나는 동안에는 유용한 적업을 수행할 수 없고 일종의 오버헤드가 발생한다.

[3] Observer Pattern: 옵서버 패턴은 객체의 상태 변화를 관찰하는 옵저버의 목록을 객체에 등록하고 상태 변화가 있을 때마다 메소드 등을 통해 객체가 직접 목록의 각 옵저버에게 통지하도록 하는 디자인 패턴이다. 주로 이벤트 핸들링 시스템을 구현하는데 사용된다.

[4] call stack: 자바스크립트는 싱글 스레드 프로그래밍 언어이다. 다시 말해 콜스텍이 하나라는 이야기이다. 따라서 스텍이 일하는 방식에 따라서 한번에 한가지 일만을 할 수 있다. 콜스텍의 각각은 스택프레임이라고 한다. 어떠한 스텍프레임에서 예외가 발생하게 되면 스텍 트레이스가 일어난다. 스텍이 쌓인 순서를 추적하는 것을 말한다. 만약 콜스텍에 스텍이 가득차서 넘치는 상황, 즉 stack overflow가 일어나게 되면 어떻게 될까. 이럴 경우, blowing the stack이 일어나게 된다. 멀티 스레드 환경에서는 dead lock과 같은 것에 대처해야 하는 것과는 대조적으로 단일 스레드이기 때문에 상대적으로 쉽게 대처할 수 있다.

[5] Just In Time: C 언어나 C++처럼 실행하기 전에 한번 컴파일 하는 것이 아니라 프로그램을 실행하는 시점에서 필요한 부분을 컴파일하는 방식이다. 인터프리터의 성능 향상을 위해 사용되는 경우가 많은데 한번 인터프리트 하면서 자주 쓰이는 코드의 기계어를 캐싱해두고 반복해서 사용하기 때문에 속도가 빠르다. 이와 다른 컴파일러는 Static-Compilation, Ahead-Of-Time-Compilation이다.

0개의 댓글