node - 기본 동작 원리와 이벤트 루프, 브라우저를 벗어난 js 실행!

정현우·2024년 1월 15일
38
post-thumbnail

[ 글의 목적: 브라우저를 벗어난 javascript 로 작성된 코드가 실제로 실행되기 까지의 과정과 해당 원리를 기록 ]

Node.js®

먼저 이전글 javascript - 기본 동작 원리, js 엔진 를 보고오는 것을 추천한다. 갑자기 알고리즘타서 이번 글이 굉장히 부담스러워졌다 ㅎㅎ🥹 (당황)

기본적인 "컨셉"은 js 실행 원리와 비슷하다. Node.js® official docs 에서는 [ "비동기 이벤트 주도 JavaScript 런타임" 으로써 Node.js 는 확장성 있는 네트워크 애플리케이션을 만들 수 있도록 설계되었습니다. ] 라고 소개되어 있다. 한 마디로 Node.js® 는 "Javascript를 브라우저 밖에서도 실행할 수 있도록 하는 Javascript의 런타임" 이다.

1. 기본 동작 원리

  • 우선 노드를 이해하기 위해 프로세스(Process)와 쓰레드(Thread) 에 대한 이해와 비동기 논블락킹 에 대한 이해가 필요하다. 이 글에서는 다루지 않겠다.

  • 노드는 "싱글 스레드로 이뤄진 이벤트 기반 async & non-blocking js 런타임" 이다. 노드의 내부적인 설계가 주로 파일 단위를 "모듈 단위" 로 나눌 수 있고, 특정 모듈은 기능 단위가 아닌 "시나리오 단위"로 구성 되어 있다는 점에서 "객체지향적인 설계(Object Oriented Program ming)"를 넘어서 "관점 지향적인 설계 방식(Aspect Oriented Programming)" 이라고도 한다.

  • 사실 노드를 가장 특별하게 만들어주는 친구는 "libuv" 이며, 이를 중심으로 살펴볼 예정이다.

1) Node.js 의 내부 구조

  • 크게 아래와 같은 구조를 가진다. ❶ js로 이뤄진 라이브러리 부분, ❷ 서로 다른 언어(js -> c++)를 연결해주기 위한 Binding, ❸ 거의 C++로 만들어진 V8 엔진 부분 ❹ C++로 구성된 이벤트 루프의 핵심 부분인 libuv, 이렇게 크게 4가지로 이뤄져 있다고 볼 수 있다.

  • 그림에서 앞 글에서 본 V8 엔진 역시 포함되어 있다. node의 핵심 부분은 "V8" 과 "libuv" 로 볼 수 있다.

2) standard library & node bindings

  • internalBinding(...) 이 무엇인가? 현대 컴퓨터에서 js와 같은 고수준의 코드는 직접적으로 OS system call 을 할 수 없으며(정확하게는 태생적으로 고수준은 저수준(c 언어 등)을 이해하기 쉽게 바인딩 되어 있는 상태이기 때문), 이를 위해 저수준의 code 로 NativeModule 을 컨트롤 해야한다. 내부 C/C++ 코드를 바인딩하는 부분이라고 보면 된다.
  1. https://github.com/nodejs/node/blob/master/src/node_constants.h
  2. https://github.com/nodejs/node/blob/master/src/node_constants.cc
  • 위 2개 링크에서 internalBinding 에 대해 더 상세하게 살펴볼 수 있다.

  • 결론적으로 const binding = internalBinding('fs'); 에서 바인딩 되는 것은 아래 사진에서 볼 수 있는 node > src > node_file.cc 이며 , 실제 저수준의 구현 function & method를 확인할 수 있다.

  • 가령 fs module의 디렉토리를 만드는 method mkdir 는 아래 MKDir 이라는 function이 binding 되어있는 것을 확인할 수 있고, 그러면 실제 구현된 저수준의 코드를 확인할 수 있다.

  • 실제 우리가 작성한 js code가 node runtime에서는 아래와 같은 흐름으로 libuv에 도달하게 된다.

  • 정확한 표현은 아니지만, 우리는 node run time 덕분에 js 코드만으로 C/C++ 의 저수준 언어에 아주 쉽게 닿고, C/C++을 사용한 코딩처럼 할 수 있게 되는 것 이다!

3) libuv

  • 역할의 정의만 보자면, "이벤트 기반 비동기 처리" 를 가능하게 해주는 핵심 부분이다. 비동기 I/O를 지원하는 "C언어 Library" 로 윈도우, 리눅스 커널을 Wrapping하여 추상화한 구조로 되어있다. 커널의 비동기 API (윈도우-IOCP, 리눅스-AIO) 로 지원할 수 없는 작업을 비동기화 하기 위한 "별도의 Thread Pool" 을 가지고 있고 Event Loop, Event Queue 를 관리 한다.

  • 일단 위 MKDir 의 내부 호출 부분을 잠깐 살펴보자! 위에서 살펴본 MKDirMKDirpSync 를 호출하는 것을 볼 수 있다. 이는 내부적으로 env->event_loop() 라는 argument를 넘기는 것을 볼 수 있다.

  • 이를 헤더파일 선언부 (node > src > node_file.h) 를 보면 아래와 같은 코드를 볼 수 있다.
int MKDirpSync(uv_loop_t* loop,
               uv_fs_t* req,
               const std::string& path,
               int mode,
               uv_fs_cb cb = nullptr);
  • uv_fs_t 과 같은 것을 Watcher 라고 하며 node > deps > uv > include > uv.h 에 존재한다.

  • 이와 같이 node는 파일시스템, 네트워킹, 스레드, 프로세스, 이벤트 루프, I/O 폴링, 시스템 워쳐(Watcher), TTY, DNS 과 관련된 Watcher 구조체를 확인할 수 있다. Watchers가 갖는 구조체 이름은 uv_type_t 로 정의돼 있는데 이름 내에 type이라는 이름 대신 앞선 목록에 해당하는 각각의 구조체가 적용된다.

4) libuv 구조 & Event Loop

아니 그래서 Watcher 든, 이벤트 루프든, 비동기 작업이든, 어떻게 libuv 까지 전달되어서 실행이 되고, 어떻게 비동기적으로 실행되길래 왜 이벤트 기반이라고 불리는 걸까?

  • 커널이 지원하는 비동기 작업을 libuv 에게 요청하면 libuv 는 대신 커널에게 이 작업을 비동기적으로 요청 해준다. 만약 커널이 지원하지 않는 비동기 작업을 libuv 에게 요청하면 libuv내부에 가지고있는 스레드 풀에게 이 작업을 요청 해준다.

  • 우리가 여기서 조금 더 집중해서 봐야할 부분은 "libuv" 는 "Event Loop" 를 가지고 있다는 점이다. 여기서 Event Loop는 앞 글에서 살펴본 "js가 브라우저에서 동작되는 원리" 의 Event Loop와 비슷하다.

  • "libuv" 는 "Event Loop" 도 각 요청을 특성에 맞게 "커널" 이나 "Thread Pool에 위임" 하고, 실행 대기중인 callbackEvent Queue 에 모았다가 Main Thread 에 의해 실행될 수 있도록 call stack 으로 옮기는 역할 을 한다.

Event Loop Phases

  • 더 나아가 Event Loop 는 모든 callback하나의 Event Queue에 담아서 관리하지 않는다. node official docs의 event loop 에 대한 글을 보면 아래와 같은 사진이 있다. 개인적으로 node official docs에 node 자체에 대한 flow chart나 시퀀스 다이어그램, 시각적인 부분이 너무 없어서 아쉽다 🥹

  • 이벤트 루프는 위와 같은 "Phases" 라고 부르는 단계를 거친다. 크게 6가지 이며, 아래 사진과 같은 순서라고 볼 수 있다.

  • Timer Phase -> Pending Callbacks Phase -> Idle, Prepare Phase -> Poll Phase -> Check Phase -> Close Callbacks Phase -> Timer Phase 의 기본적인 순서를 따르며, 이 cycle을 하나의 tick 이라고 부른다.

  • 공식레포의 node > deps > uv > src > win > core.c (코드보러가기) 에서 실제 libuv 의 하나의 tick을 실행하는 코드는 아래와 같다.

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

  r = uv__loop_alive(loop);
  if (!r)
    uv_update_time(loop);

  /* Maintain backwards compatibility by processing timers before entering the
   * while loop for UV_RUN_DEFAULT. Otherwise timers only need to be executed
   * once, which should be done after polling in order to maintain proper
   * execution order of the conceptual event loop. */
  if (mode == UV_RUN_DEFAULT && r != 0 && loop->stop_flag == 0) {
    uv_update_time(loop);
    uv__run_timers(loop);
  }

  while (r != 0 && loop->stop_flag == 0) {
    can_sleep = loop->pending_reqs_tail == NULL && loop->idle_handles == NULL;

    uv__process_reqs(loop);
    uv__idle_invoke(loop);
    uv__prepare_invoke(loop);

    timeout = 0;
    if ((mode == UV_RUN_ONCE && can_sleep) || mode == UV_RUN_DEFAULT)
      timeout = uv_backend_timeout(loop);

    uv__metrics_inc_loop_count(loop);

    if (pGetQueuedCompletionStatusEx)
      uv__poll(loop, timeout);
    else
      uv__poll_wine(loop, timeout);

    /* Process immediate callbacks (e.g. write_cb) a small fixed number of
     * times to avoid loop starvation.*/
    for (r = 0; r < 8 && loop->pending_reqs_tail != NULL; r++)
      uv__process_reqs(loop);

    /* Run one final update on the provider_idle_time in case uv__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__check_invoke(loop);
    uv__process_endgames(loop);

    uv_update_time(loop);
    uv__run_timers(loop);

    r = uv__loop_alive(loop);
    if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
      break;
  }

  /* The if statement lets the compiler compile it to a conditional store.
   * Avoids dirtying a cache line.
   */
  if (loop->stop_flag != 0)
    loop->stop_flag = 0;

  return r;
}
  • uv__run_timers, uv__process_reqs, uv__idle_invoke, uv__prepare_invoke, uv__poll, ... 등과 같은 함수를 보면 알 수 있듯, 차례대로 페이즈를 수행하는 것 을 볼 수 있다.

  • 아래 설명할 각 페이스에 대한 코드는 역시 위 core 에서 출발하면서 추적할 수 있다.

(1) Timer

  • Timer 단계는 Event Loop의 시작 단계이다.

  • 이 단계에서는 setInterval, setTimeout 과 같은 "타이머에 관련된" callback 을 처리한다. 타이머들이 호출 되자마자 Event Queue에 들어가는 아니고 내부적으로 min-heap 형태로 타이머를 구성하고 있다가 "발동 단계"가 되면 그때 Event Queuecallback 을 이동시킨다.

  • min-heap 는 "이진트리 형태인 최소 힙구조" 라서 가장 빠른 타이머를 체크할 수 있게 세팅된 자료구조다.

  • 타이머 관련된 로직은 해당 페이즈에 진입해야만 "실행될 기회를 얻을 수" 있다. 이 뜻은 사실 setTimeout(func, 1000) 에서 func 함수가 실행되기까지 최소한 1초인 것이지, 딜레이 타임 + 1초가 걸린다.

(2) Pending Callbacks

  • 이 단계에서는 pending_queue 에 들어있는 callback 들을 실행한다.

  • pending_queue 에는 이전 이벤트 루프 반복에서 수행되지 못했던 I/O 콜백, 이전 루프에서 완료된 callback (ex - Network I/O가 끝나고 "응답"받은 경우 등) 또는 Error callback 등이 쌓이게 된다.

(3) Idle, Prepare

  • Idle 에 함축된 뜻과는 다르게 "Event Loop가 매번 순회할때마다" 실행되며 Poll 을 위한 "준비작업" 을 하는 단계다. 그리고 자바스크립트 코드를 실행하지 않는다.

(4) Poll

  • 이 페이즈는 새로운 I/O 이벤트를 다루며 watcher_queue 의 콜백들을 실행한다. 여기서 "Watcher" 는 위에서 말한 구조체를 생각하면 된다.

  • watcher_queue 에는 I/O에 대한 거의 모든 콜백들이 담긴다. 한 마디로 setTimeout, setImmediate, close 콜백 등을 제외한 모든 콜백이 여기서 실행된다고 볼 수 있다.

  • 만약, Queue 가 비어있지 않다면 배정받은 시간동안 Queue 가 모두 소진될 때까지 모든 callbackcall stack 으로 올려 실행 시킨다. 여기서 "배정받은 시간" 이라는 잠시 "대기 시간" 이 존재한다.

(5) Check

  • Check 단계는 setImmediate() 만을 위한 단계 이다. 이 단계에서는 setImmediate 를 사용하여 수행한 callbackEvent Queue 에 쌓이고 call stack 으로 올라간다.

(6) Close Callbacks

  • Close 단계는 socket.on('close', () => {}) 같은 close typecallback 을 관리하는 단계이다.

  • uv_close() 를 부르면서 종료된 핸들러의 콜백들을 처리하는 페이즈라고 볼 수 있다.

nextTickQueue

  • libuv 에 "포함되어 있지 않고" Node.js 에 구현되어 있으며, 이벤트 루프의 페이즈와 상관없이 동작하는 queue다.

  • nextTickQueue는 process.nextTick 에 의해 생성된 콜백들을 저장하는 큐이며, 다른 I/O 작업이나 타이머 작업보다 먼저 처리된다. 즉, 현재 실행 중인 스크립트가 완전히 끝나고 다른 이벤트 루프 단계로 넘어가기 전에 실행 된다.

  • 긴급하게 처리해야 할 작업을 등록할 때 사용되며, 이 큐의 작업이 너무 많으면 I/O 작업이 지연될 수 있다.

microTaskQueue

  • 이 역시 libuv 에 "포함되어 있지 않고" Node.js 에 구현되어 있으며, 이벤트 루프의 페이즈와 상관없이 동작하는 queue다.

  • Promise의 Resolve된 프라미스 콜백을 가지고 있는 큐다.

  • 현재 실행 중인 스크립트가 완료된 후, 그리고 이벤트 루프의 다음 단계로 넘어가기 전에 실행며 이 큐는 nextTickQueue 가 비워진 후에 처리된다.

  • 결론적으로 Event Loop 에서 위와 같은 cycle (tick) 으로 처리하고 있다.

2. 결론

  1. nodejs는 js code를 c/c++ 로 바인딩하고, v8 api / libuv api 를 이용하여 core (node > src)을 구현한다.

  2. 위에서 살펴보았듯 "v8과 libuv는 각각 별개로 움직이지 않는다." nodejs는 하나(싱글스레드)의 이벤트루프로만 동작한다. 자바스크립트의 실행은 "Main Thread" 에 의해서만 수행되고 1개의 call stack을 가진다.

  3. call stack 실행은 동기적 blocking이기 때문에 이를 극복하기 위해 Single Thread와 궁합이 좋은 "비동기 callback" 프로그래밍 방식인 Event Loop를 추상화한 libuv library를 사용 한다.

  4. node인스턴스가 생성될때 start 함수에서 do-while 문으로 uv_run() 이 호출되고 있다. libuv 는 js엔진이 아니며 libuv 내에 있는 Event Loop 가 파라미터로 넘겨받은 v8::Isolate, v8::Context 를 이용해 js로직을 처리한다.

  5. libuv 내의 Event Loop 는 "Main Thread에 상주" 하여 자바스크립트 비동기 실행을 한다. 요청의 특징에 따라 커널 비동기 함수 또는 libuv 내의 Thread Pool 에 작업을 "위임"하며 callback 을 실행하기 위해 Event Queue 에 적재된 callback 을 empty상태의 call stack 으로 이동시킨다.

  6. 그리고 Event Loop 는 앞 서 살펴본 각 페이스 단계의 특성을 지키며 다음 큐로 넘기고, 실행한다.

왜 싱글 쓰레드를 고집하는가?

  • 이유를 파악하기 위해서는 thread와 Network I/O에 대한 관계를 알아야 한다. 사실 N/W(network)는 H/W 다음으로 OS(operation system)가 담당한다. "만약 single thread 가 아니라면?" 을 생각해보자.

  • 단편적인 N/W 처리로는 N/W요청 -> thread -> 서버처리 -> 응답 의 흐름으로, 각 요청, 연결마다 "thread를 생성하고, 유지해야 한다" 여기서 리소스의 부담이 생긴다.

  • 그리고 multi thread 의 환경에서는 "공유자원" 에 대한 문제와 그에따른 race condition 의 issue 가능성이 있다.

  • 이런 문제를 node는 multi thread 가 아니라 하나의 process가 single thread 로 처리하게 해서 해결한다. 이 signle thread의 performance 를 영끌하기 위해 비동기와 이벤트 루프를 사용하는 것이다.

  • 그래서 오히려 서버 확장이 자유롭다. process간 자원 공유할일도 없게 설계되어서 그냥 node process를 하나 더 만들면 확장이 가능하다. 현대 잘나가는 고급 프로그래밍 언어들은 대게 이러한 형태를 띄우는 것 같다. (ex - python)

  • 물론 multi thread는 CPU I/O의 연산 효율이 signle thread에 비해 높다. 그렇기에 node는 CPU 집약적인 작업에서는 효율이 좋지 않다. 하지만 이 역시 극복하는 다른 방법을 제공하긴 한다. 여기서 우리가 꼭 기억해야할 점은 Node.js의 철학은 싱글 스레드에 기반하고 있다 는 것이다.


출처

ps, official docs 와 github code로는 부족한 부분이 많고, 다른 블로그 분들의 좋은 글을 기반으로 했으나, 분명 잘못된 부분이 있을 수 있어 많은 피드백 부탁드리겠습니다.

profile
도메인 중심의 개발, 깊이의 가치를 이해하고 “문제 해결” 에 몰두하는 개발자가 되고싶습니다. 그러기 위해 항상 새로운 것에 도전하고 노력하는 개발자가 되고 싶습니다!

2개의 댓글

comment-user-thumbnail
2024년 1월 15일

늘 좋은 글 감사합니다 :)

1개의 답글