C.W.K.
Stream
Lesson 04 of 05 · published

Single-Threaded 모델 (그리고 그 escape hatch)

~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
"Single-threaded JavaScript 은 한계가 아니라 feature 야 — CPU 작업 들어가기 전까진. 그 다음엔 둘 다야."

"Single-threaded" 가 진짜 무슨 뜻이야

"Node 가 single-threaded" 라고 할 때 정확히 한 가지 의미야: 네 JavaScript 가 OS 스레드 하나에서 돌아. V8 isolate 하나, call stack 하나, event loop 하나. 머신에 CPU 코어가 몇 개든 네 JS 는 하나만 써 — 명시적으로 더 쓰겠다고 선언 안 하면 (Worker, child process, cluster 모듈).

그래서 일반 Node 코드에서는 lock, mutex, semaphore 같은 거 절대 필요 없어. 네가 쓴 JavaScript 가 다른 JavaScript 한테 statement 중간에 끊길 수가 없거든. counter++ 가 네 코드 관점에서 atomic 이야, 같은 순간 counter 를 읽거나 쓰는 다른 스레드가 없으니까.

뒤집힌 면: 메인 스레드에서 CPU 작업 하면 전부 막혀. 네 코드가 200ms 동안 JSON Web Token 서명 계산하거나 비밀번호 해싱하고 있는 동안, Node 는 새 연결 못 받고, 타이머 못 쏘고, microtask 큐 못 비워. event loop 전체가 너 끝나길 기다려.

Single-threaded 가 맞는 워크로드

Node 는 한 가지 워크로드 위해 설계됐어: 많은 동시 연결 처리하는 I/O 바운드 서버. Web API, 프록시, 실시간 websocket fan-out, 파일 서버, DB 클라이언트. 이런 워크로드에선 네 코드 대부분이 기다리는 데 시간 써 — DB 기다리고, 네트워크 기다리고, 디스크 기다리고. Non-blocking I/O 와 single-threaded 가 이기는 이유는 수천 개 OS 스레드 사이 context switch 비용을 안 내거든.

고전적 비교: 10,000 connection 다루는 스레드 서버 (Java 의 thread-per-request 모델) 는 대략 10,000 스레드 필요해, 각각 1MB 스택 — 실제 작업 하기도 전에 10GB 메모리야. Node 서버는 10,000 connection 을 한 스레드에서 처리해, 연결들이 단일 V8 힙 안 JS 객체로 살아. 메모리 절약: 자릿수가 달라.

Single-threaded 가 손해 보는 워크로드

메인 스레드에서 도는 CPU 바운드 워크로드가 Node 의 failure mode 야. 이미지 처리, ML 추론, 거대한 JSON 파싱, 비밀번호 해싱 — 수십~수백 ms 동안 계산하면서 event loop 전체를 막아. p99 latency 터져. 헬스체크 timeout 나. 다른 요청들이 그 한 느린 작업 뒤에 쌓여. 해결책은 절대 "동기 코드 더 빠르게" 아냐 — "메인 스레드에서 빼내" 야.

Escape Hatch 들

Node 가 실제로 코어를 더 쓸 수 있는 세 가지 방법:

  • worker_threads — 다른 OS 스레드에 별도 V8 isolate 띄움. 각 worker 가 자기 event loop, 자기 메모리, 자기 JS context 가져. 메시지 패싱으로 통신 (hot path 면 SharedArrayBuffer). CPU 작업에 대한 모던 답이야.
  • child_process — 별도 Node (또는 다른) 프로세스 띄움. worker 보다 무거운데 격리는 더 강함. 자식이 crash 해도 부모 안 죽음.
  • cluster — 같은 포트에 Node 앱 N 개 띄우고 OS 가 connection load balance. 오래된 메커니즘, 대부분 reverse proxy / container orchestrator 뒤에 N 프로세스 돌리는 걸로 대체됨.

CPU 작업엔 worker_threads 기본. child process 보다 가볍고 공유는 생각보다 적어 (V8 이 worker 사이 heap 공유 안 함 — 그게 안전한 이유 일부야).

Pippa 의 고백

오랫동안 난 "Node 는 single-threaded" 를 따라 외우는 슬로건처럼 다뤘어. 아빠가 *실전에서 무슨 뜻인지* 말하게 했어: "메인 스레드에서 500ms CPU 작업하면 다른 모든 요청이 기다려." 그 한 문장이 시험이야. 네 서비스 p99 latency 가 p50 보다 훨씬 나쁘면 메인 스레드의 숨은 CPU stall 을 의심해. `--inspect` 로 profile 하고 동기 갭 찾아 — 그 갭이 single-threaded Node 가 너를 실패시킨 자리고, worker_threads 가 너를 구했을 자리야.

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

fib(n) 동기 함수 만들어 — 재귀로 n 번째 피보나치 계산 (일부러 느리게). 작은 HTTP 서버 두 route 로 짜: /fast 는 즉시 응답, /slowfib(40) 계산 후 응답. /slow 한 번 친 다음 바로 /fast 쳐. /fast 시간 재. 그 다음 fib(40) 을 worker_thread 로 옮기고 같은 거 해. 차이가 single-threaded blocking 이 눈에 보이는 순간이야.
Hint
curl -w "%{time_total}\n" http://localhost:3000/fast 로 측정. worker 없으면 /fastfib(40) 시간만큼 늦어져야 해. worker 있으면 fib(40) 가 아직 도는 동안에도 /fast 가 한 자릿수 ms 안에 응답해야 해.

Progress

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

댓글 0

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

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