Node.js 이벤트 루프 - 서버 사이드 비동기의 심장
Node.js로 서버를 처음 만들 때 이런 궁금증이 들었던 적 있으신가요?
// 🤔 이 코드는 어떤 순서로 실행될까요?
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
process.nextTick(() => console.log('nextTick'));
// 답: nextTick → timeout → immediate (혹은 immediate → timeout)
“어? 브라우저 JavaScript와 다르네?” 맞습니다! Node.js의 이벤트 루프는 브라우저와는 완전히 다른 구조를 가지고 있습니다.
저도 처음 Node.js를 배울 때, “JavaScript 이벤트 루프를 이해했는데 왜 Node.js에서는 다르게 동작하지?”라고 혼란스러웠습니다. 특히 setImmediate()와 process.nextTick()의 차이를 이해하는 데 꽤 오랜 시간이 걸렸죠.
Node.js 이벤트 루프는 libuv 라이브러리 위에 구축되어 있으며, 6개의 단계(phases)를 순환합니다. 이 문서에서는 Node.js 이벤트 루프가 브라우저와 어떻게 다른지, 왜 이런 구조를 가지게 되었는지, 그리고 실제 개발에서 어떻게 활용해야 하는지 처음부터 끝까지 자세히 설명하겠습니다.
목차
- 왜 Node.js 이벤트 루프를 이해해야 할까요?
- 브라우저 vs Node.js 이벤트 루프
- Node.js 이벤트 루프의 구조
- 6가지 단계 (Phases) 깊이 파헤치기
- process.nextTick() vs setImmediate()
- Microtask와 Macrotask in Node.js
- 실전 예제로 배우는 Node.js 이벤트 루프
- 함정과 주의사항
- 실전에서 활용하기
- 결론: Node.js 이벤트 루프를 언제 어떻게 활용할까?
- 참고 자료
왜 Node.js 이벤트 루프를 이해해야 할까요?
1. 서버 성능을 최적화할 수 있습니다
// ❌ 이벤트 루프를 블로킹하는 코드
app.get('/api/users', (req, res) => {
const users = db.getAllUsers(); // 동기 I/O: 블로킹!
res.json(users);
});
// ✅ 비동기로 처리
app.get('/api/users', async (req, res) => {
const users = await db.getAllUsers(); // 비동기: 논블로킹!
res.json(users);
});
Node.js는 싱글 스레드이므로, 이벤트 루프를 블로킹하면 모든 요청이 대기합니다!
2. setImmediate와 process.nextTick의 차이를 이해할 수 있습니다
// 이 둘의 차이는 무엇일까요?
setImmediate(() => console.log('immediate'));
process.nextTick(() => console.log('nextTick'));
// nextTick이 항상 먼저 실행됩니다!
// 하지만 왜일까요?
3. 복잡한 비동기 로직의 실행 순서를 예측할 수 있습니다
const fs = require('fs');
setTimeout(() => console.log('1'), 0);
setImmediate(() => console.log('2'));
process.nextTick(() => console.log('3'));
Promise.resolve().then(() => console.log('4'));
fs.readFile(__filename, () => {
setTimeout(() => console.log('5'), 0);
setImmediate(() => console.log('6'));
process.nextTick(() => console.log('7'));
});
// 출력 순서: 3, 4, 1, 2, 7, 6, 5
이 코드의 실행 순서를 정확히 예측할 수 있다면, Node.js 이벤트 루프를 완벽히 이해한 것입니다!
브라우저 vs Node.js 이벤트 루프
브라우저 이벤트 루프
┌───────────────────────┐
│ 1. Call Stack 실행 │
│ 2. Microtask 모두 실행│
│ 3. Macrotask 하나 실행│
│ 4. 렌더링 (필요시) │
└───────────────────────┘
Node.js 이벤트 루프
┌───────────────────────┐
┌─>│ timers │ setTimeout, setInterval
│ └───────────┬───────────┘
│ ┌───────────┴───────────┐
│ │ pending callbacks │ I/O 콜백 (TCP 에러 등)
│ └───────────┬───────────┘
│ ┌───────────┴───────────┐
│ │ idle, prepare │ 내부적으로만 사용
│ └───────────┬───────────┘
│ ┌───────────┴───────────┐ ┌───────────────┐
│ │ poll │<─────┤ incoming: │
│ │ (I/O 대기) │ │ connections, │
│ └───────────┬───────────┘ │ data, etc. │
│ ┌───────────┴───────────┐ └───────────────┘
│ │ check │ setImmediate
│ └───────────┬───────────┘
│ ┌───────────┴───────────┐
└──┤ close callbacks │ socket.on('close', ...)
└───────────────────────┘
핵심 차이점:
- Node.js는 6개의 단계(phases)를 순환
- 각 단계마다 별도의 큐가 존재
- poll 단계에서 I/O를 기다림
setImmediate(),process.nextTick()같은 Node.js 전용 API 존재
Node.js 이벤트 루프의 구조
Node.js 이벤트 루프는 libuv를 기반으로 합니다.
┌─────────────────────────────────────────┐
│ Node.js Application │
│ │
│ ┌───────────────────────────────────┐ │
│ │ JavaScript (V8 Engine) │ │
│ └───────────┬───────────────────────┘ │
│ │ │
│ ┌───────────┴───────────────────────┐ │
│ │ Node.js Bindings │ │
│ └───────────┬───────────────────────┘ │
│ │ │
│ ┌───────────┴───────────────────────┐ │
│ │ libuv (Event Loop + I/O) │ │
│ │ │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ Event Loop (6 phases) │ │ │
│ │ └─────────────────────────────┘ │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ Thread Pool (I/O) │ │ │
│ │ └─────────────────────────────┘ │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
libuv의 역할
- 이벤트 루프 관리: 6개 단계 순환
- Thread Pool: I/O 작업을 별도 스레드에서 처리
- 플랫폼 추상화: OS별 차이를 숨김
6가지 단계 (Phases) 깊이 파헤치기
1. Timers Phase (타이머 단계)
실행되는 것:
setTimeout()setInterval()
setTimeout(() => {
console.log('타이머 실행!');
}, 100);
주의점:
- 타이머의 최소 지연 시간을 보장하지만, 정확한 시간을 보장하지 않습니다
- Poll 단계가 너무 오래 걸리면 타이머가 지연될 수 있습니다
2. Pending Callbacks Phase
실행되는 것:
- 이전 사이클에서 연기된 I/O 콜백
- TCP 에러 같은 시스템 작업 콜백
// 예: TCP socket 에러
socket.on('error', (err) => {
// 이 콜백은 pending callbacks 단계에서 실행
console.error(err);
});
3. Idle, Prepare Phase
실행되는 것:
- Node.js 내부적으로만 사용
- 개발자는 신경 쓸 필요 없음
4. Poll Phase (폴 단계) ⭐ 가장 중요!
역할:
- Poll 큐가 비어있지 않으면 → 큐의 콜백 실행
- Poll 큐가 비어있으면:
- Check 큐(
setImmediate)에 콜백이 있으면 → Check 단계로 이동 - 없으면 → 타이머가 만료될 때까지 대기
- Check 큐(
const fs = require('fs');
// 파일 읽기는 Poll 단계에서 처리
fs.readFile('/path/to/file', (err, data) => {
if (err) throw err;
console.log(data);
});
Poll 단계가 중요한 이유:
- I/O 이벤트를 기다리는 단계
- 대부분의 시간을 Poll 단계에서 보냄
- 타이머 체크와
setImmediate실행 여부를 결정
5. Check Phase (체크 단계)
실행되는 것:
setImmediate()콜백
setImmediate(() => {
console.log('Check 단계 실행!');
});
언제 사용할까:
- Poll 단계 직후에 실행하고 싶을 때
- I/O 콜백 안에서는
setImmediate가setTimeout보다 먼저 실행됨
6. Close Callbacks Phase
실행되는 것:
socket.on('close', ...)process.exit()
const server = http.createServer();
server.on('close', () => {
console.log('서버 종료!');
});
server.close();
process.nextTick() vs setImmediate()
가장 혼란스러운 두 API입니다!
process.nextTick()
특징:
- 어떤 단계든 상관없이 현재 작업이 끝나면 즉시 실행
- 이벤트 루프의 단계를 넘어가기 전에 실행
- Microtask와 같은 우선순위
console.log('시작');
process.nextTick(() => {
console.log('nextTick');
});
console.log('끝');
// 출력: 시작, 끝, nextTick
setImmediate()
특징:
- Check 단계에서 실행
- 다음 이벤트 루프 사이클에서 실행
console.log('시작');
setImmediate(() => {
console.log('immediate');
});
console.log('끝');
// 출력: 시작, 끝, immediate
둘의 차이: 시각적 비교
┌─────────────────────────────────────┐
│ Current Phase (예: Timers) │
│ │
│ 1. 동기 코드 실행 │
│ console.log('시작') │
│ │
│ 2. process.nextTick 등록 │
│ │
│ 3. 동기 코드 계속 실행 │
│ console.log('끝') │
│ │
│ ▼ Phase 종료 직전 │
│ │
│ 4. ⚡ nextTick Queue 비우기 │
│ console.log('nextTick') │
│ │
│ 5. Microtask Queue 비우기 │
│ (Promise.then 등) │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Next Phase │
└─────────────────────────────────────┘
↓
...
↓
┌─────────────────────────────────────┐
│ Check Phase │
│ │
│ 6. 💚 setImmediate 콜백 실행 │
│ console.log('immediate') │
└─────────────────────────────────────┘
실행 순서 비교
// 메인 모듈에서 실행
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
// 출력:
// nextTick
// promise
// timeout (또는 immediate)
// immediate (또는 timeout)
왜 timeout과 immediate의 순서가 불확실할까?
- 타이머를 등록하는 데 시간이 걸림
- 1ms 미만이면 타이머가 먼저, 아니면 immediate가 먼저
// I/O 콜백 안에서 실행
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
// 출력:
// immediate (항상 먼저!)
// timeout
왜?
- I/O 콜백은 Poll 단계에서 실행
- Poll 단계 다음이 Check 단계 (setImmediate)
- Check 단계 이후 한 바퀴 돌아서 Timers 단계 (setTimeout)
Microtask와 Macrotask in Node.js
Microtask Queue (우선순위 높음)
┌─────────────────────────────┐
│ 1. process.nextTick Queue │ ← 최우선!
└─────────────────────────────┘
┌─────────────────────────────┐
│ 2. Promise Microtask Queue │
└─────────────────────────────┘
실행 순서:
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
// 출력:
// nextTick (먼저!)
// promise
Macrotask (이벤트 루프의 각 단계)
setTimeout(() => console.log('setTimeout'), 0); // Timers
setImmediate(() => console.log('setImmediate')); // Check
fs.readFile(__filename, () => { // Poll
console.log('readFile');
});
실전 예제로 배우는 Node.js 이벤트 루프
예제 1: 복잡한 실행 순서
console.log('1');
setTimeout(() => {
console.log('2');
process.nextTick(() => console.log('3'));
}, 0);
setImmediate(() => {
console.log('4');
process.nextTick(() => console.log('5'));
});
process.nextTick(() => {
console.log('6');
setTimeout(() => console.log('7'), 0);
});
Promise.resolve().then(() => {
console.log('8');
});
console.log('9');
// 출력: 1, 9, 6, 8, 2, 4, 3, 5, 7
단계별 분석:
[동기 코드]
1. console.log('1') → 출력: 1
2. setTimeout 등록 (Timers 큐)
3. setImmediate 등록 (Check 큐)
4. nextTick 등록 (nextTick 큐)
5. Promise 등록 (Microtask 큐)
6. console.log('9') → 출력: 9
[Microtask: nextTick Queue]
7. nextTick 콜백 실행 → 출력: 6
8. setTimeout 등록 (Timers 큐)
[Microtask: Promise Queue]
9. Promise 콜백 실행 → 출력: 8
[Timers Phase]
10. setTimeout 콜백 실행 → 출력: 2
11. nextTick 등록 (nextTick 큐)
[Check Phase]
12. setImmediate 콜백 실행 → 출력: 4
13. nextTick 등록 (nextTick 큐)
[Microtask: nextTick Queue]
14. nextTick (from step 11) → 출력: 3
15. nextTick (from step 13) → 출력: 5
[Next Timers Phase]
16. setTimeout 콜백 실행 → 출력: 7
예제 2: I/O와 함께 사용하기
const fs = require('fs');
console.log('시작');
fs.readFile(__filename, () => {
console.log('파일 읽기 완료');
setTimeout(() => console.log('타이머 1'), 0);
setImmediate(() => console.log('Immediate 1'));
process.nextTick(() => console.log('nextTick 1'));
Promise.resolve().then(() => console.log('Promise 1'));
setImmediate(() => {
console.log('Immediate 2');
process.nextTick(() => console.log('nextTick 2'));
});
});
setTimeout(() => console.log('타이머 2'), 0);
setImmediate(() => console.log('Immediate 3'));
console.log('끝');
// 출력:
// 시작
// 끝
// 타이머 2 (또는 Immediate 3)
// Immediate 3 (또는 타이머 2)
// 파일 읽기 완료
// nextTick 1
// Promise 1
// Immediate 1
// Immediate 2
// nextTick 2
// 타이머 1
예제 3: 재귀적 nextTick의 위험성
// ❌ 위험: 이벤트 루프를 블로킹!
let count = 0;
function recursiveNextTick() {
if (count < 1000000) {
count++;
process.nextTick(recursiveNextTick);
}
}
process.nextTick(recursiveNextTick);
setTimeout(() => {
// 이것은 절대 실행되지 않습니다!
console.log('타이머');
}, 0);
// nextTick이 계속 실행되어 다음 단계로 넘어가지 못합니다!
해결책:
// ✅ setImmediate 사용
let count = 0;
function recursiveImmediate() {
if (count < 1000000) {
count++;
setImmediate(recursiveImmediate); // 다른 이벤트도 실행 가능
}
}
setImmediate(recursiveImmediate);
setTimeout(() => {
console.log('타이머 실행됨!');
}, 0);
예제 4: 실제 서버 예제
const http = require('http');
const server = http.createServer((req, res) => {
console.log('요청 받음');
// 무거운 작업 시뮬레이션
setImmediate(() => {
console.log('무거운 작업 시작');
// 작업을 쪼개서 처리
let iterations = 0;
function doWork() {
for (let i = 0; i < 10000; i++) {
iterations++;
}
if (iterations < 1000000) {
setImmediate(doWork); // 다른 요청도 처리 가능하게
} else {
res.end('완료!');
}
}
doWork();
});
});
server.listen(3000);
함정과 주의사항
1. process.nextTick의 과도한 사용
// ❌ nextTick으로 인한 Starvation
function recursiveNextTick() {
process.nextTick(recursiveNextTick);
}
recursiveNextTick();
// 이벤트 루프가 다음 단계로 넘어가지 못합니다!
권장사항:
- 재귀적으로 사용할 때는
setImmediate사용 process.nextTick은 정말 필요할 때만 사용
2. 동기 I/O 사용 금지
// ❌ 동기 I/O: 모든 요청이 블로킹됨!
const fs = require('fs');
app.get('/file', (req, res) => {
const data = fs.readFileSync('/path/to/file'); // 블로킹!
res.send(data);
});
// ✅ 비동기 I/O 사용
app.get('/file', async (req, res) => {
const data = await fs.promises.readFile('/path/to/file');
res.send(data);
});
3. CPU 집약적 작업 처리
// ❌ 메인 스레드에서 무거운 작업
app.get('/heavy', (req, res) => {
const result = heavyComputation(); // 이벤트 루프 블로킹!
res.json(result);
});
// ✅ Worker Thread 사용
const { Worker } = require('worker_threads');
app.get('/heavy', (req, res) => {
const worker = new Worker('./heavy-computation.js');
worker.on('message', result => {
res.json(result);
});
worker.on('error', err => {
res.status(500).json({ error: err.message });
});
});
4. setTimeout vs setImmediate 선택
// I/O 콜백 안에서는 setImmediate가 더 예측 가능
fs.readFile(__filename, () => {
// ✅ 항상 타이머보다 먼저 실행
setImmediate(() => console.log('immediate'));
// 실행 순서가 불확실
setTimeout(() => console.log('timeout'), 0);
});
실전에서 활용하기
1. 에러 처리 패턴
// ❌ 동기/비동기 혼합: 예측 불가능
function getData(callback) {
if (cache) {
return callback(null, cache); // 동기적 실행!
}
fetchFromDB((err, data) => {
callback(err, data); // 비동기적 실행!
});
}
// ✅ 항상 비동기로 통일
function getData(callback) {
if (cache) {
process.nextTick(() => {
callback(null, cache); // 비동기로 통일!
});
return;
}
fetchFromDB((err, data) => {
callback(err, data);
});
}
2. 배치 작업 처리
// 대량의 데이터를 처리할 때
async function processBatch(items) {
for (let i = 0; i < items.length; i += 100) {
const batch = items.slice(i, i + 100);
await new Promise(resolve => {
setImmediate(async () => {
await processBatchInternal(batch);
resolve();
});
});
}
}
3. 성능 모니터링
const { performance } = require('perf_hooks');
// 이벤트 루프 지연 측정
function measureEventLoopLag() {
let start = Date.now();
setImmediate(() => {
const lag = Date.now() - start;
console.log(`Event Loop Lag: ${lag}ms`);
// 100ms 이상이면 경고
if (lag > 100) {
console.warn('⚠️ Event Loop is lagging!');
}
// 계속 측정
measureEventLoopLag();
});
}
measureEventLoopLag();
4. Graceful Shutdown
const server = http.createServer(app);
// SIGTERM 시그널 처리
process.on('SIGTERM', () => {
console.log('SIGTERM 받음, 종료 시작...');
server.close(() => {
console.log('서버 종료');
// 모든 비동기 작업 완료 후 종료
setImmediate(() => {
console.log('프로세스 종료');
process.exit(0);
});
});
// 30초 후 강제 종료
setTimeout(() => {
console.error('강제 종료');
process.exit(1);
}, 30000);
});
5. 메모리 누수 방지
// ❌ nextTick 누적으로 인한 메모리 누수
const tasks = [];
function addTask(task) {
tasks.push(task);
process.nextTick(processTasks);
}
// ✅ 배치 처리로 개선
let scheduled = false;
const tasks = [];
function addTask(task) {
tasks.push(task);
if (!scheduled) {
scheduled = true;
process.nextTick(() => {
processTasks();
scheduled = false;
});
}
}
결론: Node.js 이벤트 루프를 언제 어떻게 활용할까?
Node.js 이벤트 루프를 이해하면
- ✅ 서버 성능을 최적화할 수 있습니다
- ✅ 비동기 코드의 실행 순서를 정확히 예측할 수 있습니다
- ✅ 이벤트 루프 블로킹을 방지할 수 있습니다
- ✅ 적절한 API 선택 (setTimeout vs setImmediate vs nextTick)을 할 수 있습니다
핵심 원칙
┌─────────────────────────────────────────┐
│ 각 단계마다. │
│ │
│ 1. 해당 단계의 큐에서 콜백 실행 │
│ 2. process.nextTick Queue 비우기 │
│ 3. Microtask (Promise) Queue 비우기 │
│ │
│ 다음 단계로 이동 │
└─────────────────────────────────────────┘
API 선택 가이드
// 🎯 언제 무엇을 사용할까?
// 1. 현재 작업 직후 즉시 실행
process.nextTick(() => {
// 가장 높은 우선순위
});
// 2. 비동기 작업 체이닝
Promise.resolve().then(() => {
// nextTick 다음 우선순위
});
// 3. I/O 콜백 직후 실행
setImmediate(() => {
// Check 단계에서 실행
// I/O와 함께 사용하기 좋음
});
// 4. 일정 시간 후 실행
setTimeout(() => {
// Timers 단계에서 실행
// 정확한 타이밍이 필요할 때
}, 1000);
기억해야 할 것
- Node.js는 6개의 단계를 순환합니다
- process.nextTick > Promise > setImmediate > setTimeout
- I/O 콜백 안에서는 setImmediate가 setTimeout보다 빠릅니다
- 동기 I/O는 절대 금지
- 무거운 작업은 쪼개서 처리하세요
Node.js 이벤트 루프는 고성능 서버를 만드는 핵심입니다. 이것을 이해하면 더 빠르고 안정적인 애플리케이션을 만들 수 있습니다!
참고 자료
공식 문서
필수 영상
- Bert Belder: Everything You Need to Know About Node.js Event Loop
- Philip Roberts: What the heck is the event loop anyway?
심화 학습
성능 관련
관련 문서
- JavaScript 이벤트 루프 - 브라우저 환경의 이벤트 루프
댓글