Socket.IO의 Room 기반 Pub/Sub과 Redis Adapter
이벤트가 사용자에게 도달하기까지
개요
서비스에서 실시간으로 "댓글이 추가됐다"는 이벤트를 모든 사용자가 아니라, 해당 프로젝트를 보고 있는 사용자에게만 전달해야 한다. 전체 브로드캐스트는 간단하지만, 대상을 특정하려면 약간의 추가적인 구조가 필요하다.
이 글은 Socket.IO의 Room 시스템으로 어떻게 Pub/Sub 패턴을 구현하는지, 서버가 여러 대일 때 Redis Adapter가 왜 필요한지를 정리한다.
Room이란
Socket.IO의 Room은 서버가 관리하는 구독 그룹이다. 클라이언트가 프로젝트를 확인하려고 요청하면, 서버가 해당 클라이언트를 Room에 추가한다.
// 서버가 해당 클라이언트의 소켓을 project Room에 추가
client.join(`project:${projectId}`);
// 서버가 project Room에 속한 모든 소켓에게 이벤트를 보냄
this.server.to(`project:${projectId}`).emit("message", { op: Opcode.Update, evt: "ADD_COMMENT", d: payload });
Room은 서버 메모리에 존재하는 Set이다.
// Room의 내부 구조 (개념적)
const rooms = {
"project:a3b8f2e": Set(["socketId_A", "socketId_B"]), // Client A, B가 참여
"project:c7d4e1a": Set(["socketId_C"]), // Client C만 참여
};
client.join()은 이 Set에 소켓 ID를 추가하고, server.to().emit()은 Set에 있는 모든 소켓에게 메시지를 보낸다. 클라이언트는 자신이 어떤 Room에 있는지 알 필요 없이 서버가 관리하고, 서버가 라우팅한다.
Pub/Sub 패턴을 간략하게 시각화하면 아래와 같다.
Socket.IO에서는 Room이 Channel 역할을 하고, join()으로 구독하고, to().emit()으로 발행한다.
Room 네이밍 전략
Room 이름을 어떻게 짓느냐에 따라 이벤트를 얼마나 정확하게 라우팅할 수 있는지가 달라진다. 여기서는 세 가지 레벨의 Room을 사용한다.
// 인증 레벨 (토큰 만료 시 해당 사용자의 모든 연결을 끊기 위해)
client.join(`auth:${tokenId}`);
// 워크스페이스 레벨 (특정 사용자에게만 알림을 보내기 위해)
client.join(`workspace:${workspaceId}:user:${userId}`);
// 프로젝트 레벨 (해당 프로젝트를 보는 모든 사용자에게 이벤트 전달)
client.join(`project:${projectId}`);
auth: Room은 토큰 단위로 소켓을 묶기 위해 만들었다. 토큰이 무효화되면 해당 Room의 모든 소켓을 한번에 강제 종료할 수 있다.
// 토큰 무효화 시, 해당 사용자의 모든 연결을 끊는다.
this.server.to(`auth:${tokenId}`).emit("message", { op: Opcode.Closed });
this.server.to(`auth:${tokenId}`).disconnectSockets(true);
workspace:user: Room은 특정 사용자에게 개별 알림을 보내는 데 쓴다. 프로젝트에 새 멤버가 추가되면, 해당 워크스페이스의 특정 사용자들에게만 알림을 보낸다.
// 특정 사용자들에게만 알림을 전송한다.
this.server
.to(userIds.map((x) => `workspace:${workspaceId}:user:${x}`))
.emit("message", { op: Opcode.Notification, d: payload });
인증과 권한 검증
Room에 아무나 참여할 수 있으면 보안 문제가 된다. 따라서 서버는 join() 전에 반드시 권한을 검증해야 한다.
async function setProject(client, payload) {
const hasAccess = await verifyProjectAccess(client, payload);
if (!hasAccess) throw new ForbiddenException();
client.join(`project:${payload.projectId}`);
}
Socket.IO 자체는 Room 참여에 제한을 두지 않는다. client.join()은 아무 조건 없이 호출할 수 있다. 권한 검증은 전적으로 애플리케이션 코드의 몫이기 때문에, join() 호출 전에 접근 권한을 확인하는 로직을 직접 구현해야 한다.
Redis Adapter로 서버 간 이벤트 공유
서버가 1대면 Room은 해당 서버의 메모리에서 관리되므로 문제가 없다. 하지만 사용자가 많아지면 서버 1대로는 감당이 안 되기 때문에 서버를 여러 대로 늘리게 된다. 이때 앞단에 로드밸런서가 요청을 분배하는데, 이걸 수평 확장(scale-out)이라고 한다.
문제는 사용자 A가 서버 1에, 사용자 B가 서버 2에 연결됐는데 둘 다 같은 프로젝트를 보고 있을 때다. 서버 1의 Room에는 사용자 A만 있고, 서버 2의 Room에는 사용자 B만 있다. 사용자 A가 댓글을 달면 서버 1의 Room에만 이벤트가 발생했기 때문에, 서버 2는 이 이벤트를 알 수 없다! 그렇게 되면 사용자 B는 댓글이 추가된 걸 볼 수 없다.
Redis Adapter를 통해 이 문제를 해결할 수 있다. 모든 서버가 Redis에 연결되어 있고, 한 서버에서 to().emit()을 호출하면 Redis를 통해 다른 서버들에게도 전달된다.
import { Redis } from "ioredis";
import { Server } from "socket.io";
import { createAdapter } from "@socket.io/redis-streams-adapter";
const client = new Redis({ host: "localhost", port: 6379 });
const io = new Server({
adapter: createAdapter(client, {
maxLen: 10_000,
streamName: "{event}",
}),
});
일반 Redis Pub/Sub 대신 Redis Streams 기반 어댑터(@socket.io/redis-streams-adapter)를 사용하면 더 안정적이다. 일반 Pub/Sub은 "발행 시점에 구독하고 있는 소비자만" 메시지를 받는다. 연결이 잠깐 끊긴 사이에 발행된 메시지는 놓친다.
그 점을 보완해 Redis Streams는 메시지에 영속성(persistence)을 부여한다.
Pub/Sub은 메시지를 전달만 하고 바로 사라지지만, Streams는 메시지를 메모리에 보관하기 때문에 서버 간 연결이 일시적으로 끊겨도 놓친 이벤트를 나중에 가져올 수 있다.
정리
- Room은 서버가 관리하는 구독 그룹이다. 이벤트를 특정 대상에게만 보낼 수 있다
- Room 이름을 auth/workspace/project 레벨로 분리하면 정확한 라우팅이 가능하다
- Socket.IO는 Room 참여에 제한을 두지 않으므로, 권한 검증은 직접 구현해야 한다
- 서버가 여러 대일 때는 Redis Adapter로 서버 간 이벤트를 공유할 수 있다
- Redis Streams는 Pub/Sub과 달리 메시지를 보관하기 때문에 놓친 이벤트를 복구할 수 있다