Back to the Basics

NodeJS의 내부 구성과 동작에 대하여 알아보자 본문

Programming Languages/JavaScript & Node.js

NodeJS의 내부 구성과 동작에 대하여 알아보자

9Jaeng 2024. 12. 22. 23:39
728x90

NodeJS 개발자로 공부를 하면서 정리했던 것을 다시 상기하기 위해 정리해보았다. 

하나의 블로그 글로 정리를 할 수 있는 양일까 싶지만 최대한 간략하게 정리해보려고한다..!

(글을 쓰고 보니 간략하지는 않다)

목차는 아래와 같다

1. NodeJS의 기본 구조와 기본 실행 흐름

2. NodeJS의 libuv 조금 더 자세히 - Event loop

3. 그 외 microTaskQueue, nextTickQueue 에 대하여

4. 마치며

1. NodeJS의 기본 구조와 기본 실행 흐름

NodeJs의 구조는 크게 아래와 같이 구성되어 있다 

Nodejs의 구조

일단 각각  어떤 기능을 하는지 간단하게 확인해보자

V8 엔진 : 자바스크립트 코드를 해석하고 실행하는 구글에서 개발한 엔진이다. JIT(Just-In-Time) 컴파일러를 활용하여 자바스크립트를 고속으로 실행한다. v8

Libuv : OS 커널의 기능에 접근하여 파일 시스템, 네트워크 요청, 타이머와 같은 작업을 처리한다.

아래에서 조금 더 살펴볼 것이지만 설명을 조금 덧붙이자면, NodeJS의 비동기 작업은 Libuv가 제공하는 이벤트 루프(Event Loop)를 통해 이루어지며, 이 루프는 OS와 상호작용하여 싱글쓰레드에서 논 블로킹 비동기 작업을 가능하게 한다. 그리고 Libuv는 자체 쓰레드풀을 갖고 있어서 이를 활용하기도 한다. libuv

V8 엔진과 Libuv는 모두 C++로 작성되어 있으며, NodeJS는 내부적으로 이 두 요소를 사용하여 동작한다. 또한,NodeJS는 자바스크립트 코드를 C++ 코드와 연결하기 위한 바인딩(bindings)를 통해 다양한 OS 기능을 프로그래머가 자바스크립트로 활용할 수 있도록 한다.

즉, NodeJS는 크게 V8엔진, libuv 그리고 이 둘을 자바스크립트 코드에서 사용할 수 있도록 하는 내장 라이브러리, bindings로 구성된다.

그리고 이런 여러 구조들은 서로 상호작용하며 아래와 같이 자바스크립트 코드를 실행한다. 이 과정을 조금 단순화하여 아래와 같이 단계별로 실행된다.

NodeJs 기본 실행 흐름

1.  애플리케이션에서 자바스크립트가 실행되면 V8 엔진은 자바스크립트 코드를 머신 코드로 JIT(Just-In-Time) 컴파일러를 통해 전부 바이트코드로 변환한 뒤, 콜스택에 하나씩 쌓아 실행한다.

2. 실행 중 비동기 코드를 만나면(네트워크 요청, 파일 읽기, 데이터베이스 작업 등) V8엔진은 NodeJS API를 통해 작업을 처리하려고 시도하고 Bindings를 사용하여 Libuv에 해당 작업을 위임한다.

3. 비동기 작업은 Libuv의 이벤트루프에 의해 관리된다. 비동기 요청이 오면 OS커널이 처리 가능한 작업이라면 커널에 위임하거나 처리 불가능한 작업인 경우 별도로 관리하는 쓰레드풀의 워커 쓰레드를 사용하여 작업을 하도록 한다. 커널 또는 워커 쓰레드에서 작업이 완료되면 이벤트 큐(Event Queue)에 결과를 추가한다. (비동기 함수에 대한 콜백 함수가 추가된다) 

참고로 libuv는 작업을 다른 워커쓰레드 또는 OS커널에 작업을 넘기기 때문에 단일 쓰레드가 블로킹되지 않고 다른 작업을 처리할 수 있게 해준다. 이런 특징으로 싱글쓰레드 논블로킹 비동기작업이 가능하다. (사실 다른 쓰레드를 사용하기 때문에 완전 단일 쓰레드는 아니긴 한 것 같지만)

4. 이벤트 루프는 루프를 돌면서 이벤트 큐에 대기 중인 작업을 확인하고, 완료된 작업에 대해 콜백을 실행하여(콜스텍에 쌓고 V8엔진이 실행) 결과를 애플리케이션으로 반환한다.

실제 코드 내부는 어떻게 되어있는지도 확인해 보자.

파일을 읽고 쓸 때 사용하는 NodeJS의 내장 모듈 중 하나인 fs모듈을 확인해 보았다.

readFile 코드는 내부적으로 bindings.open을 호출하고

fs.readFile

writeFile 코드는  내부적으로 writeAll을 호출하는데 결과적으로 fs.write을 호출한다.

fs.writeFilw

function write 은 내부적으로 bindings.writeBuffer를 호출한다.

위의 코드들을 보면 bindings의 어떤 함수를 호출하고 있다. 그럼 저 bindings들은 어떻게 구현되어 있는지 확인해 보자

bindings.open , bindings.writeBuffer를 확인해 보면 c++코드이고 각각 내부적으로 uv_로 시작되는 함수들을 호출한다

uv_로 시작되는 함수는 libuv함수를 의미한다.

// open

static void Open(const FunctionCallbackInfo<Value>& args) {
  ...
  ...
  // 여러 코드들
    FS_ASYNC_TRACE_BEGIN1(
        UV_FS_OPEN, req_wrap_async, "path", TRACE_STR_COPY(*path))
    AsyncCall(env, req_wrap_async, args, "open", UTF8, AfterInteger,
              uv_fs_open, *path, flags, mode);
    ...
    ...
}


// writeBuffer

static void WriteBuffer(const FunctionCallbackInfo<Value>& args) {
  ...
  ...
  // 여러 코드들
    int bytesWritten = SyncCall(env, args[6], &req_wrap_sync, "write",
                                uv_fs_write, fd, &uvbuf, 1, pos);
  ...
  ...
  }
}

즉, bindings는 JavaScript 코드와 C++ 네이티브 모듈을 연결하여 libuv와 같은 네이티브 라이브러리를 실행한다.

그리고 위의 코드가 구현된 링크를 들어가 맨 위를 확인해보면 아래와 같이 v8 관련 코드도 사용하고 있음을 알 수 있다.

지금까지의 내용을 통해 NodeJsd의 구조와 실행흐름을 간단하게 정리하자면 아래와 같다

1. 자바스크립트 코드 → V8 엔진(코드 실행 및 콜스택 처리)

2. Node.js API 호출 → Bindings → C++ 네이티브 코드로 연결

3. C++ 네이티브 코드 → Libuv(비동기 작업 처리)

4. Libuv → OS 레벨 / 워커쓰레드 작업 위임 및 수행

5. 작업 완료 → 이벤트 루프(Event Loop) → 콜백 실행 및 결과 반환

참고로 V8엔진은 자바스크립트 코드만 바이너리 파일로 변환한다. 그럼 저 C++코드들은 언제 컴파일될까?

알아보니 NodeJs 설치 또는 실행 시 NodeJs의 빌드 과정에서 node.gpy 의 파일(C++ 소스 파일 목록과 빌드 설정이 포함된 메인 빌드 스크립트)을 빌드도구 (gyp 등)으로 컴파일하여 실행 가능한 바이너리를 미리 생성해둔다고 한다. 컴파일된 결과물은 NodeJs 실행 파일에 포함되고 자바스크립트 코드에서 호출될 때 이미 살행 가능한 상태로 준비된다고 한다.

또, NodeJs 성능 등을 위해 사용자 정의 c++ 모듈을 만들어 사용할 수도 있다. 이를 위해 NodeJS 애드온 이라는 것을 제공한다.

이를 사용하여 사용자 정의 모듈을 만들 수 있다!!! 알아두면 좋을 것 같은데, 과련해서 조금 더 공부를 해봐야겠다. 참고블로그

 

2. NodeJS의 Libuv 조금 더 자세히 - Event Loop

NodeJS의 비동기 처리는 libuv 내부의 event loop를 사용하여 처리 된다고 했다. NodeJS는 비동기를 기반으로 하기 때문에 사실상 대부분이 비동기 작업이라고 할 수 있다. 그렇기 때문에 libuv의 event loop는 NodeJS의 핵심 기능이라고 할 수 있다. 

이 핵심 기능이 어떻게 진행되는지 조금 더 자세히 들여다보자.

Event-Driven Programming

NodeJs의 Event Loop는 이벤트 기반 프로그래밍(Event-Driven Programming) 모델에 따라 동작한다.
이벤트 기반 프로그래밍이란 특정 이벤트가 발생했을 때 이벤트 유형을 확인하고 해당 이벤트를 처리하기 위한 핸들러(콜백 함수)를 호출하는 방식을 말한다.

계속해서 발생하는 이벤트(HTTP 요청, 파일 읽기 등)를 감지하여 처리한다. 이를 while 또는 for 등과 같은 반복문을 사용하는 구조로 동작하고 이를 이벤트루프(Event Loop)라고 부른다

이벤트 기반 프로그래밍(Event-Driven Programming)

NodeJS의 이벤트 루프도 이벤트를 받아 해당 이벤트와 연결된 핸들러(콜백 함수)를 실행하는 메커니즘을 갖는다.

NodeJS의 이벤트 루프 구조 간단화

 libuv로 비동기 요청이 들어오면 파일 I/O과 같은 요청은 쓰레드풀로(Thread Pool), 네트워크 요청, Database 요청 등의 작업은 운영체제의 커널로 전달된다. 

작업이 완료되면 쓰레드풀 또는 커널에서 Event를 보내고 관련된 콜백함수가 Event Queue에 등록된다.

그리고 Event loop는 loop를 돌면서 큐에서 작업 완료된 콜백을 확인하고 실행하기 위해 콜스택에 푸쉬하여 실행하게 된다. 

EVNET QUEUE

위의 구조는 Event Loop를 단순화한 모습이다. Event Queue는 하나가 아니라 여러 개의 Queue로 구성되어 있다.

Node.js의 Event Loop에는 6개의 Phase가 존재한다. Event Loop는 이 6개의 Queue를 순서대로 확인하며, 각 Phase에서 완료된 콜백이 있는지 확인한다. 이러한 각 Queue를 Phase라고 부르며, 다음 Phase로 넘어가는 과정을 Tick이라고 한다.

각각을 설명하면 아래와 같다 

  1. Timers:
    • setTimeout()과 setInterval()과 같은 타이머로 예약된 콜백 함수가 실행된다.
    • 타이머가 만료된 경우에만 해당 콜백이 실행된다.
    • 타이머는 최소힙 자료구조로 구성되어있어, 남은 시간이 가장 적은 타이머의 콜백부터 실행이 된다.
  2. Pending Callbacks:
    • 이전 이벤트 루프 반복에서 실행되지 못했던 보류 중인 콜백들이 실행된다.
    • 대부분의 I/O 콜백은 Poll Phase 직후에 실행되지만, 특정 조건에서는 다음 반복으로 연기되기도 한다.
    • 예:
      • 연기된 OS I/O 콜백: 
        • 일부 파일 시스템 작업이나 네트워크 작업이 OS 레벨에서 지연되었고, Node.js로 결과가 전달되는 데 시간이 걸린 경우
        • TCP 소켓의 ECONNREFUSED와 같은 오류 콜백
        • 일부 비정상적인 네트워크 상황에서의 오류 콜백
      • OS에서 지연된 작업:
        • 특정 조건에서 완료된 I/O 작업의 콜백이 Poll Phase에서 처리되지 못하고 Pending Callbacks Phase로 넘어가는 경우
        • close 이벤트와 관련된 작업, OS 신호 처리와 관련된 작업 등등,,
  3. Idle, Prepare:
    • NodeJs 내부에서 사용되는 Phase로, 일반적인 애플리케이션 코드와는 무관하다.
    • 이벤트 루프를 준비하거나 유휴 상태를 유지하는 내부 작업이 수행된다.
  4. Poll:
    • Event Loop의 중심 Phase로, 대부분의 I/O 작업 완료 이벤트를 처리
    • 파일 읽기, 네트워크 요청 응답 등 일반적인 비동기 작업의 콜백이 여기에서 실행된다.
    • 만약 Poll Phase에서 처리할 작업이 없다면, 이벤트 루프는 다음 Phase로 넘어가거나 대기 상태에 들어간다.
    • 실행할 작업이 없으면, 새로운 I/O 이벤트가 발생하거나 타이머가 만료될 때까지 대기 상태로 진입한다(event loop Blocking)
  5. Check:
    • setImmediate()로 예약된 콜백이 실행된다.
    • Poll Phase 이후 즉시 실행되는 콜백이 처리되는 단계이다.
  6. Close Callbacks:
    • 닫기 관련 이벤트 콜백이 실행된다.
    • 예: socket.on('close'), stream.on('close') 등

위의 phase들을 loop를 돌면서 확인하는 과정을 거친다. 아래는 이해를 위한 간단한 수도코드이다.

// 타이머, OS 작업, 긴 작업을 추적하기 위한 배열
const pendingTimers = []; // setTimeout, setInterval의 대기 중인 타이머
const pendingOSTasks = []; // OS 작업 (서버 요청 등)
const pendingOperations = []; // 긴 작업 (fs 모듈 등)

// 이벤트 루프를 계속 실행할지 여부를 판단하는 함수
function shouldContinue() {
    return (
        pendingTimers.length > 0 || // 남아있는 타이머가 있는 경우
        pendingOSTasks.length > 0 || // 대기 중인 OS 작업이 있는 경우
        pendingOperations.length > 0 || // 완료되지 않은 긴 작업이 있는 경우
        hasCloseCallbacks() || // 닫기 관련 콜백이 있는 경우
        hasSetImmediateCallbacks() // setImmediate로 예약된 콜백이 있는 경우
    );
}

// 이벤트 루프 실행
while (shouldContinue()) {
    // 1) 완료된 타이머의 콜백을 실행한다
    // - setTimeout(), setInterval() 등 타이머가 만료된 경우 해당 콜백 실행
    // - 타이머는 min heap 구조로 관리되며, 가장 남은 시간이 적은 타이머부터 처리
    processPendingTimers();

    // 2) OS 작업과 긴 작업의 완료 콜백을 실행한다
    // - pendingOSTasks 와 pendingOperations를 확인하고 관련된 콜백을 실행한다.
    processPendingOSTasks();
    processPendingOperations();

    // 3) 새로운 이벤트를 대기
    // - 새로운 이벤트가 발생하거나 대기 조건이 충족될 때까지 이벤트 루프는 일시 정지한다.
    // - 대기 조건:
    //   - 새로운 OS 작업(pendingOSTask)의 완료
    //   - 새로운 긴 작업(pendingOperation)의 완료
    //   - 타이머가 만료될 시점 도래
    if (pollQueueIsEmpty()) {
        waitForEvents(); // 새로운 이벤트 발생을 대기
    }

    // 4) Poll 큐를 확인하여 I/O 작업과 네트워크 요청의 완료 콜백을 실행
    // - 파일 읽기/쓰기 작업 완료 콜백, 네트워크 요청 응답 처리
    // - Poll 큐가 비어 있다면 타이머가 대기 중인지 확인
    processPollQueue();

    // Poll 단계에서 처리할 작업이 없고, 대기 중인 타이머가 있는 경우
    if (pollQueueIsEmpty() && pendingTimers.length > 0) {
        // 가장 가까운 타이머가 만료될 때까지 대기
        waitForClosestTimer();
    }

    // 5) Check 단계: setImmediate로 예약된 콜백을 실행
    // - poll 단계 이후 즉시 실행되어야 할 콜백 처리
    processCheckQueue();

    // 6) Close 단계: 닫기 관련 이벤트의 콜백을 실행
    // - socket.on('close')와 같은 리소스 정리 작업
    processCloseCallbacks();
}

실제 event loop는 NodeJS 애플리케이션이 실행될 때 uv_run을 호출한다. 아래의 코드는 이의 일부이다. 주석으로 간단하게 설명을 작성하였다

int uv_run(uv_loop_t* loop, uv_run_mode mode) {
  int timeout;
  int r;
  int ran_pending;

  r = uv__loop_alive(loop); // 현재 이벤트 루프가 활성 상태인지 확인 (대기 중인 타이머, 핸들, I/O 작업 등이 있는지 확인)
  if (!r)
    uv__update_time(loop); 

  while (r != 0 && loop->stop_flag == 0) {
    uv__update_time(loop); // 현재 시간을 업데이트(타이머 계산과 이벤트 대기를 위한 기준 시간)
    uv__run_timers(loop); // 만료된 타이머가 있는지 확인하고 실행
    ran_pending = uv__run_pending(loop); // 이전 루프에서 보류된(pending) 콜백 실행
    uv__run_idle(loop);  // Node.js 내부적으로 사용되는 준비 작업 처리
    uv__run_prepare(loop); // Node.js 내부적으로 사용되는 준비 작업 처리

	// 타이머나 I/O 작업이 없으면 대기 시간을 설정한다
    timeout = 0;
    if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
      timeout = uv_backend_timeout(loop); // 타임아웃 값을 계산

    uv__io_poll(loop, timeout); // I/O 작업을 처리 (소켓, 파일 디스크립터 등의 입력/출력을 감지하고, 준비된 작업을 실행)

    /* Run one final update on the provider_idle_time in case uv__io_poll
     * returned because the timeout expired, but no events were received. This
     * call will be ignored if the provider_entry_time was either never set (if
     * the timeout == 0) or was already updated b/c an event was received.
     */
    uv__metrics_update_idle_time(loop);

    uv__run_check(loop); // Check 단계에서 실행될 콜백 처리 (setImmediate)
    uv__run_closing_handles(loop); // 닫기(close) 이벤트와 관련된 핸들을 처리

///----- 생략

}

위의 코드에서도 확인할 수 있듯이 이벤트루프는 각 phase를 확인하는 과정을 거친다. 그리고 libuv에서 내부적으로 관리하는 타이머를 사용하여 시간을 확인하는 것도 볼 수 있다. 

Thread Pool & Kernel

비동기 요청이 오면 os 커널에서 수행이 가능한 경우 커널에 요청을, 불가능한 경우 libuv의 Thread pool의 워커쓰레드에서 실행을 한다. 워커쓰레드는 기본 4개이며 128개까지 늘릴 수 있다고 한다. (UV_THREADPOOL_SIZE 를 수정하여 가능함)

위의 그림에서 볼 수 있듯이 event loop에서의 실제 작업은 두 방향으로 나뉜다.

timer phase의 경우 내부적으로 타이머를 관리하기 때문에 타이머 시간을 비교하면서 확인할 수 있지만, 비동기 IO를 처리하는 poll phase는 해당 작업의 완료 여부를 어떻게 알 수 있을까?

위의 그림을 보면 epoll, kqueue등이 적혀있다.  epoll이나 kqueue는 각각 리눅스, macOS에서 사용하는 I/O 통지 모델이다.

다수의 파일 디스크립터(file descripter =fd)에서 발생하는 이벤트(읽기, 쓰기, 오류 등)를 감지한다(epoll_wait을 통해 감지) 그리고 이벤트가 발생한 fd를 반환한다.

참고로 fd(file descripter)란 특정 프로세스 내에서 고유한 숫자로 구성되며 리소스(파일, 네트워크, 소켓등)를 특정하는 역할을 한다.  OS는 이 fd를 사용하여 어떤 파일이나 소켓을 대상으로 작업할지를 결정할 수 있다.

 

만약 http와 같은 요청을 한다고 가정해 보자. 다음 단계에 따라 이벤트를 감지하고 처리한다.

- http요청을 만나면 http.get은 libuv를 통해 OS 네트워크 스택에 작업을 요청한다

- OS 커널이 socket() 을 통해 네트워크 소켓을 열고 fd를 생성(fd=4)한다

- libuv 가 epoll_ctl()을 통해 모니터링할 fd를 epoll에 추가한다

- epoll은 데이터 수신 이벤트를 모니터링하도록 한다 (os 커널에서 작업한다)

- 작업 완료 시 특정 fd작업에 완료 이벤트가 발생하면 epoll은 이를 Nodejs의 이벤트 루프에 알린다.

- libuv는 epoll에서 반환된 fd와 이벤트를 확인하고 벤트 큐에 해당 작업(콜백)을 추가한다.

- Node.js 이벤트 루프가 돌면서 Poll Phase에서 이벤트를 확인하고 콜백을 실행한다.

정리를 해보자면 Event loop는 워커 쓰레드, 커널에서 완료된 작업은 각 phase의 콜백 queue에 등록되고 event loop는 각 phase를 순서대로 돌면서 콜백 큐를 처리하게 된다. 이렇게 nodejs는 싱글 쓰레드에서 none blocking  비동기 작업을 진행한다.

 

3. 그 외 microTaskQueue, nextTickQueue 에 대하여

위에서 6가지 phase를 확인해 보았다. 이를 브라우저쪽 용어로는 Macrotask라고 한다. 이 6가지 phase는 libuv에 의해 관리된다.

그런데 그 외에 두 가지가 더 있다. Microtask라고 불리며, 각각 nextTickQueue와 promise queue로 구성된다.

nextTickQueue는 NodeJS가 자체적으로 만들어 관리되고, Promise.then(), queueMicrotask() 등은 V8 엔진Microtask Queue가 관리한다. (둘을 모두 Microtask로 묶이지만 관리 주체는 다르다)

nextTickQueue는 process.nextTick()의 콜백이 들어가고 Promise queue는 Promise.resolve().then() 또는 queueMicrotask 로 등록된 콜백 등록된다.

 

실행 순서

이 두 큐는 모두 높은 우선순위를 가지며 nextTickQueue가 우선순위가 제일 높다.

우선 순위가 다르기 때문에 실행 순서 또한 다르다.

- process.nextTick()은 모든 phase가 완료되기 전에 또는 phase가 끝나기 직전에 무조건 먼저 실행된다. 

- Promise Microtask는 보통 각 phase가 끝난 뒤 실행

그런데 노드 11 버전 전, 후로 조금 다르다.

NodeJS 10 이하에서는 Promise 등의 Microtask가 비교적 덜 자주 실행되는 이슈가 있었다고 한다.  #22257 그래서 11 버전 이상부터는 각 phase가 끝날 때마다 Microtask를 좀 더 자주 실행하는 방향으로 변경되었다고 한다.

위의 git issue의 코드를 지금 돌려보면

for (let i = 0; i < 2; i++) {
	setTimeout(() => {
		console.log("Timeout ", i);
		Promise.resolve().then(() => {
			console.log("Promise 1 ", i);
		}).then(() => {
			console.log("Promise 2 ", i);
		});
	})
}

수정 전의 결과는 아래와 같고

Timeout  0
Timeout  1
Promise 1  0
Promise 2  0
Promise 1  1
Promise 2  1

수정 후의 결과는 아래와 같다

Timeout  0
Promise 1  0
Promise 2  0
Timeout  1
Promise 1  1
Promise 2  1

 

수정 후에는 아래와 같은 단계를 거친다

// 1.첫 번째 콜스택 
	// for 루프 실행
  		// 첫 번째 setTimeout이 Macrotask Queue에 등록 (i = 0)
		// 두 번째 setTimeout이 Macrotask Queue에 등록 (i = 1)
	// 모든 동기 작업이 완료된 후 이벤트 루프 시작
 
// 2.첫 번째 setTimeout 실행
	// Timers Phase
    // 1. 첫 번째 setTimeout의 콜백이 실행되어 콜스택에 올라간다.
		// console.log(Timeout 0)이 실행
    // 2. Promise가 호출
    	// Promise.resolve().then()은 Microtask Queue에 Promise 1 등록
		// .then() 체이닝으로 Microtask Queue에 Promise 2 등록
    // 3. Microtask Queue 처리
	    // 이벤트 루프가 Microtask Queue를 확인
        	// console.log(Promise 1 0) 실행
            // 이후 Promise 2 0 실행

// 3.두 번째 setTimeout 실행
	// Timers Phase
    // 1. 두 번째 setTimeout의 콜백이 실행되어 콜스택에 올라간다.
		// console.log(Timeout 1)이 실행
    // 2. Promise가 호출
    	// Promise.resolve().then()은 Microtask Queue에 Promise 1 등록
		// .then() 체이닝으로 Microtask Queue에 Promise 2 등록
    // 3. Microtask Queue 처리
	    // 이벤트 루프가 Microtask Queue를 확인
        	// console.log(Promise 1 1) 실행
            // 이후 Promise 2 1 실행

수정 전에는 모든 첫 번째, 두 번째 setTimeout이 실행되고 나서 Microtask Queue를 처리하였기 때문에 모든 Promise.then 콜백 실행은 뒤에 실행이 되었었다.

이를 정리해보면 아래와 같다. 

 

  • NodeJS 10 이하에서는 이벤트 루프의 한 틱이 전부 끝난 다음에  Microtask를 일괄 처리하여, 콜백 실행 시점이 다소 지연될 수 있었다.
  • NodeJS11 이후로는 브라우저와 유사한 방식으로, phase 중간에도 Microtask를 자주 확인하여 실행하는 방식(특히 Promise)

 

아래의 그림은 Macrotask, Microtask Queue를 포함한 루프를 보여준다. 

 

전체적인 실행 확인

마지막으로, 간단한 스크립트를 통해 실행 순서를 확인해보자

console.log("Start"); // 동기 코드

setTimeout(() => {
  console.log("Timers Phase: setTimeout");

  process.nextTick(() => {
    console.log("Next Tick: Inside setTimeout");
  });

  Promise.resolve().then(() => {
    console.log("Microtask: Inside setTimeout");
  });
}, 0);

setImmediate(() => {
  console.log("Check Phase: setImmediate");

  process.nextTick(() => {
    console.log("Next Tick: Inside setImmediate");
  });

  Promise.resolve().then(() => {
    console.log("Microtask: Inside setImmediate");
  });
});

process.nextTick(() => {
  console.log("Next Tick: Initial");
});

Promise.resolve().then(() => {
  console.log("Microtask: Initial");
});

console.log("End"); // 동기 코드

1. 초기화 단계

동기 코드가 실행되면서 setTimeout, setImmediate, process.nextTick, Promise.resolve등이 각 큐에 등록된다.

동기 코드가 다 실행이 되어 콜스택이 비게되면 이벤트 루프가 시작된다.

2. nextTickQueue 실행

우선순위가 가장 높은 process.nextTick의 콜백이 먼저 실행된다.

3. microTaskQueue실행

그 다음 우선순위인 Promise 관련 콜백이 실행된다.

microTask가 모두 실행이 되었으니 Event loop의 각 phase를 돌면서 확인한다

4. Timer Phase 확인

설정한 타이머가 완료되었다면 setTimeout 콜백이 실행되어 콜스택에 올라간다.

현재 동기 코드인 console.log("Timers Phase: setTimeout"); 가 먼저 실행이 되고 

process.nextTick , Promise.resolve().then이 nextTickQueue와 microTaskQueue에 등록된다

그리고 콜스택이 비게되면 nextTickQueue, microTaskQueue를 순서대로 실행한다.

5. Check Phase

setImmediate의 콜백이 콜스택에 올라가고

현재 동기 코드인 console.log("Check Phase: setImmediate")  가 먼저 실행이 되고

 process.nextTick , Promise.resolve().then이 nextTickQueue와 microTaskQueue에 등록된다

그리고 콜스택이 비게되면 nextTickQueue, microTaskQueue를 순서대로 실행한다.

참고로! timer와 check phase 중 어떤 것이 먼저 실행 될지는 타이머의 완료 시간에 따라 다르다. 만약 timer phase를 체크할 때 

아직 타이머 시간이 완료되지 않았다면 다음 phase로 넘어가 check phase를 먼저 실행하기 때문이다.

따라서 실행결과는 아래의 두 가지로 나올 수 있다

Start
End
Next Tick: Initial
Microtask: Initial
Check Phase: setImmediate
Next Tick: Inside setImmediate
Microtask: Inside setImmediate
Timers Phase: setTimeout
Next Tick: Inside setTimeout
Microtask: Inside setTimeout




Start
End
Next Tick: Initial
Microtask: Initial
Timers Phase: setTimeout
Next Tick: Inside setTimeout
Microtask: Inside setTimeout
Check Phase: setImmediate
Next Tick: Inside setImmediate
Microtask: Inside setImmediate

4. 마치며

NodeJS이 내구 구조와 흐름에 대해서는 TIL로 정리한 적이 있었다. 이번엔 정리를 하면서 다시 상기해 보기 위해 블로깅을 해보았다. 

먼저 간략하게 NodeJs의 구성에 대해서 살펴보았고 기본 실행 흐름에 대해서 살펴보았다.

그리고 NodeJS의 핵심인 비동기를 구현하는 Libuv에 대해서 조금 더 자세히 공부해보았다.

그 후 NodeJS의 비동기를 구현하는 다른 Queue에 대해서 확인해보고 전체적인 실행 흐름을 확인해보았다.

중간중간 조금 더 알아보면 좋을 것 같은 부분도 있었는데, 글이 너무 길어질 것 같기도 하고 너무 장황해질 것 같아 여기서 마무리 하겠다.

 

참고자료들

- 유데미 강의 중 일부 : Node JS: Advanced Concepts

- 책 내용중 일부 : 컴퓨터 밑바닥의 비밀

- 공식문서들 : NodeJs, libuv, v8

- 블로그들 : 블로그1 , 블로그2

728x90
Comments