Socket.IO의 Room 기반 Pub/Sub과 Redis Adapter

이벤트가 사용자에게 도달하기까지

작성일: 2025. 12. 134 min

개요

서비스에서 실시간으로 "댓글이 추가됐다"는 이벤트를 모든 사용자가 아니라, 해당 프로젝트를 보고 있는 사용자에게만 전달해야 한다. 전체 브로드캐스트는 간단하지만, 대상을 특정하려면 약간의 추가적인 구조가 필요하다.

이 글은 Socket.IO의 Room 시스템으로 어떻게 Pub/Sub 패턴을 구현하는지, 서버가 여러 대일 때 Redis Adapter가 왜 필요한지를 정리한다.

Room이란

excalidraw: Room 기본 개념 — project:A Room에 속한 소켓에게만 이벤트가 전달된다

Socket.IO의 Room은 서버가 관리하는 구독 그룹이다. 클라이언트가 프로젝트를 확인하려고 요청하면, 서버가 해당 클라이언트를 Room에 추가한다.

typescript
// 서버가 해당 클라이언트의 소켓을 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이다.

typescript
// 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 패턴을 간략하게 시각화하면 아래와 같다.

excalidraw: Pub/Sub 패턴 — Publisher가 Channel에 발행하면, 구독한 Subscriber에게만 전달된다

Socket.IO에서는 Room이 Channel 역할을 하고, join()으로 구독하고, to().emit()으로 발행한다.

Room 네이밍 전략

Room 이름을 어떻게 짓느냐에 따라 이벤트를 얼마나 정확하게 라우팅할 수 있는지가 달라진다. 여기서는 세 가지 레벨의 Room을 사용한다.

typescript
// 인증 레벨 (토큰 만료 시 해당 사용자의 모든 연결을 끊기 위해)
client.join(`auth:${tokenId}`);

// 워크스페이스 레벨 (특정 사용자에게만 알림을 보내기 위해)
client.join(`workspace:${workspaceId}:user:${userId}`);

// 프로젝트 레벨 (해당 프로젝트를 보는 모든 사용자에게 이벤트 전달)
client.join(`project:${projectId}`);

auth: Room은 토큰 단위로 소켓을 묶기 위해 만들었다. 토큰이 무효화되면 해당 Room의 모든 소켓을 한번에 강제 종료할 수 있다.

typescript
// 토큰 무효화 시, 해당 사용자의 모든 연결을 끊는다.
this.server.to(`auth:${tokenId}`).emit("message", { op: Opcode.Closed });
this.server.to(`auth:${tokenId}`).disconnectSockets(true);

workspace:user: Room은 특정 사용자에게 개별 알림을 보내는 데 쓴다. 프로젝트에 새 멤버가 추가되면, 해당 워크스페이스의 특정 사용자들에게만 알림을 보낸다.

typescript
// 특정 사용자들에게만 알림을 전송한다.
this.server
  .to(userIds.map((x) => `workspace:${workspaceId}:user:${x}`))
  .emit("message", { op: Opcode.Notification, d: payload });

인증과 권한 검증

Room에 아무나 참여할 수 있으면 보안 문제가 된다. 따라서 서버는 join() 전에 반드시 권한을 검증해야 한다.

typescript
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를 통해 다른 서버들에게도 전달된다.

typescript
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)을 부여한다.

excalidraw: Redis Adapter — 서버 1의 이벤트가 Redis Streams를 통해 서버 2로 전달된다

Pub/Sub은 메시지를 전달만 하고 바로 사라지지만, Streams는 메시지를 메모리에 보관하기 때문에 서버 간 연결이 일시적으로 끊겨도 놓친 이벤트를 나중에 가져올 수 있다.

정리

  • Room은 서버가 관리하는 구독 그룹이다. 이벤트를 특정 대상에게만 보낼 수 있다
  • Room 이름을 auth/workspace/project 레벨로 분리하면 정확한 라우팅이 가능하다
  • Socket.IO는 Room 참여에 제한을 두지 않으므로, 권한 검증은 직접 구현해야 한다
  • 서버가 여러 대일 때는 Redis Adapter로 서버 간 이벤트를 공유할 수 있다
  • Redis Streams는 Pub/Sub과 달리 메시지를 보관하기 때문에 놓친 이벤트를 복구할 수 있다

참고 자료