NodeJS Event Loop파헤치기

김범준
직방 기술 블로그
19 min readJun 16, 2021

--

안녕하세요! 직방 서비스개발그룹 백엔드팀 아파트파트에서 근무중인 김범준입니다. 이번 포스팅에서는 NodeJS의 Event Loop에 대한 내용을 정리해보려 합니다. 그럼 같이 시작해 볼까요?

<Event Loop 가 무엇인지 설명할 수 있나요?>

NodeJS 기반 서비스를 만들고 있는 직방에서 면접 시 자주 물어보는 질문 중 하나가 바로 이 질문입니다. 면접관으로 참석하면 으레 지원자분께 물어보곤 했는데 과연 나는 얼마나 이 개념을 이해하고 당당히 질문을 던졌는지 복기해보며 반성과 함께 이 포스팅 작성을 시작하겠습니다.

(모두 다 아는!) NodeJS 특징

NodeJS를 경험한 분이라면 다음의 특징을 모두 알고 계실겁니다.

  1. Single Thread 기반
  2. Event Driven 아키텍처
  3. Non Blocking I/O 모델

그런데 특징들을 언뜻 살펴보면 양립하기 힘든 특징이 있는 것 같지 않나요? Single Thread 기반인데 어떻게 Non Blocking I/O를 제공할 수 있는지 말입니다. 그 해답은 Event Loop를 추상화하여 제공하는 libuv 라는 library에 있습니다. libuv에 대해 깊게 알아보기 전에 NodeJS의 구조와 중요 구성요소를 먼저 살펴봅시다.

NodeJS 의존성 구조

공식문서에 따른 NodeJS 구성도를 표현한다면 다음과 같습니다

[출처]: https://blog.usejournal.com/nodejs-architecture-concurrency-model-f71da5f53d1d

NodeJS를 구성하는 많은 라이브러리와 도구가 있지만 가장 핵심적인 역할을 담당하는 중요 요소 2가지를 살펴보겠습니다.

  1. V8: 구글에 의해 개발된 자바스크립트 엔진으로 heap memory 할당, call stack 실행, garbage collector기능과 JS코드를 기계어로 해석하여 OS가 바로 실행할 수 있는 상태로 만들어주는 것이 주된 업무입니다. (NodeJS 환경에서 자바스크립트로 File과 Network의 I/O를 할 수 있는 이유가 여기에 있었네요!)
  2. libuv: 비동기 I/O를 지원하는 C언어 Library로 윈도우, 리눅스 커널을 Wrapping하여 추상화한 구조로 되어있습니다. 커널의 비동기 API (윈도우- IOCP, 리눅스-AIO) 로 지원할 수 없는 작업을 비동기화 하기 위한 별도의 Thread Pool을 가지고 있고 Event Loop, Event Queue를 관리합니다.

자바스크립트 실행을 담당하는 stack 호출은 자바스크립트 엔진인 V8에서 담당하고 비동기 I/O Event Loop는 libuv가 담당하는것을 알 수 있습니다. (그런데 여기서 잠깐!) NodeJS를 구성하고 있는 libuv가 Thread Pool을 가지고 있다면 NodeJS는 Single Thread 기반이 맞는가? 라는 의문이 듭니다.

NodeJS = Single Thread?

아래 코드를 NodeJS에서 실행해 보겠습니다.

FILO (First In Last Out) 특징에 따라 call stack 실행 순서는 다음과 같이 진행될 것입니다.

call stack 실행 순서 (https://www.jsv9000.app/)

그런데 만약 third function에 a+b와 같이 참조할 수 없는 변수 사용, third 자신의 재귀적 호출로 의도적 에러를 발생 시킨다면

의도적인 에러 생성

익숙하게 봤던 stack trace와 함께 에러를 확인할 수 있을 것입니다.

// a + b의 경우
ReferenceError: a is not defined
at third (/Users/example/sample.js:2:3)
at second (/Users/example/sample.js:8:3)
at first (/Users/example/sample.js:13:3)
at Object.<anonymous> (/Users/example/sample.js:16:1)
// 재귀로 third를 호출한 경우
RangeError: Maximum call stack size exceeded
at third (/Users/example/sample.js:2:3)
at third (/Users/example/sample.js:2:3)
at third (/Users/example/sample.js:2:3)
at third (/Users/example/sample.js:2:3)
at third (/Users/example/sample.js:2:3)
at third (/Users/example/sample.js:2:3)
at third (/Users/example/sample.js:2:3)
at third (/Users/example/sample.js:2:3)

위 stack trace를 확인해보면 단 하나의 call stack 에서 실행이 일어나며 동기적으로 실행된다는 것을 알 수 있습니다.

다른 예시도 살펴보겠습니다. 다음은 setInterval을 통해 1초에 한번씩 ‘hi’ 를 찍는 간단한 실행 문구입니다.

setInterval(() => {
console.log('hi');
}, 1000);
// 결과
hi
hi
hi
hi
...

예상대로 1초에 한번씩 ‘hi’ 가 찍히는 것을 확인 할 수 있습니다. 만약 setInterval 다음에 무한루프를 의도적으로 넣는다면 어떻게 될까요?

setInterval(() => {
console.log('hi');
}, 1000);
while(true) {
}
// 결과는??

결과는 아무것도 찍히지 않습니다. setInterval이 만약 스레드로 동작하여 별도의 실행 컨텍스트를 갖는다면 무한루프의 blocking을 만나더라도 실행에 문제가 없을 것입니다. 하지만 결과는 무한루프의 blocking으로 인해 setInterval의 callback이 실행되지 못합니다. 같은 실행 컨텍스트 안에 있기 때문입니다. 즉, 자바스크립트 실행은 Main Thread에 의해서만 진행된다고 볼 수 있습니다.

만약 동기적 call stack 실행만 있다고 가정 했을때 File 또는 Network와 같은 I/O가 느려진다면 남은 실행은 점점 blocking되어 느려질 것입니다. 해당 병목을 해결하기 위해 NodeJS가 선택한 방식이 비동기 callback 프로그래밍 모델인 Event Loop입니다. Single Thread와 궁합이 좋은 방식입니다.

Main Thread == Event Loop

NodeJS에 대한 흔한 오해 중 하나는 자바스크립트 실행을 위한 Main Thread 1개 + Event Loop를 위한 Thread 1개 총 2개의 Thread를 가지고 있다고 생각하는 것입니다. 하지만 이는 사실이 아닙니다. Event Loop는 Main Thread 안에서 실행되며 비동기 callback 작업이 수행될 수 있도록 도와줍니다. Event Loop를 포함한 NodeJS 아키텍처를 다시한번 살펴보겠습니다

[출처]: https://blog.usejournal.com/nodejs-architecture-concurrency-model-f71da5f53d1d (NodeJS Architecture)

libuv 안에 Event Loop + Event Queue + Thread Pool 이 있는걸 보실 수 있을 겁니다. 각각의 요소들을 통해 어떻게 비동기 callback을 수행하는지 그 과정을 같이 알아보겠습니다.

  1. 요청이 들어오면 Event Loop가 해당 요청이 Blocking I/O인지 아닌지 판별합니다.
  2. 커널의 비동기 I/O (윈도우의 IOCP, 리눅스의 AIO)의 지원을 받을 수 있는 Non-Blocking I/O 요청이면 커널의 interface로 해당 요청을 처리 한 후Event Queue에 callback을 등록합니다.
  3. Blocking I/O라면 (예를 들면 File / Network 작업들) libuv 내의 별도의 Thread Pool에서 Worker Thread를 선택하여 작업을 위임합니다. Worker Thread는 작업을 완료한 후 Event Queue로 callback을 등록합니다.
  4. Event Loop는 주기적으로 call stack이 비어있는지 체크하고 Event Queue에 실행 대기중인 callback이 있다면 callback들을 call stack으로 이동시켜 Main Thread에 의해 실행될 수 있게 만들어줍니다.

해당 과정을 한 눈에 볼 수 있도록 표현한 그림이 있어 참조토록 하겠습니다.

[출처]: https://sjh836.tistory.com/149 (빨간색코딩 님)

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

해당 과정을 순차적인 flow로 설명한 영상사이트가 있어서 같이 소개 드리겠습니다. 시간이 되신다면 한번 보시길 추천드립니다 (영상은 10분 부터 보시면 됩니다)

Event Loop Phases

그럼 단순히 Event Loop는 모든 callback을 하나의 Event Queue에 담아서 관리할까요? 그렇지 않습니다. Event Loop의 구조를 좀더 상세히 살펴보면 반복적으로 순회하는 단계들이 있습니다.

[출처]: https://nodejs.org/ko/docs/guides/event-loop-timers-and-nexttick/
  • Timer
  • Pending callbacks
  • idle, prepare
  • Poll
  • Check
  • Close callbacks

총 6개의 단계로 진행되는데 좀더 쉬운 그림으로 보면 다음과 같이 표현할 수 있습니다

[출처]: https://www.voidcanvas.com/nodejs-event-loop/

각 단계에 대해서 한번 알아봅시다

1. Timer

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

2. Pending Callbacks

이 단계에서는 pending_queue에 들어있는 callback들을 실행합니다. pending_queue에는 이전 루프에서 완료된 callback (예를 들면, Network I/O가 끝나고 응답받은 경우) 또는 Error callback 등이 쌓이게 됩니다.

3. Idle, Prepare

Idle 에 함축된 뜻과는 다르게 Event Loop가 매번 순회할때마다 실행되며 4번째 단계인 Poll을 위한 준비작업을 하는 단계입니다.

4. Poll

대기중인 callback을 call stack으로 가장 많이 올려보내는 단계로 이 단계에서는 새로운 수신 커넥션을 위한 소켓과 데이터를 설정합니다. Poll 단계에서는 watch_queue를 바라보며 작업을 수행하는데 만약, Queue가 비어있지 않다면 배정받은 시간동안 Queue가 모두 소진될 때까지 모든 callback을 call stack으로 올려 실행시킵니다.

5. Check

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

6. Close Callbacks

Close 단계는 아래와 같은 close type의 callback을 관리하는 단계입니다.

socket.on('close', () => {})

Event Loop는 이렇게 6개 단계를 반복적으로 순회하며 callback을 처리하는데요 더 자세한 내용을 알고 싶으시다면 evan-moon 님의 블로그 글을 참조해 보시길 추천드립니다.

자, 여기서 문제! 그럼 Event Loop는 Event Queue 한개로만 동작할까요? 답은 아니오 입니다. 위에서 살펴본 각 단계마다 Event Queue를 소유하고 있으며 Event Loop는 각 단계를 돌며 해당 단계의 Event Queue에서 실행할 callback을 call stack으로 이동시킵니다.

Event Loop workflow

NodeJS에서 어플리케이션을 실행하고 종료할때까지 어떤 흐름으로 Event Loop가 실행될까요? 여기 그림을 한번 참조해보겠습니다.

[출처]: https://www.voidcanvas.com/nodejs-event-loop/
node main.js

위 명령으로 main.js라는 node application을 실행시키면 가장 먼저 Event Loop가 활성화 상태인지 체크 합니다. 즉, Event Loop가 진행해야 하는 작업이 있다면 Timer 단계부터 Event Loop가 시작되어 Close Callbacks까지 돌고 다음 루프를 돌기전에 계속 해서 Event loop의 상태를 체크합니다. 만약 Event Loop가 활성화 되지 않아도 되는 상태라면 즉, 앞으로 진행해야 할 작업이 없다면 프로세스를 종료하게 됩니다.

정말 위와 같이 구현되어 있는지 nodejs의 소스코드를 살펴보겠습니다. (Node Version 16.3.0)

NodeJS 어플리케이션이 구동될 때 NodeMainInstance의 Run 함수를 호출합니다. 함수 중간 SpinEventLoop 함수 호출을 확인할 수 있습니다.

//위치 => src/node_main_instance.cc 127 라인int NodeMainInstance::Run(const EnvSerializeInfo* env_info) {
.......
생략
.......
CHECK_NOT_NULL(env);
{
Context::Scope context_scope(env->context());

if (exit_code == 0) {
LoadEnvironment(env.get(), StartExecutionCallback{});

exit_code = SpinEventLoop(env.get()).FromMaybe(1);
}

ResetStdio();
.......
생략
.......
}
.......
생략
.......

return exit_code;
}

SpinEventLoop 함수를 찾아가봅시다. 함수를 살펴보면 uv_run 호출을 볼 수 있는데 이 함수가 바로 Event loop를 생성하는 함수입니다. 중간에 loop가 살아있는지 체크하는 부분도 확인할 수 있습니다.

//위치: src/api/embed_helpers.cc 17라인Maybe<int> SpinEventLoop(Environment* env) {
.......
생략
.......

env->set_trace_sync_io(env->options()->trace_sync_io);
{
.......
생략
.......
do {
if (env->is_stopping()) break;
uv_run(env->event_loop(), UV_RUN_DEFAULT);
if (env->is_stopping()) break;

platform->DrainTasks(isolate);

more = uv_loop_alive(env->event_loop());
.......
생략
.......
} while (more == true && !env->is_stopping());
.......
생략
.......
return EmitProcessExit(env);
}

그럼 uv_run 함수도 살펴봐야겠죠?

//위치: deps/uv/src/unix/core.c 365라인int uv_run(uv_loop_t* loop, uv_run_mode mode) {
int timeout;
int r;
int ran_pending;

r = uv__loop_alive(loop);
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);
uv__run_idle(loop);
uv__run_prepare(loop);


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

uv__io_poll(loop, timeout);

uv__metrics_update_idle_time(loop);

uv__run_check(loop);
uv__run_closing_handles(loop);


if (mode == UV_RUN_ONCE) {
uv__update_time(loop);
uv__run_timers(loop);
}

r = uv__loop_alive(loop);
if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
break;
}
if (loop->stop_flag != 0)
loop->stop_flag = 0;

return r;
}

while문 안쪽을 보면 위에서 설명했던 6개 단계를 순차적으로 실행하는 것을 확인할 수 있습니다. while 마지막 부근에서는 loop가 alive한지 체크하고 alive 하다면 지속적으로 loop를 실행할 것입니다. 위와 같은 방법으로 Event Loop는 각 단계를 순회하며 call stack 으로 실행할 callback을 넘겨주고 Main Thread는 blocking 없이 명령을 실행할 수 있는 것입니다.

정리

Single Thread 기반인 NodeJS가 Event Loop의 도움을 받아 Non-Blocking 수행을 어떻게 진행하는지 긴 시간동안 알아보았습니다. 정리를 짧게 해보자면 다음과 같습니다.

  1. NodeJS에서 자바스크립트의 실행은 Main Thread에 의해서만 수행되고 1개의 call stack을 가집니다.
  2. call stack 실행은 동기적 blocking이기 때문에 NodeJS에서는 이를 극복하기 위해 Single Thread와 궁합이 좋은 비동기 callback 프로그래밍 방식인 Event Loop를 추상화한 libuv library를 사용합니다.
  3. libuv 내의 Event Loop는 Main Thread에 상주하여 자바스크립트 비동기 실행의 임무를 수행합니다. 요청의 특징에 따라 커널 비동기 함수 또는 libuv내의 Thread Pool에 작업을 위임하며 callback을 실행하기 위해 Event Queue에 적재된 callback을 empty상태의 call stack으로 이동시킵니다.
  4. Event Loop는 6개의 단계로 이루어져 있으며 각 단계별로 Event Queue를 소유합니다. Event Loop는 각 단계를 순차적으로 순회하며 반복적으로 callback들을 처리합니다.

마치며 남기는 글

눈에 보이지 않는 개념인 Event Loop는 인터넷 상에 잘못된 자료가 많다고 합니다. 이 글 또한 구글링을 통해 수집한 자료를 토대로 작성한 포스팅인 만큼 틀린 부분이 있을 수 있습니다. 혹시나 자료에 잘못된 부분이 있거나 정정하고 싶은 내용이 있다면 언제든 댓글을 남겨주시기 바랍니다 :)

참고자료

https://blog.usejournal.com/nodejs-architecture-concurrency-model-f71da5f53d1d
https://medium.com/technofunnel/node-js-single-threaded-event-based-architecture-9f73daee37a1
https://betterprogramming.pub/learn-node-js-under-the-hood-37966a20e127
https://evan-moon.github.io/2019/08/01/nodejs-event-loop-workflow/
https://www.voidcanvas.com/nodejs-event-loop/
https://sjh836.tistory.com/149
https://blog.naver.com/pjt3591oo/221976414901
http://docs.libuv.org/en/v1.x/

--

--