본문 바로가기

Project/Node.js

공식문서에서 EventLoop의 MacroQueue를

Event Loop란?

Node.js가 논 블로킹 I/O 작업을 수행할 수 있게 도와주는 기능입니다. 어떻게 도와주냐면, 이벤트 루프가 libuv에 작업을 넘기면서 논 블로킹을 하게끔 도와줍니다. 그래서 자바스크립트가 싱글 스레드로 동작함에도 불구하고 논 블로킹 I/O 작업을 수행할 수 있게 합니다. 이러한 I/O 작업이 완료되면 libuv은 Node.js에 이벤트를 전달하게되고, 콜백을 Polling Queue에 추가해 실행할 수 있게 합니다.

  • 공식 홈페이지에서는 libuv라고 적혀있지 않고 커널(Kernel)이라고 적혀있습니다. libuv가 좀 더 맞다고 생각해 자체적으로 변환했습니다.


Event Loop는 단계를 순차적으로 이동한다. 모든 단계를 한 번 도는 것을 Tick 이라고 한다.

먼저 각 단계에 관해서 간단하게 설명을 하자면, 각 단계는 독립적인 콜백 함수가 저장되는 FIFO(First In, First Out) Queue가 존재하고 있습니다. 위에서 설명한 독립적인 Queue가 됩니다.

이벤트 루프는 각 단계에 진입해서, 현재 실행중인 단계의 Queue에 작업이 전부 소진되거나 최대 콜백 수가 실행될 때까지 해당 단계의 Queue에서 콜백 함수를 실행하게 됩니다. 최대 콜백 수에 대해서는 몇개인지는 정확하게 알아보지 못했습니다. 추후 추가해서 설명하겠습니다.

이러한 작업이 끝나고, Queue에 존재하는 콜백 함수가 없다면? 다음 단계로 이동하게 되는 것입니다. 특히나, Polling 단계에서 처리되는 새로운 이벤트는 libuv(커널)에 의해서 대기열에 저장이 됩니다.

Polling 이벤트가 처리되는 동안에 Polling 이벤트를 대기열에 넣을 수도 있습니다. 따라서 Polling Queue에 들어가 있는 콜백함수를 오래 실행하게 된다면? Polling에 오래 머물러 있게 되는 것입니다. Event Loop가 오래 머물게 된다면 타이머의 임계값보다 훨씬 더 오래 실행될 수 있는 상황이 발생할 수 있습니다.


여기에서 단계별 요약본을 먼저 보고 시작하겠습니다.

Timers

  • setTimeout, setInterval로 스케줄링된 콜백을 실행하게 됩니다.
  • 정확히 예고한 시간에 실행되는 것은 아닙니다. 타이머가 만료된 후 가능한 빠르게 실행이 될 것입니다. 가능한 빠르게 라고 했을 뿐, 우선 처리되지는 않습니다. 이전 단계가 처리중이라면? 지연될 수도 있습니다.
  • 또한, 운영체제 스케줄링이나 기타 콜백 실행으로 인해서 지연될 수도 있습니다.

추상적인 내용을 구체화하자면, 정확히 예고한 시간이라는 것은 setTimeout(function(){}, 100); 100ms에 바로 시작하는 것은 아니라는 것입니다. 그래서 100ms는 임계값입니다. 최소 100ms 이후에 시작이 될 것이다. 라는 의미가 되겠습니다.

Pending Callbacks

  • 특정 작업이 지원된 I/O 콜백을 처리하게 됩니다.
  • 예를들어 일부 시스템 작업인 TCP 에러와 같은 것들이 있습니다. 완료된 이후 호출 되어야할 콜백이 여기에 삽입됩니다.

Idle | Prepare

  • libuv 내부에서 사용되며, Event Loop 초기화와 같은 내부 작업을 처리합니다.
  • 일반적으로 Node.js 사용자 코드에서는 이 단계가 눈에 띄지 않습니다.

   
Poll

  • 대부분의 I/O 작업인 파일 읽기, 쓰기, 네트워크 요청 등이 처리된 콜백이 삽입됩니다.
  • Polling Queue에 대기중인 I/O 작업이 완료된 콜백이 있다면 이를 처리합니다.
  • 해당 단계에서는 대기 중인 타이머가 만료되었는지 확인합니다.

추상적인 내용을 구체화하자면, 대기 중인 타이머가 만료되었는지 확인을 한다고 했는데, 이는 Polling Queue에서 대기중인 콜백을 전부 처리한 후 확인을 하는 것입니다. 그리고 위에서 말했던것 처럼 단계를 순차적으로 돌아본다고 얘기를 했습니다. 사실 이 내용이 정확한지는 모르겠지만, 어쨌든 순차적으로 이동하기 때문에 마지막 순서까지 돌고 새로운 Tick이 시작되었을 때 Timers를 살펴볼 것입니다. 그래서 확인을 할 뿐이지 즉시 Timers로 넘어가는 일은 없을 것입니다.
   
Check

  • setImmediate로 스케줄링된 콜백을 처리합니다.
  • setImmediate는 Poll 단계가 끝난 직후 실행됩니다.

   
Close Callbacks

  • 소켓이나 핸들처럼 리소스가 닫힐 떄 실행되는 콜백을 처리하게 됩니다.
  • 예를 들어, socket.on('close', callback)이 이 단계에서 실행됩니다.

 

위의 대략적인 내용을 정리했고, 조금은 추상적인 내용을 풀어서 설명했습니다. 아래에서 나오는 내용들은 좀 더 자세한 내용이 작성되어 있습니다. 

 

Event Loop는 어떻게 단계를 순차적으로 살펴볼 수 있을 까요?

Node.js의 Event Loop는 위 단계를 순차적으로 반복하게 됩니다. 반복을 Tick이라고 말할 수 있습니다. Event Loop는 항상 Timers 단계에서 시작을 하게 됩니다.

각 단계는 다음 단계로 이동하기 전에, 자신의 Queue에 있는 작업을 모두 처리하거나, 처리 가능한 만큼 처리하게 됩니다. Queue에 있는 작업이 전부 소진되거나, 최대 콜백 갯수를 초과하는 순간이 다음 단계로 넘어가는 순간이 될 것입니다.

Poll 단계진행하고 난 후 만료된 타이머가 존재 한다면? Timers 단계로 돌아가게 됩니다. 그리고 새로운 작업이 없다면 Event Loop는 잠시 쉬어갑니다. 그 사이에 Timers의 임계값을 넘어 콜백 함수가 삽입되었다면 바로 Timers 단계로 넘어가게 됩니다. 여기에서도 바로 Timers 단계로 넘어간다고 작성을 했지만, 제 생각엔 순차적으로 이동하기 때문에 Polling 단계가 종료되고 난다면? Check 단계와 Close Callback 단계로 이동을 하고 나서 Timers로 돌아갈 것으로 예측됩니다.


단계별로 나눠진 이유가 어떻게 될까?

작업의 우선순위를 관리합니다.

  • 서로 다른 유형의 작업인 타이머, I/O 콜백, setImmediate를 우선순위에 따라서 처리하기 위해 단계를 구분하게 되었습니다.

성능의 최적화 입니다.

  • 동일한 유형의 작업을 하나의 단계에서 집중적으로 처리하게 됩니다. 그래서 CPU 캐싱과 메모리 접근 효율을 높이게 됩니다.

구조적 간소화입니다.

  • libuv와 운영 체제 스케줄링을 기반으로 합니다. 작업의 흐름을 명확히 정의하기 위해 단계적으로 설계되어 있습니다.

 

아래 내용에서는 Poll과 Check 단계에 대해서 자세하게 설명을 하고 있습니다.


   
Poll 단계의 역할

Poll Queue에 작업이 있다면? Timers 단계로 넘어가기까지 시간이 오래 걸릴 수 있습니다. 그래서 Timers에 들어간 콜백들은 임계값을 정하고 가게되는데, 그 임계값이 최소이고 어디까지 늘어날지는 예측할 수 없습니다. Poll Queue가 비어있다면? 즉시(즉시라기보단, Check 단계에서도 존재하지 않으면 Timer로 넘어가지 않을까?) Timers 단계로 돌아가 만료된 타이머 콜백을 처리하게 됩니다.

왜 Poll Queue 떄문에 Timers의 콜백함수들이 느려지게 될까?

Event Loop는 단일 스레드에서 순차적으로 작업을 처리하기 떄문입니다. 그래서 Poll Queue에 들어가 있는 작업들을 처리중에 있다면, 임계값만큼의 시간이 지났어도 대기 상태로 존재할 수 있습니다.



Check 단계에 대해서 설명해보겠습니다.

Check는 Polling 단계가 완료된 직후에 콜백을 실행하게 됩니다. 만약에, Timers와 Check에 둘다 들어가 있는 상태라면Poll 단계에 아무런 값이 없고, Check Queue에 setImmediate()가 대기열에 존재한다면? 이벤트 루프는 대기하지 않고 Check 단계로 진행될 수 있습니다.

setImmediate()는 실제로 이벤트 루프의 별도 단계에서 실행되는 특별한 타이머입니다. 이 타이머는 폴링 단계가 완료된 후 콜백을 실행하도록 예약하는 libuv API를 사용하고 있기 때문입니다.

여기에서도 보면, Poll 단계의 콜백함수가 전부 정리가 된다면? 다음 단계인 check로 이동하는 것을 알 수 있습니다. 즉시 Timers로 간다는 것보다는, 확실하게 Check나 Close Queue에 콜백이 없다면? 바로 Timers로 이동한다고 생각할 수 있습니다.


Timers의 setTimeout, Check의 setImmediate

호출되는 방식이 다른 함수들입니다. 

setImmediate()는 현재 Poll 단계가 완료되면 스크립트를 즉시 실행하도록 설계되어 있습니다. setTimeOut()은 최소 임계값(ms)가 경과한 후 스크립트를 실행하도록 예약합니다. 여기서 예약이란 최소 시간(ms) 이후에 실행될 것임을 알리는 것이라고 볼 수 있습니다. Timers가 실행되는 순서는 Timers가 호출되는 컨텍스트에 따라 달라지게됩니다. 두 Timers가 모두 메인 모듈 내에서 호출되는 경우 타이밍은 프로세스의 성능에 따라 제한될 것입니다. 

이벤트 루프의 초기화 상태가 있습니다. setTimeout은 알다시피 Timer에 들어가고, setImmediate()는 Check에 배치되게 됩니다. 이 두 콜백을 서로 다른 단계에서 실행이 되지만, 메인 모듈에서 호출될 경우 초기 이벤트 루프에서 두 단계가 겹치는 타이밍 문제로 인해 실행 순서가 불확실해집니다. Poll 단계가 예상보다 빠르게 실행되면 타이머가 준비되지 않았다면 Poll부터 시작될 수 있습니다.

두 단계가 겹치는 타이밍 문제라는 것은, 제 생각대로 풀이하자면 어떤 순서가 먼저 시작될지 Event Loop의 단계에 따라서 달라질 것이라는 의미라고 생각이 됩니다. 그래서 확실히 어떤 값이 빠르게 될지 예측할 수 없습니다.

fs.readFile(__filename, () => {

   setTimeout(() => {
      console.log('timeout');
   }, 0);
   
   setImmediate(() => {
      console.log('immediate');
   });
});


위 코드처럼 I/O 사이클, I/O 사이클이라는 것은 Polling Queue에 삽입되는 내용이 되겠죠? 그렇다는 얘기는 Check 단계에 존재하고 있는 setImmediate의 콜백이 먼저 실행될 것입니다. Timers의 수에 관계없이 바로 실행됩니다. setTimeOut이 아무리 빨라도, I/O 주기 내에 예약된 경우 항상 타이머 전에 실행될 것입니다.

다시 풀어 쓰자면? I/O 작업이후, 한 Tick상(Event Loop의 순차적 이동)에서는 다음 단계인 Check가 먼저 도달하게 됩니다. 그래서 I/O 주기 내에서 setTimeout()과 setImmediate()가 존재한다면? setImmediate()를 먼저 처리해야합니다.

그래서 I/O 작업 이후 바로 실행해야 할 콜백은 setImmediate()를 사용해야하고, 특정 지연시간이 필요한 경우에는 setTimeout()을 사용하면 됩니다.

Polling의 단계로 인해서 Check 단계가 Timers보다 앞서 실행될 수 있다는 점을 설명했습니다. 근데, 꼭 I/O 작업이 있는 곳에서만 setTimeout과 setImmediate를 사용하는 함수는 아니기에, 이 함수 말고 다른 방법을 사용할 순 없을까요?



Process.nextTick()

Process.nextTick()은 사실 이벤트 루프의 일부가 아닙니다. 대신 이에 따른 결과는 이벤트 루프의 현재 단계에 관계없이 현재 작업이 완료된 후 처리됩니다. process.nextTick()은 호출할 때마다, 이벤트 루프의 사이클 전에 해결이 됩니다. 이를 재귀적으로 호출하게 된다면? I/O를 계속해서 동작하지 못하게 막을 수 있습니다. 그래서 좋지 못한 결과를 낳을 수도 있습니다. Event Loop가 단계를 실행하지 못하게 하는 것을 의미합니다. 우리는 MicroTask, Macro 이러한 Queue들이 존재한다는 것을 미리 알고 있습니다. Process.nextTick() 또한 nextTick Queue를 갖고 있습니다.


그렇지만 왜 쓰게 할까요?

명확한 타이밍에 콜백함수를 사용하기 위해서 입니다. 그리고 API가 필요하지 않은 곳에서도 항상 비동기식으로 동작해야한다는 철학때문입니다.

function apiCall(arg, callback){
   if(typeof arg !== 'string'){
      return process.nextTick(callback, new TypeError('~');
   }
}

해당 코드는 인수를 확인하고, 올바르지 않은 경우 오류를 콜백에 전달하게 될 것입니다.

process.nextTick()을 통해 전달된 arg, callback을 다시 전달하게 될 것입니다. 이를 통해 process.nextTick()을 사용해 apiCall()이 항상 먼저 실행하도록 보장하게 되는 것입니다. 이를 위해 현재 실행중인 이벤트 루프의 현재 단계가 끝난 후, JS 콜스택이 비워진 시점에 실행됩니다. 이를 통해 다른 작업들인 이벤트 핸들러, 타이머들 보다 우선적으로 실행됩니다. 이는 동기식으로 에러를 반환하지 않고, 비동기적으로 코드 흐름을 유지할 수 있게 해줍니다.

  • 현재 이벤트 루프의 끝에서 콜백을 실행합니다.
  • nextTick Queue에 콜백을 추가하기 떄문에, 이벤트 루프의 다음 단계보다 먼저 실행됩니다.
  • 현재 실행 중인 작업이 끝난 직후, 다른 이벤트가 처리되기 전에 실행됩니다.

process.nextTick()의 역할

callback이 항상 비동기적으로 호출됩니다. 즉, js 콜 스택이 비워진 후 실행되므로, 재귀 호출 같은 경우에도 스택 오버플로를 방지할 수 있습니다. 콜백 순서를 제어합니다.

process.nextTick()은 이벤트 루프의 다른 작업(Timers, I/O)보다 항상 우선 실행됩니다. 이를 통해 항상 우선 실행되도록 보장합니다.

동기적으로 에러를 발생시키는 것의 문제점입니다. 아래의 코드를 보고 파악할 수 있는데, 동기적으로 실행한다면 우리가 원하는 에러가 발생하지 않을 수 있습니다.

let bar;

function someAsyncApiCall(callback) {
   callback();
}

someAsyncApiCall(() => {
   consolelog('bar', bar); // undefined
});

bar = 1;

 

콜백이 호출되었을때, bar가 아직 초기화되지 않은 상태입니다. 그래서 undefined를 출력하게 되고, 이를 process.nextTick()으로 해결해야합니다.

let bar;

// 콜백을 비동기적으로 실행
function someAsyncApiCall(callback) {
  process.nextTick(callback);
}

// 콜백에서 초기화된 변수를 참조
someAsyncApiCall(() => {
  console.log('bar', bar); // 1
});

bar = 1;


파일을 읽는 코드에서 에러를 동기적으로 발생시킨다면? 호출자가 이를 잡지 못할 수 있다.

const fs = require('fs');

function readFileAsync(file, callback) {
  if (typeof file !== 'string') {
    // 에러를 즉시 발생시킴
    callback(new TypeError('The file name must be a string'));
    return;
  }

  fs.readFile(file, callback);
}

readFileAsync(123, (err, data) => {
  if (err) console.error(err); // 이 부분이 실행되지 않을 수도 있음
});

// 종료가 된 상태이기 떄문에, 다시 실행되기엔 말이 안되지.

const fs = require('fs');

function readFileAsync(file, callback) {
  if (typeof file !== 'string') {
    // 에러를 process.nextTick()으로 예약
    process.nextTick(callback, new TypeError('The file name must be a string'));
    return;
  }

  fs.readFile(file, callback);
}

readFileAsync(123, (err, data) => {
  if (err) console.error(err); // TypeError가 안전하게 출력됨
});

 

비동기적으로 전달되어서, 호출자가 적절하게 처리할 시간을 가질 수 있게 됩니다. process.nextTick()은 현재 이벤트 루프 단계가 완료된 직후 실행하도록 예약합니다. 그래서 이를 통해 비동기적인 동작을 보장하고, 변수 초기화 및 이벤트 핸들러 등록과 같은 작업이 완료된 후 콜백을 실행할 수 있습니다. 에러를 안전하게 처리하고, 이벤트 핸들러가 등록될 시간을 보장합니다.

 

애매하거나 틀린 부분

  • Event Loop의 시작점이 항상 Timers 단계다?
    • Event Loop는 특정 조건에 따라 시작 단계가 다를 수 있습니다. Node.js가 실행 중인 환경, 이벤트 대기 여부 등에 따라 시작 지점은 다를 수 있으므로 "항상 Timers 단계에서 시작한다"는 표현은 지나치게 단정적입니다.
    • 공식 문서에서는 Poll 단계가 Loop의 중심적 역할을 한다고 명시하고 있습니다.
  • Polling 단계가 끝난 후 Timers로 바로 이동?
    • Poll 단계가 끝나도 Timers로 바로 이동하지 않습니다. Event Loop는 모든 단계를 순서대로 순회하며 작업이 없을 경우에만 다음 단계로 넘어갑니다.
    • 또한, Poll 단계에서 타이머 임계값이 초과되었는지 확인한 후에야 Timers로 돌아갑니다. "Polling Queue에서 대기 중인 작업이 모두 처리된 후에도 Check, Close Callbacks를 건너뛰고 Timers로 바로 간다"는 표현은 부정확합니다.
  • setTimeout의 임계값 최소 시간이 정확히 100ms?
    • setTimeout의 임계값은 Node.js 또는 브라우저 환경에 따라 다를 수 있습니다. 100ms는 일반적인 예시일 뿐 정확한 값은 환경에 따라 변동됩니다.

추가하면 좋을 내용

  • Microtasks와 Macrotasks
    • Node.js의 Event Loop는 Microtasks QueueMacrotasks Queue를 분리하여 처리합니다.
      • process.nextTick()은 Microtasks Queue에 추가되며, 이벤트 루프의 단계와 관계없이 가장 우선순위가 높습니다.
      • Promise의 .then()도 Microtasks Queue에서 처리됩니다.
      • Macrotasks는 Timers, I/O 콜백 등 Event Loop의 단계별 작업에서 처리됩니다.
    • Microtasks는 단계 사이에서 처리되기 때문에 "Tick과 단계의 차이"를 명확히 설명할 필요가 있습니다.
  • libuv와 커널의 역할 구분
    • libuv는 비동기 I/O와 이벤트 루프를 지원하는 C++ 라이브러리입니다. 여기서 Polling, Timers, Idle 등 주요 Event Loop 동작을 관리합니다. 하지만 파일 I/O와 같은 작업은 운영체제의 커널로 위임됩니다. "libuv가 모든 I/O를 처리한다"고 기술한 부분은 애매한 표현입니다.

 

읽어봐야하는 내용.

https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick

https://blog.logrocket.com/complete-guide-node-js-event-loop/