본문 바로가기

IT/Javascript, Typescript

Node.js의 동작 원리를 알아보자 (+ 이벤트 루프, libuv, 스레드 풀)

앞서 "왜 자바스크립트가 싱글 스레드이고, 어떻게 논블로킹 I/O를 지원하는지" 알아 보았습니다. 오래 걸리는 작업은 웹 브라우저에 위임하는게 핵심 이었는데요, 과연 서버용 JS 환경인 node.js에서는 어떻게 동작할까요? 이번 포스팅에서 좀더 자세하게 알아보도록 하겠습니다.

 


이벤트 루프(Event Loop)란?

간단하게 이벤트 루프에 대해서 알아보겠습니다. 자바스크립트는 여러 이유 때문에 싱글 스레드 언어로 기획되었습니다. 그래서 하나의 콜 스택만 가지고, 한번에 하나의 작업 밖에 수행하지 못합니다. 

 

그래서 자바스크립트는 웹 브라우저에 긴 I/O 작업(네트워크, 타이머 등)을 위임합니다. 웹 브라우저가 백그라운드에서 작업을 수행하고, 다시 콜 스택에 불러오는 것이죠.

https://www.howdy-mj.me/javascript/asynchronous-programming

자바스크립트는 이벤트 루프(Event Loop)백그라운드에서 완료된 작업을 콜 스택에 불러오는 작업을 수행합니다. 

  • 비동기 작업이 콜 스택에 추가되면, 콜 스택에서 즉시 삭제하고 WebAPI에게 작업을 맡깁니다.
  • WebAPI는 백그라운드에서 작업을 수행하다가, 완료 시 콜백을 별도의 큐에 저장합니다.
  • 이벤트 루프는 콜 스택이 빌 때, 콜백을 하나 씩 콜 스택에 추가합니다.

 

자세한 이벤트 루프의 동작은 이전 글을 참조해주세요.

 


Libuv 라이브러리

Libuv 라이브러리의 역할

https://sjh836.tistory.com/149

 

Node.js 에서도 "일을 대신 처리해주는" 환경이 필요하지 않을까요? 그래서 Node.js에서는 libuv 라이브러리가 웹 브라우저 대신 I/O 작업을 위임받아 처리해줍니다.

 

H/W를 제어하는 I/O 작업은 OS가 수행해야 합니다. 그래서 libuv는 OS 커널의 동작을 추상화하고, OS의 비동기 API와 통신해 "어떤 기능을 지원하는지"를 미리 파악할 수 있습니다.

 

그래서 libuv는 OS 커널에서 제공하는 비동기 작업(Socket 통신)은 모두 OS에 위임해 처리해 줍니다.

 

Libuv 라이브러리의 스레드 풀

그렇다면 OS가 지원하지 않는 모든 작업을 이벤트 루프가 수행하면 될까요? Node.js에서 이벤트 루프는 곧 메인 스레드입니다. 즉, 자바스크립트 코드 실행과 비동기 작업 모두 이벤트 루프가 수행합니다.

 

암호화나 압축 등의 CPU 집약적 작업을 수행한다면, 이벤트 루프가 블로킹돼 프로그램 또한 지연될 것입니다. 그래서 libuv 라이브러리는 별도의 스레드 풀을 가지고, OS가 지원하지 않거나 CPU 집약적인 작업을 처리합니다.

 

스레드 풀을 사용하는 경우는 다음과 같습니다.

  • crypto, fs, zlib, DNS lookup 등의 라이브러리가 스레드 풀을 사용합니다.
  • 파일 I/O 작업도 추상화 문제(파일 시스템 때문에..?)가 있어, fs 라이브러리도 스레드 풀을 사용합니다.
  • worker_threads 모듈을 이용해 개발자가 직접 스레드 풀을 사용할 수 있습니다.

 

Node.js의 이벤트 루프(Event Loop)

NodeJS의 이벤트 루프는 웹 API의 기능(Timer)이나, I/O 작업의 콜백을 효율적으로 지원하기 위해 고안되었습니다. 그래서 각 콜백을 사용 목적과 특성에 따라 분리하고, 다르게 동작합니다.

 

NodeJS의 이벤트 루프의 단계에 대해 알아보도록 하겠습니다.

 

timers Phase

setTimeout, serInterval에 등록된 타이머를 및 콜백을 관리합니다.

 

하지만 실질적으로 poll 단계에서 타이머를 기다리고, timers 단계는 만료된 타이머의 콜백을 실행하는 역할을 합니다.

  • 최소 힙(Min-Heap) 자료구조에 시간 기준으로 타이머를 저장합니다.
  • timers 단계 도달 시 타이머를 하나씩 꺼내 검사합니다.
  • 만료 시 등록된 콜백을 수행하고, 그렇지 않으면 바로 다음 단계로 넘어갑니다.

 

그래서 setTimeout(fn, 1000)의 실행 시간이 보장되지 않습니다. 만약 timers나 poll 단계를 탐색하는데 1초 이상이 소요된다면, 함수 fn는 그 시간만큼 실행이 지연될 것입니다.

 

timer 단계는 모든 타이머 객체를 소진하거나, 일정 시간이 흐르면 다음 단계로 순회합니다.

 

Pending Callbacks Phase

이전 이벤트 루프에서 수행되지 못했던 I/O 콜백들을 처리합니다. 이벤트 루프는 싱글 스레드로 동작하므로, 대부분의 단계에 실행 시간 제한이 있습니다. 그래서 큐의 모든 콜백을 실행하지 못할 경우 이 Pending 큐에 저장해 따로 실행합니다.

 

또한 TCP 연결에서의 에러 처리 콜백의 경우도 Pending 큐에서 관리됩니다.

 

Idle, Prepare Phase

이 단계는 JS 코드를 실행하지 않고, Node.js의 내부 관리를 합니다. 

 

Poll Phase

watcher 큐에 담긴 콜백들을 먼저 실행합니다. watcher 큐에는 대부분의 I/O 관련(DB, HTTP, FILE 등) 콜백이 저장됩니다.

 

I/O 작업은 큐에 담긴 순서대로 처리된다는 보장이 없습니다. (예를 들면, 네트워크 상황에 따라 응답 시간이 각각 다릅니다) 또한 I/O 작업은 운영체제가 맡아 수행하기 때문에, 이와 교신하며 완료를 검사하는 watcher를 큐에 담아 관리합니다.

  • 만약 watcher가 I/O 작업이 완료되었다는 신호를 보내면, 큐에 저장된 순서와 관계없이 콜백을 수행합니다.

 

또한 watcher 큐가 빌 경우, poll 단계를 잠시 대기하며 아래와 같이 동작합니다.

  • close, pending 큐에 처리하지 못한 콜백이 있다면 즉시 다음 단계로 넘어갑니다.
  • 둘 다 비어있다면 timers 단계를 체크하고, 다음 타이머가 수행될 때까지 대기합니다.
  • timers도 비어있다면, 다음에 올 I/O 작업을 위해 잠시 대기합니다.

Check Phase

setImmediate에 등록된 콜백을 관리하는 특수한 단계입니다.

 

Close Phase

Socket 통신의 종료(Close 이벤트) 콜백을 처리하는 단계입니다. 제한 시간이 끝나기 전까지 큐에 담긴 콜백을 순서대로 실행합니다.

 

nextTickQueue, microTaskQueue

두 큐는 이벤트 루프의 일부(libuv)가 아니고, Node.js에 구현되어 있고 이벤트 루프의 각 단계 사이마다 수행됩니다.

  • nextTickQueue는 process.nextTick()을, microTaskQueue는 resolved Promse 콜백을 처리합니다.
  • 내부적으로 nextTickQueue가 더 높은 우선 순위를 가지고 가지며, 큐가 빌 때까지 모두 수행됩니다.

 

References

Node.js 이벤트 루프(Event Loop) 샅샅이 분석하기 | 쿠키의 개발 블로그 (korecmblog.com)

Node.js 정리해보기 (velog.io)

nodejs의 내부 동작 원리 (libuv, 이벤트루프, 워커쓰레드, 비동기) (tistory.com)