Socket Room 구독 패턴
실시간 애플리케이션에서 모든 클라이언트가 모든 이벤트를 받으면 안 된다. 채팅 앱을 생각해보면, A 채팅방에 있는 사람이 B 채팅방의 메시지까지 받을 이유가 없다. 이걸 해결하는 게 Room이라는 개념이다.
Room이 필요한 이유
Socket.IO의 기본 동작은 브로드캐스트다. 서버에서 io.emit('event', data)를 호출하면 연결된 모든 클라이언트가 이벤트를 받는다. 소규모 앱에서는 문제없지만, 사용자가 늘어나면 두 가지 문제가 생긴다.
- 불필요한 트래픽: 관련 없는 데이터까지 전송되니 대역폭이 낭비된다
- 보안 문제: 사용자가 접근 권한이 없는 데이터를 받을 수 있다
이걸 해결하려면 클라이언트를 그룹으로 나눠서, 특정 그룹에 속한 클라이언트에게만 이벤트를 보내야 한다. Socket.IO에서는 이 그룹을 Room이라고 부른다.
Socket.IO Room의 동작 원리
Room은 서버 사이드에서만 관리되는 개념이다. 클라이언트는 Room의 존재를 직접 알지 못하고, 서버가 소켓을 Room에 넣거나 빼는 방식으로 동작한다.
// 서버 사이드
io.on('connection', (socket) => {
// Room에 참가
socket.join('room-123');
// Room에서 나가기
socket.leave('room-123');
// 특정 Room에만 이벤트 전송
io.to('room-123').emit('update', data);
// 자신을 제외하고 Room에 전송
socket.to('room-123').emit('update', data);
});
Room은 별도 생성 과정이 필요 없다. join()을 호출하면 해당 이름의 Room이 없을 경우 자동으로 만들어지고, 마지막 소켓이 leave()하면 자동으로 사라진다. 메모리에만 존재하는 가벼운 구조다.
하나의 소켓은 동시에 여러 Room에 속할 수 있다. 실제로 모든 소켓은 기본적으로 자신의 socket.id를 이름으로 하는 개인 Room에 자동 참가되어 있다. 그래서 io.to(socket.id).emit()으로 특정 클라이언트에게만 메시지를 보낼 수 있는 것이다.
클라이언트의 구독 요청 패턴
Room은 서버에서 관리하지만, "어떤 Room에 참가할지"는 클라이언트가 결정해야 한다. 클라이언트가 서버에 "나 이 리소스 구독할게"라고 요청하고, 서버가 해당 소켓을 Room에 넣는 흐름이다.
가장 단순한 방식은 이벤트로 요청하는 것이다:
// 클라이언트
socket.emit('subscribe', { room: 'project-456' });
// 서버
socket.on('subscribe', ({ room }) => {
socket.join(room);
});
하지만 이 방식은 보안에 취약하다. 클라이언트가 아무 Room 이름이나 보내면 서버가 그냥 넣어주기 때문이다. 실제 프로덕션에서는 권한 검증이 필수다.
Opcode 기반 구독 요청
단순 이벤트 이름 대신, opcode(숫자 코드)로 메시지 타입을 구분하는 방식이 있다. 모든 메시지를 하나의 message 이벤트로 보내고, op 필드로 의도를 구분한다:
// 클라이언트 → 서버 Opcode
const Opcode = {
AUTH: 10, // 인증
WORKSPACE: 20, // 워크스페이스 구독
PROJECT: 30, // 프로젝트 구독
} as const;
// 워크스페이스 구독 요청
socket.emit('message', {
op: Opcode.WORKSPACE,
d: { workspaceId: 'ws-123' }
});
// 프로젝트 구독 요청
socket.emit('message', {
op: Opcode.PROJECT,
d: { projectId: 'proj-456' }
});
서버는 op 값에 따라 분기하면서, 해당 소켓의 인증 상태와 리소스 접근 권한을 확인한 뒤 Room에 참가시킨다. 이 방식의 장점은 메시지 구조가 통일되어 있어서 미들웨어로 공통 처리(로깅, 검증 등)를 적용하기 쉽다는 것이다.
React에서의 구독 훅 패턴
React 앱에서 소켓 Room 구독을 관리할 때는 커스텀 훅으로 추상화하는 게 깔끔하다. 컴포넌트가 마운트될 때 구독하고, 언마운트될 때 해제하는 라이프사이클을 useEffect로 관리한다.
워크스페이스 구독 훅
export function useWorkspaceSubscription(
workspaceId: string | null | undefined
) {
const { subscribeWorkspace, state } = useSocketContext();
useEffect(() => {
if (
workspaceId &&
state.isAuthenticated &&
state.currentWorkspaceId !== workspaceId
) {
subscribeWorkspace(workspaceId);
}
}, [workspaceId, state.isAuthenticated, state.currentWorkspaceId, subscribeWorkspace]);
}
이 훅의 핵심은 세 가지 조건을 모두 만족할 때만 구독을 요청하는 것이다:
workspaceId가 존재해야 함 (null이나 undefined가 아닐 때)- 소켓이 인증된 상태여야 함 (
state.isAuthenticated) - 이미 같은 워크스페이스를 구독 중이 아니어야 함 (
currentWorkspaceId !== workspaceId)
세 번째 조건이 없으면, 컴포넌트가 리렌더될 때마다 같은 구독 요청이 반복 전송된다. 상태로 현재 구독 중인 ID를 추적해서 중복 요청을 방지한다.
프로젝트 구독 훅
export function useProjectSubscription(
projectId: string | null | undefined,
accessKey?: string | null
) {
const { subscribeProject, state } = useSocketContext();
useEffect(() => {
if (projectId && state.currentProjectId !== projectId) {
subscribeProject(projectId, accessKey ?? undefined);
}
}, [projectId, accessKey, state.currentProjectId, subscribeProject]);
}
프로젝트 구독은 워크스페이스와 다르게 isAuthenticated 조건이 없다. 이유는 게스트 접근 때문이다. 비인증 사용자도 accessKey가 있으면 프로젝트의 실시간 업데이트를 받을 수 있어야 한다. 서버에서 accessKey를 검증해서 권한을 부여하는 방식이다.
Context에서의 구독 함수 구현
구독 훅이 호출하는 실제 구독 함수는 SocketProvider(Context) 안에 정의된다:
const subscribeWorkspace = (workspaceId: string) => {
if (socket?.connected && state.isAuthenticated) {
socket.emit('message', {
op: Opcode.WORKSPACE,
d: { workspaceId }
});
setState((prev) => ({
...prev,
currentWorkspaceId: workspaceId
}));
}
};
const subscribeProject = (projectId: string, accessKey?: string) => {
if (socket?.connected) {
const message = accessKey
? { op: Opcode.PROJECT, d: { projectId, accessKey } }
: { op: Opcode.PROJECT, d: { projectId } };
socket.emit('message', message);
setState((prev) => ({
...prev,
currentProjectId: projectId
}));
}
};
여기서도 워크스페이스는 isAuthenticated를 체크하고, 프로젝트는 체크하지 않는다. 인증 여부와 관계없이 accessKey 기반 접근이 가능해야 하기 때문이다.
상태 업데이트(setState)를 서버 응답을 기다리지 않고 바로 하는 점도 주목할 만하다. 이건 낙관적 업데이트(Optimistic Update)다. 서버에서 에러가 오면 에러 상태로 전환하지만, 정상 흐름에서는 요청 즉시 UI 상태를 반영한다.
구독 후 이벤트 처리
Room에 참가하면 서버가 해당 Room으로 보내는 이벤트를 받게 된다. 받은 이벤트를 어떻게 처리하느냐가 실시간 업데이트의 핵심이다.
이벤트 핸들러 훅
export function useSocketEventHandler() {
const socket = useSocket();
const queryClient = useQueryClient();
const { currentWorkspaceId } = useSocketState();
useEffect(() => {
if (!socket) return;
const handleMessage = (data: SocketMessage) => {
if (data.op === ServerOpcode.UPDATE && data.evt && data.d) {
handleUpdateEvent(data.evt, data.d as UpdateMessageData);
} else if (data.op === ServerOpcode.NOTIFICATION) {
handleNotificationEvent();
}
};
socket.on('message', handleMessage);
return () => { socket.off('message', handleMessage); };
}, [socket, queryClient, currentWorkspaceId]);
}
ServerOpcode.UPDATE(42)가 오면 이벤트 타입(evt)에 따라 분기하고, ServerOpcode.NOTIFICATION(41)이면 알림을 처리한다.
React Query 캐시 무효화
이벤트를 받았을 때 가장 자연스러운 처리 방법은 React Query의 캐시를 무효화(invalidate)하는 것이다. 직접 상태를 업데이트하는 대신, 해당 데이터를 refetch하게 만든다:
const handleUpdateEvent = (evt: string, d: UpdateMessageData) => {
switch (evt) {
case UpdateEvent.EDIT_PROJECT:
if (d.project?.id) {
queryClient.invalidateQueries({
queryKey: projectKeys.detail(d.project.id)
});
}
break;
case UpdateEvent.ADD_COMMENT:
case UpdateEvent.EDIT_COMMENT:
case UpdateEvent.REMOVE_COMMENT:
queryClient.invalidateQueries({
queryKey: commentKeys.all
});
break;
case UpdateEvent.REMOVE_PROJECT:
if (d.project?.id) {
queryClient.invalidateQueries({
queryKey: projectKeys.detail(d.project.id)
});
}
// 워크스페이스의 프로젝트 목록도 무효화
if (currentWorkspaceId) {
queryClient.invalidateQueries({
queryKey: workspaceKeys.projects(currentWorkspaceId)
});
}
break;
}
};
이 방식의 장점은 서버로부터 최신 데이터를 다시 가져오기 때문에 클라이언트-서버 간 데이터 불일치가 발생하지 않는다는 것이다. 소켓 이벤트에 전체 데이터를 실어 보내는 방식은 페이로드가 크고, 클라이언트가 직접 캐시를 조작해야 해서 복잡도가 높다. "뭔가 바뀌었다"는 신호만 소켓으로 보내고, 실제 데이터는 REST API로 가져오는 게 더 실용적이다.
계층적 구독 구조
실제 앱에서는 리소스가 계층 구조를 가지는 경우가 많다. 예를 들어 워크스페이스 > 프로젝트 > 버전 같은 구조에서, 각 계층마다 별도의 Room을 운영한다.
워크스페이스 Room ("workspace:ws-123")
├── 프로젝트 생성/삭제 이벤트
└── 워크스페이스 설정 변경 이벤트
프로젝트 Room ("project:proj-456")
├── 버전 추가/삭제 이벤트
├── 댓글 이벤트
└── 프로젝트 설정 변경 이벤트
클라이언트는 현재 보고 있는 화면에 따라 필요한 Room만 구독한다:
- 워크스페이스 목록 화면 → 워크스페이스 Room만 구독
- 프로젝트 상세 화면 → 워크스페이스 + 프로젝트 Room 구독
이렇게 하면 각 화면에서 필요한 이벤트만 받을 수 있고, 서버도 이벤트를 최소한의 클라이언트에게만 전송하게 된다.
단일 활성 구독
위 구현에서 currentWorkspaceId와 currentProjectId를 상태로 관리하면서, 새 ID로 구독하면 이전 구독이 자동으로 대체되는 구조다. 한 번에 하나의 워크스페이스, 하나의 프로젝트만 구독한다. 이건 SPA의 라우팅 구조와 맞물린다. 사용자가 다른 프로젝트 페이지로 이동하면 자연스럽게 새 프로젝트를 구독하게 된다.
서버에서도 이전 Room에서 leave하고 새 Room에 join하는 처리를 해야 한다:
// 서버 사이드 예시
socket.on('message', (data) => {
if (data.op === Opcode.PROJECT) {
// 이전 프로젝트 Room에서 나가기
const prevRooms = [...socket.rooms].filter(r => r.startsWith('project:'));
prevRooms.forEach(room => socket.leave(room));
// 새 프로젝트 Room에 참가
socket.join(`project:${data.d.projectId}`);
}
});
이렇게 하면 이전 Room의 이벤트를 계속 받는 문제를 방지할 수 있다.
연결 끊김과 재구독
네트워크가 불안정하면 소켓이 끊겼다가 다시 연결된다. Socket.IO는 자동 재연결을 지원하지만, Room 정보는 서버 메모리에 있기 때문에 재연결 시 이전 Room에 자동으로 다시 들어가지 않는다.
Socket.IO의 reconnection 옵션:
const socket = io(url, {
reconnection: true, // 자동 재연결 활성화
reconnectionAttempts: 5, // 최대 재시도 횟수
reconnectionDelay: 1000, // 첫 재시도까지 대기 (ms)
reconnectionDelayMax: 5000, // 최대 대기 시간 (ms)
});
재연결 후에는 인증과 구독을 다시 해야 한다. React 훅 패턴에서는 이걸 자연스럽게 처리할 수 있다. connect 이벤트에서 인증을 다시 수행하고, 인증이 완료되면 isAuthenticated 상태가 변경되면서 구독 훅의 useEffect가 다시 실행되기 때문이다:
재연결 → connect 이벤트 → AUTH 전송 → SUCCESS_AUTH 수신
→ isAuthenticated = true → useWorkspaceSubscription useEffect 재실행
→ currentWorkspaceId가 null이므로 조건 충족 → 구독 요청 전송
이 흐름이 가능한 건 disconnect 시 상태를 초기화하기 때문이다:
socketInstance.on('disconnect', () => {
setState((prev) => ({
...prev,
isConnected: false,
isAuthenticated: false,
currentWorkspaceId: null,
currentProjectId: null,
}));
});
currentWorkspaceId를 null로 리셋하니까, 재연결 후 구독 훅의 currentWorkspaceId !== workspaceId 조건이 다시 참이 되어 재구독이 트리거된다. 별도의 재구독 로직을 작성할 필요가 없다.
Room vs Namespace
Socket.IO에는 Room과 비슷해 보이는 Namespace라는 개념도 있다. 둘 다 클라이언트를 그룹화하는 데 쓰이지만 용도가 다르다.
| 구분 | Room | Namespace |
|---|---|---|
| 관리 위치 | 서버에서만 | 클라이언트 + 서버 |
| 연결 | 기존 연결 내 그룹 | 별도 연결 |
| 동적 생성 | join()으로 즉시 | 클라이언트가 연결 시 지정 |
| 미들웨어 | Room별 불가 | Namespace별 가능 |
| 용도 | 리소스별 이벤트 분리 | 기능/권한별 완전 분리 |
Room은 하나의 연결 안에서 논리적 그룹을 만드는 거고, Namespace는 아예 별도의 연결을 만드는 것이다. 리소스 구독에는 Room이 적합하고, "관리자용 소켓"과 "일반 사용자용 소켓"을 완전히 분리하고 싶을 때는 Namespace가 적합하다.
대부분의 실시간 앱에서는 Room만으로 충분하다. Namespace까지 쓰는 경우는 멀티테넌트 아키텍처나 완전히 다른 기능 영역을 분리할 때 정도다.
정리
Socket Room 구독 패턴의 핵심은 "필요한 이벤트만, 권한이 있는 클라이언트에게만"이다:
- 서버가 Room을 관리하고, 클라이언트는 구독 요청만 보낸다
- 인증/권한 검증은 서버에서 join 전에 수행한다
- React 훅으로 선언적 구독: 컴포넌트 마운트 = 구독, 언마운트 = 해제
- 중복 방지: 현재 구독 ID를 상태로 추적해서 같은 Room에 반복 요청하지 않는다
- 재연결 자동 처리: disconnect 시 상태 초기화 → 재연결 후 훅이 재구독 트리거
- 이벤트 → 캐시 무효화: 소켓은 "변경 알림"만, 실제 데이터는 REST로 fetch