Node.js 서버를 여러 프로세스로 확장하면 CPU 자원을 더 활용할 수 있다.
하지만 프로세스가 분리되는 순간 하나의 문제가 생긴다.
프로세스의 메모리는 서로 공유되지 않는다.
일반적인 HTTP 서버라면 이 문제를 외부 DB나 캐시를 통해 쉽게 해결할 수 있지만, Socket.IO처럼 지속적인 연결을 유지하는 시스템에서는 이야기가 달라진다.
Client -> Socket.IO Worker -> Connection State
여기서 Worker가 여러 개가 되면 연결 상태 역시 분리된다.
Worker 1 -> Connection A
Worker 2 -> Connection B
따라서 서로 다른 Worker에 연결된 클라이언트 사이에서 이벤트를 전달하려면 프로세스 간 통신 계층이 필요하다.
Node.js Cluster 기본 구조
Node.js의 Cluster를 이용하면 여러 Worker 프로세스를 실행할 수 있다.
간단한 예제를 보자.
const cluster = require('cluster');
const http = require('http');
const os = require('os');
if (cluster.isPrimary) {
const cpuCount = os.cpus().length;
for (let i = 0; i < cpuCount; i++) {
cluster.fork();
}
cluster.on('exit', (worker) => {
console.log(`Worker ${worker.process.pid} exited`);
});
}
else {
const server = http.createServer((req, res) => {
res.writeHead(200);
res.end(`Worker PID: ${process.pid}`);
});
server.listen(3000);
console.log(`Worker started: ${process.pid}`);
}
실행하면 하나의 Primary Process가 여러 Worker를 생성한다.
Primary -> Worker 1 / Worker 2 / Worker 3 / Worker 4
각 Worker는 별도의 Node.js 프로세스다.
따라서 다음과 같은 변수는 서로 공유되지 않는다.
let users = [];
users.push('PlayerA');
Worker 1에서 users에 PlayerA를 넣었다고 해도 Worker 2의 users에는 PlayerA가 존재하지 않는다.
Worker 1 -> users = [PlayerA]
Worker 2 -> users = []
이것이 Cluster 환경에서 가장 먼저 이해해야 하는 부분이다.
Socket.IO 단일 프로세스
Cluster를 적용하기 전에 Socket.IO의 기본 구조부터 보면 이해하기 쉽다.
const http = require('http');
const { Server } = require('socket.io');
const server = http.createServer();
const io = new Server(server);
io.on('connection', (socket) => {
console.log(`Connected: ${socket.id}`);
socket.on('chat', (message) => {
io.emit('chat', message);
});
});
server.listen(3000);
단일 프로세스에서는 매우 단순하다.
Client A -> Socket.IO Server -> Client B
서버 하나가 모든 Socket 연결을 관리하기 때문이다.
예를 들어 A가 메시지를 보내면 서버가 모든 클라이언트에게 전달할 수 있다.
socket.on('chat', (message) => {
io.emit('chat', message);
});
문제는 서버를 여러 Worker로 나누는 순간 발생한다.
Cluster + Socket.IO
다음과 같이 Socket.IO 서버를 Cluster 환경에서 실행한다고 하자.
const cluster = require('cluster');
const http = require('http');
const os = require('os');
const { Server } = require('socket.io');
if (cluster.isPrimary) {
const cpuCount = os.cpus().length;
for (let i = 0; i < cpuCount; i++) {
cluster.fork();
}
}
else {
const server = http.createServer();
const io = new Server(server);
io.on('connection', (socket) => {
console.log(
`Worker ${process.pid} connected: ${socket.id}`
);
socket.on('chat', (message) => {
io.emit('chat', message);
});
});
server.listen(3000);
}
겉보기에는 정상적으로 동작할 것처럼 보인다.
하지만 Worker가 여러 개 존재한다.
Worker 1 -> Socket A
Worker 2 -> Socket B
A가 Worker 1에 연결되고 B가 Worker 2에 연결됐다고 하자.
A가 메시지를 보낸다.
socket.on('chat', (message) => {
io.emit('chat', message);
});
여기서 io.emit()은 현재 Worker의 Socket.IO 인스턴스가 관리하는 연결을 기준으로 동작한다.
따라서 Worker 1에서 발생한 이벤트를 Worker 2가 자동으로 알 수 있는 것은 아니다.
Worker 1 -> io.emit() -> Worker 1의 Socket
Worker 1 -X-> Worker 2의 Socket
이것이 멀티 프로세스 Socket.IO의 핵심 문제다.
Redis를 추가한다
이 문제를 해결하려면 Worker 사이에서 메시지를 전달할 수 있는 공유 계층이 필요하다.
여기서 Redis를 사용할 수 있다.
Worker 1 / Worker 2 / Worker 3 -> Redis
Redis의 Pub/Sub을 이용하면 한 Worker가 발행한 이벤트를 다른 Worker에게 전달할 수 있다.
Worker 1 -> Publish -> Redis -> Subscribe -> Worker 2
Socket.IO에서는 이 기능을 직접 구현하기보다 Redis Adapter를 사용하는 것이 일반적이다.
Socket.IO Redis Adapter
현재 Socket.IO에서는 @socket.io/redis-adapter를 사용할 수 있다.
필요한 패키지를 설치한다.
npm install socket.io @socket.io/redis-adapter redis
서버 코드는 다음과 같이 구성할 수 있다.
const http = require('http');
const { Server } = require('socket.io');
const { createClient } = require('redis');
const { createAdapter } = require('@socket.io/redis-adapter');
async function startServer() {
const server = http.createServer();
const io = new Server(server);
const pubClient = createClient({
url: 'redis://localhost:6379'
});
const subClient = pubClient.duplicate();
await Promise.all([
pubClient.connect(),
subClient.connect()
]);
io.adapter(
createAdapter(pubClient, subClient)
);
io.on('connection', (socket) => {
console.log(`Connected: ${socket.id}`);
socket.on('chat', (message) => {
io.emit('chat', message);
});
});
server.listen(3000, () => {
console.log('Server started');
});
}
startServer();
핵심은 다음 코드다.
io.adapter(
createAdapter(pubClient, subClient)
);
이 설정을 하면 Socket.IO의 서버 간 이벤트 전달을 Redis를 통해 처리할 수 있다.
Cluster와 Redis Adapter 결합
이제 Cluster와 Redis Adapter를 결합할 수 있다.
const cluster = require('cluster');
const os = require('os');
if (cluster.isPrimary) {
const cpuCount = os.cpus().length;
for (let i = 0; i < cpuCount; i++) {
cluster.fork();
}
}
else {
startWorker();
}
async function startWorker() {
const http = require('http');
const { Server } = require('socket.io');
const { createClient } = require('redis');
const { createAdapter } = require('@socket.io/redis-adapter');
const server = http.createServer();
const io = new Server(server);
const pubClient = createClient({
url: 'redis://localhost:6379'
});
const subClient = pubClient.duplicate();
await Promise.all([
pubClient.connect(),
subClient.connect()
]);
io.adapter(
createAdapter(pubClient, subClient)
);
io.on('connection', (socket) => {
console.log(
`Worker ${process.pid}: ${socket.id}`
);
socket.on('chat', (message) => {
io.emit('chat', {
worker: process.pid,
message
});
});
});
server.listen(3000);
}
구조는 이렇게 된다.
Primary -> Worker 1 / Worker 2 / Worker 3 / Worker 4
각 Worker는 자신의 Socket 연결을 관리한다.
Worker 1 -> Client A
Worker 2 -> Client B
Worker 3 -> Client C
그리고 Worker 사이의 이벤트 전달은 Redis를 사용한다.
Worker 1 -> Redis -> Worker 2 / Worker 3 / Worker 4
따라서 A가 메시지를 보내면 다음과 같은 흐름이 만들어진다.
Client A -> Worker 1 -> Redis -> Worker 2 / Worker 3 -> Client B / Client C
Redis Pub/Sub의 기본 원리
Redis Adapter의 내부 개념을 이해하려면 Pub/Sub 자체를 이해하면 된다.
Redis에는 Publish와 Subscribe라는 개념이 있다.
Publisher는 메시지를 발행한다.
await publisher.publish(
'chat',
JSON.stringify({
user: 'PlayerA',
message: 'Hello'
})
);
Subscriber는 특정 Channel을 구독한다.
await subscriber.subscribe(
'chat',
(message) => {
const data = JSON.parse(message);
console.log(data);
}
);
구조는 다음과 같다.
Publisher -> Redis Channel -> Subscriber
Socket.IO에서는 이 개념을 서버 간 이벤트 전달에 활용한다.
Socket.IO Worker -> Redis Adapter -> Redis -> Redis Adapter -> Socket.IO Worker
Room에서도 같은 문제가 발생한다
Socket.IO의 Room을 사용하는 경우에도 멀티 서버 환경을 고려해야 한다.
예를 들어 게임 방을 만든다고 하자.
socket.join('room-1');
그리고 방에 메시지를 보낸다.
io.to('room-1').emit('gameState', data);
단일 서버에서는 간단하다.
하지만 여러 Worker가 존재하면 Room과 Socket 연결이 각각 다른 Worker에 존재할 수 있다.
Worker 1 -> Room 1 -> Player A
Worker 2 -> Room 1 -> Player B
이때 Worker 1에서 발생한 Room 이벤트가 Worker 2까지 전달되어야 한다.
Redis Adapter를 사용하면 이러한 서버 간 이벤트 전달을 처리할 수 있다.
socket.join('room-1');
socket.on('gameState', (data) => {
io.to('room-1').emit('gameState', data);
});
구조는 다음과 같다.
Player A -> Worker 1 -> Redis -> Worker 2 -> Player B
따라서 Room 기반 게임이나 채팅 시스템에서 특히 중요한 구조가 된다.
Sticky Session
Redis Adapter를 사용한다고 해서 모든 연결 문제가 해결되는 것은 아니다.
Load Balancer를 사용한다면 Socket.IO의 연결 방식과 세션 관리도 고려해야 한다.
예를 들어 다음과 같은 구조가 있다.
Client -> Load Balancer -> Server A / Server B
Client가 최초 연결한 서버와 이후 요청이 전달되는 서버가 달라질 수 있다.
특히 HTTP Long-Polling을 사용하는 경우에는 세션 고정이 필요할 수 있다.
이를 Sticky Session이라고 한다.
개념적으로는 다음과 같다.
Client A -> Load Balancer -> Server A
이후에도
Client A -> Load Balancer -> Server A
를 유지한다.
WebSocket으로 업그레이드된 이후에는 연결 자체가 유지되지만, 초기 연결 과정과 애플리케이션 구성에 따라 세션 문제를 고려해야 한다.
따라서 실서비스에서는 다음을 함께 설계한다.
Load Balancer -> Sticky Session / WebSocket 설정 -> Socket.IO Adapter -> Redis
Redis가 모든 상태 저장소는 아니다
Redis를 사용한다고 해서 모든 데이터를 Redis에 넣는 것은 좋은 설계가 아니다.
각 시스템의 역할을 분리해야 한다.
영구 데이터 -> Database
캐시 -> Redis
세션 -> Redis
실시간 이벤트 -> Redis Pub/Sub
대규모 이벤트 스트림 -> Kafka 등의 Message Broker
Redis Pub/Sub은 메시지를 실시간으로 전달하는 데 적합하지만 메시지를 영구적으로 보관하는 Queue와는 성격이 다르다.
따라서 메시지의 유실을 허용할 수 있는가, 재처리가 필요한가, 순서 보장이 필요한가 등에 따라 시스템을 선택해야 한다.
게임 서버에서의 응용
이 구조는 게임 서버에서도 그대로 적용할 수 있다.
예를 들어 여러 게임 서버가 존재한다고 하자.
Game Server 1 / Game Server 2 / Game Server 3
각 서버가 독립적으로 플레이어를 관리한다.
Player A -> Game Server 1
Player B -> Game Server 2
A와 B가 같은 게임 이벤트를 공유해야 한다면 서버 간 메시지 전달이 필요하다.
Game Server 1 -> Message Broker -> Game Server 2
여기서 Redis Pub/Sub을 사용할 수도 있고, 게임의 요구사항에 따라 다른 Message Broker를 사용할 수도 있다.
중요한 것은 특정 기술이 아니다.
서버 간 통신을 서버 내부 메모리에 의존하지 않도록 만드는 것이다.
핵심 코드만 보면
결국 중요한 코드는 많지 않다.
Cluster는 Worker를 만든다.
if (cluster.isPrimary) {
for (let i = 0; i < os.cpus().length; i++) {
cluster.fork();
}
}
Socket.IO는 연결을 관리한다.
io.on('connection', (socket) => {
socket.on('chat', (message) => {
io.emit('chat', message);
});
});
Redis Adapter는 Worker / Server 사이의 Socket.IO 이벤트 전달을 연결한다.
io.adapter(
createAdapter(pubClient, subClient)
);
이 세 개를 합치면 구조가 완성된다.
Cluster -> Multi Process
Socket.IO -> Connection Management
Redis Adapter -> Cross Process / Server Event Propagation
최종 구조
전체 시스템을 하나의 흐름으로 표현하면 다음과 같다.
Client -> Load Balancer -> Node.js Worker -> Socket.IO -> Redis Adapter -> Redis -> Other Worker -> Socket.IO -> Client
서버가 여러 대라면 다음과 같이 확장된다.
Client -> Load Balancer -> Server A / Server B / Server C -> Redis -> Server A / Server B / Server C
여기서 각각의 역할은 명확하다.
Node.js Cluster -> CPU 코어 활용
Socket.IO -> 실시간 연결 관리
Redis -> 서버 간 이벤트 전달
Redis Adapter -> Socket.IO와 Redis 연결
Load Balancer -> 서버 분산
마무리
Node.js Cluster를 적용하는 것은 단순히 Worker를 여러 개 만드는 작업이다.
진짜 문제는 그 다음에 발생한다.
프로세스가 분리되면 메모리도 분리된다.
Process A -> Memory A
Process B -> Memory B
따라서 Stateful한 Socket 연결을 여러 프로세스에서 처리하려면 공유 계층이 필요하다.
Worker A -> Redis -> Worker B
Socket.IO에서는 Redis Adapter를 이용해 이 문제를 해결할 수 있다.
Socket.IO A -> Redis Adapter -> Redis -> Redis Adapter -> Socket.IO B
결국 핵심은 멀티 프로세스 환경에서 연결 상태와 이벤트 전달을 어떻게 분리할 것인가다.
Cluster는 서버를 늘린다.
Socket.IO는 연결을 관리한다.
Redis는 프로세스와 서버 사이의 이벤트를 전달한다.
이 세 가지의 역할을 분리해서 이해하면 Node.js 실시간 서버의 스케일 아웃 구조를 훨씬 명확하게 볼 수 있다.