Event Loop
Node.js의 이벤트 루프는 Node.js 애플리케이션의 핵심 작동 원리로, 비동기 I/O 작업을 효율적으로 처리하는 메커니즘이다.
브라우저의 자바스크립트 이벤트 루프와 유사하지만 몇 가지 중요한 차이점이 있다.
Node.js 이벤트 루프는 단일 스레드 모델에서도 높은 동시성을 달성할 수 있게 해주는 핵심 메커니즘이다.
이벤트 루프의 다양한 단계와 비동기 API를 이해하면 효율적인 Node.js 애플리케이션을 개발할 수 있다.
이벤트 루프를 효과적으로 활용하기 위한 핵심 원칙은 다음과 같다:
- 비동기 API 활용하기: 가능한 한 비동기 API를 사용하여 이벤트 루프 블로킹을 방지한다.
- 작업 분할하기: 큰 작업은 작은 단위로 분할하여 이벤트 루프가 다른 작업을 처리할 수 있게 한다.
- 적절한 비동기 메커니즘 선택하기:
process.nextTick()
,setImmediate()
,setTimeout()
등을 상황에 맞게 사용한다. - CPU 집약적 작업 위임하기: 워커 스레드나 자식 프로세스를 사용하여 CPU 집약적 작업을 처리한다.
Node.js 이벤트 루프에 대한 깊은 이해는 확장 가능하고 효율적인 애플리케이션 개발에 필수적이다. 이벤트 루프를 효과적으로 활용하면 단일 스레드 모델의 한계를 극복하고 최적의 성능을 달성할 수 있다.
Node.js 이벤트 루프의 기본 개념
Node.js가 싱글 스레드 언어인 자바스크립트를 사용하면서도 어떻게 높은 동시성을 달성할 수 있는 이유는 이벤트 루프에 있다. 이벤트 루프는 Node.js가 논블로킹 I/O 작업을 수행하면서도 다른 작업을 계속 처리할 수 있게 해주는 메커니즘이다.
Node.js의 이벤트 루프는 libuv라는 C++ 라이브러리에 의해 구현된다. libuv는 운영체제의 비동기 I/O 연산에 대한 추상화 계층을 제공하며, 이벤트 큐를 관리한다.
Node.js 이벤트 루프의 구조와 단계
Node.js의 이벤트 루프는 여러 단계(Phase)로 구성되어 있으며, 각 단계마다 특정 유형의 작업을 처리한다.
이벤트 루프의 한 번의 순회(tick)는 다음과 같은 순서로 진행된다:
- 타이머(Timers):
setTimeout()
과setInterval()
로 예약된 콜백을 실행한다. - 보류 중인 콜백(Pending Callbacks): 이전 루프에서 연기된 I/O 콜백을 실행한다.
- 유휴 준비(Idle, Prepare): 내부용으로만, 사용자 코드에서는 접근할 수 없다.
- 폴링(Poll): 새로운 I/O 이벤트를 가져와서 관련 콜백을 실행한다. 필요하다면 여기서 블로킹될 수 있다.
- 체크(Check):
setImmediate()
콜백을 실행한다. - 종료 콜백(Close Callbacks): 소켓이나 핸들의
close
이벤트와 같은 종료 콜백을 실행한다.
이 각 단계 사이에 Node.js는 마이크로태스크 큐(Microtask Queue)에 있는 작업들(Promise 콜백 및 process.nextTick()
콜백)을 실행한다.
각 단계의 상세 분석
타이머(Timers)
타이머 단계에서는setTimeout()
과setInterval()
로 예약된 콜백을 실행한다.
지정된 시간이 지났다고 해서 콜백이 정확히 그 시간에 실행되는 것은 아니다.
타이머 콜백은 지정된 시간이 지난 후 다음 이벤트 루프의 타이머 단계에서 실행된다.보류 중인 콜백(Pending Callbacks)
이 단계에서는 이전 루프에서 연기된 I/O 콜백을 실행된다. 예를 들어, TCP 오류와 같은 일부 시스템 작업의 콜백이 여기서 처리된다.유휴 준비(Idle, Prepare)
이 단계는 Node.js 내부에서만 사용되며, 사용자 코드에서는 직접 접근할 수 없다.폴링(Poll)
폴링 단계는 이벤트 루프에서 가장 중요한 단계이다.
이 단계에서 Node.js는:- 폴링 큐에 있는 이벤트(새로운 연결, 데이터 수신 등)를 처리.
- 큐가 비었다면, 다음을 결정합니다:
- 만약
setImmediate()
로 예약된 스크립트가 있다면, 폴링 단계를 빠져나와 체크 단계로 진행. - 만약 타이머가 만료되었다면, 타이머 단계로 돌아간다.
- 그렇지 않다면, 새로운 I/O 이벤트가 발생할 때까지 폴링 단계에서 대기.
- 만약
체크(Check)
이 단계에서는setImmediate()
콜백을 실행한다.setImmediate()
는 현재 폴링 단계가 완료된 후 바로 실행되어야 하는 콜백을 예약하는 데 사용된다.종료 콜백(Close Callbacks)
이 단계에서는close
이벤트와 같은 종료 이벤트의 콜백을 실행한다.
마이크로태스크의 실행 시점
Node.js에서 마이크로태스크는 두 가지 큐에서 관리된다:
- nextTick 큐:
process.nextTick()
으로 등록된 콜백을 포함한다. - Promise 마이크로태스크 큐: Promise의
.then()
,.catch()
,.finally()
콜백을 포함한다.
이 두 큐는 이벤트 루프의 각 단계 사이에 실행된다.
그리고 중요한 점은 nextTick
큐가 Promise 마이크로태스크 큐보다 우선순위가 높다는 것.
setImmediate()
vs. setTimeout(0)
vs. process.nextTick()
이 세 가지 함수는 모두 비동기적으로 코드를 실행하지만, 실행 시점이 다르다:
- process.nextTick(): 현재 단계가 완료된 후, 다음 단계로 넘어가기 전에 즉시 실행된다.
이벤트 루프의 진행을 막을 수 있으므로 주의해서 사용해야 한다. - setTimeout(fn, 0): 다음 타이머 단계에서 실행된다. 실제로는 최소 1ms의 지연이 발생한다(Node.js 내부 구현에 따라).
- setImmediate(): 현재 폴링 단계가 완료된 후 다음 체크 단계에서 실행된다.
그러나 I/O 콜백 내에서는 setImmediate()
가 setTimeout(0)
보다 항상 먼저 실행된다:
이벤트 루프의 블로킹 방지하기
Node.js는 싱글 스레드이므로, 이벤트 루프를 블로킹하면 애플리케이션 전체의 성능이 저하된다.
다음은 이벤트 루프 블로킹을 방지하는 방법이다:
- CPU 집약적 작업 피하기: 복잡한 계산은 워커 스레드(Worker Threads)나 자식 프로세스(Child Processes)로 분리한다.
- 동기 API 대신 비동기 API 사용하기: 예를 들어
fs.readFileSync()
대신fs.readFile()
을 사용한다. - 큰 작업 분할하기: 큰 작업은 작은 단위로 분할하고 비동기 함수를 사용하여 이벤트 루프가 다른 작업을 처리할 수 있도록 한다.
|
|
이벤트 루프 디버깅 및 모니터링
Node.js 애플리케이션의 성능을 최적화하려면 이벤트 루프의 동작을 모니터링하는 것이 중요하다:
Node.js 내장 성능 API 사용하기:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
const { performance, PerformanceObserver } = require('perf_hooks'); const obs = new PerformanceObserver((items) => { console.log(items.getEntries()); performance.clearMarks(); }); obs.observe({ entryTypes: ['measure'] }); // 측정 시작 performance.mark('A'); // 코드 실행... // 측정 종료 performance.mark('B'); performance.measure('A to B', 'A', 'B');
외부 툴과 라이브러리 활용하기:
- Node.js 내장
--trace-event-categories
플래그 - Clinic.js
- Node.js 코어 덤프 분석
- Node.js 내장
심화 주제: Libuv와 이벤트 루프의 관계
Node.js 이벤트 루프는 libuv 라이브러리에 의해 구현된다.
libuv는 다양한 운영체제에서 일관된 비동기 I/O API를 제공한다:
- 운영체제별 비동기 메커니즘: libuv는 운영체제에 따라 다른 기술을 사용한다:
- Linux: epoll
- macOS/iOS: kqueue
- Windows: IOCP (I/O Completion Ports)
- 스레드 풀: libuv는 내부적으로 스레드 풀을 사용하여 파일 시스템 작업과 같은 블로킹 작업을 처리한다. 기본적으로 4개의 스레드가 있으며
UV_THREADPOOL_SIZE
환경 변수를 통해 최대 128개까지 조정할 수 있다. - C 언어 바인딩: Node.js는 libuv의 기능을 자바스크립트에서 접근할 수 있도록 C++ 바인딩을 제공한다.
실제 애플리케이션에서의 이벤트 루프 활용
Node.js 애플리케이션에서 이벤트 루프를 효과적으로 활용하는 방법:
웹 서버에서의 비동기 처리
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
const http = require('http'); const fs = require('fs'); const server = http.createServer((req, res) => { // 비동기 파일 읽기 작업 fs.readFile('./large-file.txt', (err, data) => { if (err) { res.statusCode = 500; res.end('Error loading file'); return; } // 응답 전송 res.end(data); }); // 위의 파일 읽기 작업이 진행되는 동안 // 서버는 다른 요청을 계속 처리할 수 있음 }); server.listen(3000);
데이터베이스 쿼리와 비동기 처리
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
const { MongoClient } = require('mongodb'); async function main() { const client = new MongoClient('mongodb://localhost:27017'); await client.connect(); const db = client.db('mydb'); const collection = db.collection('users'); // 비동기 데이터베이스 쿼리 const findUserPromise = collection.findOne({ username: 'john' }); // 데이터베이스 쿼리가 진행되는 동안 다른 작업 수행 가능 console.log('쿼리를 실행하는 동안 다른 작업 수행 중...'); // 쿼리 결과 기다리기 const user = await findUserPromise; console.log(user); await client.close(); } main().catch(console.error);
복잡한 작업의 분할과 비동기 처리
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
function processLargeDataset(dataset, callback) { const results = []; let index = 0; function processChunk() { // 한 번에 100개 항목만 처리 const chunk = dataset.slice(index, index + 100); if (chunk.length === 0) { callback(results); return; } // 청크 처리 chunk.forEach(item => { results.push(processItem(item)); }); // 인덱스 업데이트 index += chunk.length; // 이벤트 루프가 다른 작업을 처리할 수 있도록 다음 청크는 setImmediate로 예약 setImmediate(processChunk); } processChunk(); } function processItem(item) { // 항목 처리 로직... return item * 2; } // 사용 예 const largeDataset = new Array(10000).fill(0).map((_, i) => i); processLargeDataset(largeDataset, results => { console.log(`처리 완료: ${results.length} 항목`); }); console.log('대규모 데이터셋 처리가 백그라ун드에서 진행 중...');