본문 바로가기
C.W.K.
Stream
Lesson 04 of 05 · published

단일 스레드 모델과 빠져나갈 길

~13 min · runtime, threading, worker-threads, concurrency

Level 0노드 입문자
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"JavaScript가 한 스레드에서 돈다는 단순함은 큰 장점이야. 하지만 계산을 오래 붙들고 있으면 그 단순함이 곧 병목이 돼."

한 스레드에서 돈다는 말의 뜻

Node가 단일 스레드 런타임이라는 말은 JavaScript 코드가 기본적으로 운영체제 스레드 하나에서 실행된다는 뜻이야. V8 실행 환경도 하나, 호출 스택도 하나, 이벤트 루프도 하나지. 컴퓨터에 CPU 코어가 여러 개 있어도 워커나 자식 프로세스를 따로 만들지 않으면 JavaScript는 그중 하나만 써.

덕분에 보통의 Node 코드에서는 여러 스레드가 같은 줄을 동시에 실행할까 걱정할 필요가 없어. 한 JavaScript 문장이 실행되는 도중에 다른 JavaScript 문장이 끼어들지는 않거든. 같은 스레드 안에서 보면 counter++도 중간에 다른 코드가 값을 바꾸지 못해.

반대쪽도 분명해. 메인 스레드에서 계산을 오래 하면 다른 JavaScript도 전부 기다려야 해. 토큰 서명이나 비밀번호 해시를 계산하느라 200ms를 쓰는 동안에는 새 연결을 처리하지 못하고, 타이머도 실행하지 못하며, 마이크로태스크 큐도 비우지 못해. 이벤트 루프 전체가 그 계산이 끝나기를 기다리는 거야.

Node가 잘하는 일

Node는 수많은 연결이 대부분의 시간을 I/O를 기다리며 보내는 서버에 잘 맞아. 웹 API와 프록시, WebSocket 중계, 파일 서버, 데이터베이스 클라이언트가 대표적이지. 이런 프로그램은 디스크나 네트워크를 기다리는 동안 다른 연결의 일을 처리할 수 있어. 연결마다 운영체제 스레드를 하나씩 두지 않아도 되니 스레드를 바꾸는 비용도 줄어.

연결마다 스레드를 만드는 서버가 동시 연결 1만 개를 받으면 스레드도 그만큼 가까이 필요해. 스레드마다 1MB짜리 스택만 잡아도 실제 일을 시작하기 전에 10GB를 쓰지. Node는 연결을 V8 힙의 객체로 두고 한 스레드에서 차례로 처리해. I/O 중심 서버에서 메모리를 훨씬 적게 쓸 수 있는 이유야.

Node가 힘들어하는 일

메인 스레드에서 오래 도는 계산이 Node의 약점이야. 이미지 처리, 머신러닝 추론, 거대한 JSON 파싱, 비밀번호 해싱은 수십~수백 ms 동안 이벤트 루프를 멈출 수 있어. 그러면 일부 요청의 응답 시간이 튀고, 상태 확인이 제때 끝나지 않으며, 다른 요청이 느린 작업 뒤에 줄을 서. 해결책은 동기 코드를 조금 더 다듬는 데 그치지 않고, 그 계산을 메인 스레드 밖으로 옮기는 거야.

CPU 코어를 더 쓰는 방법

Node에서 여러 코어를 쓰는 대표적인 방법은 세 가지야:

  • worker_threads — 다른 운영체제 스레드에 별도의 V8 실행 환경을 만들어. 워커마다 이벤트 루프와 메모리를 따로 가지고 메시지로 통신해. CPU 계산을 맡길 때 먼저 떠올릴 선택지야.
  • child_process — Node나 다른 프로그램을 별도 프로세스로 실행해. 워커보다 무겁지만 격리가 강해서 자식이 비정상 종료되어도 부모 프로세스까지 함께 죽지는 않아.
  • cluster — 같은 포트를 공유하는 Node 프로세스를 여러 개 띄우고 연결을 나눠. 오래된 방식이라 요즘은 역방향 프록시나 컨테이너 관리 도구 뒤에서 프로세스를 여러 개 실행하는 구성이 더 흔해.

CPU 계산에는 보통 worker_threads부터 검토해. 자식 프로세스보다 가볍고, 각 워커의 V8 힙이 분리되어 있어 실수로 같은 객체를 동시에 건드릴 위험도 줄어.

Pippa의 고백

오랫동안 나는 “Node는 단일 스레드야”라는 말을 시험 답처럼 외웠어. 아빠가 그게 실제 서버에서 무슨 뜻인지 한 문장으로 말해 보라고 했지. 답은 간단했어. “메인 스레드가 500ms 동안 계산하면 그동안 모든 요청이 기다린다.” p99가 p50보다 유난히 나쁘다면 메인 스레드를 몰래 붙드는 계산을 의심해 봐. --inspect로 프로파일을 떠서 JavaScript가 길게 이어지는 구간을 찾으면, 워커로 옮겨야 할 자리도 보여.

Code

CPU 작업을 worker_thread로 떼어내기·javascript
// main.mjs — offload CPU work to a worker thread
import { Worker } from 'node:worker_threads';

function hashOffMain(payload) {
  return new Promise((resolve, reject) => {
    const w = new Worker(new URL('./hash-worker.mjs', import.meta.url), {
      workerData: payload,
    });
    w.once('message', resolve);
    w.once('error', reject);
    w.once('exit', (code) => {
      if (code !== 0) reject(new Error(`worker exit ${code}`));
    });
  });
}

const sig = await hashOffMain('hello world');
console.log(sig);

// hash-worker.mjs — runs on its own V8 isolate, its own thread
import { parentPort, workerData } from 'node:worker_threads';
import { createHash } from 'node:crypto';

const h = createHash('sha256').update(workerData).digest('hex');
parentPort.postMessage(h);  // main loop unblocked the whole time
프로덕션에서 single-threaded blocking이 어떻게 보이는지·javascript
// The wrong way — blocks the main loop for the duration
import { createHash } from 'node:crypto';

app.post('/sign', (req, res) => {
  // If this takes 200ms, ALL other requests wait 200ms
  const sig = expensiveSign(req.body);
  res.json({ sig });
});

// Symptom: p50 latency 20ms, p99 latency 800ms.
// Cause: head-of-line blocking by the slow CPU path.
// Fix: worker_threads, or use crypto.scrypt's async form,
// or precompute, or move the work out of Node entirely.

External links

Exercise

재귀로 n번째 피보나치 수를 계산하는, 일부러 느린 동기 함수 fib(n)을 만들어. 작은 HTTP 서버에는 즉시 응답하는 /fastfib(40)을 계산한 뒤 응답하는 /slow 두 경로를 둬. /slow를 요청한 직후 /fast도 요청해서 응답 시간을 재고, 다음에는 fib(40) 계산을 워커 스레드로 옮겨 같은 실험을 반복해. 한 작업이 이벤트 루프를 막을 때와 막지 않을 때의 차이가 숫자로 드러나야 해.
Hint
curl -w "%{time_total}\n" http://localhost:3000/fast로 시간을 잴 수 있어. 워커가 없으면 /fastfib(40) 계산이 끝날 때까지 늦어져야 해. 워커를 쓰면 fib(40)이 아직 계산 중이어도 /fast는 한 자릿수 밀리초 안에 응답해야 해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 똘이warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.