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 이벤트 루프를 이해해야 할까요?

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', ...)
   └───────────────────────┘

핵심 차이점:

  1. Node.js는 6개의 단계(phases)를 순환
  2. 각 단계마다 별도의 큐가 존재
  3. poll 단계에서 I/O를 기다림
  4. 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의 역할

  1. 이벤트 루프 관리: 6개 단계 순환
  2. Thread Pool: I/O 작업을 별도 스레드에서 처리
  3. 플랫폼 추상화: 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 (폴 단계) ⭐ 가장 중요!

역할:

  1. Poll 큐가 비어있지 않으면 → 큐의 콜백 실행
  2. Poll 큐가 비어있으면:
    • Check 큐(setImmediate)에 콜백이 있으면 → 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 콜백 안에서는 setImmediatesetTimeout보다 먼저 실행됨

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 이벤트 루프를 이해하면

  1. 서버 성능을 최적화할 수 있습니다
  2. 비동기 코드의 실행 순서를 정확히 예측할 수 있습니다
  3. 이벤트 루프 블로킹을 방지할 수 있습니다
  4. 적절한 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);

기억해야 할 것

  1. Node.js는 6개의 단계를 순환합니다
  2. process.nextTick > Promise > setImmediate > setTimeout
  3. I/O 콜백 안에서는 setImmediate가 setTimeout보다 빠릅니다
  4. 동기 I/O는 절대 금지
  5. 무거운 작업은 쪼개서 처리하세요

Node.js 이벤트 루프는 고성능 서버를 만드는 핵심입니다. 이것을 이해하면 더 빠르고 안정적인 애플리케이션을 만들 수 있습니다!

참고 자료

공식 문서

필수 영상

심화 학습

성능 관련

관련 문서

댓글