Node.js 이벤트 루프 심층 분석: 매크로태스크, 마이크로태스크 및 process.nextTick
Daniel Hayes
Full-Stack Engineer · Leapcell

Node.js에서의 비동기 이해
Node.js는 비차단적이고 비동기적인 특성으로 유명하며, 이 특성은 I/O 바운드 작업을 처리하는 데 매우 효율적입니다. 이러한 효율성의 핵심에는 이벤트 루프가 있으며, 이는 Node.js가 메인 실행 스레드를 차단하지 않고 장시간 실행되는 작업을 수행할 수 있도록 하는 핵심 원리입니다. 개발자들은 setTimeout
, setImmediate
, Promises, async/await
및 process.nextTick
을 사용할 때 예측하기 어려운 실행 순서를 종종 접하게 됩니다. 이 겉보기의 혼란은 이벤트 루프가 관리하는 다양한 태스크 큐의 정교한 오케스트레이션에서 비롯됩니다. 매크로태스크, 마이크로태스크 및 process.nextTick
이 어떻게 상호 작용하는지 이해하는 것은 강력하고 성능이 뛰어난 Node.js 애플리케이션을 작성하는 데 중요하며, 레이스 컨디션을 디버깅하고 매우 반응성이 좋은 시스템을 설계하는 데 도움이 됩니다. 이 글에서는 Node.js 이벤트 루프의 복잡성을 풀고 이러한 기본 개념을 설명하며 실제 예제를 통해 동작을 시연합니다.
Node.js 이벤트 루프의 내부 작동
Node.js 이벤트 루프는 콜백을 처리하는 연속적인 사이클입니다. 별도의 스레드가 아니라 Node.js가 비동기 작업을 효율적으로 처리할 수 있도록 하는 메커니즘입니다. Node.js가 시작되면 이벤트 루프를 초기화한 다음 스크립트 실행을 시작합니다. 스크립트가 비동기 작업을 접하면 해당 콜백을 등록하고 실제 작업은 기본 시스템(예: I/O를 위한 운영 체제 커널)에 오프로드합니다. 이러한 작업이 완료되면 해당 콜백은 다양한 큐에 배치되어 이벤트 루프에 의해 실행될 차례를 기다립니다. 이벤트 루프는 특정 순서에 따라 이러한 큐에서 콜백을 가져오며, 고유한 단계에 의해 주도됩니다.
매크로태스크
매크로태스크는 이벤트 루프의 특정 단계에서 처리되는 더 크고 시간이 오래 걸리는 작업을 나타냅니다. 각 단계에는 자체 매크로태스크 큐가 있습니다. 이벤트 루프가 한 단계에서 다음 단계로 이동할 때, 다음 단계로 이동하기 전에 해당 단계의 매크로태스크 큐 내의 모든 보류 중인 콜백을 처리합니다. 매크로태스크의 일반적인 예는 다음과 같습니다.
- 타이머 (
setTimeout
,setInterval
): 이러한 콜백은 타이머 단계 큐에 배치됩니다. - I/O 콜백 (파일 시스템, 네트워크): 이것들은 I/O 콜백 단계 큐에 배치됩니다. 여기에는
fs.readFile
,http.get
등의 콜백이 포함됩니다. setImmediate
: 이러한 콜백은 특히 I/O 콜백 후 및 이벤트 루프의 다음 틱 전에 실행되도록 설계되었습니다. 이것들은 체크 단계 큐에 있습니다.
예제를 통해 설명해 보겠습니다.
console.log('Start'); setTimeout(() => { console.log('setTimeout callback'); }, 0); setImmediate(() => { console.log('setImmediate callback'); }); console.log('End');
이 코드를 실행하면 일반적으로 다음과 같이 표시됩니다.
Start
End
setTimeout callback
setImmediate callback
그러나 setTimeout
이 I/O 작업 내에 있으면 setTimeout
과 setImmediate
의 순서는 각자의 단계 특성으로 인해 덜 예측 가능해질 수 있습니다.
const fs = require('fs'); console.log('Start'); fs.readFile(__filename, () => { setTimeout(() => { console.log('setTimeout inside I/O'); }, 0); setImmediate(() => { console.log('setImmediate inside I/O'); }); }); console.log('End');
이 경우 fs.readFile
콜백 자체는 I/O 매크로태스크입니다. 완료되면 이벤트 루프는 I/O 콜백 단계로 들어갑니다. 이를 처리한 후 setImmediate
가 일반적으로 처리되는 체크 단계로 이동한 다음, 마지막으로 setTimeout
을 위한 타이머 단계로 이동합니다. 따라서 다음과 같이 볼 가능성이 높습니다.
Start
End
setImmediate inside I/O
setTimeout inside I/O
마이크로태스크
마이크로태스크는 현재 실행 중인 매크로태스크가 완료된 후, 이벤트 루프가 다음 단계로 이동하기 전에 실행되는 더 작지만 더 긴급한 작업입니다. 이는 이벤트 루프가 다음 단계로 진행하기 전에 모든 마이크로태스크 큐가 완전히 비워진다는 것을 의미합니다. 이는 마이크로태스크가 후속 매크로태스크보다 우선 순위가 높다는 것을 의미합니다. 마이크로태스크의 주요 예는 다음과 같습니다.
- Promise 콜백 (
.then()
,.catch()
,.finally()
): Promise가 해결되거나 거부되면 연결된.then()
또는.catch()
콜백이 마이크로태스크로 큐에 추가됩니다. async/await
:await
키워드는async
함수를 효과적으로 일시 중지하고awaited Promise가 정해진 후에 함수 나머지 부분을 마이크로태스크로 예약합니다.queueMicrotask
API: 마이크로태스크를 큐에 넣는 직접적인 방법입니다.
다음 예제를 고려해 보세요.
console.log('Start'); setTimeout(() => { console.log('setTimeout macrotask'); }, 0); Promise.resolve().then(() => { console.log('Promise microtask'); }); console.log('End');
결과는 다음과 같습니다.
Start
End
Promise microtask
setTimeout macrotask
여기서 Promise.resolve().then()
은 마이크로태스크를 큐에 추가합니다. 동기 console.log('End')
가 완료된 후 마이크로태스크 큐가 확인되고 Promise microtask
가 실행되며, 그 후 이벤트 루프가 setTimeout macrotask
를 처리하기 위해 타이머 단계로 이동합니다.
다른 마이크로태스크를 추가하면 다음과 같습니다.
console.log('Start'); setTimeout(() => { console.log('setTimeout macrotask'); }, 0); Promise.resolve().then(() => { console.log('First Promise microtask'); }); Promise.resolve().then(() => { console.log('Second Promise microtask'); }); console.log('End');
결과는 모든 마이크로태스크가 새로운 매크로태스크가 처리되기 전에 비워짐을 보여줍니다.
Start
End
First Promise microtask
Second Promise microtask
setTimeout macrotask
process.nextTick
의 특별한 경우
process.nextTick
은 Node.js의 고유한 구성 요소로, 실행 우선 순위 면에서 매크로태스크 및 마이크로태스크와 모두 분리되어 있습니다. process.nextTick
에 전달된 콜백은 이벤트 루프의 현재 단계에서 다른 모든 마이크로태스크 또는 매크로태스크보다 먼저 실행됩니다. 이것들은 사실상 Node.js가 다른 큐를 처리하려고 시도하기 직전에 현재 C++ 스택 프레임의 끝에서 실행됩니다. 이것은 process.nextTick
을 일시 중단하는 것을 원하지만 거의 즉시 발생하도록 보장하려는 상황에 이상적인데, 일반적으로 오류 처리 또는 setTimeout(fn, 0)
의 지연을 도입하지 않고 동기 코드를 분해하기 위한 것입니다.
작동 방식에서의 우선 순위를 살펴보겠습니다.
console.log('Start'); setTimeout(() => { console.log('setTimeout macrotask'); }, 0); Promise.resolve().then(() => { console.log('Promise microtask'); }); process.nextTick(() => { console.log('process.nexttick callback'); }); console.log('End');
결과는 process.nextTick
의 지배력을 명확하게 보여줍니다.
Start
End
process.nexttick callback
Promise microtask
setTimeout macrotask
process.nextTick
콜백은 모든 동기 코드 완료 직후에 실행되고, 마이크로태스크가 실행된 다음, 마지막으로 이벤트 루프가 매크로태스크 단계로 진행합니다. 무한 루프로 process.nextTick
큐를 차단하면 이벤트 루프를 굶주리게 하고 다른 콜백 실행을 방해할 수 있으므로 process.nextTick
을 신중하게 사용하는 것이 중요합니다.
이벤트 루프 단계 재검토
흐름을 요약하면 다음과 같습니다.
- 타이머 단계:
setTimeout
및setInterval
콜백을 실행합니다. - 보류 중인 콜백 단계: 대부분의 시스템 콜백(예: TCP 오류)을 실행합니다.
- 유휴, 준비 단계: Node.js에 내부적입니다.
- 폴링 단계:
- 새 I/O 이벤트를 검색합니다.
- I/O 이벤트에 대한 콜백을 실행합니다.
- 핵심은 폴링 큐가 비어 있으면 이벤트 루프가 새 I/O 이벤트가 도착할 때까지 여기서 차단될 수 있고,
setImmediate
콜백이 있는 경우 체크 단계로 이동한다는 것입니다.
- 체크 단계:
setImmediate
콜백을 실행합니다. - 닫힌 콜백 단계:
close
핸들(예:socket.on('close', ...)
)을 실행합니다.
이러한 각 단계 사이와 모든 동기 실행이 완료된 후 Node.js는 다음 순서로 내부 큐를 확인하고 비웁니다.
process.nextTick
큐- 마이크로태스크 큐 (Promises,
queueMicrotask
)
이 연속적인 사이클은 Node.js의 동시성 모델의 기반을 형성하여, 전통적인 멀티 스레딩에 의존하지 않고도 많은 수의 동시 연결을 효율적으로 처리할 수 있도록 합니다.
결론
Node.js 이벤트 루프는 매크로태스크, 마이크로태스크 및 우선 순위가 높은 process.nextTick
의 상호 작용을 통해 Node.js의 비동기 기능을 지원하는 엔진입니다. 고유한 우선 순위와 이벤트 루프 단계의 순환적 특성을 이해함으로써 개발자는 예측 가능하고 성능이 뛰어나며 복원력 있는 Node.js 애플리케이션을 작성하여 비차단 아키텍처를 진정으로 활용할 수 있습니다. 이러한 개념을 숙달하는 것이 동시성을 효과적으로 관리하고 복잡한 비동기 흐름을 디버깅하는 데 중요합니다.