Node.js 애플리케이션에서 응답 지연이나 타임아웃이 발생하면 원인은 대개 이벤트 루프 블로킹입니다. 개발 단계에서는 setTimeout이 밀리거나 API 응답이 튕기는 현상으로 드러나고, 운영 환경에서는 CPU 사용률이 100%로 치솟거나 요청 지연(99th percentile latency)이 급증하는 것으로 관찰됩니다. 이 글에서는 이벤트 루프의 동작 원리를 설명하고, 실무에서 자주 만나는 블로킹 사례와 이를 해결하는 구체적인 코드 예시(나쁜 예/좋은 예)를 제시합니다.
이벤트 루프 블로킹의 기술적 메커니즘
Node.js는 싱글 스레드 이벤트 루프(실제로는 libuv 기반의 쓰레드풀 포함) 위에서 논블로킹 I/O를 처리합니다. 자바스크립트 스레드가 CPU 바운드 작업(예: 복잡한 계산, 동기 파일 I/O, 동기 암호화 함수)을 수행하면 이벤트 루프가 해당 작업이 끝날 때까지 다른 콜백을 처리하지 못해 응답 지연이 발생합니다. 측정 예로 setTimeout으로 1000ms를 줬는데 콜백이 3000ms 후에 실행되면 이벤트 루프가 블로킹됐다고 판단하면 됩니다.
실무 예시 — 블로킹 코드(Bad Case)
아래는 동기적으로 피보나치 수를 계산해 이벤트 루프를 막는 예입니다. 이 코드를 실행하면 다른 요청이나 타이머가 지연됩니다.
// bad.js
const http = require('http');
function fib(n){
if(n <= 1) return n;
return fib(n-1) + fib(n-2);
}
http.createServer((req, res) => {
if(req.url === '/fib'){
const start = Date.now();
const v = fib(40); // CPU 바운드, 블로킹 발생
res.end(`fib: ${v} took ${Date.now()-start}ms`);
} else {
// 이 요청은 fib 실행 중 응답이 지연됨
res.end('ok');
}
}).listen(3000);
실무 예시 — 비동기로 옮긴 해결책(Good Case)
CPU 바운드 작업은 worker_threads로 분리하거나 별도 서비스로 빼는 것이 안전합니다. 아래는 worker_threads를 이용한 개선 예시입니다.
// worker-fib.js
const { parentPort, workerData } = require('worker_threads');
function fib(n){
if(n <= 1) return n;
return fib(n-1)+fib(n-2);
}
parentPort.postMessage(fib(workerData));
// main.js
const http = require('http');
const { Worker } = require('worker_threads');
function runFib(n){
return new Promise((resolve, reject) =>{
const w = new Worker('./worker-fib.js', { workerData: n });
w.on('message', resolve);
w.on('error', reject);
});
}
http.createServer(async (req, res) => {
if(req.url === '/fib'){
const start = Date.now();
const v = await runFib(40); // 메인 스레드 비동기 유지
res.end(`fib: ${v} took ${Date.now()-start}ms`);
} else res.end('ok');
}).listen(3000);
또 다른 실무 팁: fs.readFileSync, crypto.pbkdf2Sync 같은 동기 API는 피하고, 가능한 경우 스트리밍과 비동기 API(fs.promises, stream)를 사용합니다. I/O는 libuv 쓰레드풀(기본 4개)을 이용하므로 많은 동시 파일 작업은 쓰레드풀 크기(UV_THREADPOOL_SIZE)로 조정할 수 있습니다.
운영·아키텍처 관점에서의 제언
단일 인스턴스에 모든 책임을 두면 이벤트 루프 블로킹에 취약합니다. CPU 바운드 로직은 별도 서비스(마이크로서비스), 워커풀, 혹은 배치로 분리하고, I/O는 스트리밍으로 처리하는 것이 좋습니다. 모니터링 지표로는 event-loop lag, 95/99 percentile latency, CPU 사용률, 쓰레드풀 큐 길이를 계측하십시오. PM2 같은 프로세스 매니저의 클러스터 모드로 수평 확장하거나, 성능이 중요한 작업은 Rust/Go 같은 언어로 구현해 RPC로 호출하는 방안도 고려해야 합니다.
마무리
이벤트 루프 블로킹 문제는 원인 파악(어떤 함수가 블로킹하는지)과 적절한 분리(워커, 비동기 API, 서비스 분리)로 해결됩니다. 애플리케이션 특성에 따라 적절한 전략을 조합하면 안정성과 응답성을 동시에 확보할 수 있습니다. 작은 CPU 바운드 작업도 반복적으로 발생하면 전체 시스템 퍼포먼스를 급격히 저하시킬 수 있으니, 코드 한 줄의 동기 호출이 전체 서비스 경험을 망칠 수 있다는 점을 항상 염두에 두시기 바랍니다.