Node.js 인터뷰 질문 37
질문: Node.js의 이벤트 루프에 대해 자세히 설명해주세요.
답변:
Node.js의 이벤트 루프는 Node.js가 비동기 작업을 처리하는 핵심 메커니즘입니다. 이벤트 루프 덕분에 Node.js는 단일 스레드로 동작하면서도 높은 동시성을 제공할 수 있습니다.
이벤트 루프의 기본 개념
이벤트 루프는 Node.js가 입출력 작업을 차단하지 않고 비동기적으로 처리할 수 있게 해주는 싱글 스레드 루프입니다. 이는 JavaScript가 비동기 콜백을 실행할 수 있게 해주는 메커니즘으로, 리소스를 효율적으로 사용하면서 수천 개의 동시 연결을 처리할 수 있게 합니다.
console.log("시작");
setTimeout(() => {
console.log("타임아웃 콜백 실행");
}, 0);
console.log("종료");
// 출력 순서:
// 시작
// 종료
// 타임아웃 콜백 실행
위 예제에서 볼 수 있듯이, setTimeout
콜백은 즉시 실행되지 않고 다른 동기 코드가 모두 실행된 후에 처리됩니다. 이것이 이벤트 루프의 기본 동작입니다.
이벤트 루프의 구조와 단계
Node.js의 이벤트 루프는 libuv 라이브러리를 기반으로 하며, 다음과 같은 단계(phases)로 구성됩니다:
- 타이머(Timers):
setTimeout()
및setInterval()
로 예약된 콜백 실행 - 대기 중인 콜백(Pending Callbacks): 이전 반복에서 연기된 I/O 콜백 실행
- 유휴(Idle, Prepare): 내부용으로만 사용
- 폴링(Poll): 새로운 I/O 이벤트 검색 및 I/O 관련 콜백 실행
- 체크(Check):
setImmediate()
콜백 실행 - 종료 콜백(Close Callbacks):
socket.on('close', ...)
같은 종료 이벤트 콜백 실행
각 단계 사이에 NextTick 큐 및 기타 마이크로태스크 큐(Promise 콜백 등)가 처리됩니다.
이벤트 루프의 간략한 의사 코드는 다음과 같습니다:
// 이벤트 루프 의사 코드
while (true) {
// 1. 타이머 단계
executeExpiredTimers();
// 2. 대기 중인 콜백 단계
executeIOCallbacks();
// 3. 유휴, 준비 단계(내부용)
prepareForPolling();
// 4. 폴링 단계
if (hasOutstandingIOOrTimers()) {
pollForIO();
executeIOCallbacks();
} else {
// 타이머나 IO 콜백이 없으면 잠시 대기
waitForNotifications();
}
// 5. 체크 단계
executeSetImmediateCallbacks();
// 6. 종료 콜백 단계
executeCloseHandlers();
// 종료 조건 체크 (참조 카운트가 0이면 종료)
if (shouldExit()) {
break;
}
// 각 단계 사이에 nextTick 큐와 마이크로태스크 큐 처리
processNextTickQueue();
processPromiseCallbacks();
}
주요 비동기 API와 이벤트 루프의 상호작용
1. setTimeout()
vs setImmediate()
vs process.nextTick()
이 세 가지 함수는 모두 비동기 작업을 예약하지만, 실행 시점이 다릅니다:
process.nextTick()
: 현재 작업이 완료된 후, 다음 이벤트 루프 단계가 시작되기 전에 실행됩니다. 모든 이벤트 루프 단계 사이에 처리되며, 우선순위가 가장 높습니다.setImmediate()
: 현재 폴링 단계가 완료된 후, 체크 단계에서 실행됩니다.setTimeout(fn, 0)
: 타이머 단계에서 실행되며, 최소 지연 시간은 약 1ms입니다.
console.log("시작");
// 1. nextTick
process.nextTick(() => {
console.log("nextTick 콜백");
});
// 2. setTimeout
setTimeout(() => {
console.log("setTimeout 콜백");
}, 0);
// 3. setImmediate
setImmediate(() => {
console.log("setImmediate 콜백");
});
console.log("종료");
// 일반적인 출력 순서:
// 시작
// 종료
// nextTick 콜백
// setTimeout 콜백
// setImmediate 콜백
참고:
setTimeout(fn, 0)
과setImmediate()
의 실행 순서는 이벤트 루프의 현재 상태와 성능에 따라 다를 수 있습니다. 그러나 I/O 콜백 내에서는setImmediate()
가 항상setTimeout(fn, 0)
보다 먼저 실행됩니다.
2. Promise와 이벤트 루프
Promise 콜백(.then()
, .catch()
, .finally()
)은 마이크로태스크 큐에서 처리되며, process.nextTick()
다음으로 높은 우선순위를 가집니다.
console.log("시작");
// 1. Promise
Promise.resolve().then(() => {
console.log("Promise 콜백");
});
// 2. nextTick
process.nextTick(() => {
console.log("nextTick 콜백");
});
// 3. setTimeout
setTimeout(() => {
console.log("setTimeout 콜백");
}, 0);
console.log("종료");
// 출력 순서:
// 시작
// 종료
// nextTick 콜백
// Promise 콜백
// setTimeout 콜백
이벤트 루프의 실행 과정 상세 설명
이벤트 루프가 어떻게 작동하는지 더 자세히 살펴보겠습니다:
1. 타이머 단계
이 단계에서는 setTimeout()
과 setInterval()
로 예약된 콜백이 실행됩니다. 타이머는 최소 지연 시간을 보장하지만, 정확한 시간에 실행되는 것은 보장하지 않습니다.
const startTime = Date.now();
setTimeout(() => {
const delay = Date.now() - startTime;
console.log(`타이머가 ${delay}ms 후에 실행됨`);
}, 100);
// CPU를 점유하는 작업
for (let i = 0; i < 1e9; i++) {} // 약 1초 소요
위 예제에서 타이머는 100ms 후에 실행되도록 설정되었지만, 실제로는 약 1초 후에 실행됩니다. 이는 이벤트 루프가 for 루프가 완료될 때까지 차단되기 때문입니다.
2. 대기 중인 콜백 단계
이 단계에서는 이전 반복에서 연기된 I/O 콜백이 실행됩니다. 예를 들어, TCP 오류와 같은 특정 시스템 작업의 콜백이 여기서 처리됩니다.
3. 폴링 단계
폴링 단계는 이벤트 루프에서 가장 중요한 단계 중 하나입니다. 이 단계에서는:
- 새로운 I/O 이벤트를 계산합니다.
- I/O 관련 콜백을 실행합니다.
- 필요하다면 (예: 타이머나
setImmediate
콜백이 없는 경우) 이벤트 루프는 이 단계에서 일시 중지하여 I/O 작업이 완료될 때까지 기다립니다.
const fs = require("fs");
// 비동기 파일 읽기
fs.readFile("example.txt", (err, data) => {
if (err) throw err;
console.log("파일 내용:", data.toString());
});
console.log("파일 읽기 요청 후");
위 예제에서 readFile
콜백은 폴링 단계에서 실행됩니다. 파일 읽기 작업이 완료되면, 해당 콜백이 다음 폴링 단계에서 실행됩니다.
4. 체크 단계
이 단계에서는 setImmediate()
콜백이 실행됩니다. setImmediate()
는 현재 폴링 단계가 완료된 후 콜백을 실행하도록 설계되었습니다.
const fs = require("fs");
fs.readFile("example.txt", () => {
setTimeout(() => {
console.log("setTimeout 콜백");
}, 0);
setImmediate(() => {
console.log("setImmediate 콜백");
});
});
// I/O 콜백 내에서의 출력 순서:
// setImmediate 콜백
// setTimeout 콜백
I/O 작업 콜백 내에서는 setImmediate()
가 항상 setTimeout(fn, 0)
보다 먼저 실행됩니다. 이는 I/O 작업 후에 폴링 단계가 끝나고 바로 체크 단계로 넘어가기 때문입니다.
5. 종료 콜백 단계
이 단계에서는 socket.on('close', ...)
와 같은 종료 이벤트 콜백이 실행됩니다.
nextTick 큐와 마이크로태스크 큐
이벤트 루프의 각 단계 사이에는 두 개의 특별한 큐가 처리됩니다:
- NextTick 큐:
process.nextTick()
으로 등록된 콜백이 포함됩니다. - 마이크로태스크 큐: Promise 콜백이 포함됩니다.
이 두 큐는 이벤트 루프의 모든 단계 사이에 처리되며, NextTick 큐가 마이크로태스크 큐보다 먼저 처리됩니다.
Promise.resolve().then(() => console.log("Promise 1"));
Promise.resolve().then(() => {
console.log("Promise 2");
process.nextTick(() => console.log("nextTick 내부 Promise"));
});
Promise.resolve().then(() => console.log("Promise 3"));
process.nextTick(() => console.log("nextTick 1"));
process.nextTick(() => {
console.log("nextTick 2");
Promise.resolve().then(() => console.log("Promise 내부 nextTick"));
});
process.nextTick(() => console.log("nextTick 3"));
// 출력 순서:
// nextTick 1
// nextTick 2
// nextTick 3
// Promise 1
// Promise 2
// Promise 3
// Promise 내부 nextTick
// nextTick 내부 Promise
위 예제에서 볼 수 있듯이, 모든 nextTick 콜백이 먼저 처리된 후 Promise 콜백이 처리됩니다. 중첩된 nextTick과 Promise도 각각의 큐 처리 순서를 따릅니다.
이벤트 루프 블로킹 방지하기
Node.js의 이벤트 루프는 단일 스레드이므로, CPU 집약적인 작업은 이벤트 루프를 차단하여 다른 작업의 실행을 지연시킬 수 있습니다. 이를 방지하기 위한 몇 가지 접근 방식이 있습니다:
1. 작업 분할
큰 작업을 작은 단위로 나누고, setImmediate()
를 사용하여 이벤트 루프가 다른 작업을 처리할 수 있는 기회를 제공합니다.
function processData(data, callback) {
let i = 0;
const dataLength = data.length;
function process() {
// 일정량의 데이터만 처리
const start = i;
const end = Math.min(i + 1000, dataLength);
for (; i < end; i++) {
// 데이터 처리 작업
data[i] = data[i] * 2;
}
// 모든 데이터를 처리했는지 확인
if (i < dataLength) {
// 다음 덩어리를 처리하기 위해 setImmediate 사용
setImmediate(process);
} else {
// 모든 처리가 완료됨
callback(data);
}
}
process();
}
const largeArray = new Array(10000000).fill(1);
processData(largeArray, (result) => {
console.log("처리 완료:", result.length);
});
2. 워커 스레드 사용
Node.js 10부터는 워커 스레드를 사용하여 CPU 집약적인 작업을 별도의 스레드로 오프로드할 수 있습니다.
const {
Worker,
isMainThread,
parentPort,
workerData,
} = require("worker_threads");
if (isMainThread) {
// 메인 스레드 코드
const worker = new Worker(__filename, {
workerData: { data: [1, 2, 3, 4, 5] },
});
worker.on("message", (result) => {
console.log("워커로부터 받은 결과:", result);
});
worker.on("error", (error) => {
console.error("워커 오류:", error);
});
worker.on("exit", (code) => {
if (code !== 0) {
console.error(`워커가 오류 코드 ${code}로 종료됨`);
}
});
} else {
// 워커 스레드 코드
const { data } = workerData;
// CPU 집약적인 작업 수행
const result = data.map((x) => fibonacci(x));
// 결과를 메인 스레드로 전송
parentPort.postMessage(result);
}
// CPU 집약적인 피보나치 계산
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
3. 자식 프로세스 활용
child_process
모듈을 사용하여 별도의 프로세스에서 CPU 집약적인 작업을 실행할 수 있습니다.
const { fork } = require("child_process");
const computeProcess = fork("compute.js");
computeProcess.send({ number: 40 });
computeProcess.on("message", (result) => {
console.log("피보나치(40) 결과:", result);
});
// compute.js
process.on("message", (message) => {
const result = fibonacci(message.number);
process.send(result);
});
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
Node.js 버전별 이벤트 루프 변경 사항
Node.js 이벤트 루프는 버전에 따라 몇 가지 변경 사항이 있었습니다:
Node.js 11부터의 변경 사항
Node.js 11부터 이벤트 루프 처리 방식이 변경되어, 이제 각 이벤트 루프 단계마다 모든 마이크로태스크가 처리됩니다. 이전 버전에서는 각 단계에서 하나의 콜백이 처리된 후에만 마이크로태스크가 처리되었습니다.
// Node.js 10 및 이전 버전
setTimeout(() => {
console.log("setTimeout 1");
Promise.resolve().then(() => console.log("Promise in setTimeout 1"));
}, 0);
setTimeout(() => {
console.log("setTimeout 2");
Promise.resolve().then(() => console.log("Promise in setTimeout 2"));
}, 0);
// 출력 순서:
// setTimeout 1
// Promise in setTimeout 1
// setTimeout 2
// Promise in setTimeout 2
// Node.js 11 이후
// 출력 순서가 동일하지만, 내부 처리 방식이 다름
이벤트 루프와 관련된 일반적인 문제
1. 이벤트 루프 블로킹
앞서 언급했듯이, CPU 집약적인 작업은 이벤트 루프를 차단하여 애플리케이션의 응답성을 저하시킬 수 있습니다.
// 나쁜 예시 - 이벤트 루프 블로킹
app.get("/calculate", (req, res) => {
// CPU 집약적인 작업
const result = calculateResult(req.query.input); // 오래 걸리는 동기 작업
res.send({ result });
});
// 좋은 예시 - 워커 스레드 사용
const { Worker } = require("worker_threads");
app.get("/calculate", (req, res) => {
const worker = new Worker("./calculator.js", {
workerData: { input: req.query.input },
});
worker.on("message", (result) => {
res.send({ result });
});
worker.on("error", (error) => {
res.status(500).send({ error: error.message });
});
});
2. 콜백 지옥 및 해결책
비동기 코드는 종종 콜백 지옥(callback hell)으로 이어질 수 있습니다. 이를 해결하기 위한 몇 가지 접근 방식이 있습니다:
// 콜백 지옥 예시
fs.readFile("file1.txt", (err, data1) => {
if (err) throw err;
fs.readFile("file2.txt", (err, data2) => {
if (err) throw err;
fs.readFile("file3.txt", (err, data3) => {
if (err) throw err;
// 세 파일의 데이터로 작업
});
});
});
// Promise를 사용한 해결책
const readFile = (filename) => {
return new Promise((resolve, reject) => {
fs.readFile(filename, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
};
readFile("file1.txt")
.then((data1) => {
// data1로 작업
return readFile("file2.txt");
})
.then((data2) => {
// data2로 작업
return readFile("file3.txt");
})
.then((data3) => {
// data3로 작업
})
.catch((err) => {
console.error("오류:", err);
});
// async/await를 사용한 해결책
const fs = require("fs").promises;
async function readFiles() {
try {
const data1 = await fs.readFile("file1.txt");
// data1로 작업
const data2 = await fs.readFile("file2.txt");
// data2로 작업
const data3 = await fs.readFile("file3.txt");
// data3로 작업
} catch (err) {
console.error("오류:", err);
}
}
readFiles();
이벤트 루프 디버깅 및 모니터링
이벤트 루프의 동작을 디버깅하고 모니터링하기 위한 몇 가지 도구와 기법이 있습니다:
1. node --trace-event-categories
를 사용한 이벤트 루프 추적
node --trace-event-categories v8,node,node.async_hooks your-script.js
이 명령어는 이벤트 루프 활동에 대한 추적 정보를 생성합니다. Chrome 개발자 도구의 성능 탭에서 이 정보를 시각화할 수 있습니다.
2. 타이머 및 비동기 작업 디버깅
const { performance } = require("perf_hooks");
// 비동기 작업의 시간 측정
const start = performance.now();
someAsyncOperation(() => {
const duration = performance.now() - start;
console.log(`비동기 작업이 ${duration}ms 걸림`);
});
3. 타사 도구 사용
- Clinic.js: Node.js 애플리케이션의 성능 문제를 진단하는 도구 모음
- Node Inspector: Chrome 개발자 도구를 사용하여 Node.js 프로세스를 디버깅
응용 프로그램 설계를 위한 이벤트 루프 모범 사례
1. 비동기 작업 활용하기
가능한 한 비동기 API를 사용하여 이벤트 루프 차단을 방지합니다.
// 나쁜 예
const fs = require("fs");
const data = fs.readFileSync("file.txt"); // 동기식, 이벤트 루프 차단
processData(data);
// 좋은 예
const fs = require("fs");
fs.readFile("file.txt", (err, data) => {
// 비동기식, 이벤트 루프 차단하지 않음
if (err) throw err;
processData(data);
});
2. 오류 처리
모든 비동기 작업에서 오류를 적절히 처리합니다.
// 비동기 오류 처리
process.on("uncaughtException", (err) => {
console.error("처리되지 않은 예외:", err);
// 안전하게 정리한 후 프로세스 종료
process.exit(1);
});
process.on("unhandledRejection", (reason, promise) => {
console.error("처리되지 않은 거부:", promise, "이유:", reason);
// 안전하게 정리한 후 프로세스 종료
process.exit(1);
});
3. 청크로 스트림 처리
대용량 데이터를 처리할 때는 스트림을 사용하여 메모리 효율성을 높이고 이벤트 루프 차단을 방지합니다.
const fs = require("fs");
const server = require("http").createServer();
server.on("request", (req, res) => {
// 나쁜 예: 전체 파일을 메모리에 로드
// fs.readFile('./video.mp4', (err, data) => {
// if (err) throw err;
// res.end(data);
// });
// 좋은 예: 스트림 사용
const stream = fs.createReadStream("./video.mp4");
stream.pipe(res);
});
server.listen(3000);
요약
- 이벤트 루프는 Node.js가 비동기 작업을 처리하는 핵심 메커니즘으로, 단일 스레드로 동작하면서도 높은 동시성을 제공합니다.
- 이벤트 루프는 타이머, 대기 중인 콜백, 폴링, 체크, 종료 콜백 등의 여러 단계로 구성됩니다.
process.nextTick()
, Promise 콜백,setTimeout()
,setImmediate()
는 이벤트 루프의 다른 단계에서 실행됩니다.- CPU 집약적인 작업은 이벤트 루프를 차단할 수 있으므로, 워커 스레드, 자식 프로세스, 작업 분할 등의 기법을 사용하여 이를 방지해야 합니다.
- 콜백 지옥을 피하기 위해 Promise와 async/await를 활용하는 것이 좋습니다.
- 이벤트 루프의 효율적인 활용을 위해 비동기 API를 사용하고, 오류를 적절히 처리하며, 대용량 데이터는 스트림으로 처리하는 것이 좋습니다.
Node.js의 이벤트 루프를 이해하는 것은 효율적이고 확장 가능한 Node.js 애플리케이션을 개발하는 데 필수적입니다. 이벤트 루프의 동작 원리를 알면 비동기 코드를 더 효과적으로 작성하고 디버깅할 수 있습니다.