Node.js 이벤트 루프의 동작 원리와 비동기 성능 최적화 전략
Node.js는 싱글 스레드(Single-threaded) 기반이면서도 고성능 비동기 I/O를 지원하여 현대적인 백엔드 아키텍처에서 널리 활용되고 있습니다. 하지만 실무에서 대용량 데이터를 처리하거나 복잡한 연산을 수행하다 보면 “왜 내 서버가 멈추지?” 또는 “왜 비동기 작업이 예상보다 늦게 처리되지?“와 같은 의문을 갖게 됩니다.
이러한 현상의 근본 원인은 Node.js의 심장부인 **이벤트 루프(Event Loop)**와 Libuv의 동작 방식을 정확히 이해하지 못한 데서 비롯됩니다. 이번 포스팅에서는 이벤트 루프의 6가지 단계를 깊이 있게 파헤치고, 실제 애플리케이션의 성능을 최적화하는 전략을 살펴보겠습니다.
1. Node.js 이벤트 루프의 6가지 단계
이벤트 루프는 매 루프(Tick)마다 특정 순서에 따라 큐에 쌓인 콜백들을 처리합니다. 각 단계는 자신만의 큐를 가지고 있으며, 해당 큐가 비워지거나 실행 한도에 도달할 때까지 콜백을 실행합니다.
| 단계 (Phase) | 설명 |
|---|---|
| Timer | setTimeout(), setInterval()의 콜백이 실행됩니다. |
| Pending Callbacks | 이전 루프에서 지연된 일부 I/O 콜백을 처리합니다. |
| Idle, Prepare | 내부적인 시스템 처리에 사용됩니다. |
| Poll | 새로운 I/O 이벤트를 가져오고, I/O 관련 콜백을 실행합니다. |
| Check | setImmediate()의 콜백이 실행됩니다. |
| Close Callbacks | 'close' 이벤트 관련 콜백(예: socket.on('close', ...))을 처리합니다. |
특히 중요한 것은 Poll 단계입니다. 대부분의 I/O 작업(네트워크, 파일 읽기 등)의 결과가 여기서 처리되며, 루프가 이 단계에서 새로운 이벤트를 기다리며 대기할 수도 있습니다.
2. Microtask Queue: nextTick과 Promise
이벤트 루프의 각 단계 사이사이에 실행되는 특별한 큐가 있습니다. 바로 Microtask Queue입니다. 여기에는 process.nextTick()과 Promise의 then 콜백이 포함됩니다.
console.log('Start');
setTimeout(() => console.log('Timeout'), 0);
setImmediate(() => console.log('Immediate'));
process.nextTick(() => console.log('NextTick'));
Promise.resolve().then(() => console.log('Promise'));
console.log('End');
실행 결과 순서:
Start->End(동기 코드 실행)NextTick->Promise(Microtask Queue가 우선순위가 가장 높음)Timeout(Timer 단계)Immediate(Check 단계)
Microtask Queue는 이벤트 루프의 어느 단계에서든 현재 작업이 끝나면 즉시 실행되므로, 너무 많은 nextTick 작업을 쌓으면 이벤트 루프가 다음 단계로 넘어가지 못하는 ‘굶주림(Starvation)’ 현상이 발생할 수 있습니다.
3. CPU 집약적 작업의 문제와 최적화
Node.js의 메인 스레드는 하나뿐이므로, 복잡한 암호화나 대량의 JSON 파싱 같은 CPU 집약적 작업이 메인 스레드를 점유하면 이벤트 루프가 멈추게(Blocking) 됩니다. 이로 인해 모든 후속 요청의 응답 시간이 급격히 증가합니다.
최적화 방안 1: Worker Threads 활용
Node.js 10.5.0부터 지원되는 worker_threads 모듈을 사용하면 별도의 스레드에서 무거운 연산을 수행할 수 있습니다.
const { Worker, isMainThread, parentPort } = require('worker_threads');
if (isMainThread) {
// 메인 스레드: 워커 스레드 생성
const worker = new Worker(__filename);
worker.on('message', (result) => console.log('결과:', result));
} else {
// 워커 스레드: 무거운 연산 수행
const heavyTask = () => { /* ... 복잡한 계산 ... */ };
parentPort.postMessage(heavyTask());
}
최적화 방안 2: 작업 분할 (Partitioning)
큰 작업을 작은 단위로 쪼개어 이벤트 루프가 중간에 다른 I/O를 처리할 틈을 주는 방식입니다. setImmediate()를 활용하여 작업을 예약할 수 있습니다.
function chunkedTask(data, index = 0) {
if (index >= data.length) return;
// 100개씩 처리 후 다음 작업을 큐에 예약
for (let i = 0; i < 100 && index < data.length; i++, index++) {
process(data[index]);
}
setImmediate(() => chunkedTask(data, index));
}
4. Libuv의 스레드 풀(Thread Pool) 이해하기
Node.js는 모든 I/O를 직접 싱글 스레드로 처리하지 않습니다. 파일 I/O나 암호화 작업 등은 Libuv의 스레드 풀에서 비동기적으로 처리됩니다. 기본값은 4개이며, 작업량이 많을 경우 이를 조정하여 성능을 높일 수 있습니다.
스레드 풀 크기 조정 (Environment Variable)
# 운영 환경의 코어 수에 맞춰 조정 (예: 8개)
export UV_THREADPOOL_SIZE=8
node app.js
결론
Node.js 이벤트 루프의 동작 원리를 이해하는 것은 단순한 지식 습득을 넘어, 고성능 애플리케이션을 설계하고 트러블슈팅하는 데 필수적인 역량입니다.
- Microtask Queue의 남용을 피하고,
- CPU 집약적 작업은 워커 스레드나 작업 분할을 통해 격리하며,
- Libuv 스레드 풀 설정을 통해 인프라 자원을 최적화해야 합니다.
싱글 스레드의 제약을 극복하고 Node.js의 비동기적 강점을 극대화하여, 더 빠르고 안정적인 서비스를 구축해 보시기 바랍니다.