Node.js는 JavaScript를 기반으로 서버 애플리케이션을 개발할 수 있는 런타임이다. 비동기 I/O 처리에 강점이 있으며, 적은 수의 스레드로도 다수의 네트워크 연결을 효율적으로 처리할 수 있다.
하지만 Node.js가 모든 작업을 자동으로 병렬 처리하는 것은 아니다. 기본적으로 JavaScript 코드는 하나의 메인 스레드에서 실행되며, CPU를 오래 사용하는 작업이 실행되면 다른 요청의 처리가 지연될 수 있다.
따라서 안정적인 게임 서버와 백엔드 시스템을 개발하려면 다음 개념을 이해해야 한다.
- CPU 집약 작업과 I/O 집약 작업의 차이
- Worker Threads를 이용한 CPU 작업의 병렬 처리
- 프로세스 분리와 Cluster를 이용한 멀티코어 활용
- 이벤트 루프 지연의 원인과 측정
- 메모리 누수와 Garbage Collection의 관계
이 글에서는 각 개념의 동작 원리와 실제 서버 설계에서의 활용 방법을 살펴본다.
CPU 집약 작업과 I/O 집약 작업
서버에서 실행되는 작업은 필요한 자원에 따라 크게 CPU 집약 작업과 I/O 집약 작업으로 구분할 수 있다.
CPU 집약 작업 (CPU-bound)
CPU 집약 작업은 데이터를 계산하거나 변환하는 데 CPU 연산 자원을 많이 사용하는 작업이다.
대표적인 예시는 다음과 같다.
- 복잡한 수학 계산
- 대규모 데이터 정렬 및 탐색
- 이미지 및 영상 인코딩
- 압축과 압축 해제
- 대규모 암호화 및 해싱
- 물리 시뮬레이션
- 복잡한 게임 로직 계산
예를 들어 다음 코드를 살펴보자.
function calculate() {
let result = 0;
for (let i = 0; i < 1_000_000_000; i++) {
result += Math.sqrt(i);
}
return result;
}
console.log(calculate());
이 코드는 반복문을 통해 대량의 계산을 수행한다. 실행 중에는 JavaScript 메인 스레드가 계산에 집중하므로 다른 JavaScript 콜백을 처리하기 어렵다.
서버가 이 작업을 요청 처리 과정에서 실행하면 다른 사용자의 HTTP 요청이나 WebSocket 메시지 처리까지 지연될 수 있다.
핵심은 CPU 집약 작업의 실행 시간이 길어질수록 메인 스레드가 다른 작업을 처리하지 못하는 시간이 증가한다는 것이다.
I/O 집약 작업 (I/O-bound)
I/O 집약 작업은 CPU 연산보다 외부 자원의 응답을 기다리는 시간이 큰 작업이다.
대표적인 예시는 다음과 같다.
- MySQL 쿼리 실행
- Redis 요청
- 외부 API 호출
- 파일 읽기 및 쓰기
- 네트워크 통신
- 클라이언트의 요청 대기
예를 들어 다음 코드를 살펴보자.
import { setTimeout as delay } from "node:timers/promises";
async function fetchUser() {
console.log("사용자 조회 시작");
await delay(1000);
console.log("사용자 조회 완료");
}
fetchUser();
console.log("다른 작업 실행");
실행 결과는 다음과 같다.
사용자 조회 시작
다른 작업 실행
사용자 조회 완료
await는 현재 비동기 함수의 실행을 일시 중단하고, 이후 코드를 이어서 실행할 수 있도록 한다. 이때 다른 JavaScript 작업이 실행될 수 있다.
단, 위 예제의 delay()는 실제 데이터베이스나 네트워크 I/O가 아니라 타이머를 이용한 대기 예제다.
실제 Node.js에서는 네트워크 I/O를 운영체제의 비동기 기능 등으로 처리하며, 일부 파일 시스템 작업이나 암호화 작업 등은 libuv의 스레드 풀을 이용할 수 있다.
CPU-bound와 I/O-bound 비교
| 구분 | CPU-bound | I/O-bound |
|---|---|---|
| 주요 병목 | CPU 연산 | 외부 자원 응답 대기 |
| 대표 작업 | 시뮬레이션, 압축, 계산 | DB 조회, API 호출 |
| 비동기 처리 효과 | 메인 스레드 점유 문제를 해결하지 못함 | 대기 시간 동안 다른 작업 처리 가능 |
| 주요 최적화 | 알고리즘 개선, Worker Threads, 작업 분리 | 커넥션 풀, 비동기 I/O, 캐싱, 타임아웃 |
I/O 작업을 async/await로 작성했다고 해서 CPU 계산까지 병렬로 실행되는 것은 아니다.
async function processRequest() {
const data = await fetchData();
// 이 계산은 여전히 현재 JavaScript 스레드에서 실행된다.
const result = expensiveCalculation(data);
return result;
}
await는 비동기 작업의 완료를 기다리는 문법이다. CPU 연산을 별도의 스레드로 이동시키는 기능은 아니다.
Worker Threads
Worker Threads는 Node.js에서 여러 JavaScript 스레드를 사용하여 작업을 실행할 수 있도록 제공하는 기능이다.
일반적인 Node.js 프로그램에서는 메인 스레드가 JavaScript 코드를 실행한다. CPU 집약 작업을 Worker Thread로 이동하면 메인 스레드가 요청 처리와 네트워크 이벤트를 계속 처리하면서 별도의 스레드에서 계산을 수행할 수 있다.
Worker Threads가 필요한 이유
다음 코드는 CPU 집약 작업을 메인 스레드에서 실행한다.
function calculate(iterations) {
let result = 0;
for (let i = 0; i < iterations; i++) {
result += Math.sqrt(i);
}
return result;
}
console.log(calculate(100_000_000));
계산이 실행되는 동안 이벤트 루프의 JavaScript 콜백 처리가 지연될 수 있다.
이를 Worker Thread로 분리하면 CPU 계산을 다른 스레드에서 수행할 수 있다.
Worker Thread 구현
먼저 계산을 수행할 worker.js를 작성한다.
import { parentPort, workerData } from "node:worker_threads";
function calculate(iterations) {
let result = 0;
for (let i = 0; i < iterations; i++) {
result += Math.sqrt(i);
}
return result;
}
const result = calculate(workerData.iterations);
parentPort.postMessage(result);
workerData는 Worker를 생성할 때 전달한 초기 데이터이며, parentPort.postMessage()는 메인 스레드에 결과를 전달한다.
다음으로 메인 스레드에서 Worker를 생성한다.
import { Worker } from "node:worker_threads";
function runWorker(iterations) {
return new Promise((resolve, reject) => {
const worker = new Worker(
new URL("./worker.js", import.meta.url),
{
workerData: { iterations },
}
);
worker.once("message", resolve);
worker.once("error", reject);
worker.once("exit", (code) => {
if (code !== 0) {
reject(new Error(`Worker 종료 코드: ${code}`));
}
});
});
}
const result = await runWorker(100_000_000);
console.log("계산 결과:", result);
이 구조에서는 메인 스레드가 계산을 직접 수행하지 않는다. Worker가 계산을 완료하면 결과를 메시지로 전달한다.
실제 운영 코드에서는 Worker의 오류와 종료 이벤트를 중복 처리하지 않도록 Promise 정착 여부를 관리하는 것이 좋다.
Worker Threads의 데이터 전달
Worker와 메인 스레드는 기본적으로 서로 다른 JavaScript 실행 환경을 사용한다.
일반 객체나 배열을 postMessage()로 전달하면 데이터가 구조화 복제(Structured Clone)될 수 있다. 큰 데이터를 반복적으로 전달하면 복사 비용이 발생할 수 있다.
대용량 바이너리 데이터는 ArrayBuffer의 소유권을 이전하거나 SharedArrayBuffer를 사용하는 방법도 있다. 다만 공유 메모리를 사용할 때는 동시 접근과 동기화 문제를 직접 고려해야 한다.
Worker Threads의 주의점
Worker Thread를 생성하는 데도 비용이 발생한다. 요청마다 Worker를 새로 생성하면 스레드 초기화 비용과 메모리 사용량이 증가할 수 있다.
따라서 반복적으로 CPU 작업을 처리하는 서버에서는 Worker Pool을 구성하는 방법이 일반적이다.
Worker Pool은 제한된 수의 Worker를 유지하고, 작업을 큐에 넣어 유휴 Worker에 배분한다.
작업 요청
|
v
작업 큐
|
+------> Worker 1
+------> Worker 2
+------> Worker 3
+------> Worker 4
Worker 수를 무조건 늘리는 것이 좋은 것은 아니다. CPU 코어 수, 작업의 연산량, 메모리 사용량, 메인 스레드의 부하를 함께 고려해야 한다.
Worker Threads는 CPU 집약 작업을 병렬 실행하기 위한 수단이지, 모든 비동기 작업에 적용해야 하는 기본 도구는 아니다.
프로세스 분리와 Cluster
Worker Threads가 스레드 단위로 실행 환경을 분리한다면, 프로세스 분리는 별도의 운영체제 프로세스에서 애플리케이션을 실행하는 방식이다.
Node.js의 Cluster 모듈은 여러 Node.js 프로세스를 실행하고, 하나의 서버 포트를 여러 프로세스가 공유하도록 구성할 수 있다.
프로세스와 스레드의 차이
| 구분 | Worker Threads | Cluster |
|---|---|---|
| 실행 단위 | 스레드 | 프로세스 |
| JavaScript 실행 환경 | Worker마다 분리 | 프로세스마다 분리 |
| 메모리 | 프로세스 내 메모리 공간 공유 가능 | 기본적으로 독립된 메모리 공간 |
| 통신 | 메시지, 공유 메모리 | IPC 메시지 등 |
| 주요 목적 | CPU 작업 병렬 처리 | 서버 요청 처리의 멀티프로세스화 |
프로세스는 기본적으로 독립된 메모리 공간을 사용하므로 한 프로세스의 JavaScript 힙이 다른 프로세스의 힙과 직접 공유되지 않는다.
이러한 분리는 장애 격리에 도움이 되지만, 프로세스 간 통신과 데이터 동기화 비용이 발생한다.
Cluster의 기본 구조
일반적인 Cluster 구성은 Primary 프로세스와 Worker 프로세스로 나뉜다.
- Primary: Worker 프로세스를 생성하고 관리한다.
- Worker: 실제 서버 로직을 실행하고 클라이언트 요청을 처리한다.
Primary Process
|
+------------+------------+
| | |
v v v
Worker 1 Worker 2 Worker 3
| | |
v v v
HTTP 요청 HTTP 요청 HTTP 요청
여러 Worker가 요청을 처리하면 하나의 JavaScript 스레드만 사용하는 서버보다 여러 CPU 코어를 활용하기 쉬워진다.
Cluster 구현 예제
server.js를 작성한다.
import cluster from "node:cluster";
import { availableParallelism } from "node:os";
import { createServer } from "node:http";
if (cluster.isPrimary) {
const workerCount = availableParallelism();
console.log(`Primary PID: ${process.pid}`);
for (let i = 0; i < workerCount; i++) {
cluster.fork();
}
cluster.on("exit", (worker, code, signal) => {
console.log(
`Worker 종료: PID=${worker.process.pid}, ` +
`code=${code}, signal=${signal}`
);
// 운영 환경에서는 재시작 횟수와 간격을 제한해야 한다.
});
} else {
const server = createServer((req, res) => {
res.writeHead(200, {
"Content-Type": "application/json; charset=utf-8",
});
res.end(
JSON.stringify({
pid: process.pid,
message: "Hello from Node.js Cluster",
})
);
});
server.listen(3000, () => {
console.log(`Worker ${process.pid} listening on port 3000`);
});
}
실행하면 Primary 프로세스가 여러 Worker를 생성한다. 각 Worker는 같은 포트에서 서버를 실행하고, Cluster의 연결 분배 메커니즘에 따라 요청을 처리한다.
다만 연결 분배 방식은 운영체제와 구성에 영향을 받는다. Cluster를 사용한다고 요청이 항상 완벽하게 균등 분배되는 것은 아니다.
또한 위 예제는 기본적인 구조를 설명하기 위한 코드다. 운영 환경에서는 Worker 재시작 횟수 제한, 비정상 종료 대응, 종료 신호 처리, 로그 관리 등이 추가되어야 한다.
Cluster를 사용할 때 주의할 점
각 Worker의 메모리는 독립적이다. 따라서 한 Worker의 일반 객체에 저장한 데이터는 다른 Worker에서 자동으로 공유되지 않는다.
예를 들어 다음과 같은 코드는 문제가 될 수 있다.
const sessions = new Map();
sessions.set("user-1", {
nickname: "Player",
});
Worker 1에서 저장한 세션은 Worker 2의 sessions에 자동으로 나타나지 않는다.
여러 프로세스에서 공유해야 하는 데이터는 Redis나 데이터베이스 같은 외부 저장소를 사용하거나, 별도의 공유 서비스로 분리해야 한다.
특히 게임 서버에서 매치 상태, 사용자 세션, 재화, 인증 정보를 단순한 프로세스 내부 메모리에만 보관하면 프로세스 간 데이터 불일치가 발생할 수 있다.
Cluster와 외부 오케스트레이션
Cluster는 하나의 머신 내부에서 여러 Node.js 프로세스를 실행하는 데 유용하다. 하지만 서버 여러 대에 걸친 확장, 배포, 장애 복구까지 전부 해결하지는 않는다.
서버를 여러 대 운영하는 환경에서는 다음과 같은 구성을 고려할 수 있다.
Load Balancer
|
+---------+---------+
| |
v v
Server A Server B
| |
Node.js Worker Node.js Worker
| |
+---------+---------+
|
Redis / MySQL
운영 환경에 따라 Cluster 대신 컨테이너를 여러 개 실행하고, 로드 밸런서나 Kubernetes로 확장과 재시작을 관리할 수도 있다.
핵심은 Cluster가 멀티프로세스 실행 기능이고, Kubernetes 같은 오케스트레이터는 여러 컨테이너와 머신의 배포 및 운영을 관리하는 시스템이라는 점이다.
이벤트 루프 지연 (Event Loop Lag)
Node.js의 이벤트 루프는 타이머, 네트워크 I/O 등에서 발생한 이벤트와 콜백을 적절한 시점에 처리하도록 한다.
Node.js는 비동기 I/O를 활용해 많은 연결을 효율적으로 처리하지만, JavaScript 메인 스레드에서 긴 작업을 실행하면 이벤트 루프가 다른 콜백을 제때 처리하지 못할 수 있다.
이때 발생하는 지연을 이벤트 루프 지연이라고 한다.
이벤트 루프 지연이 발생하는 이유
대표적인 원인은 다음과 같다.
- 오래 실행되는 동기식 반복문
- 복잡한 JSON 직렬화 및 역직렬화
- 대규모 데이터의 동기식 처리
- 비효율적인 알고리즘
- 동기식 파일 시스템 API 사용
- 지나치게 많은 콜백 처리
- 과도한 Garbage Collection으로 인한 일시 정지
예를 들어 실시간 게임 서버에서 한 번의 메시지 처리에 200ms가 소요된다고 가정하자.
function handleMessage(message) {
// 복잡한 동기 계산
expensiveCalculation(message);
// 게임 상태 갱신
updateGameState(message);
}
이 작업이 메인 스레드를 200ms 동안 점유한다면, 해당 시간 동안 같은 스레드에서 처리해야 하는 다른 JavaScript 콜백도 지연될 수 있다.
실시간 게임에서는 이러한 지연이 입력 반응 속도 저하, 상태 동기화 지연, 요청 타임아웃 등으로 이어질 수 있다.
이벤트 루프 지연 측정
Node.js는 perf_hooks 모듈의 monitorEventLoopDelay()를 이용해 이벤트 루프 지연을 측정할 수 있다.
import {
monitorEventLoopDelay,
} from "node:perf_hooks";
const histogram = monitorEventLoopDelay({
resolution: 20,
});
histogram.enable();
setInterval(() => {
console.log({
minMs: histogram.min / 1e6,
maxMs: histogram.max / 1e6,
meanMs: histogram.mean / 1e6,
p99Ms: histogram.percentile(99) / 1e6,
});
histogram.reset();
}, 5000);
측정값은 나노초 단위이므로 밀리초로 변환하기 위해 (10^6)으로 나눈다.
min: 측정 구간에서 관측된 최소 지연max: 측정 구간에서 관측된 최대 지연mean: 평균 지연p99: 관측된 지연 분포의 99백분위수
이 값들은 이벤트 루프가 얼마나 지연되는지 파악하는 데 도움이 된다. 단, p99는 요청 응답 시간의 99백분위수와 다른 지표다. 이벤트 루프 지연이 낮더라도 DB 응답이나 네트워크 대기로 인해 요청 응답 시간이 길어질 수 있다.
이벤트 루프 지연을 줄이는 방법
알고리즘 최적화
불필요한 반복이나 중복 계산을 제거하고 적절한 자료구조를 사용한다.
CPU 작업 분리
연산량이 많은 작업은 Worker Threads 또는 별도의 작업 처리 서비스로 이동한다.
동기식 API 최소화
서버 요청 경로에서는 대규모 파일 처리 등 오래 걸리는 동기식 작업을 피한다.
작업 분할
하나의 큰 작업을 작은 단위로 나누어 여러 이벤트 루프 턴에 걸쳐 처리할 수 있다. 다만 작업 분할은 지연을 완화하는 방법이지, 실제 CPU 병렬 실행을 보장하는 방법은 아니다.
모니터링
이벤트 루프 지연뿐 아니라 CPU 사용률, 메모리, GC 시간, 요청 응답 시간도 함께 측정한다.
메모리 누수와 Garbage Collection
Node.js는 V8 JavaScript 엔진을 기반으로 동작하며, 더 이상 사용되지 않는 메모리를 자동으로 회수하기 위해 Garbage Collection(GC)을 수행한다.
하지만 GC가 존재한다고 해서 메모리 누수가 자동으로 해결되는 것은 아니다.
Garbage Collection의 기본 원리
JavaScript에서는 객체를 직접 해제하는 대신 객체가 더 이상 도달 가능하지 않을 때 GC가 해당 메모리를 회수할 수 있다.
예를 들어 다음 코드를 살펴보자.
function createUser() {
const user = {
id: "user-1",
nickname: "Player",
};
return user;
}
let currentUser = createUser();
currentUser = null;
currentUser가 객체를 더 이상 참조하지 않게 되면 해당 객체는 다른 곳에서 참조되지 않는다는 전제하에 GC의 회수 대상이 될 수 있다.
실제 회수 시점은 GC가 결정하므로 currentUser = null을 실행했다고 해서 즉시 메모리가 해제되는 것은 아니다.
V8은 도달 가능성을 중심으로 객체를 추적하며, 세대별 수집과 여러 GC 최적화 기법을 사용한다. 일부 GC 작업은 병렬 또는 동시 실행될 수 있지만, 특정 단계에서는 JavaScript 실행이 일시적으로 멈출 수도 있다.
메모리 누수란?
메모리 누수는 더 이상 필요하지 않은 객체가 참조 관계 때문에 계속 메모리에 남아 있는 현상이다.
다음은 전역 배열에 요청 데이터를 계속 추가하는 예제다.
const requestHistory = [];
function handleRequest(req) {
requestHistory.push({
url: req.url,
timestamp: Date.now(),
});
}
요청이 들어올 때마다 객체가 추가되지만 배열에서 제거하지 않는다.
이 배열이 계속 커지면 객체는 여전히 도달 가능한 상태이므로 GC가 회수할 수 없다.
실제 서버에서는 다음과 같은 상황에서 메모리 누수가 발생하기 쉽다.
- 제한 없이 증가하는 배열이나 Map
- 해제되지 않은 이벤트 리스너
- 종료되지 않은 타이머
- 연결 종료 후 제거되지 않은 세션
- 캐시의 만료 및 크기 제한 누락
- 클로저에 남아 있는 불필요한 참조
- 완료된 작업의 결과를 계속 보관하는 큐
메모리 누수와 메모리 사용량 증가의 차이
메모리 사용량이 증가했다고 해서 반드시 메모리 누수인 것은 아니다.
예를 들어 캐시가 정상적으로 데이터를 저장하거나 V8이 향후 할당에 대비해 힙 메모리를 확보한 경우에도 프로세스의 메모리 사용량은 증가할 수 있다.
따라서 다음을 구분해야 한다.
- 정상적인 메모리 사용: 캐시, 작업량, 데이터 크기에 따라 증가하며 적절한 조건에서 안정화된다.
- 메모리 누수: 필요하지 않은 객체가 계속 참조되어 장기간 유지된다.
- 높은 일시적 메모리 사용: 대규모 요청이나 순간적인 작업량 증가로 메모리 사용량이 일시적으로 상승한다.
메모리 누수를 확인하려면 단순히 RSS 수치 하나만 보는 것이 아니라 힙 사용량의 추세, GC 이후의 메모리 수준, 힙 스냅샷의 객체 참조 관계 등을 함께 분석해야 한다.
메모리 상태 측정
Node.js는 process.memoryUsage()로 프로세스의 메모리 사용량을 확인할 수 있다.
setInterval(() => {
const memory = process.memoryUsage();
console.log({
rssMB: Math.round(memory.rss / 1024 / 1024),
heapUsedMB: Math.round(memory.heapUsed / 1024 / 1024),
heapTotalMB: Math.round(memory.heapTotal / 1024 / 1024),
externalMB: Math.round(memory.external / 1024 / 1024),
arrayBuffersMB: Math.round(memory.arrayBuffers / 1024 / 1024),
});
}, 5000);
각 지표의 의미는 다음과 같다.
| 지표 | 의미 |
|---|---|
rss | 프로세스가 실제 물리 메모리에서 점유하는 상주 메모리 |
heapUsed | V8 힙에서 현재 사용 중인 메모리 |
heapTotal | V8이 힙에 확보한 전체 메모리 |
external | JavaScript 객체와 연결된 V8 힙 외부 메모리 |
arrayBuffers | ArrayBuffer 및 관련 버퍼가 사용하는 메모리 |
arrayBuffers는 external에 포함되는 값이므로 두 수치를 합산하면 중복 계산할 수 있다.
또한 rss에는 JavaScript 힙 외의 메모리도 포함되므로, 힙 사용량이 안정적이어도 RSS가 증가할 수 있다.
메모리 누수 분석 방법
메모리 누수가 의심된다면 다음 순서로 분석할 수 있다.
- 부하가 낮은 상태에서 기준 메모리 사용량을 기록한다.
- 일정한 요청 부하를 반복적으로 발생시킨다.
- GC 이후에도 힙 사용량이 계속 증가하는지 확인한다.
- 힙 스냅샷을 비교해 반복적으로 증가하는 객체를 찾는다.
- 해당 객체를 참조하는 배열, Map, 이벤트 리스너, 클로저 등을 추적한다.
- 불필요한 참조를 제거하고 동일한 부하로 개선 여부를 검증한다.
메모리 사용량이 높다는 이유만으로 무조건 GC를 강제하는 것은 적절하지 않다. 근본 원인이 불필요한 객체 참조라면 GC 옵션을 변경해도 누수를 해결할 수 없다.
성능 최적화 전략 정리
Node.js 서버 성능을 개선할 때는 모든 문제를 하나의 기술로 해결하려고 해서는 안 된다. 먼저 병목의 원인을 측정하고, 그에 맞는 방법을 선택해야 한다.
| 문제 | 우선 고려할 방법 |
|---|---|
| CPU 계산이 너무 느림 | 알고리즘 최적화, Worker Threads |
| 여러 CPU 코어 활용 부족 | Worker Pool, Cluster, 별도 프로세스 |
| DB 응답 대기 시간이 김 | 쿼리 최적화, 인덱스, 커넥션 풀, 캐싱 |
| 이벤트 루프 지연 증가 | 동기 작업 제거, CPU 작업 분리, 작업 분할 |
| 힙 사용량이 지속적으로 증가 | 힙 스냅샷, 참조 관계 분석, 누수 수정 |
| 서버 한 대의 처리 용량 부족 | 로드 밸런싱, 수평 확장, 외부 상태 저장소 |
실시간 게임 서버에서는 추가로 메시지 처리 시간, Room별 작업량, 틱 처리 시간, WebSocket 연결 수, 재접속률 등을 측정하는 것이 유용하다.
예를 들어 게임 상태 계산이 병목이라면 Worker Threads를 검토할 수 있다. 반면 여러 프로세스에서 게임 상태를 일관되게 관리해야 한다면 단순히 Cluster를 추가하는 것만으로는 해결되지 않는다. 상태 소유권과 프로세스 간 통신 구조까지 설계해야 한다.