이벤트 루프 블로킹(Event Loop Blocking)이란?
이벤트 루프란?
이벤트 루프(Event Loop)는 Node.js에서 비동기 작업을 효율적으로 처리하기 위한 핵심 메커니즘이다.
Node.js는 기본적으로 JavaScript 코드를 하나의 메인 스레드에서 실행한다. 이벤트 루프는 이 스레드에서 실행할 작업을 관리하고, 비동기 작업이 완료되었을 때 적절한 시점에 콜백을 실행한다.
일반적인 동기 코드가 실행되는 동안에는 다음 JavaScript 코드가 실행될 수 없다. 따라서 특정 작업이 오랜 시간 동안 메인 스레드를 점유하면 다른 요청을 처리하지 못하는 상황이 발생한다.
이처럼 이벤트 루프가 다음 작업을 처리하지 못하도록 오랫동안 점유되는 현상을 **이벤트 루프 블로킹(Event Loop Blocking)**이라고 한다.
이벤트 루프 블로킹이 발생하는 원리
Node.js는 네트워크 요청이나 파일 입출력과 같은 작업을 비동기 방식으로 처리할 수 있다. 하지만 모든 작업이 자동으로 비동기 실행되는 것은 아니다.
JavaScript 코드 자체에서 실행하는 CPU 집약적인 연산은 기본적으로 메인 스레드에서 동기적으로 수행된다.
예를 들어 서버가 요청을 처리하는 도중 매우 큰 데이터를 반복문으로 계산한다고 가정하자. 해당 계산이 끝날 때까지 이벤트 루프는 다른 JavaScript 콜백을 실행할 수 없다.
이때 새로운 HTTP 요청이 들어오더라도 요청 처리 코드가 즉시 실행되지 못하고 대기할 수 있다.
핵심은 비동기 API를 사용하느냐가 아니라, 메인 스레드가 다른 작업을 처리할 수 없는 상태로 얼마나 오래 점유되느냐이다.
이벤트 루프 블로킹을 발생시키는 대표적인 원인
CPU 집약적인 연산
대량의 반복문, 복잡한 알고리즘, 큰 데이터의 동기식 변환 등은 이벤트 루프를 블로킹할 수 있다.
function calculate() {
let result = 0;
for (let i = 0; i < 10_000_000_000; i++) {
result += i;
}
return result;
}
app.get('/calculate', (req, res) => {
const result = calculate();
res.json({ result });
});
위 코드는 계산이 완료될 때까지 메인 스레드를 점유한다.
계산이 진행되는 동안 같은 Node.js 프로세스에서 처리해야 하는 다른 요청도 지연될 수 있다. 반복 횟수는 예시이며, 실제 지연 시간은 CPU와 실행 환경에 따라 달라진다.
동기식 파일 입출력
Node.js의 동기식 파일 API도 이벤트 루프 블로킹의 원인이 될 수 있다.
const fs = require('node:fs');
app.get('/file', (req, res) => {
const data = fs.readFileSync('./large-file.txt', 'utf8');
res.send(data);
});
readFileSync()는 파일 읽기가 끝날 때까지 현재 JavaScript 실행을 멈춘다.
파일 크기가 크거나 저장 장치의 응답이 느리면 요청 처리 지연이 커질 수 있다.
반면 비동기 API를 사용하면 파일 입출력이 완료되기를 기다리는 동안 다른 작업을 진행할 수 있다.
const fs = require('node:fs/promises');
app.get('/file', async (req, res, next) => {
try {
const data = await fs.readFile('./large-file.txt', 'utf8');
res.send(data);
} catch (error) {
next(error);
}
});
단, await를 사용했다고 해서 모든 작업이 자동으로 비블로킹이 되는 것은 아니다. await 뒤에서 실행되는 함수 자체가 CPU를 오래 점유한다면 이벤트 루프는 여전히 블로킹될 수 있다.
동기식 암호화와 해시 연산
비밀번호 해싱이나 암호화 작업에서도 주의가 필요하다.
일부 암호화 API는 동기식 버전을 제공한다. 이러한 함수를 메인 스레드에서 실행하면 연산이 끝날 때까지 다른 JavaScript 작업이 지연될 수 있다.
const crypto = require('node:crypto');
app.post('/hash', (req, res) => {
const hash = crypto.pbkdf2Sync(
req.body.password,
'salt',
600_000,
32,
'sha256'
);
res.json({ hash: hash.toString('hex') });
});
실제 서비스에서는 검증된 비밀번호 해싱 라이브러리와 적절한 비동기 처리 방식을 선택해야 한다. 위 코드는 동기 연산이 이벤트 루프를 점유할 수 있음을 보여 주기 위한 예시다.
큰 JSON 데이터 처리
JSON.parse()와 JSON.stringify()는 동기적으로 실행된다.
매우 큰 JSON 데이터를 파싱하거나 직렬화하면 이벤트 루프가 장시간 점유될 수 있다.
app.post('/process', (req, res) => {
const json = JSON.stringify(req.body);
res.json({
length: json.length
});
});
작은 JSON 데이터를 처리하는 것은 일반적으로 문제가 되지 않는다. 그러나 대용량 데이터나 복잡한 중첩 객체를 반복해서 처리한다면 CPU 사용량과 이벤트 루프 지연을 측정해야 한다.
비동기 처리와 이벤트 루프 블로킹의 차이
비동기 처리와 비블로킹 처리는 관련이 있지만 동일한 개념은 아니다.
- 동기 처리: 작업이 끝날 때까지 현재 실행 흐름이 기다린다.
- 비동기 처리: 작업 완료 후 이어서 실행할 코드를 등록하고, 완료 시점에 결과를 처리한다.
- 블로킹: 현재 스레드가 다른 작업을 진행하지 못하도록 점유되거나 대기하는 상태다.
- 비블로킹: 현재 스레드가 작업의 완료를 기다리느라 멈추지 않고 다른 작업을 진행할 수 있는 방식이다.
Node.js에서는 네트워크 입출력 같은 작업을 비동기적으로 처리할 수 있다. 일부 파일 시스템 작업과 암호화 작업은 libuv의 스레드 풀을 활용하기도 한다.
그러나 스레드 풀을 사용하는 작업이라고 해서 비용이 사라지는 것은 아니다. 스레드 풀의 작업이 과도하게 몰리면 다른 작업이 대기할 수 있고, 완료된 작업의 콜백이 메인 스레드에서 실행될 때도 시간이 필요하다.
또한 Promise나 async/await를 사용한다는 사실만으로 CPU 집약적인 연산이 별도 스레드에서 실행되는 것은 아니다.
이벤트 루프 블로킹이 서비스에 미치는 영향
요청 응답 시간 증가
이벤트 루프가 오래 점유되면 HTTP 요청을 수신했더라도 요청 처리 콜백이 실행될 때까지 기다려야 할 수 있다.
그 결과 평균 응답 시간뿐 아니라 특정 구간의 지연 시간인 P95, P99도 증가할 수 있다.
처리량 감소
Node.js 프로세스가 일정 시간 동안 처리할 수 있는 요청 수는 제한되어 있다.
이벤트 루프가 요청 하나의 CPU 연산에 오래 묶이면 그동안 다른 요청을 처리할 기회가 줄어든다. CPU 집약적인 요청이 많아질수록 전체 처리량이 감소할 수 있다.
실시간 통신 지연
WebSocket을 사용하는 실시간 게임 서버에서도 이벤트 루프 블로킹은 문제가 된다.
예를 들어 한 게임 방의 요청을 처리하는 과정에서 무거운 계산이 실행되면 같은 프로세스에서 처리하는 다른 게임 방의 메시지 전달도 지연될 수 있다.
이로 인해 다음과 같은 문제가 발생할 수 있다.
- 플레이어의 입력 처리 지연
- 게임 상태 동기화 지연
- 매칭 및 정산 처리 지연
- 핑과 응답 시간의 변동 증가
- 클라이언트의 연결 타임아웃
따라서 실시간 게임 서버에서는 개별 요청의 실행 시간뿐 아니라 이벤트 루프 지연을 지속적으로 관찰해야 한다.
이벤트 루프 블로킹을 해결하는 방법
비동기 API 사용
동기식 파일 입출력이나 동기식 네트워크 관련 작업은 가능한 한 적절한 비동기 API로 대체한다.
다만 비동기 API를 선택하는 것만으로 CPU 집약적인 JavaScript 연산까지 해결되지는 않는다.
Worker Threads 활용
CPU 집약적인 연산은 Node.js의 worker_threads 모듈을 이용해 별도 스레드로 분리할 수 있다.
const { Worker } = require('node:worker_threads');
function runWorker() {
return new Promise((resolve, reject) => {
const worker = new Worker('./worker.js');
worker.once('message', resolve);
worker.once('error', reject);
worker.once('exit', (code) => {
if (code !== 0) {
reject(new Error(`Worker exited: ${code}`));
}
});
});
}
이 방식에서는 무거운 연산을 worker.js에서 수행하도록 구성할 수 있다. 메인 스레드는 해당 연산의 실행을 기다리느라 직접 점유되지 않는다.
실제 구현에서는 작업량이 많을 때마다 Worker를 새로 생성하기보다 Worker 풀을 운영하는 방식을 고려할 수 있다. 작업 분배, 결과 전달, 예외 처리, 종료 관리 등의 비용도 함께 고려해야 한다.
작업 분할
큰 작업을 작은 단위로 나누고 각 단위 사이에 이벤트 루프가 다른 작업을 처리할 기회를 제공할 수 있다.
function processBatch(items, batchSize = 100) {
let index = 0;
function processNextBatch() {
const end = Math.min(index + batchSize, items.length);
while (index < end) {
processItem(items[index]);
index++;
}
if (index < items.length) {
setImmediate(processNextBatch);
}
}
processNextBatch();
}
위 코드는 작업을 여러 묶음으로 나누는 개념을 보여 준다. processItem() 자체가 지나치게 무겁다면 묶음 크기를 더 줄이거나 Worker Threads로 분리해야 한다. 실제 적용에서는 오류 처리와 완료 통지, 취소, 동시 실행 제한도 설계해야 한다.
데이터 처리량 제한
요청 한 번에 지나치게 큰 데이터를 처리하지 않도록 제한하는 것도 효과적이다.
- 요청 본문 크기 제한
- 페이지네이션 적용
- 대용량 파일의 스트리밍 처리
- 배치 작업의 크기 제한
- CPU 집약적인 API의 동시 실행 제한
이러한 방법은 불필요한 메모리 사용량과 긴 동기 실행 시간을 줄이는 데 도움이 된다.
프로세스와 작업 분리
CPU 집약적인 작업을 별도의 서비스나 작업 처리 프로세스로 분리할 수도 있다.
예를 들어 실시간 게임 서버는 플레이어 입력과 게임 상태 동기화에 집중하고, 통계 집계나 대규모 데이터 분석은 별도의 작업 처리 시스템으로 전달할 수 있다.
이렇게 하면 실시간 요청 처리와 무거운 계산 작업 사이의 자원 경쟁을 줄일 수 있다.
이벤트 루프 지연 측정하기
이벤트 루프 블로킹을 예방하려면 실제로 지연이 발생하는지 측정해야 한다.
Node.js는 perf_hooks 모듈의 monitorEventLoopDelay()를 통해 이벤트 루프 지연을 관찰할 수 있다.
const {
monitorEventLoopDelay
} = require('node:perf_hooks');
const histogram = monitorEventLoopDelay({
resolution: 20
});
histogram.enable();
setInterval(() => {
console.log({
meanMs: histogram.mean / 1e6,
maxMs: histogram.max / 1e6,
p99Ms: histogram.percentile(99) / 1e6
});
histogram.reset();
}, 10_000);
이 코드는 이벤트 루프 지연의 평균, 최댓값, P99를 밀리초 단위로 출력한다. 측정값은 해당 관찰 구간에서 수집한 지연 통계이며, 실제 요청 지연 시간과 완전히 동일한 값은 아니다.
운영 환경에서는 다음 항목을 함께 관찰하는 것이 좋다.
- 이벤트 루프 지연의 평균과 P99
- CPU 사용률
- 요청 처리 시간의 P95와 P99
- 처리량과 오류율
- Worker 스레드 및 스레드 풀의 작업 대기
- 메모리 사용량과 가비지 컬렉션으로 인한 지연
이 지표들을 함께 분석하면 이벤트 루프 블로킹이 실제 서비스 지연의 원인인지 판단하는 데 도움이 된다.