이미지 리사이즈 요청 하나가 서버 전체를 멈춘 이유
문제 발생
썸네일 생성 요청이 들어오면 그동안 다른 모든 요청의 응답이 멈췄습니다. DB도 한가하고 CPU 코어도 남아 있었는데 처리량이 오르지 않았습니다.
app.post("/thumbnail", (req, res) => {
const out = resizeSync(req.body.image); // 순수 계산, 300ms
res.send(out);
});원인 분석
JavaScript 실행은 한 스레드에서 일어납니다. 300ms 동안 이벤트 루프가 그 함수 안에 있으므로, 그사이 도착한 요청은 큐에서 기다립니다. 코어가 남아도 쓰이지 않습니다.
Node.js 문서는 이 경우를 위한 도구와, 쓰면 안 되는 경우를 함께 적습니다 — 워커(스레드)는 CPU 집약적인 JavaScript 연산을 수행하는 데 유용합니다. I/O 집약적인 작업에는 큰 도움이 되지 않습니다. Node.js의 내장 비동기 I/O 연산이 워커보다 효율적입니다.
즉 "느리니까 워커로 옮기자"는 답이 아닙니다. DB 조회가 느린 것은 워커로 해결되지 않습니다 — 그건 이미 비동기이고, 워커를 쓰면 스레드 관리와 통신 비용만 늘어납니다.
워커가 진짜 분리되는 이유도 문서에 있습니다. 각 워커는 자신의 V8 인스턴스와 이벤트 루프를 갖고, child_process나 cluster와 달리 ArrayBuffer를 전송하거나 SharedArrayBuffer를 공유해 메모리를 공유할 수 있습니다.
해결 방안
- CPU 작업만 워커로 옮깁니다.
import { Worker } from "node:worker_threads";
const worker = new Worker("./resize-worker.js", { workerData: buffer });
worker.on("message", (out) => res.send(out));-
워커 풀을 만듭니다. 요청마다 워커를 새로 만들면 생성 비용이 이득을 잡아먹습니다. 코어 수만큼 만들어 재사용합니다.
-
큰 데이터는 복사하지 말고 전송합니다.
postMessage의 transfer list로ArrayBuffer소유권을 넘기면 복사 비용이 사라집니다 — 이미지처럼 큰 버퍼에서 차이가 큽니다. -
작업이 정말 JS여야 하는지 봅니다. 이미지 처리는 네이티브 바인딩 라이브러리가 이미 스레드 풀에서 처리하는 경우가 많습니다. 그러면 워커 없이도 이벤트 루프를 막지 않습니다.
-
cluster와 혼동하지 않습니다. 여러 요청을 병렬로 받는 것이 목표면 프로세스를 늘리는 쪽(cluster, 컨테이너 복제)이 단순합니다. 워커는 한 요청 안의 계산을 옮기는 도구입니다. -
이벤트 루프 지연을 측정합니다. 추측 대신
perf_hooks의 이벤트 루프 지연 히스토그램으로 확인하면, 어떤 코드가 루프를 잡고 있는지 드러납니다.
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.