본문으로 건너뛰기

Node.js 아키텍처 분석

무료2020-04-26#Node#Nodejs C++ Bindings#Nodejs运行原理#Nodejs底层实现#Nodejs事件队列#Nodejs与libuv

매일 사용하고 있는 Node.js, 어떻게 구현되었는지 생각해 보셨나요?

1. Node.js가 만든 전설

I have a job now, and this guy is the reason why I have that now. His hobby project is what I use for living. Thanks. —— Shajan Jacob

2009년 Ryan Dahl은 JSConf EU 컨퍼런스에서 Node.js를 발표했습니다. 초기에는 비동기 모델을 통해 전통적인 웹 서버의 고동시성(High Concurrency) 병목 현상을 돌파하고자 했으며, 이후 점차 성숙해지고 응용 범위가 넓어지면서 번영하는 Node.js 생태계가 형성되었습니다.

Node.js를 통해 브라우저 밖으로 나온 JavaScript 언어는 걷잡을 수 없이 발전했습니다.

Any application that can be written in JavaScript, will eventually be written in JavaScript. —— Jeff Atwood

(출처: The Principle of Least Power)

이미 2017년에 NPM은 방대한 커뮤니티 모듈 덕분에 세계에서 가장 큰 패키지 레지스트리(package registry)가 되었으며, 현재 모듈 수는 125만 개를 초과했고 지금도 빠르게 증가하고 있습니다(매일 900개 이상 신규 등록).

심지어 Node.js 엔지니어라는 직군이 새로운 직업으로 자리 잡기도 했습니다. 그렇다면, 전설적인 색채를 띤 Node.js 자체는 어떻게 구현되었을까요?

2. Node.js 아키텍처 개요

JS 코드는 V8 엔진 위에서 실행되며, Node.js에 내장된 fs, http 등의 핵심 모듈은 C++ Bindings를 통해 libuv, c-ares, llhttp 등의 C/C++ 라이브러리를 호출하여 운영체제가 제공하는 플랫폼 기능을 활용합니다.

그중에서 가장 중요한 부분은 V8libuv입니다.

3. 소스 코드 의존성

V8

V8 is Google’s open source high-performance JavaScript and WebAssembly engine, written in C++. It is used in Chrome and in Node.js, among others.

C++로 작성된 JavaScript 엔진으로, Google에서 관리하며 Chrome 브라우저와 Node.js에서 사용됩니다.

libuv

libuv is cross-platform support library which was originally written for Node.js. It’s designed around the event-driven asynchronous I/O model.

Node.js를 위해 특별히 제작된 C 언어 기반의 크로스 플랫폼 비동기 I/O 라이브러리로, 비차단(non-blocking) 파일 시스템, DNS, 네트워크, 자식 프로세스, 파이프, 시그널, 폴링 및 스트리밍 처리 메커니즘을 제공합니다.

운영체제 수준에서 비동기로 처리할 수 없는 작업(예: 파일 I/O, DNS 쿼리 등)은 스레드 풀(thread pool)을 통해 완료합니다. 구체적인 이유는 Complexities in File I/O를 참조하세요.

P.S. 스레드 풀의 용량은 설정 가능하며 기본값은 4개 스레드입니다. 자세한 내용은 Thread pool work scheduling을 참조하세요.

또한, Node.js의 이벤트 루프와 이벤트 큐도 모두 libuv에서 제공합니다.

Libuv provides the entire event loop functionality to NodeJS including the event queuing mechanism.

구체적인 동작 메커니즘은 아래 그림과 같습니다.

기타 의존성 라이브러리

그 외에도 다음과 같은 C/C++ 라이브러리에 의존합니다.

  • llhttp: TypeScript와 C로 작성된 경량 HTTP 파싱 라이브러리로, 이전의 http_parser보다 1.5배 빠릅니다. 시스템 호출이나 메모리 할당을 포함하지 않아(데이터를 캐싱하지도 않음) 요청당 메모리 점유율이 극히 낮습니다.

  • c-ares: 비동기 DNS 요청을 처리하기 위한 C 라이브러리로, Node.js의 dns 모듈이 제공하는 resolve() 계열 메서드에 대응합니다.

  • OpenSSL: 범용 암호화 라이브러리로, 네트워크 전송 시 TLS 및 SSL 프로토콜 구현에 주로 사용되며 Node.js의 tls, crypto 모듈에 대응합니다.

  • zlib: 빠른 압축 및 해제 기능을 제공합니다.

P.S. Node.js 소스 코드 의존성에 대한 더 자세한 정보는 Dependencies를 참조하세요.

4. 핵심 모듈

브라우저가 제공하는 DOM/BOM API처럼, Node.js는 JavaScript 런타임 환경뿐만 아니라 다음과 같은 일련의 플랫폼 API를 확장하여 제공합니다.

  • 파일 시스템 관련: fs 모듈

  • HTTP 통신: http 모듈

  • 운영체제 관련: os 모듈

  • 멀티 프로세스: child_process, cluster 모듈

이러한 내장 모듈을 핵심 모듈이라고 하며, 브라우저 세상을 벗어난 JavaScript에게 손과 발이 되어주었습니다.

5. C++ Bindings

핵심 모듈 아래에는 C++ Bindings 레이어가 있어, 상위의 JavaScript 코드와 하위의 C/C++ 라이브러리를 연결합니다.

하위 모듈은 더 나은 성능을 위해 C/C++로 구현되지만, 상위의 JavaScript 코드는 C/C++와 직접 통신할 수 없으므로 가교(즉, Binding)가 필요합니다.

Bindings, as the name implies, are glue codes that “bind” one language with another so that they can talk with each other. In this case (Node.js), bindings simply expose core Node.js internal libraries written in C/C++ (c-ares, zlib, OpenSSL, llhttp, etc.) to JavaScript.

또한, Bindings를 통해 신뢰할 수 있는 기존의 오픈 소스 라이브러리를 재사용할 수 있으므로 모든 하위 모듈을 처음부터 직접 만들 필요가 없습니다.

파일 I/O를 예로 들어, 현재 JS 파일의 내용을 읽어 표준 출력으로 내보내는 코드는 다음과 같습니다.

// readThisFile.js
const fs = require('fs')
const path = require('path')
const filePath = path.resolve(__filename);

// Parses the buffer into a string
function callback (data) {
  return data.toString()
}

// Transforms the function into a promise
const readFileAsync = (filePath) => {
  return new Promise((resolve, reject) => {
    fs.readFile(filePath, (err, data) => {
      if (err) return reject(err)
      return resolve(callback(data))
    })
  })
}

(() => {
  readFileAsync(filePath)
    .then(console.log)
    .catch(console.error)
})()

여기서 사용된 fs.readFile 인터페이스는 V8이 제공하는 것도, JS 자체 기능도 아닙니다. Node.js가 C++ Binding의 형태로 libuv를 활용해 구현한 것입니다.

// https://github.com/nodejs/node/blob/v14.0.0/lib/fs.js#L58
const binding = internalBinding('fs');
// https://github.com/nodejs/node/blob/v14.0.0/lib/fs.js#L71
const { FSReqCallback, statValues } = binding;

// https://github.com/nodejs/node/blob/v14.0.0/lib/fs.js#L297
function readFile(path, options, callback) {
  callback = maybeCallback(callback || options);
  options = getOptions(options, { flag: 'r' });
  if (!ReadFileContext)
    ReadFileContext = require('internal/fs/read_file_context');
  const context = new ReadFileContext(callback, options.encoding);
  context.isUserFd = isFd(path); // File descriptor ownership

  const req = new FSReqCallback();
  req.context = context;
  req.oncomplete = readFileAfterOpen;

  if (context.isUserFd) {
    process.nextTick(function tick() {
      req.oncomplete(null, path);
    });
    return;
  }

  path = getValidatedPath(path);
  const flagsNumber = stringToFlags(options.flags);
  binding.open(pathModule.toNamespacedPath(path),
              flagsNumber,
              0o666,
              req);
}

마지막의 binding.open은 파일 서술자(file descriptor)를 열기 위한 C++ 호출입니다. 세 개의 인자는 각각 파일 경로, C++ fopen의 파일 접근 모드 문자열(예: r, w+), 8진수 형식의 파일 읽기/쓰기 권한(666은 모든 사용자에게 읽기/쓰기 권한 부여), 그리고 반환 데이터를 받는 req 콜백입니다.

여기서 internalBinding은 C++ binding loader이며, internalBinding('fs')가 실제로 로드하는 C++ 코드는 node/src/node_file.cc에 위치합니다.

이제 핵심적인 부분들은 거의 파악되었습니다. 그렇다면, 한 줄의 Node.js 코드는 실제로 어떻게 실행될까요?

6. 동작 원리

먼저, 작성된 JavaScript 코드는 V8 엔진에 의해 실행됩니다. 실행 중에 등록된 이벤트 리스너는 보존되며, 해당 이벤트가 발생할 때 알림을 받습니다.

네트워크나 파일 I/O 등의 이벤트가 발생하면, 등록된 콜백 함수가 이벤트 큐에 줄을 섭니다. 그 후 이벤트 루프에 의해 꺼내져 콜 스택(call stack)에 놓이게 됩니다. 콜백 함수 실행이 끝나면(콜 스택이 비워지면) 이벤트 루프는 다시 다음 함수를 꺼내 올립니다.

실행 과정에서 I/O 작업을 만나면 libuv 스레드 풀의 특정 워커(worker)에게 처리를 맡깁니다. 작업이 끝나면 libuv는 이벤트를 생성하여 이벤트 큐에 넣습니다. 이벤트 루프가 해당 이벤트를 처리할 차례가 되면 대응하는 콜백 함수가 메인 스레드에서 실행되기 시작하며, 메인 스레드는 그동안 다른 작업을 계속 수행하며 비차단 상태를 유지합니다.

Node.js는 마치 카페와 같습니다. 가게에는 서빙 담당(메인 스레드)이 한 명뿐입니다. 많은 손님이 몰려오면 줄을 서서 기다립니다(이벤트 큐 진입). 차례가 된 손님의 주문은 매니저(libuv)에게 전달됩니다. 매니저는 주문을 바리스타(worker 스레드)에게 할당하고, 바리스타는 다양한 재료와 도구(하위 의존 C/C++ 모듈)를 사용하여 주문받은 커피를 만듭니다. 보통 4명의 바리스타가 근무하며, 바쁠 때는 인원이 추가될 수도 있습니다. 주문을 매니저에게 전달한 후 서빙 담당은 커피가 나올 때까지 기다리지 않고 다음 주문을 처리합니다. 커피가 완성되면 출고 라인(IO Events 큐)에 놓이고, 카운터에 도달하면 서빙 담당이 이름을 부르고 손님이 커피를 가져갑니다.

참고 자료

댓글

아직 댓글이 없습니다

댓글 작성