본문으로 건너뛰기
개발 머꼬
개발 노트Node.js
hohyeon.dev39

이미지 리사이즈 요청 하나가 서버 전체를 멈춘 이유

  • #Engineering Note
  • #Node.js
  • #Performance

문제 발생

썸네일 생성 요청이 들어오면 그동안 다른 모든 요청의 응답이 멈췄습니다. 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_processcluster와 달리 ArrayBuffer를 전송하거나 SharedArrayBuffer를 공유해 메모리를 공유할 수 있습니다.

해결 방안

  1. CPU 작업만 워커로 옮깁니다.
import { Worker } from "node:worker_threads";

const worker = new Worker("./resize-worker.js", { workerData: buffer });
worker.on("message", (out) => res.send(out));
  1. 워커 풀을 만듭니다. 요청마다 워커를 새로 만들면 생성 비용이 이득을 잡아먹습니다. 코어 수만큼 만들어 재사용합니다.

  2. 큰 데이터는 복사하지 말고 전송합니다. postMessage의 transfer list로 ArrayBuffer 소유권을 넘기면 복사 비용이 사라집니다 — 이미지처럼 큰 버퍼에서 차이가 큽니다.

  3. 작업이 정말 JS여야 하는지 봅니다. 이미지 처리는 네이티브 바인딩 라이브러리가 이미 스레드 풀에서 처리하는 경우가 많습니다. 그러면 워커 없이도 이벤트 루프를 막지 않습니다.

  4. cluster와 혼동하지 않습니다. 여러 요청을 병렬로 받는 것이 목표면 프로세스를 늘리는 쪽(cluster, 컨테이너 복제)이 단순합니다. 워커는 한 요청 안의 계산을 옮기는 도구입니다.

  5. 이벤트 루프 지연을 측정합니다. 추측 대신 perf_hooks의 이벤트 루프 지연 히스토그램으로 확인하면, 어떤 코드가 루프를 잡고 있는지 드러납니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.