안녕하세요. 개발팀 프론트엔드 개발자 에이든입니다. 오늘은 이벤트루프에 대해서 보다 깊게 알아 보는 글입니다.
이벤트루프를 자세히 알아 보다 보면 NodeJS 환경이 어떻게 구성되어 있고, 어떻게 동작하는지 이해할 수 있고 이걸 이용해서 어플리케이션의 성능을 끌어 올리고 효율적인 아키텍처를 구성할 수도 있습니다.
Intro
자바스크립트는 싱글 스레드 기반에 언어라고 합니다. 싱글 스레드라는 것은 하나의 메인 스레드와 하나의 콜스택을 갖는다는 것을 의미합니다. 그리고 하나의 콜스택을 갖는다는 것은 하나의 실행 컨텍스트를 가진다는 것이고 한 번에 하나의 작업만 수행할 수 있다는 것을 의미합니다. 그럼 결국 동기적으로 작업을 수행하게 됩니다.
그런데 자바스크립트는 비동기 처리를 지원하는 언어입니다. 그럼 하나의 스레드만 사용하는 언어가 비동기 처리를 지원하는 것일까요?
그것의 해답은 Event Loop에 있습니다. 이 글에서는 Event Loop에 대해서 자세히 알아 보도록 하겠습니다.
그런데 그 전에 우리는 비동기와 동기에 대해 좀 더 자세히 알아보죠. 왜냐하면 비동기가 무엇이고 동기가 무엇인지 정확하게 알아야 Event Loop의 동작을 조금 더 명확하게 이해할 수 있기 때문입니다.
자바스크립트 Synchronous/Asynchronous & Blocking/Non-blocking
사실 비동기와 동기 개념은 자바스크립트에 국한된 개념이 아닌, 프로그래밍 세계 전반에서 사용되는 개념입니다. 어떤 작업을 처리할 때 효율성, 철학에 맞게 채택되어, 혹은 혼합되어 사용되고 있죠. 이 글에서는 자바스크립트로 예를 들어 설명하도록 하겠습니다.
Synchronous/Asynchronous
먼저 Synchronous와 Asynchronous의 단어 의미부터 알아보죠. 잠시 구글 AI의 힘을 빌려 보겠습니다. Thank you Google!
Syn은 그리스어로 함께, chrono는 시간을 의미하고 두 개의 단어가 합쳐져 ‘동시에 일어나는’, ‘시간을 맞추는’이라는 뜻을 갖게 되었습니다. 그리고 Synchronous 앞에 부정을 나타내는 ‘A’ 가 붙어 Asynchronous는 ‘시간을 맞추지 않는’ 이라는 뜻을 갖게 되었습니다. 재밌지 않나요? 벌써 개념이 조금은 이해되지 않나요?
그럼 이제 코드와 함께 설명해보겠습니다.
자바스크립트를 사용해 보신 적이 있으시다면 setTimeout이라는 함수가 자바스크립트의 대표적인 비동기 함수라는 것을 아실 것입니다. 예시 코드를 한 번 살펴 볼까요?
setTimeout(() => console.log('a'), 3000)
setTimeout(() => console.log('b'), 1000)
setTimeout(() => console.log('c'), 2000)
JavaScript
복사
로그의 순서는 어떻게 나올까요? 우리는 b, c, a순으로 나올 거라는 걸 예상할 수 있습니다. 결과의 출력 순서가 코드의 실행 순서와 다르죠? 그럼 다른 예시를 한 번 보죠.
fs.readFile('file1.txt', 'utf-8', function(error, data) {
console.log('1 readAsync: %s',data);
});
fs.readFile('file3.txt', 'utf-8', function(error, data) {
console.log('1 readAsync: %s',data);
});
fs.readFile('file2.txt', 'utf-8', function(error, data) {
console.log('1 readAsync: %s',data);
});
JavaScript
복사
이전에 본 setTimeout 예시에서는 순서를 예상할 수 있었습니다. 그런데 이번 예시에서는 어떤 파일 먼저 출력할지 모릅니다.
이렇게 실행 결과가 코드의 실행 순서에 맞게 나오지 않고, 순서가 보장되지 않는 작업 처리 방식을 비동기라고 합니다. 그리고 setTimeout, fs.readFile함수와 같은 함수를 비동기 함수라고 합니다.
반대로, 코드의 실행 순서에 맞게 결과가 나오는 것을 동기라고 합니다. 예시 코드를 보죠.
console.log("Task 1 completed");
console.log("Task 2 completed");
console.log("Task 3 completed");
// output
// Task 1 completed
// Task 2 completed
// Task 3 completed
JavaScript
복사
const fs = require("fs");
console.log("Start");
const data = fs.readFileSync("example.txt", "utf8");
console.log("File content:", data);
console.log("End");
// output
// Start
// File content: Hello, World!
// End
JavaScript
복사
출력의 순서가 코드 실행의 순서와 동일합니다. 동기와 비동기 개념의 키는 순서입니다. 이전 코드의 결과를 기다리고 다음 코드를 실행 하는 방식으로 순차적으로 처리하면 동기, 결과를 기다리지 않고 다음 코드를 그대로 실행하면 비동기입니다.
Blocking/Non-blocking
동기 비동기와 함께 자주 나오는 개념이 블로킹, 논블로킹입니다. 동기와 비동기가 순서에 관점에서 본 것이라면, 블로킹과 논블로킹은 제어권에 관점에 본 것입니다.
아래 그림을 보면 A가 작업을 진행 중인데, B의 작업이 실행되면서 제어권이 넘어갔습니다. 이때 A는 작업을 즉시 멈추고, B의 작업이 끝날 때까지 기다린 다음, 제어권을 넘겨 받아 이어서 작업을 진행하게 됩니다.
아래 그림은 B에게 제어권이 중간에 넘어가지만, 즉시 돌려 받아 작업을 멈추지 않고 계속 진행합니다. 이것이 논 블로킹입니다.
Async + Non-blocking
자바스크립트가 I/O를 처리할 때 일반적으로 사용하는 방식입니다. 호출자는 제어권을 넘겨주지도 않고 비동기 작업의 순서도 신경 쓰지 않고 오는 대로 처리합니다. 예제 코드를 보시죠.
const fs = require("fs");
console.log("1️⃣ 파일 읽기 시작");
const data = fs.readFile("example.txt", "utf8", (err, data) => {
console.log(data);
}); // 블로킹
console.log("📄 파일 내용:", data);
console.log("2️⃣ 파일 읽기 완료");
JavaScript
복사
Sync + Blocking
호출자는 I/O 작업을 수행하기 위해 함수를 호출하고 제어권을 넘깁니다. 제어권이 넘어 간 호출자는 제어권이 돌아 올 때까지 다른 일을 수행하지 못하고 기다리고 있습니다.
const fs = require("fs");
console.log("1️⃣ 파일 읽기 시작");
const data = fs.readFileSync("example.txt", "utf8"); // 블로킹
console.log("📄 파일 내용:", data);
console.log("2️⃣ 파일 읽기 완료");
JavaScript
복사
Sync + Non-blocking
Sync + Non-blocking 은 호출자가 제어권을 다른 프로세스로 넘기지는 않지만 응답이 올 때까지 기다립니다.
class MyTask implements Runnable {
@Override
public void run() {
// 비동기로 실행할 작업
System.out.println("Hello from a thread!");
}
}
public class Main {
public static void main(String[] args) {
// Thread 객체 생성
Thread thread = new Thread(new MyTask());
// 스레드 실행
thread.start();
// Non-Blocking이므로 다른 작업 계속 가능
System.out.println("Main thread is running...");
// Sync를 위해 스레드의 작업 완료 여부 확인
while (thread.isAlive()) {
System.out.println("Waiting for the thread to finish...");
}
System.out.println("Thread finished!");
System.out.println("Run the next tasks");
}
}
Java
복사
스레드를 별도로 생성하여 메인 스레드가 블로킹 되지 않도록 하여 메인 스레드가 다른 작업이 가능하지만, while문으로 스레드 작업을 완료할 때 까지 기다리고 있습니다. 결국 동기적으로 수정된다고 볼 수 있습니다.
이러한 케이스는 위 두 조합에 비해 많지는 않지만 가끔 사용되는 조합입니다. 좋은 활용 사례를 보여주는 게 게임 화면입니다.
이 게임은 각 사용자고 모두 준비 상태가 되어야 게임에 돌입합니다. 병렬적으로 각 사용자에게 요청하고 게임 클라이언트는 모든 사용자가 완료 상태가 될 때까지 애니메이션, 프로그레스 바 그리기 등을 수행합니다.
Async + blocking
이 조합은 Sync + Blocking과 동작에 있어 별 차이도 없고 성능상 차이도 없어 프로그래밍 세계에서도 거의 사용하지 않는 방식이라고 합니다.
Event Loop 맛보기
자바스크립트가 NodeJS 환경에서 싱글스레드로 운영이 가능한 이유는 이벤트 루프에 있다고 하였습니다. 그럼 이벤트 루프가 어떻게 싱글스레드 운영을 가능하게 하는지 살펴 보겠습니다.
Event Loop의 역할
이벤트 루프는 Libuv라고 하는 NodeJS의 구성요소에 의해 관리됩니다. libuv는 비동기 처리 요청을 감지하고 커널이나 스레드 풀에 비동기 처리를 위임 합니다. 그리고 비동기 처리가 완료 되고 콜백을 실행할 준비가 되면 태스크 큐에 등록합니다. 이벤트 큐는 콜 스택을 계속적으로 체크하여 비어 있으면 태스크 큐에서 태스크를 꺼내어 콜 스택으로 전달합니다. 간단히 정리하면 아래와 같이 동작합니다.
1.
setTimeout 실행
2.
libuv 콜백 전달
3.
delay 시간이 만료되면 태스크 큐에 콜백 전달
4.
이벤트 루프가 콜 스택이 비어 있는지 확인하고 비어 있다면 큐에서 태스크를 꺼내어 전달
5.
콜 스택에서 콜백 실행
이번에는 예제를 통해 살펴보죠. 너무나도 쉬운 예제이기 때문에 이벤트 루프에 대해 조금이라도 아시는 분이라면 바로 답을 맞출 수 있을 것입니다.
const bar = () => {
setTimeout(() => console.log("Second"), 0);
};
const foo = () => console.log("First");
const baz = () => console.log("Third");
bar();
foo();
baz();
JavaScript
복사
답이 예상 되시나요? 정답은 이렇습니다.
그러면 왜 이런 순서를 출력 되는지 동작 과정을 살펴 볼까요?
1.
동기 함수인 bar 호출
2.
콜 스택에서 bar가 실행됨
3.
bar 내부에 비동기 함수인 setTimeout이 호출
4.
setTimeout은 콜 스택에서 실행 되고 콜백은 webAPI로 인계됨
5.
foo가 호출되고 콜스택에서 실행됨, ‘First’ 출력
6.
baz가 호출되고 콜스택에서 실행됨, ‘Third’ 출력
7.
setTimout의 delay시간이 만료되면 콜백이 태스크 큐에 등록됨
8.
이벤트 루프가 콜 스택이 비어 있는지 체크하고 비어 있다면 콜 스택에 전달, 콜백 실행, ‘Second’ 출력
이런 과정으로 인해 출력 순서가 First → Third → Second 순으로 나오게 됩니다. 그럼 다음 예제를 볼까요? 이번에도 출력 순서를 맞춰 보세요.
console.log('start'); // 동기 코드
setTimeout(() => { // 비동기 코드
console.log('timeout');
}, 0);
Promise.resolve('Task completed').then(res => console.log(res)); // 비동기 코드
console.log('end'); // 동기 코드
JavaScript
복사
이번에도 정답이 예상 되시나요? 정답은 다음과 같습니다.
정답 보기
어떤가요? 정답을 맞추셨나요? 해답는 Microtask Queue에 있습니다. 그냥 태스크 큐만 있는 거 아니었어? 하실 수도 있습니다. 뒤에서 자세히 다루어 보죠.
NodeJS의 비동기 처리 구조
이벤트 루프를 좀 더 Deep하게 이해하기 위해서는 NodeJS가 어떻게 생겼는지 부터 알아야 합니다. 그래서 NodeJS 구조에 대해 살펴 보겠습니다.
NodeJS 기본 구조
•
Core library - 개발자들이 사용하는 ‘path’ 와 같은 라이브러리를 의미합니다. 일반적인 자바스크립트 코드라고 생각하시면 됩니다.
•
Node Bindings - 자바스크립트 코드로 I/O, 네트워크, 하드웨어 API에 접근할 때 C++코드와 연결하는 역할을 합니다
•
V8 엔진 - Javascript 엔진으로 코드를 해석하고 실행하는 역할을 합니다. V8엔진에 대해 자세히 다뤄 보면 좋겠지만 이번 글에서는 다루지 않겠습니다. 궁금하신 분은 링크를 참고해주세요.
•
Libuv - 이벤트 루프와 스레드 풀을 관리하고 I/O를 비롯한 다양한 비동기 작업을 관리하고 최적화합니다. 이번 글에 핵심입니다.
libuv
이벤트 루프에 있어 NodeJS 구성요소 중에서 가장 중요한 역할을 하는 것은 libuv입니다. libuv는 NodeJS에서 사용하는 커널을 추상화한 비동기 I/O 라이브러리입니다. Windows, MacOS, Linux등 멀티 플랫폼을 지원합니다. libuv가 하는 역할은 아래와 같습니다.
•
이벤트 루프 관리
•
커널이 처리하는 여러 I/O 지원
•
스레드 풀 관리
사실 위 두 가지 말고도 libuv가 하는 일이 많지만, 이벤트 루프를 설명하기엔 이 정도만 다루어도 될 것 같습니다.
위 그림은 NodeJS 시스템이 어떻게 동작하는지 간단하게 그린 그림입니다.
자바스크립트 코드는 V8엔진에서 실행하고 비동기 작업들은 libuv로 넘어 오게 됩니다. 그럼 lib는 커널이 수행할 수 있는 비동기 I/O작업인지 판단합니다. 만약, 커널에서 처리할 작업(Domain look up, TCP, UDP)이라면 커널한테 작업을 위임합니다. 커널이 지원하지 않는 비동기 작업(압축, 암호화, 파일 시스템)이라면 libuv 내부에 존재하는 워커 스레드 풀로 작업을 위임합니다. 그리고 커널과 스레드 풀은 작업이 완료되면 결과를 libuv로 반환합니다.
커널 지원 작업
커널 미지원 작업
동작 순서를 정리하면 다음과 같습니다.
1.
비동기 작업 libuv에 요청
2.
해당 비동기 작업의 콜백을 libuv에 등록
3.
libuv는 커널 지원 작업인지 판별
4.
커널 지원 작업이면 커널에 작업을 위임, 아니면 스레드 풀에 위임
5.
작업이 완료 되면 커널과 스레드 풀은 libuv에 응답 반환
6.
libuv는 등록되어 있는 콜백을 태스크 큐에 넣는다.
7.
콜 스택이 비어 있으면 태스크 큐에서 콜백을 꺼내어 실행
비동기 콜백은 libuv에 등록된다고 하였는데, 이 콜백 은 카테고리화된 여러 구조체에 나뉘어 저장되어 있습니다.
•
uv_fs_t - 파일 시스템 콜백
•
uv_connect_t - 네트워크 콜백
•
uv_timer_t - 타이머 콜백
스레드 풀
NodeJS가 싱글 스레드(메인 스레드)로 동작할 수 있는 이유는 스레드 풀이 있기 때문입니다. 스레드 풀은 비동기 작업을 위임 받아 처리하는 역할을 합니다. 하나의 스레드를 생성/소멸 시키는 것은 비용과 시간이 들어가기에 미리 풀을 만들어 놓고 할당/반납하는 방식으로 재사용합니다. 하지만, 너무 많은 스레드를 만들어 놓는 것도 자원 낭비가 될 수 있기 때문에 NodeJS는 기본 4개로 스레드를 제한하고 있습니다. 또한 UV_THREADPOOL_SIZE 변수를 이용해서 스레드 풀의 사이즈를 조정할 수도 있습니다. 최대로 128개까지 설정 가능합니다.
스레드 풀을 사용한다고 하나의 비동기 작업이 멀티 스레딩이 되는 것은 아닙니다. 하나의 작업은 하나의 스레드에서만 처리됩니다. 작업을 멀티 스레딩으로 처리하기 위해서는 worker_threads 모듈을 사용해야 합니다.
UV_THREADPOOL_SIZE 설정
export UV_THREADPOOL_SIZE 10
JavaScript
복사
Macrotask Queue & Microtask Queue
이전 섹션인 Event Loop 맛보기 섹션에서 보았던 아래 코드를 기억하시나요? 해답은 Microtask Queue에 있다고 말씀드렸는데요. 이전 섹션에서 본 태스크 큐는 Microtask queue와 Macrotask queue로 나뉩니다.
then 함에 있는 console.log가 timeout 보다 먼저 실행된 이유는 setTimeout 콜백이 실행되는 큐보다 Microtask queue가 먼저 실행되었기 때문입니다.
console.log('start'); // 동기 코드
setTimeout(() => { // 비동기 코드
console.log('timeout');
}, 0);
Promise.resolve('Task completed').then(res => console.log(res)); // 비동기 코드
console.log('end'); // 동기 코드
JavaScript
복사
Microtask Queue
위에 그림을 보면 Microtask Queue(이하 마이크로 큐)가 없는 것을 확인할 수 있습니다. 마이크로 큐는 이벤트루프와는 별개로 독립적인 공간에 있습니다. 마이크로 큐는 libuv이 관리하지 않고, V8엔진이 관리합니다. 마이크로 큐는 다음 특징을 가집니다.
•
매크로 큐처럼 단계별로 순번이 오면 실행하는 것이 아닌 콜백 함수가 준비 상태가 되면 바로 등록되어 실행됩니다.(단, 큐의 순서대로)
•
마이크로 큐는 이벤트 루프의 각 페이즈가 종료되고 틱 되기 전에 실행되어 큐가 다 비어질 때까지 계속 실행합니다.
•
마이크로 태스크 큐는 매크로 큐와 다르게 최대 실행 가능한 태스크 개수가 없습니다.(따라서, 무한 루프가 돌지 않도록 주의해야 합니다. 이벤트 루프로 못 넘어 갈 수 있습니다.)
•
매크로 큐보다 우선 순위가 높습니다.
•
마이크로 큐에 등록되는 함수로는 PromiseJob이 있습니다. Promise.then, catch, finally가 여기에 해당합니다.
Animation Frame Queue(Render Queue)는 브라우저 환경에서 애니메이션을 렌더하기 위해 존재하는 큐로 requestAnimationFrame 콜백이 실행되는 큐입니다. 마이크로 큐, 매크로 큐와는 별개의 큐로 존재하여 repaint 직전에 큐에 담긴 콜백을 실행한다.
마이크로 큐의 동작 순서는 아래와 같습니다.
1.
동기 코드 실행(Call stack)
2.
마이크로 큐 실행
3.
Timer 큐 실행
4.
마이크로 큐 실행
5.
Pending 큐 실행
6.
마이크로 큐 실행
7.
Poll 큐 실행
8.
마이크로 큐 실행
9.
Check 큐 실행
10.
순환
console.log('start'); // 동기 코드
setTimeout(() => { // 비동기 코드
console.log('timeout');
}, 0);
Promise.resolve('Task completed').then(res => console.log(res)); // 비동기 코드
console.log('end'); // 동기 코드
JavaScript
복사
그럼 이 코드가 이제는 이해 되시나요? timer 큐보다 우선 순위가 높은 마이크로 큐가 먼저 실행되어 ‘timeout’보다 ‘Task completed’가 먼저 실행되게 된 것입니다.
nextTick Queue
javascript는 이벤트 루프 진입 전 먼저 안전하게 비동기 처리를 하기 위한 process.nextTick 함수를 제공합니다. 이 함수에 콜백을 담으면 마이크로 큐와 별도로 nextTick Queue라는 곳에 등록되게 됩니다. 마이크로 큐와 비슷하게 동작하지만, 마이크로 큐와는 무관한 큐이며, 마이크로 큐보다 높은 우선 순위를 갖습니다.
1.
동기 코드 실행(Call stack)
2.
nextTickQueue 실행 후 마이크로 큐 실행
3.
Timer 큐 실행
4.
nextTickQueue 실행 후 마이크로 큐 실행
5.
Pending 큐 실행
6.
nextTickQueue 실행 후 마이크로 큐 실행
7.
Poll 큐 실행
8.
nextTickQueue 실행 후 마이크로 큐 실행
9.
Check 큐 실행
10.
순환
console.log("1️⃣ 동기 코드 실행");
setTimeout(() => console.log("⏳ setTimeout 실행 (Timers Phase)"), 0);
Promise.resolve().then(() => console.log("⚡ Promise 실행 (Microtask Queue)"));
process.nextTick(() =>
console.log("🔥 process.nextTick 실행 (Next Tick Queue)")
);
console.log("2️⃣ 동기 코드 종료");
JavaScript
복사
주의할 점
앞에서 말했듯 이, 마이크로 큐와 nextTaskQueue는 실행 한도가 없습니다. 따라서 무한루프가 되지 않도록 주의가 필요합니다.
function endlessMicrotask() {
Promise.resolve().then(() => {
console.log("♾️ 무한 마이크로태스크 실행");
endlessMicrotask();
});
}
endlessMicrotask();
setTimeout(() => {
console.log("timeout");
}, 0);
console.log("✅ 동기 코드 실행 완료");
JavaScript
복사
Event loop Phase
앞에서도 잠깐 언급 했듯, 이벤트 루프는 사실 하나의 큐만 가지고 운영되는 것이 아닙니다. 6단계에 걸쳐 운영 되며, 각 단계별로 독립적인 큐를 가지고 있어 단계별로 맡은 이벤트 콜백들을 실행합니다. 이 단계들은 순서가 존재하며, 1~6단계까지 다 실행되면 다시 1단계로 돌아와 순환하며 실행 되죠. 그래서 이벤트 루프라고 불립니다.
이벤트 루프는 6단계로 되어 있습니다.
1.
Timer - setTimeout(), setInterval()
2.
I/O Callbacks(Pending) Phase - TCP Error와 같은 일부 I/O 콜백을 처리합니다.
3.
Idle, Prepare Phase - 이벤트루프 내부에서 사용하는 단계입니다.
4.
Poll Phase - 대부분의 I/O 콜백들이 실행되는 단계입니다.
5.
Check Phase - setImmediate 콜백이 실행됩니다.
6.
Close Callbacks Phase - 소켓이나 헨들러가 socket.destroy 와 같은 함수 실행으로 종료 되었습니다.
각 페이즈는 태스크를 독립적으로 가지고 있습니다. 이런 태스크 큐를 통칭하여 Macrotask Queue라고 합니다.
각 페이즈마다 큐에 있는 태스크를 다 꺼내어 실행하거나 각 페이즈마다 실행할 수 있는 최대 개수만큼 실행 후 다음 페이즈로 넘어 갑니다. 이때 다음 페이즈로 넘어 가는 동작을 Tick 이라고 합니다.
Timer
setTimeout과 setInterval의 콜백이 실행되는 단계입니다. 타이머 실행이 요청되면 min-heap 에 타이머가 저장되고 timer 단계에 진입할 때마다 타이머를 체크하여 delay가 만료된 타이머가 가리키는 콜백을 실행합니다.
setTimout(() => {console.log('timeout1')}, 100)
setTimout(() => {console.log('timeout2')}, 200)
setTimout(() => {console.log('timeout3')}, 300)
setTimout(() => {console.log('timeout4')}, 400)
JavaScript
복사
Pending
일반적인 Javascript API의 콜백을 관리하지는 않습니다. 따라서 깊게 파고들 필요는 없습니다. 이 단계에서는 주로 비동기 작업을 하다 발생한 I/O에러 콜백을 실행합니다.
•
TCP 소켓 에러
•
DNS 에러
•
내부 비동기 작업 실패
Idle
이벤트 내부적으로 각 페이즈의 작업들에 준비 시간을 주기 위한 단계로, 자바스크립트를 실행되지 않습니다. 공식 문서에 별도의 설명은 존재하지 않습니다.
Poll
I/O 이벤트들의 콜백을 실행합니다. 쉽게 생각하면, timer, check, close단계에서 실행되는 콜백을 제외하고 전부 poll 단계에서 실행된다고 보면 됩니다. 따라서 아주 핵심적인 부분이라고 할 수 있습니다.
•
파일 시스템 콜백 처리
•
HTTP 응답 콜백 처리
•
디비 쿼리 결과 콜백 처리
다른 페이즈 같은 경우, 실행할 태스크가 없으면 다음 페이즈로 넘어 갑니다. 하지만 poll 페이즈는 조금 다르게 동작합니다. 폴 페이즈는 다음 루프를 살펴 봅니다.
•
다음 단계인 check, close 단계에 실행할 태스크가 있다면 페이즈를 종료하고 다음 단계로 넘어 갑니다.
•
만료된 실행가능한 timer가 있다면 페이즈를 종료하고 다음 페이즈로 넘어 갑니다.
•
타이머는 있지만, 아직 만료되지 않았다면 그 타이머의 남은 delay가 poll 페이즈의 대기 시간이 됩니다.
•
아무것도 없다면 다음 I/O 이벤트가 들어 올 때까지 무기한 대기 합니다.
Check
poll 단계에서 실행할 작업이 없고 check에 실행할 작업이 있다면 바로 check 페이즈로 넘어 오게 됩니다. check 페이즈는 오직 setImmediate 콜백만을 관리합니다.
Close
close 타입의 콜백 (예. socket.on(‘close’, ()=>{}) ) 이 여기서 핸들링된다. 이는 정리(cleanup) phase와 유사하다고 볼수 있다.
setTimeout VS setImmediate
일반적으로 I/O작업이 있으면 setImmediate의 콜백이 setTimeout의 콜백보다 먼저 실행됩니다.
앞에서 배웠듯, poll페이즈 다음은 check페이즈이고 setImmediate은 페이즈에 진입하자마자 바로 실행되기 때문이죠.
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
});
// setImmediate
// setTimeout
JavaScript
복사
하지만 I/O 작업이 없을 경우에는 setImmediate와 setTimeout(() ⇒ {}, 0) 중 어느 쪽이 먼저 실행될까요? 그건 실행 시점마다 다릅니다. 사실, setTimeout(() ⇒ {}, 0)은 0ms에 콜백을 실행하겠다는 것이 아니라 “최소 0ms이 지나면 실행하겠다”를 보장하는 것입니다. 그래서 최소 0ms가 지났냐를 보장하려면 사실상 1ms가 되어야 콜백을 실행할 수 있는 조건이 됩니다. 그래서 0ms라고 해도 등록 즉시 바로 콜백이 실행되는 것이 아니라 최소 다음 루프에서 timer 페이즈에 진입되었을 때 실행할 수 있는 것이죠. 반면, setImmediate는 check페이즈에 진입 하면 바로 실행됩니다. 최소 조건이 없는 것이지요. 따러서 os 타이머 성능에 따라 실행 결과가 달라질 수 있습니다.(타이머가 생성되고 실행되는데 시간도 있기 때문에)
console.log('setTimeout');
console.log('setImmediate');
// 첫 실행
// setImmediate
// setTimeout
// 두 번째 실행
// setTimeout
// setImmediate
JavaScript
복사