Node.js 아키텍처 및 동작 분석

1ilsang
클라이밍 하실래염?
#nodejs#v8#libuv#undici#event-loop
Published
cover

작년 오픈소스 컨트리뷰션 Node.js에 참여하면서 노드의 동작 방식을 많이 이해할 수 있었다.

이번 포스트는 Node.js의 구조와 내부 의존성의 존재의의 및 연결성을 정리해 보려고 한다.

글의 구성은 아래의 3단계를 거쳐나갈 예정이다.

  1. Node.js가 무엇인지
  2. 전체 아키텍처와 구성 요소
  3. 실제 예제 코드로 동작 흐름 이해

Let's Dive In!

TL;DR!

  1. Node.js는 JavaScript 런타임 "환경"이다.
  2. V8 엔진과 libuv 라이브러리를 사용하여 비동기, 이벤트 기반의 네트워크 앱이 되었다.
  3. 내부 모듈은 JS/C++ 페어로 작성되어 있다.

Node.js 이해하기

Node의 구조를 깊이 있게 이해하기 위해 만들어진 배경과 해결하고자 한 문제를 알아보자. Node.js는 왜/어떻게 탄생했을까?

당시의 상황

ryan-dahl

Node.js: The Documentary | An origin story - YouTube

Node.js의 역사는 2009년 Ryan DahlJSConf EU 컨퍼런스에서 발표하면서 시작되었다. 당시의 상황을 이해해 보자.

Dahl은 야후의 사진 공유 커뮤니티 Flickr에 파일 업로드 진행이 얼마나 되었는지 알기 위해 서버에 쿼리를 전송하는 과정을 보고 확장성이 뛰어난 실시간(동시성) 웹 서버에 대해 고민하게 된다.

당시의 Apache HTTP Server와 같은 환경에선 많은 수의 동시 연결을 처리하는 데 어려움이 있었다. 리퀘스트 하나당 하나의 컨텍스트, 멀티 프로세스/쓰레드 방식이므로 다른 컨텍스트에 개입하기 힘들었다. 하나의 작업이 끝날 때까지 기다리는 것(blocking)이 보편적이었던 시대였다.

Node는 이 문제에서 탄생했다. 어떻게 두 가지를 동시에 처리할 수 있을까? 모든 것을 비동기로 처리하고 IO를 기다리지 않는다면(non-blocking) 어떨까?

출처:

Node.js란?

As an asynchronous event-driven JavaScript runtime, Node.js is designed to build scalable network applications.

공식 설명에서 한 줄 요약이 잘되어 있다. 각 키워드를 가볍게 살펴보자.

Asynchronous
  • 비동기는 작업을 기다리지 않는다
Event-driven
  • 이벤트 기반은 이벤트 수신(listen)과 발신(emit)으로 작업 완료 타이밍을 알려준다
JavaScript runtime
확장 가능한 네트워크 앱

즉, Node.js는 작업을 기다리지 않고 이벤트로 처리하는 JavaScript 런타임 환경을 제공하는 네트워크 앱이다.

Browser와의 차이점

browser vs node

브라우저는 WWW의 모든 정보를 보고 상호작용할 수 있는 애플리케이션이다(JavaScript는 페이지의 동적인 작업을 위해 개발된 언어이다). 또한 브라우저는 샌드박스 환경에서 실행되므로 JavaScript로 파일 시스템 접근이나 네트워크 작업에 제한이 있다.

V8 엔진이 나타나면서 브라우저 밖에서도 JavaScript를 실행시킬 수 있게 되었다. 이 덕분에 샌드박스 환경 및 UI와 렌더링 엔진이 없는, 운영 체제와 더 밀접한 앱들이 등장할 수 있었다. Node.js가 그러한 앱 중 하나이다.

새로운 앱인 만큼 내부 구현체들도 사뭇 다르다. 대표적인 예로 이벤트 루프의 구현체를 보면, Chrome에서는 libevent를 사용(Safari는 자체 구현)하고 있으며 Node.js에서는 libuv를 사용한다.

NOTE> 이벤트 루프의 웹 표준은 WHATWG권고하고 있지만 강제성은 없다.

이와 같은 실행 환경 및 구현체의 차이는 window vs global 부터 WebAPIN-API 등의 다양한 차이를 만들어냈다.

아키텍처 살펴보기

이제 Node.js의 아키텍처를 확인하고 내부 구현체들을 살펴보자.

한눈에 보기

node architecture

주요 구성 요소

요소설명
V8
- 자바스크립트 코드를 바이트 코드로 변환 및 실행하는 엔진
- 노드에서 V8은 격리(Isolate)된 상태로 구현돼 있으며 자체 메모리 공간을 가지고 있다
  - Worker Thread가 독립적인 실행이 되는 이유
libuv
- 비동기 I/O, 이벤트 루프, 스레드 풀 등을 제공
- V8에서 처리할 수 없는 작업을 진행(ex. 네트워크, 파일 시스템 접근 등)
C/C++ Addons
- 성능, 확장성 등의 이유로 C/C++ 코드가 필요할 경우 애드온으로 작성해 노드에서 실행시킬 수 있다
- 노드에서 제공하는 Node-API 인터페이스를 활용하면 쉽게 만들 수 있다
Builtin Modules
- 노드에 내장된 자바스크립트 코드
- 개발자가 직접 사용하는 모듈(fs, path, http 등) 및 내부용 코드가 존재
Bindings
- 자바스크립트 코드에서 사용되는 API를 C++로 구현한 것
- V8, libuv, C/C++ 애드온 등과 연결되는 인터페이스 역할을 한다
- 대부분의 모듈이 JS/C++ 페어로 구성되어 있다
Other Deps
- Node.js가 의존하는 외부 모듈

동작 흐름

deps

https://www.mwanmobile.com/node-js-animated-event-loop

$ node FILE.cjs
  1. Node.js는 최초 부트스래핑 이후 유저가 지정한 애플리케이션(FILE.cjs)의 자바스크립트 코드를 읽어온다.
  2. 앱 코드는 V8에서 변환되어 실행 혹은 Bindings 영역을 거치며 필요한 내부 라이브러리 호출을 진행한다.
  3. 이때 비동기 작업이 필요하다면 libuv로 내부 호출이 일어나게 된다.
  4. 비동기 작업이 완료된다면 이벤트 루프의 페이즈에 맞춰 완료된 이벤트(callback)가 실행된다.
  5. 모든 작업이 완료되면 이벤트 루프가 종료되면서 앱이 종료된다.

코드로 이해하는 Node.js

간단한 서버 앱을 통해 노드를 이해해 보자.

// hello.cjs
const fs = require('fs');
const http = require('http');
const server = http.createServer();
 
server.on('request', async (req, res) => {
  if (req.url === '/') {
    fs.readFile('hello.txt', 'utf-8', (err, data) => {
      const json = JSON.stringify({ data });
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.write(json);
      res.end();
    });
  }
});
server.listen(3001, () => console.info(`Server is running...`));
hello text file

특정 요청이 올 때마다 파일을 읽어 반환하는 코드를 분석해 보자.

Bootstrapping

$ node hello.cjs

Node.js 또한 애플리케이션이기 때문에 다른 앱과 마찬가지로 실행될 때 부트스트래핑 과정이 먼저 진행된다.

bootstrapping init

복잡해 보이지만, 크게 2단계로 나눌 수 있다.

  1. Node.js 프로세스 시작(C++ Land)
    • main 함수 실행
    • V8 및 libuv 초기화
    • Realm(실행 컨텍스트) 생성 및 실행 환경 설정
  2. StartExecution(JavaScript Land)

여기서 우리는 run_main_module.js를 통해 hello.cjs가 실행되는 것을 확인할 수 있다. 바로 밑에서 나오지만, 아직 이벤트 루프가 실행되지 않았다.

bootstrapping event loop

이후 SpinEventLoop(C++ Land)를 호출해 libuv의 uv_run이 실행되며 이벤트 루프가 시작된다.

노드는 부트스트래핑 과정에서 C++과 JavaScript의 경계를 넘나들며 실행된다. 이 과정은 Bindings를 통해 이루어지며 C++에서 JavaScript로, JavaScript에서 C++로 호출이 가능하다.

NOTE> Node.js는 제공된 파일을 한 번 읽고 이벤트 루프를 실행시킨다.

Request(Socket) listen

server.on('request', ...);

이벤트 루프의 poll phase에서 유저의 요청(localhost:3001/)을 인지한다. 콜백 이벤트를 실행시켜야 하므로 C++ TCP Land에서 JavaScript Land로 이동해야 한다.

이 과정은 Binding 함수인 HTTPParser을 이용한다.

  1. 새로운 연결이 될 때마다 HTTPParser 객체가 생성된다.
  2. HTTPParser 객체는 C++ 코드에서 Binding 하고 있다.
  3. 작업이 완료되면 콜백이 실행된다.

File I/O

server.on('request', async (req, res) => {
  // ...
    fs.readFile('hello.txt', 'utf-8', (err, data) => {

위에서 소켓 연결과 콜백 실행을 확인했었다. request 콜백 내부에는 파일을 읽는 코드가 있다.

V8은 파일 작업을 할 수 없기 때문에 libuv가 필요하다.

  1. 콜백 실행 도중 bindings 영역을 거쳐 다시 libuv에 파일 읽기를 요청하게 된다.
  2. libuv는 파일이 완료되면 이벤트 루프(poll phase)에 결과를 전달한다.
  3. 이후 AfterInteger 함수를 통해 libuv에서 전달받은 값을 기반으로 readFile 콜백을 실행하게 된다.

Response

fs.readFile('hello.txt', 'utf-8', (err, data) => {
  // ...
  res.write(json);
  res.end();
});

readFile 콜백을 실행하면 파일을 읽은 결과를 res 객체에 담아 클라이언트에게 응답을 보내게 된다.

마찬가지로 V8은 네트워크 작업을 할 수 없기 때문에 libuv가 필요하다.

  1. res.write

  2. res.end

마무리

이번 포스트에서는 Node.js의 아키텍처와 동작 방식을 살펴보았다.

  • Node.js는 V8 엔진과 libuv 라이브러리를 사용하여 비동기, 이벤트 기반의 네트워크 애플리케이션을 만들 수 있는 JavaScript 런타임 환경이다.
  • C/C++ 애드온을 통해 성능과 확장성을 높일 수 있으며, 다양한 내장 모듈을 제공하여 개발자들이 쉽게 사용할 수 있도록 돕는다.

꽤나 어려운 내용이었지만, 노드의 동작 방식을 이해하는 데 도움이 되었기를 바란다.

다이빙 과정이 흥미로웠다면 아래의 글도 추천한다.

📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍