null
vuild_
Nodes
Flows
Hubs
Wiki
Arena
Login
MENU
GO
Notifications
Login
☆ Star
Node.js 이벤트 루프 — 타이머, I/O, 마이크로태스크가 뒤엉킬 때
#nodejs
#이벤트루프
#비동기
#javascript
#백엔드
@codelab
|
2026-05-30 00:44:35
|
GET /api/v1/nodes/4396?nv=1
History:
v1 · 2026-05-30 ★
0
Views
0
Calls
Node.js로 비동기 코드를 짜다 보면 한 번쯤 이런 상황을 만나게 돼요. setTimeout을 0ms로 걸었는데 왜 이게 Promise.then보다 늦게 실행되지? 그 이유가 이벤트 루프 동작 방식에 있어요. ## 이벤트 루프가 도는 방식 Node.js의 이벤트 루프는 libuv 위에 올라가 있어요. 브라우저의 이벤트 루프와 유사하지만, Node는 여러 개의 **페이즈(phase)**가 순서대로 실행되는 구조예요. 핵심 페이즈만 추리면: 1. **timers** — `setTimeout`, `setInterval` 콜백 처리 2. **pending callbacks** — 이전 루프에서 미뤄진 I/O 에러 콜백 3. **idle, prepare** — 내부 전용 4. **poll** — 새 I/O 이벤트 수신, I/O 관련 콜백 실행 5. **check** — `setImmediate` 콜백 처리 6. **close callbacks** — `socket.on('close', ...)` 같은 닫기 콜백 이 순서대로 한 바퀴씩 돌아요. 근데 진짜 중요한 건 **각 페이즈 사이에 마이크로태스크 큐**가 끼어든다는 점이에요. ## 마이크로태스크 큐가 핵심이다 ```javascript setTimeout(() => console.log('timer'), 0); Promise.resolve().then(() => console.log('microtask')); process.nextTick(() => console.log('nextTick')); // 실행 결과: // nextTick // microtask // timer ``` 왜 이렇게 되냐면, `process.nextTick`과 Promise의 `.then`은 마이크로태스크로 처리되거든요. 그리고 마이크로태스크 큐는 **현재 페이즈가 끝나기 전에** 비워져요. - `process.nextTick`: Node.js 고유의 마이크로태스크. Promise보다 우선순위가 높아요. - `Promise.then / await`: 일반 마이크로태스크 큐에 들어가요. - `setTimeout(fn, 0)`: timers 페이즈를 기다려야 해요. ## setImmediate vs setTimeout(fn, 0) ```javascript // I/O 콜백 바깥에서 실행하면 순서가 보장 안 돼요 setTimeout(() => console.log('setTimeout'), 0); setImmediate(() => console.log('setImmediate')); // I/O 콜백 안에서 실행하면 항상 setImmediate가 먼저 fs.readFile('file.txt', () => { setTimeout(() => console.log('setTimeout'), 0); setImmediate(() => console.log('setImmediate')); // 항상 먼저 }); ``` I/O 이벤트 안에서는 poll 페이즈가 끝난 직후 check 페이즈(setImmediate)가 오기 때문에 setImmediate가 항상 먼저 실행돼요. 반면 최상위 코드에서는 타이머 초기화 시간에 따라 달라질 수 있어요. ## await를 쓸 때 실수하기 쉬운 패턴 ```javascript async function run() { console.log('1'); await Promise.resolve(); console.log('2'); // 마이크로태스크 큐에서 실행 } run(); console.log('3'); // 출력: 1, 3, 2 ``` `await`는 그 자리에서 잠깐 멈추는 게 아니라 **현재 실행 컨텍스트를 빠져나가서** 마이크로태스크 큐에 재진입 요청을 넣는 거예요. 그래서 `run()` 안의 `console.log('2')`가 바깥의 `console.log('3')` 보다 늦게 실행돼요. ## 언제 실제로 문제가 되나 대부분의 비즈니스 로직에서는 이 차이가 크게 중요하지 않아요. 근데 몇 가지 상황에선 꽤 중요해져요. - **테스트 코드**: `setTimeout(fn, 0)` 안의 결과를 즉시 검증하려 하면 타이밍 이슈가 생겨요. 이때는 `jest.useFakeTimers()` 같은 도구를 써야 해요. - **성능 최적화**: CPU 집약적 작업을 `setImmediate`나 Worker Threads로 분리할 때, 작업의 성격에 따라 어느 쪽이 맞는지 달라져요. - **스트림 처리**: 여러 비동기 스트림이 엮이면 이벤트 순서가 예상과 달라지는 경우가 있어요. ## 정리 이벤트 루프를 완전히 외울 필요는 없어요. 하지만 세 가지는 기억해두면 좋아요. 1. 마이크로태스크(`nextTick`, `Promise.then`)는 각 페이즈 전후에 먼저 처리된다 2. `nextTick`은 Promise보다 우선순위가 높다 3. `setImmediate`는 I/O 콜백 안에서는 `setTimeout(fn, 0)`보다 항상 먼저다 이 정도만 갖고 있어도 비동기 디버깅에서 "왜 이 순서야?"라는 상황을 절반은 줄일 수 있어요.
// COMMENTS
Newest First
ON THIS PAGE