몇 달 전, 팀에서 Python으로 만든 텔레그램 웹훅 서버를 Kotlin으로 재작성하게 되었다. 팀장님의 이유는 단순했다 - “Python보다 Kotlin이 빠르니까.”
정확한 벤치마크나 성능 측정 데이터는 없었다. 그저 컴파일 언어가 인터프리터 언어보다 빠르다는 일반적인 통념에 기반한 결정이었다. 하지만 실제로 마이그레이션을 완료하고 나니, 체감할 만한 성능 차이는 없었다.
사실 예상된 결과였다. 작은 규모의 웹훅 서버에서 병목은 대부분 네트워크 I/O에서 발생한다. CPU가 복잡한 연산을 수행하는 시간보다, 외부 API 응답을 기다리거나 데이터베이스 쿼리 결과를 기다리는 시간이 훨씬 길다.
이 경험을 통해 문득 Node.js에 대한 의문이 들었다. 개발자 커뮤니티에서 “Node.js는 싱글스레드라서 느리다”는 이야기를 자주 듣곤 했는데, 정말 그럴까? Netflix, PayPal, LinkedIn 같은 대규모 서비스들이 Node.js를 채택한 이유가 있지 않을까?
호기심이 생겨 Node.js의 내부 동작 방식에 대해 깊이 파보기로 했다.
Worker Threads의 발견
// worker_threads 모듈을 사용한 멀티스레드 예제
const { Worker, isMainThread, parentPort } = require('worker_threads');
if (isMainThread) {
// 메인 스레드
const worker = new Worker(__filename);
worker.on('message', (result) => {
console.log('계산 결과:', result);
});
worker.postMessage({ num: 1000000 });
} else {
// 워커 스레드
parentPort.on('message', (data) => {
const result = calculatePrimes(data.num);
parentPort.postMessage(result);
});
}
function calculatePrimes(max) {
// CPU 집약적인 작업
const primes = [];
for (let i = 2; i <= max; i++) {
if (isPrime(i)) primes.push(i);
}
return primes.length;
}
가장 먼저 발견한 것은 Node.js가 완전한 싱글스레드가 아니라는 사실이었다. Node.js 10.5.0부터 Worker Threads라는 기능이 추가되어, CPU 집약적인 작업을 별도의 스레드에서 처리할 수 있게 되었다.
이는 특히 이미지 처리, 대용량 데이터 파싱, 암호화 같은 CPU 바운드 작업에서 유용하다. 메인 스레드가 블로킹되지 않고 다른 요청을 계속 처리할 수 있기 때문이다.
이벤트루프: 컨텍스트 스위칭이 아닌 스케줄링
처음에는 Node.js가 여러 작업을 처리할 때 OS 레벨에서 컨텍스트 스위칭을 한다고 생각했다. 하지만 실제로는 그보다 훨씬 효율적인 방식을 사용하고 있었다.
// 이벤트루프가 어떻게 동작하는지 보여주는 예제
console.log('1: 시작');
setTimeout(() => {
console.log('2: setTimeout 콜백');
}, 0);
Promise.resolve().then(() => {
console.log('3: Promise 콜백');
});
process.nextTick(() => {
console.log('4: nextTick 콜백');
});
console.log('5: 끝');
// 출력 순서:
// 1: 시작
// 5: 끝
// 4: nextTick 콜백
// 3: Promise 콜백
// 2: setTimeout 콜백
이벤트루프는 단일 스레드에서 작업들을 효율적으로 스케줄링하는 메커니즘이다. 각 작업은 다음과 같은 우선순위로 처리된다:
- 동기 코드 실행: 현재 실행 중인 코드를 완료
- process.nextTick 큐: 가장 높은 우선순위의 비동기 작업
- 마이크로태스크 큐: Promise 콜백 등
- 타이머 페이즈: setTimeout, setInterval 콜백
- I/O 콜백: 파일 시스템, 네트워크 작업 완료 콜백
- setImmediate: I/O 이벤트 다음에 즉시 실행
- close 콜백: 소켓이나 핸들이 닫힐 때
핵심은 이 모든 과정이 하나의 스레드에서 일어난다는 점이다. OS의 스레드 스케줄러가 개입하지 않으므로 컨텍스트 스위칭 오버헤드가 없다. 이것이 Node.js가 높은 동시성을 효율적으로 처리할 수 있는 이유다.
libuv와 숨겨진 스레드 풀
더 흥미로운 발견은 Node.js가 내부적으로 멀티스레드를 사용한다는 사실이었다.
const crypto = require('crypto');
const fs = require('fs');
// 파일 I/O - libuv의 스레드 풀 사용
console.time('file');
for (let i = 0; i < 4; i++) {
fs.readFile(__filename, () => {
console.timeEnd('file');
});
}
// 암호화 - libuv의 스레드 풀 사용
console.time('crypto');
for (let i = 0; i < 4; i++) {
crypto.pbkdf2('password', 'salt', 100000, 512, 'sha512', () => {
console.timeEnd('crypto');
});
}
// 네트워크 I/O - OS의 비동기 인터페이스 사용 (스레드 풀 X)
const https = require('https');
console.time('https');
for (let i = 0; i < 4; i++) {
https.get('https://www.google.com', (res) => {
res.on('data', () => {});
res.on('end', () => {
console.timeEnd('https');
});
});
}
libuv는 Node.js의 비동기 I/O를 담당하는 C 라이브러리로, 기본적으로 4개의 워커 스레드를 가진 스레드 풀을 운영한다. 다음과 같은 작업들이 이 스레드 풀에서 처리된다:
- 파일 시스템 작업 (fs 모듈)
- DNS 조회 (dns.lookup)
- 일부 암호화 작업 (crypto 모듈)
- 압축 작업 (zlib 모듈)
// 스레드 풀 크기는 환경 변수로 조정 가능 (최대 1024)
process.env.UV_THREADPOOL_SIZE = 8;
네트워크 I/O는 스레드 풀을 사용하지 않고 OS의 비동기 인터페이스(epoll, kqueue, IOCP 등)를 직접 사용한다는 점도 인상적이었다. 이는 웹 서버가 수천 개의 동시 연결을 효율적으로 처리할 수 있게 해준다.
클러스터 모듈로 멀티코어 활용하기
const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;
if (cluster.isMaster) {
console.log(`마스터 프로세스 ${process.pid} 실행`);
// CPU 개수만큼 워커 생성
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.log(`워커 ${worker.process.pid} 종료`);
cluster.fork(); // 워커가 죽으면 새로 생성
});
} else {
// 워커들이 포트를 공유
http.createServer((req, res) => {
res.writeHead(200);
res.end(`워커 ${process.pid}가 처리\n`);
}).listen(8000);
console.log(`워커 ${process.pid} 시작`);
}
싱글스레드의 한계를 극복하는 또 다른 방법은 클러스터 모듈이다. 이를 통해 여러 개의 Node.js 프로세스를 생성하고, 각 프로세스가 동일한 포트를 공유하도록 할 수 있다.
PM2 같은 프로세스 매니저를 사용하면 더욱 쉽게 클러스터링을 구현할 수 있다:
# CPU 코어 수만큼 프로세스 생성
pm2 start app.js -i max
작업 유형에 따른 성능 차이
// I/O 바운드 작업 - Node.js의 강점
async function fetchMultipleAPIs() {
const urls = [
'https://api1.example.com',
'https://api2.example.com',
'https://api3.example.com'
];
// 병렬로 처리 - 매우 효율적
const results = await Promise.all(
urls.map(url => fetch(url).then(r => r.json()))
);
return results;
}
// CPU 바운드 작업 - Worker Threads 활용
const { Worker } = require('worker_threads');
function runCPUIntensiveTask(data) {
return new Promise((resolve, reject) => {
const worker = new Worker('./cpu-intensive-task.js');
worker.postMessage(data);
worker.on('message', resolve);
worker.on('error', reject);
});
}
결과적으로 Node.js의 성능은 작업의 특성에 크게 좌우된다는 것을 알게 되었다.
I/O 바운드 작업에서의 강점
Node.js는 다음과 같은 I/O 중심 작업에서 뛰어난 성능을 보인다:
- 웹 API 서버
- 실시간 채팅 애플리케이션
- 데이터 스트리밍
- 마이크로서비스 아키텍처
비동기 I/O와 이벤트 기반 아키텍처 덕분에 적은 메모리로도 수천 개의 동시 연결을 처리할 수 있다.
CPU 바운드 작업에서의 대응
CPU 집약적인 작업의 경우:
- Worker Threads를 사용하여 병렬 처리
- 클러스터링으로 멀티코어 활용
- 필요시 C++ 애드온이나 WebAssembly 활용
- 정말 무거운 연산은 별도 서비스로 분리
오해에서 이해로
“Node.js는 싱글스레드라서 느리다”는 말은 절반의 진실이었다. JavaScript 실행은 싱글스레드에서 이루어지지만, Node.js 플랫폼 자체는 필요에 따라 멀티스레드를 활용한다. 그리고 많은 웹 애플리케이션에서 병목은 CPU가 아닌 I/O에 있다.
Netflix, PayPal, LinkedIn 같은 기업들이 Node.js를 선택한 이유가 있었다. 적절한 상황에서 적절한 도구를 사용한다면, Node.js는 충분히 강력하고 효율적인 플랫폼이다.
다음에는 실제로 다양한 시나리오에서 벤치마크를 수행해보고 싶다. 이론을 넘어 실제 데이터로 검증해보는 것도 흥미로울 것 같다.