Node.js는 동시성에 몰빵한 런타임(!)이다.
Python이 동시성과 병렬성을 분리하고, Go가 통합했다면, Node.js는 아예 처음부터 동시성 하나에 올인했다. “싱글스레드 + 이벤트 루프"라는 구조로, CPU 바운드는 과감하게 포기하고 I/O 동시성을 극한까지 밀어붙인 설계다.
왜 싱글스레드인가?
Node.js가 태어난 배경을 보면 이해가 된다. 2009년, 당시 웹 서버의 주류는 Apache 같은 “요청 하나 = 스레드 하나” 모델이었다. 동시 접속이 수천, 수만이 되면 스레드도 그만큼 만들어야 했고, 메모리와 컨텍스트 스위칭 비용이 폭발했다. 이른바 C10K 문제.
Ryan Dahl의 아이디어는 단순했다: 웹 서버가 하는 일의 대부분은 I/O 대기다. DB 쿼리 날리고 기다리고, 파일 읽고 기다리고, API 호출하고 기다린다. 그렇다면 스레드를 여러 개 만들 필요 없이, 한 스레드에서 “기다리는 동안 다른 일"을 하면 되지 않나?
이게 Node.js의 핵심 설계 철학이다. 앞서 본 음식점 비유에서, 직원 1명이 파스타 물 올려놓고 소스 만드는 그 패턴을 런타임 레벨에서 강제한 것이다.
Event Loop
Node.js의 심장은 이벤트 루프다. 구조를 단순화하면 이렇다:
┌───────────────────────────┐
┌─>│ timers │ setTimeout, setInterval 콜백
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ 시스템 레벨 콜백 (TCP 에러 등)
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ poll │ I/O 이벤트 대기 및 콜백 실행
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ check │ setImmediate 콜백
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ close callbacks │ socket.on('close') 등
│ └─────────────┬─────────────┘
└──────────────────────────────┘
이벤트 루프는 이 phase들을 계속 순회하면서, 각 phase에 등록된 콜백을 실행한다. 핵심은 모든 I/O가 non-blocking이라는 것이다. 파일을 읽든, DB에 쿼리를 날리든, Node.js는 OS에 요청만 던져놓고 바로 다음 작업으로 넘어간다. 결과가 돌아오면 콜백이 이벤트 루프의 해당 phase에서 실행된다.
Python의 asyncio와 비슷하게 보이지만, 결정적 차이가 있다. asyncio는 기존 동기 생태계 위에 나중에 얹은 것이고, Node.js는 처음부터 이 모델로 설계됐다. 그래서 Python에서는 requests vs aiohttp 같은 파편화가 생기지만, Node.js에서는 fs.readFile, http.get 등 표준 라이브러리 자체가 비동기다.
예시: HTTP 서버
const http = require('http');
const server = http.createServer((req, res) => {
// 이 콜백은 이벤트 루프의 poll phase에서 실행됨
// 요청마다 스레드를 만들지 않는다!
res.writeHead(200);
res.end('Hello');
});
server.listen(3000);
// 싱글스레드로 수만 동시 접속 처리 가능
Apache가 요청 10,000개에 스레드 10,000개를 만들 때, Node.js는 스레드 1개로 처리한다. I/O 대기 시간에 다른 요청의 콜백을 실행하면 되니까.
예시: Non-blocking I/O
const fs = require('fs');
// non-blocking: 파일 읽기를 OS에 맡기고 바로 다음 줄로
fs.readFile('big-file.txt', (err, data) => {
console.log('파일 읽기 완료'); // 나중에 실행됨
});
console.log('이게 먼저 출력됨'); // 이게 먼저!
// 이게 Python asyncio와의 차이:
// asyncio는 await를 써야 비동기, 안 쓰면 동기
// Node.js는 기본이 비동기, 동기를 쓰려면 readFileSync를 명시적으로 써야 함
예시: async/await (현대 Node.js)
콜백 지옥은 옛날 얘기다. 현대 Node.js는 Promise + async/await를 쓴다:
const fs = require('fs').promises;
async function processFiles() {
const [config, data] = await Promise.all([
fs.readFile('config.json', 'utf8'),
fs.readFile('data.csv', 'utf8'),
]);
// 두 파일을 동시에 읽기 시작, 둘 다 끝나면 여기로
return parse(config, data);
}
문법은 Python의 asyncio와 비슷하지만, 전염성 문제의 심각도가 다르다. Node.js는 생태계 자체가 async 기반이라 “동기 라이브러리를 async로 감싸야 하는” 상황이 거의 없다.
그래서 CPU 바운드는?
Node.js의 약점이다. 이벤트 루프가 싱글스레드이므로, CPU를 오래 쓰는 작업을 하면 전체가 멈춘다.
app.get('/heavy', (req, res) => {
// 피보나치 계산하는 동안 다른 모든 요청이 멈춤!
const result = fibonacci(45);
res.json({ result });
});
app.get('/light', (req, res) => {
// /heavy가 끝날 때까지 이 요청도 응답 못함
res.json({ message: 'hello' });
});
이건 asyncio에서 time.sleep()을 쓰면 이벤트 루프 전체가 멈추는 것과 같은 문제다. 해결책으로 worker_threads가 있긴 하지만, 이건 결국 Python의 threading과 비슷한 방향으로 가는 것이고 Node.js의 본래 설계 철학과는 다른 방향이다.
Node.js에서도 안심하면 안 된다
“Node.js는 비동기니까 뭘 해도 빠르겠지"라고 생각하기 쉬운데, 사실 비동기 I/O에도 두 종류가 있다.
네트워크 I/O는 진짜 이벤트 루프가 직접 처리한다. OS 커널(epoll/kqueue)이 “이 소켓에 데이터 왔어"라고 알려주면 이벤트 루프가 콜백을 실행하는 방식이라, 동시 연결이 수만 개여도 문제없다.
파일시스템 I/O는 다르다. OS가 파일 읽기/쓰기에 대해서는 이런 non-blocking 메커니즘을 제공하지 않는다. 그래서 Node.js(libuv)는 뒤에 워커 스레드 풀을 몰래 돌리고 있다. 기본 4개.
쉽게 말하면 이렇다:
- 네트워크: 직원 1명이 주문 수백 개를 동시에 받을 수 있다 (기다리면서 다른 일 가능)
- 파일시스템: 뒤에 보조 직원 4명이 있고, 한 명이 한 건씩 처리한다
// async/await이라 괜찮아 보이지만...
const results = await Promise.all(
hundredFiles.map(f => fs.promises.readFile(f))
);
// 100개 요청 → 워커 4개가 순서대로 처리 → 96개는 줄 서서 대기
문제는 이 워커 풀을 dns.lookup이나 crypto 같은 다른 작업도 같이 쓴다는 것이다. 파일 읽기가 풀을 꽉 채우면 DNS 조회까지 느려진다.
UV_THREADPOOL_SIZE로 풀 크기를 늘릴 수 있긴 하지만(최대 1024), 결국 OS 스레드를 쓰는 거라 한계가 있다. “async/await이니까 알아서 빠르겠지"가 아니라, 내가 하는 I/O가 이벤트 루프에서 도는 건지, 워커 풀에서 도는 건지를 알아야 한다.
요약
Node.js는 “웹 서버가 하는 일의 99%는 I/O 대기"라는 전제 하에, 동시성 하나에 올인한 런타임이다. 그 전제가 맞는 한(웹 서버, API 서버, 실시간 채팅 등) 극도로 효율적이지만, CPU 바운드가 끼어드는 순간 설계의 한계가 드러난다. 동시성과 병렬성을 모두 필요로 하는 워크로드라면, Go의 goroutine이나 Python의 multiprocessing 같은 별도의 병렬성 메커니즘이 필요하다.