Node.js는 CPU를 많이 쓰는 연산에 약하다.
Node.js를 처음 배울 때 꼭 듣는 말입니다. 근데 조금 더 공부해서 async/await을 배우고 나면 이런 궁금증이 생깁니다.
비동기로 처리하면 CPU 부하도 분산되지 않을까?
그렇지 않습니다. TSBM의 Node.js 기초 스터디에서 이야기 한 내용들을 바탕으로 글을 작성해볼게요.
async/await은 비동기 흐름을 동기적으로 보이게 해주는 일종의 문법적 설탕(syntax sugar)입니다. 즉 코드를 깔끔하게 만들어주지만, Node.js의 동작 구조 자체를 바꾸지는 않습니다.
async/await은 Promise 기반의 syntactic sugar입니다. await 키워드를 만나면 해당 함수의 실행 컨텍스트가 일시 중단(suspend)되고, Promise가 resolve될 때까지 이벤트 루프는 다른 작업을 처리합니다.
await은 완료되었다는 신호가 올 때까지 기다리라는 표현일 뿐, 병렬로 실행하는 도구가 아닙니다. 하지만 이것이 CPU 연산 자체를 분산시키는 것은 아닙니다.
// ❌ 이렇게 해도 CPU 부하는 분산되지 않습니다 async function heavyComputation() { let result = 0; for (let i = 0; i < 1000000000; i++) { result += Math.sqrt(i); } return result; } // 메인 스레드를 블로킹합니다 await heavyComputation();
위 코드에서 async 키워드가 붙어있어도, 실제 반복문은 메인 스레드에서 동기적으로 실행됩니다. 이 시간 동안 Node.js는 다른 요청을 처리할 수 없습니다.
Node.js를 공부할 때 자주 듣는 말이 "싱글 스레드"인데요. 사실 싱글 스레드로 보이는 이벤트 루프 뒤에는 정교한 구조가 있습니다.

많은 사람들이 Node.js가 직접 I/O 대기시간을 기다린다고 생각하지만 사실은 OS가 기다립니다.
Node.js는 커널에게 이렇게 요청합니다.
이 소켓이 읽을 수 있게되면(readable) 알려줘
OS 커널은 이미 수백 개 소켓을 동시에 대기하고 있어서 수많은 HTTP 요청이 동시에 발생해도 처리가 가능합니다.
이후 커널에서 Linux의 epoll, macOS의 kqueue, Windows의 IOCP와 같은 비동기 이벤트 통지 시스템을 통해 Node.js로 신호를 보냅니다.
여기서 핵심은 제일 하단에 있는 libuv입니다.
libuv는 Node.js의 비동기 I/O를 담당하는 C 언어 기반 엔진인데요. Node.js가 싱글 스레드인데도 여러 요청을 동시에 처리할 수 있는 이유가 libuv 덕분입니다.
libuv는 다음과 같은 역할을 합니다.

libuv는 자체적으로 스레드풀을 가지고 있습니다. 이 스레드 풀은 네트워크 I/O가 아닌 다음과 같은 역할을 합니다.
libuv 스레드풀이 사용되는 경우:
fs.readFile() 등의 파일 시스템 작업dns.lookup)pbkdf2, randomBytes 등)libuv 스레드풀이 사용되지 않는 경우:
http, net, dgram 등) → OS의 비동기 I/O 사용 (epoll, kqueue, IOCP)setTimeout, setInterval) → 이벤트 루프에서 직접 관리process.nextTick(), Promise → 마이크로태스크 큐에서 관리Node.js의 스레드 풀은 자동으로 확장되지 않습니다.
process.env.UV_THREADPOOL_SIZE로 수동 설정 가능)즉 100개의 파일 읽기 요청을 동시에 보내도, 실제로 병렬로 처리되는 것은 4개뿐입니다. 나머지는 큐에서 대기합니다.

결과적으로 Node.js의 진짜 강점은 'OS 수준의 비동기 이벤트 기반 설계' 입니다.
스레드 풀이 전부가 아닌, 이벤트 루프 + 커널 + 비동기 시스템을 모두 이해하는 것이 중요합니다.
1. Worker Threads 사용
const { Worker } = require('worker_threads'); const worker = new Worker('./heavy-task.js'); worker.on('message', result => { console.log('작업 완료:', result); });
2. Child Process 활용
const { fork } = require('child_process'); const child = fork('./cpu-intensive.js'); child.on('message', result => { console.log('결과:', result); });
3. 작업 분할 (Chunking)
setImmediate()로 이벤트 루프에 제어권 반환async function processLargeArray(array) { for (let i = 0; i < array.length; i += 1000) { await new Promise(resolve => setImmediate(resolve)); // 1000개씩 처리 processChunk(array.slice(i, i + 1000)); } }
4. 외부 서비스 위임
Node.js가 "싱글 스레드"라는 말은 반은 맞고 반은 틀립니다. 정확히는 "JavaScript 실행은 싱글 스레드, I/O 처리는 멀티 스레드"입니다.
핵심은:
✅ I/O 대기 시간은 OS와 libuv가 비동기로 처리 (매우 효율적)
❌ CPU 연산 시간은 메인 스레드가 동기로 처리 (병목 가능)
따라서 Node.js는:
Node.js의 비동기는 "기다림"을 효율적으로 처리하는 것이지, "연산"을 빠르게 하는 것이 아닙니다. 이 차이를 이해하고 올바르게 사용하는 것이 핵심입니다.