Skip to Content

3장. 블로킹이 문제다 — Node.js가 던진 질문

만화로 보는 요약 — 먼저 읽어보세요

만화로 보는 요약 — 먼저 읽어보세요

“싱글 스레드”라는 Node 프로세스를 열어보면 스레드가 11개 있습니다. 그 11이라는 숫자에 이 장의 답이 다 들어 있어요.

면접 실전 질문: ① Node.js가 싱글 스레드라는 말은 정확히 무엇이 하나라는 뜻인가요? ② 파일 읽기는 이벤트 루프에서 처리되나요? ③ 이벤트 루프를 막으면 어떤 일이 벌어지나요?


배경 — 도구는 2002년에 나왔다. 왜 7년을 기다렸나

2장에서 커널은 할 일을 다 했습니다. epoll은 2002년에 완성됐고, 같은 계열인 kqueue는 10,000 연결에서도 0.92 μs로 평평했어요(2장 실측).

그런데 그 뒤로 7년 동안, 웹 개발자 대부분은 여전히 연결당 스레드로 서버를 짰습니다.

도구가 있는데 왜 안 썼을까요? 2장 마지막 질문이 그 답이었습니다.

“이 구조로 코드를 짜면 read() 다음 줄에 결과가 오지 않습니다. 로직의 순서를 어떻게 표현해야 할까요?”

이벤트 기반으로 짜려면 코드를 뒤집어야 합니다. “읽고 → 처리하고 → 응답한다”가 “읽기를 요청하고 → 나중에 불릴 함수를 등록하고 → 지금은 손을 뗀다”가 돼요. C로 이걸 쓰면 상태 머신을 손으로 짜야 합니다. 그래서 성능이 절박한 소수(nginx를 만드는 사람들)만 그 길을 갔어요.

물론 고수준 언어에도 도구는 있었습니다. 파이썬 Twisted(2002), 루비 EventMachine(2003) 같은 이벤트 프레임워크가 Node보다 먼저 나와 있었어요. 그런데 주류가 되지 못했습니다. 왜 못 했는지가, Node는 왜 됐는지의 답이기도 합니다.

2009년, 한 사람이 이미 그렇게 생긴 언어를 가져옵니다.

스토리 — 잘못된 기본값을 바꾸자

이 절의 연도·인명·발표 내용은 📄 문서 기반입니다(1차 출처: 2009 JSConf EU 발표와 이후 인터뷰).

2009년 11월, 베를린에서 열린 JSConf EU에서 라이언 달(Ryan Dahl)이 Node.js를 발표합니다.

그의 발표는 새 프레임워크 소개가 아니라 고발에 가까웠습니다. 논지를 한 줄로 줄이면 이렇습니다 — 우리는 I/O를 잘못하고 있다. 기본값이 블로킹인 게 문제다.

무슨 뜻이냐면 — 대부분의 언어에서 read()는 기다립니다. 기다리지 않으려면 특별한 노력을 해야 해요. 논블로킹은 예외적인 선택이고, 블로킹이 기본값입니다. 달의 주장은 이 기본값이 거꾸로 됐다는 거였습니다.

왜 하필 자바스크립트였나

달이 자바스크립트를 고른 이유는 언어가 우아해서가 아니었습니다. 그는 Lua와 Haskell을 먼저 시도했다가 접었어요. 그러고 나서 자바스크립트에 정착한 이유가 둘입니다.

첫째, 콜백이 이미 모국어였습니다. 브라우저의 자바스크립트는 처음부터 그렇게 생겼어요. onclick, setTimeout, XMLHttpRequest — 전부 “나중에 불러줘”입니다. 다른 언어 개발자에게는 낯선 뒤집힌 코드가, 자바스크립트 개발자에게는 그냥 평소 쓰던 방식이었습니다.

둘째, 표준 라이브러리가 없다시피 했습니다. 이게 결정적이었어요. 자바나 파이썬에는 이미 블로킹 방식으로 만들어진 파일·네트워크 라이브러리가 산더미처럼 쌓여 있습니다. 논블로킹으로 전환하려면 그걸 다 다시 만들어야 해요. 브라우저 밖 자바스크립트에는 그 짐이 없었습니다. 백지였죠.

여기에 전제 조건이 하나 더 있었습니다. 2008년에 공개된 구글의 V8 엔진입니다. 빠른 자바스크립트 실행기가 브라우저 밖으로 나와 있지 않았다면 이 선택 자체가 불가능했어요.

그래서 Node는 처음부터 규칙을 하나 세울 수 있었습니다. “I/O는 전부 논블로킹. 예외 없음.”

콜백에서 async/await까지

물론 대가가 있었습니다. 모든 I/O가 콜백이니, 순서대로 세 번 I/O를 하면 콜백이 3중으로 중첩됩니다. 여기에 에러 처리까지 붙으면 오른쪽으로 계속 밀려나요. **콜백 지옥(callback hell)**이라는 이름까지 붙었습니다.

이후 10년은 그 대가를 갚는 역사입니다.

시기도구해결한 것
2009콜백논블로킹은 됐지만 중첩
2015 (ES6)Promise중첩을 체인으로 폄
2017 (ES2017)async/await블로킹처럼 보이는 논블로킹 코드

마지막 줄을 기억해두세요. async/await가 한 일은 성능 개선이 아닙니다. 논블로킹의 기계를 그대로 두고, 코드 모양만 블로킹처럼 되돌린 것이에요. 이 책 5부에서 볼 자바의 가상 스레드가 정확히 같은 일을 합니다. 접근 방식은 다르지만 목표가 같아요.

그리고 2018년, 달은 같은 컨퍼런스 무대에 다시 올라 「Node.js에 대해 후회하는 10가지」를 발표합니다. 자기가 만든 것의 설계 실수를 조목조목 짚는 발표였어요. 이 책에서 그를 인용하는 이유가 이겁니다. 그는 정답을 팔지 않았습니다. 기본값을 뒤집었을 뿐이고, 그 대가도 스스로 인정했어요.

핵심 — “싱글 스레드”를 열면 11개가 나온다

Node를 설명할 때 가장 많이 쓰이는 문장이 “싱글 스레드 이벤트 루프”입니다. 그런데 실제로 프로세스를 열어보면 다릅니다. 측정했습니다.

측정 환경: Apple M1(8코어) / 램 16GB / macOS 26.5 / Node v22.17.0.

① 스레드가 11개 있다

Node 프로세스를 띄우고 OS 스레드 수를 셌습니다.

const pid = process.pid; const threads = () => parseInt(execSync(`ps -M ${pid} | tail -n +2 | wc -l`).toString().trim(), 10); console.log(`OS 스레드 수: ${threads()}개`);
Node v22.17.0 / UV_THREADPOOL_SIZE=(기본 4) 시작 직후 OS 스레드 수: 11개

✅ 실측

싱글 스레드라면서 11개입니다. 그럼 “싱글”은 뭐가 하나라는 뜻일까요?

여러분의 자바스크립트 코드를 실행하는 스레드가 하나라는 뜻입니다.

이벤트 루프 — 그 하나뿐인 스레드가 무한 반복하는 고리입니다. “준비된 게 있나” 물어보고, 준비된 것의 콜백을 하나씩 꺼내 실행하고, 다시 물어봅니다. 7장에서 이 고리를 직접 뜯어봅니다.

콜백을 한 번에 하나씩만 실행하니 자바스크립트 코드 안에서는 락이 필요 없습니다. 두 콜백이 같은 변수를 동시에 건드릴 수 없으니까요. 이게 Node가 판 진짜 상품이에요.

다만 오해하면 안 되는 게 있습니다. 사라지는 건 데이터 레이스지 경쟁 조건 전부가 아닙니다. await로 손을 뗀 사이에 다른 콜백이 끼어드는 건 막아주지 않아요. 아래 코드는 안전해 보이지만 안전하지 않습니다.

if (!cache[key]) { // ① 확인 cache[key] = await fetch(key); // ② await 하는 동안 다른 콜백이 ①을 통과한다 }

나머지 10개는 여러분의 코드를 실행하지 않습니다. 이 중 4개가 libuv(Node가 I/O를 처리하려고 쓰는 C 라이브러리) 스레드 풀이라는 건 바로 아래에서 실측으로 확인할 거예요. 남은 6개는 V8과 런타임의 백그라운드 작업자들입니다(GC 보조, 컴파일러 등 — 개수만 셌지 각각의 정체까지 확인하지는 않았습니다).

worker_threads로 자바스크립트를 실행하는 스레드를 더 띄울 수는 있습니다. “싱글”은 기본값이 하나라는 뜻이에요.

② 파일 I/O는 이벤트 루프가 아니라 스레드 풀에서 돈다

2장 마지막에 이런 구멍을 하나 남겼습니다. epollkqueue는 소켓에는 통하지만 일반 파일에는 통하지 않는다. 디스크 fd는 언제 물어봐도 “준비됨”이라 질문 자체가 성립하지 않는다고요.

그럼 Node는 파일을 어떻게 논블로킹으로 읽을까요? 답은 간단합니다. 못 합니다. 그래서 스레드에 떠넘깁니다.

정확히는 전통적인 POSIX 인터페이스로는 못 합니다. 리눅스의 io_uring이 이 구멍을 메우기 시작했고 libuv도 일부 파일 연산에 쓰기 시작했어요. 다만 이 장의 macOS 경로는 여전히 풀입니다. 📄 문서 기반 — 7장에서 다룹니다.

확인해봤습니다. libuv 스레드 풀에서 도는 작업 중 pbkdf2(비밀번호에서 키를 뽑아내는 함수인데, 일부러 오래 걸리게 만들어서 풀 동작을 관찰하기 좋습니다) 8개를 동시에 던지고, 완료 시각을 찍었습니다.

for (let i = 0; i < 8; i++) { pbkdf2('pw', 'salt', 800_000, 64, 'sha512', () => { console.log(`#${i} ${elapsed()} ms`); }); }

세 실험의 전체 소스는 docs/book/code/async/ch03-threadpool.mjs에 있습니다. 아래는 발췌예요.

기본 설정에서의 결과입니다.

#3 340 ms ┐ #0 352 ms │ 1차 — 4개 #2 356 ms │ #1 358 ms ┘ #4 674 ms ┐ #5 693 ms │ 2차 — 4개 #7 694 ms │ #6 702 ms ┘

✅ 실측

완료가 4개씩 뭉칩니다. 두 덩어리, 2단 계단이에요. 8개를 동시에 던졌는데 4개가 먼저 끝나고, 나머지 4개는 그 두 배 시간에 끝났어요. 동시에 처리되는 개수가 4개라는 뜻입니다.

이 4가 바로 libuv 스레드 풀의 기본 크기입니다. 환경변수로 바꿔서 인과를 확인했습니다.

UV_THREADPOOL_SIZE8개 작업의 완료 패턴프로세스의 OS 스레드 수
1319 / 642 / 959 / 1264 / 1548 / 1823 / 2150 / 2449 ms — 8단 계단8개
4 (기본)340358 ms, 674702 ms — 2단 계단11개
8507~595 ms — 계단 없음15개

✅ 실측 (같은 환경, 2026-08, 1회 측정). 계단 구조와 스레드 수(8/11/15)는 반복해도 그대로였지만 절대값은 회차마다 ±10% 정도 흔들립니다. 이 표에서 읽어야 할 건 값이 아니라 몇 단으로 끊기는가예요.

오른쪽 열을 보세요. 8 = 7 + 1, 11 = 7 + 4, 15 = 7 + 8. 풀 크기를 올린 만큼 OS 스레드가 정확히 그만큼 늘어납니다. 상시 스레드 7개에 풀 크기가 더해지는 구조예요(이 7은 V8이 코어 수에 맞춰 띄우는 것이라 기계마다 달라집니다).

여기까지는 pbkdf2로 잰 겁니다. 정작 우리 관심사는 파일이니, 파일로도 직접 확인했습니다. 120MB 파일 8개를 동시에 readFile 했어요.

UV_THREADPOOL_SIZE8개 전부 완료까지 (3회)
1801 / 216 / 224 ms
4 (기본)96 / 88 / 92 ms
881 / 80 / 86 ms

✅ 실측 (같은 환경, 3회 실행 전부 표기. 방금 만든 파일이라 OS 페이지 캐시에 올라간 상태입니다 — 차가운 디스크 읽기라면 값이 달라져요. 풀 1의 첫 회 801ms는 캐시가 덜 데워진 탓으로 보입니다(추정).)

왜 3회를 다 적었냐면, 제 첫 측정이 틀렸기 때문입니다. 한 번씩만 재고 “풀 8이 오히려 느리다(120ms)“고 썼는데, 3회로 늘리니 80~86ms — 120ms는 노이즈였어요. 그래서 이 책의 숫자에는 회차를 붙입니다.

풀이 파일 I/O의 목을 쥐고 있습니다. 스레드 하나로 줄이자 안정 구간 기준으로도 2.4배 느려졌어요(약 220ms vs 92ms). 파일 읽기가 이벤트 루프가 아니라 풀에서 처리된다는 직접 증거입니다.

그런데 파일 쪽은 pbkdf2 같은 깔끔한 계단이 나오지 않았습니다. 8개가 거의 동시에 끝나요. readFile이 파일 하나를 여러 번의 작은 읽기로 쪼개 큐에 넣기 때문에, 파일 단위가 아니라 조각 단위로 뒤섞여 처리되거든요. 그래서 계단은 pbkdf2로 보여드린 겁니다 — 작업 하나가 쪼개지지 않아 풀 크기가 그대로 드러나니까요.

그럼 풀을 키우면 되나

그것도 아닙니다. 근거는 파일 표가 아니라 앞의 pbkdf2 표 안에 이미 있었어요. 작업 하나가 끝나는 데 걸린 시간을 보세요.

풀 크기작업 1개당8개 전체
1319 ms2,449 ms
4340~358 ms702 ms
8507~595 ms595 ms

개별 작업은 계속 느려지는데(중앙값 319 → 354 → 519 ms), 전체 소요는 아직 줄어듭니다(2,449 → 702 → 595 ms). 스레드가 늘수록 서로 CPU를 두고 경합하니까요(추정 — M1은 8코어 중 4개가 효율 코어라, 풀 8에서는 절반이 느린 코어에 얹히는 것도 한몫했을 겁니다). 전체 이득이 개별 손해를 아직 이기고 있을 뿐이고, 이 균형은 언젠가 뒤집힙니다.

정리하면 이렇습니다.

  • 풀에서 도는 것: fs.* 파일 연산 / cryptopbkdf2·scrypt·randomBytes / zlib / dns.lookup
  • 풀을 안 거치는 것: net·http 같은 소켓 I/O — epoll/kqueue로 직행합니다

dns.lookup을 눈여겨보세요. HTTP 호출은 소켓이라 풀을 안 쓰는데, 호출 전 도메인 이름을 푸는 단계가 풀을 씁니다. “네트워크 호출인데 왜 파일 읽기에 막히지?”라는 장애가 여기서 나옵니다. 피하려면 dns.resolve*를 쓰세요 — 소켓을 직접 써서 풀을 안 탑니다.

여기서 이 장의 진짜 결론이 나옵니다.

Node는 블로킹을 없앤 게 아닙니다. 블로킹을 여러분 눈에서 치웠을 뿐이에요. 소켓은 커널의 epoll로 진짜 논블로킹이 되지만, 파일과 crypto·zlib 같은 네이티브 CPU 작업은 스레드 4개짜리 풀에서 여전히 블로킹으로 처리됩니다. 여러분이 자바스크립트로 짠 CPU 작업은 풀에 가지도 못해요 — 그건 ③에서 봅니다.

1장에서 본 “대기를 스레드로 표현하는” 방식이 사라진 게 아니라, 프레임워크 안쪽으로 숨은 겁니다.

③ 루프를 막으면 전부 멈춘다

싱글 스레드의 대가는 단순합니다. 그리고 즉각적이에요. 타이머 5개를 10ms 간격으로 예약해두고, 15ms 지점에서 동기 CPU 작업으로 500ms 동안 루프를 붙잡아봤습니다.

for (const p of [10, 20, 30, 40, 50]) { setTimeout(() => record(p), p); } setTimeout(() => { const start = now(); while (now() - start < 500) hash(); // 동기 — 루프를 놓지 않는다 }, 15);
동기 블로킹: 15ms에 시작해 500ms 동안 CPU 점유 10ms 예약 → 11ms 실행 (정시) 20ms 예약 → 516ms 실행 (+496ms 지각) 30ms 예약 → 516ms 실행 (+486ms 지각) 40ms 예약 → 516ms 실행 (+476ms 지각) 50ms 예약 → 516ms 실행 (+466ms 지각)

✅ 실측

블로킹 전에 실행된 10ms 타이머만 정시입니다. 나머지 넷은 전부 516ms에 한꺼번에 터졌어요. 각자 예약 시각이 달랐는데 도착 시각은 같습니다. 루프가 풀리는 순간 밀려 있던 콜백이 한 번에 쏟아진 거예요.

실서버로 옮기면 이런 뜻입니다. 요청 하나가 CPU를 500ms 쓰면, 그동안 들어온 모든 요청이 500ms씩 밀립니다. 스레드 풀 서버였다면 그 요청 하나만 느렸을 거예요.

이게 이벤트 루프 모델의 청구서입니다. 그리고 이 청구서는 Node만의 것이 아니에요 — 15장에서 WebFlux로 같은 장면을 다시 보게 됩니다.

그리고 자바 진영은 이 청구서를 받아들고 정확히 반대되는 두 결론을 냅니다.

정리

  • Node가 바꾼 건 성능이 아니라 기본값입니다. “논블로킹이 예외”에서 “논블로킹이 기본”으로. 자바스크립트가 선택된 건 콜백이 모국어였고, 갈아엎을 블로킹 라이브러리가 없었기 때문입니다.
  • “싱글 스레드”는 자바스크립트 코드를 실행하는 스레드가 하나라는 뜻입니다. 실제 프로세스에는 11개가 있었습니다(상시 7 + 풀 4).
  • 파일과 CPU 작업은 여전히 스레드에서 블로킹으로 처리됩니다. UV_THREADPOOL_SIZE를 1/4/8로 바꾸자 완료 계단이 8단/2단/없음으로, OS 스레드가 8/11/15개로 정확히 따라 움직였습니다. 2장이 남긴 “파일에는 epoll이 안 통한다”는 구멍을 Node는 스레드 풀로 덮은 겁니다.
  • 루프를 막으면 전부 멈춥니다. 500ms 동기 작업 하나에 대기 중인 콜백 넷이 전부 516ms로 밀렸습니다.
  • 이 장의 한 줄: 콜백은 대기를 없앤 게 아니라, 대기를 코드 밖으로 옮겼습니다. 그리고 옮긴 자리에는 여전히 스레드가 있었어요.

생각해볼 질문

  1. UV_THREADPOOL_SIZE를 128로 올리면 파일 I/O 처리량이 32배가 될까요? 위 파일 표의 4→8 결과가 이미 힌트를 줍니다. 그리고 1장의 세 측정 중 무엇이 먼저 막아설까요?
  2. async/await는 “블로킹처럼 보이는 논블로킹 코드”를 만들어줍니다. 그런데 왜 자바는 같은 문법을 도입하는 대신 다른 길(가상 스레드)을 갔을까요?
  3. 콜백 지옥은 문법으로 풀렸습니다. 그런데 콜백으로 코드를 뒤집으면 문법 말고도 잃는 게 있습니다. 스택 트레이스와 로그는 어떻게 될까요?

3번은 13장의 주제이고, 2번이 다음 장입니다. 자바 진영은 Node를 보며 두 갈래로 갈라졌어요. 한쪽은 “우리도 이벤트 루프로 가자”며 리액티브를 만들었고, 다른 쪽은 “코드를 뒤집는 게 애초에 잘못”이라며 14년을 기다려 다른 답을 내놓습니다.

4장. 리액티브의 부상과 피로 — 그리고 Loom이 온 이유

Last updated on