Node.js 이벤트 루프를 서버 요청의 흐름으로 이해하기
Node.js 이벤트 루프를 서버 요청의 흐름으로 이해하기
Node.js는 요청마다 새로운 스레드를 만드는 대신 하나의 JavaScript 실행 흐름과 비동기 I/O를 조합한다. 그래서 많은 연결을 효율적으로 다루지만, JavaScript 스레드를 오래 점유하는 작업에는 취약하다.
- 네트워크와 파일 I/O의 대기는 런타임이 처리하고 완료 콜백이 이벤트 루프로 돌아온다.
- 긴 동기 연산은 다른 요청의 콜백까지 지연시킨다.
- CPU 작업은 나누거나 Worker Thread와 별도 작업 큐로 옮긴다.
목차
- #요청 하나가 처리되는 흐름
- #이벤트 루프를 막는 코드
- #CPU 작업을 분리하는 방법
- #요청 흐름을 이벤트 루프 관점에서 그려보기
- #평균 응답 시간만 보면 놓치는 것
- #작업을 나누는 세 가지 선택
- #결론
- #관련 노트
요청 하나가 처리되는 흐름
서버가 DB 요청을 보낸 뒤 결과를 기다리는 동안 JavaScript 스레드는 다른 연결을 처리할 수 있다. DB 응답이 도착하면 완료 작업이 큐에 들어가고, 현재 실행 중인 코드가 끝난 뒤 이어서 처리된다.
app.get("/users/:id", async (req, res) => {
const user = await repository.findById(req.params.id);
res.json(user);
});
await가 스레드를 멈추는 것이 아니라 현재 함수의 나머지 실행을 나중으로 미룬다는 점이 중요하다.
이벤트 루프를 막는 코드
app.get("/report", (req, res) => {
const result = calculateLargeReport(); // 긴 동기 CPU 작업
res.json(result);
});
이 함수가 2초 동안 CPU를 사용하면 그 프로세스의 다른 요청도 콜백을 실행하지 못한다. 평균 응답 시간보다 상위 지연 시간이 갑자기 커지는 형태로 나타나기 쉽다.
async로 선언해도 내부에서 큰 배열 정렬, 이미지 처리, 동기 파일 I/O를 수행하면 이벤트 루프를 막는다.
CPU 작업을 분리하는 방법
작업이 작다면 여러 tick으로 나누고, 크다면 Worker Thread나 별도 worker 서비스로 옮긴다. HTTP 요청은 작업 ID를 반환하고 클라이언트가 상태를 조회하게 만들 수도 있다.
const job = await queue.enqueue({ type: "REPORT", userId });
res.status(202).json({ jobId: job.id });
요청 흐름을 이벤트 루프 관점에서 그려보기
Node.js 서버의 한 요청을 단순화하면 다음과 같이 볼 수 있다.
sequenceDiagram
participant C as Client
participant J as JavaScript
participant R as Runtime / OS
participant D as Database
C->>J: GET /users/42
J->>R: DB I/O 등록
R->>D: query
Note over J: 다른 요청의 JavaScript 실행 가능
D-->>R: result
R-->>J: 완료 콜백을 큐에 등록
J-->>C: JSON responseawait를 만나는 순간 함수 전체가 별도 스레드로 이동하는 것은 아니다. Promise가 완료된 뒤 실행할 나머지 부분을 등록하고 현재 JavaScript 실행권을 돌려준다. DB 드라이버와 운영체제가 I/O를 처리하는 동안 이벤트 루프는 다른 요청의 콜백을 실행할 수 있다.
반대로 아래 코드는 async 함수 안에 있어도 이벤트 루프를 막는다.
app.get("/statistics", async (request, response) => {
const rows = await repository.findAll();
// 예시를 위한 의도적인 CPU 병목
const ranked = rows
.map(calculateComplexScore)
.sort((a, b) => b.score - a.score);
response.json(ranked.slice(0, 100));
});
DB를 기다리는 동안에는 다른 요청을 처리할 수 있지만, map과 sort가 JavaScript 스레드를 오래 점유하는 동안에는 다른 요청의 응답 콜백도 실행되지 못한다.
평균 응답 시간만 보면 놓치는 것
CPU 작업은 모든 요청을 일정하게 느리게 만들기보다 특정 시점의 요청을 한꺼번에 기다리게 한다. 예를 들어 평소에는 40ms인 API가 2초짜리 리포트 계산과 겹치면 그 뒤에 도착한 짧은 요청까지 밀린다.
| 관측값 | 해석 |
|---|---|
| DB 대기 시간만 큼 | 외부 I/O나 쿼리 병목 후보 |
| 이벤트 루프 지연이 큼 | 긴 동기 JavaScript 또는 GC 후보 |
| CPU 사용률이 높고 처리량 정체 | 계산 작업 분리 필요 |
| 평균은 정상인데 p99만 급증 | 간헐적인 긴 작업 또는 큰 GC 후보 |
요청 시간뿐 아니라 event loop delay, CPU profile, heap 사용량, 외부 호출 시간을 분리해 본다. “Node.js가 느리다”라는 한 문장으로는 원인을 고를 수 없다.
작업을 나누는 세 가지 선택
1. 계산을 여러 tick으로 나누기
짧은 배열 처리라면 한 번에 처리할 개수를 제한하고 이벤트 루프에 실행 기회를 돌려줄 수 있다.
import { setImmediate } from "node:timers/promises";
export async function processInChunks(items, chunkSize = 200) {
const results = [];
for (let index = 0; index < items.length; index += chunkSize) {
const chunk = items.slice(index, index + chunkSize);
results.push(...chunk.map(calculateScore));
await setImmediate();
}
return results;
}
전체 CPU 사용량이 줄어드는 것은 아니지만 다른 요청이 실행될 틈을 만든다.
2. Worker Thread로 옮기기
이미지 변환이나 큰 계산처럼 CPU 사용 자체가 크다면 Worker Thread가 적합하다. 다만 worker 생성 비용이 있으므로 요청마다 새 worker를 만들기보다 pool을 둔다.
// 개념을 보여주기 위한 예시 인터페이스
const result = await workerPool.run({
task: "CALCULATE_REPORT",
payload: sanitizedRows,
});
3. 비동기 작업 큐로 분리하기
사용자가 즉시 결과를 받을 필요가 없다면 HTTP 요청에서는 작업만 등록하고 202 Accepted를 반환한다.
app.post("/reports", async (request, response) => {
const job = await reportQueue.enqueue({
requestedBy: request.user.id,
range: request.body.range,
});
response.status(202).json({ jobId: job.id });
});
큐를 선택하면 진행 상태, 재시도, 중복 작업 방지, 결과 보관이라는 새로운 책임도 생긴다. 단순히 “백그라운드로 보냈다”로 끝나지 않는다.
결론
Node.js 서버 성능은 스레드 수보다 이벤트 루프를 얼마나 짧게 점유하는지에 크게 좌우된다. 느린 요청을 찾을 때 외부 I/O 대기와 CPU 점유를 구분해야 한다.