Socket.IO 실시간 통신: Room과 Broadcasting, Namespace, 인증, React 연동, Redis Adapter
이 글의 핵심
Socket.IO로 이벤트를 주고받는 기본부터 Room과 Namespace로 대상을 나누는 법, 연결 시 인증, React에서 소켓 수명 관리, 서버를 여러 대로 늘릴 때 Redis Adapter가 필요한 이유를 다룹니다.
이 글의 핵심
Socket.IO로 실시간 통신을 구현하는 글입니다. WebSocket, Room, Broadcasting, Namespace, 인증을 예제로 정리했습니다.
실무에서 마주치는 문제들
폴링이 비효율적이에요
클라이언트가 몇 초마다 “새 메시지 있나요?”라고 묻는 폴링은 대부분의 요청이 빈 응답으로 끝나면서도 매번 HTTP 헤더와 인증 처리 비용을 치릅니다. 간격을 줄이면 서버 부하가 늘고, 늘리면 메시지가 늦게 도착합니다. 연결을 하나 열어 두고 서버가 필요할 때 먼저 보내는 방식이 이 딜레마를 없앱니다.
WebSocket이 복잡해요
브라우저의 WebSocket API 자체는 단순하지만, 실제 서비스에 쓰려면 연결이 끊겼을 때의 재연결과 지수 백오프, 끊긴 연결을 감지하는 하트비트, “어떤 메시지에 대한 응답인지” 짝을 맞추는 요청-응답 구조, 사용자 그룹별 전송을 전부 직접 만들어야 합니다. Socket.IO는 이런 기능을 이벤트 이름 기반 API로 감싸 제공합니다.
Room 관리가 필요해요
채팅방마다 참가자 연결 목록을 관리하고, 연결이 끊기면 목록에서 빼는 코드를 직접 짜면 누락되기 쉽습니다. Socket.IO의 Room은 서버 쪽에서 소켓을 이름 붙은 그룹에 넣고 빼는 기능이며, 연결이 끊기면 자동으로 모든 Room에서 빠집니다.
한 가지 오해를 먼저 풀어야 합니다. Socket.IO는 WebSocket 구현체가 아니라 WebSocket(또는 HTTP 롱폴링) 위에서 돌아가는 자체 프로토콜입니다. 그래서 Socket.IO 서버에 브라우저의 순수 new WebSocket()이나 다른 언어의 표준 WebSocket 클라이언트로 접속하면 연결은 되더라도 메시지를 주고받을 수 없습니다. 반드시 Socket.IO 클라이언트 라이브러리를 써야 하고, 서버와 클라이언트의 메이저 버전도 맞아야 합니다.
Socket.IO란?
핵심 특징
Socket.IO는 실시간 양방향 통신 라이브러리입니다. 주요 장점:
- 자동 재연결: 연결 끊김 처리
- Room: 그룹 통신
- Broadcasting: 다중 전송
- Fallback: WebSocket 불가 시 폴링
- 간단한 API: 직관적인 문법
연결 과정을 보면 Fallback의 의미가 드러납니다. Socket.IO 클라이언트는 기본적으로 먼저 HTTP 롱폴링으로 연결을 맺고, 가능하면 WebSocket으로 업그레이드합니다. 회사 프록시나 방화벽이 WebSocket을 막는 환경에서도 연결 자체는 유지되는 이유입니다. 대신 폴링 단계가 있기 때문에 서버를 여러 대로 늘리면 같은 클라이언트의 요청이 항상 같은 서버로 가도록 하는 스티키 세션이 필요해집니다(8장에서 다룹니다). WebSocket만 쓰도록 transports: ['websocket']을 지정하면 이 제약은 사라지지만 폴백도 함께 사라집니다.
설치 및 기본 사용
설치
npm install socket.io socket.io-client
socket.io는 서버용, socket.io-client는 클라이언트용 패키지입니다. 서버와 프런트엔드가 다른 프로젝트라면 각자 필요한 패키지만 설치합니다.
서버
Socket.IO 서버는 Node.js HTTP 서버에 붙여서 동작합니다. Express를 쓴다면 createServer(app)으로 만든 서버를 넘기면 같은 포트에서 REST API와 Socket.IO가 함께 돌아갑니다. 이때 app.listen()이 아니라 httpServer.listen()을 호출해야 합니다. app.listen()은 내부적으로 새 HTTP 서버를 만들기 때문에 Socket.IO가 붙은 서버는 아무 포트도 열지 않게 됩니다.
// server.ts
import { createServer } from 'http';
import { Server } from 'socket.io';
const httpServer = createServer();
const io = new Server(httpServer, {
cors: {
origin: 'http://localhost:3000',
},
});
io.on('connection', (socket) => {
console.log('User connected:', socket.id);
socket.on('message', (data) => {
console.log('Message:', data);
socket.emit('message', `Echo: ${data}`);
});
socket.on('disconnect', () => {
console.log('User disconnected:', socket.id);
});
});
httpServer.listen(3001, () => {
console.log('Socket.IO server running on :3001');
});
cors.origin은 브라우저가 다른 출처(포트가 다르면 다른 출처)에서 접속할 때 필요합니다. 설정이 없으면 브라우저 콘솔에 has been blocked by CORS policy 에러가 나고 클라이언트는 계속 재연결을 시도합니다. 개발 중에 origin: '*'로 풀어 두는 경우가 많은데, 쿠키 기반 인증을 쓰는 서비스라면 운영 환경에서는 반드시 실제 도메인으로 제한해야 합니다.
socket.id는 연결마다 새로 발급되는 임시 식별자입니다. 새로고침하거나 재연결하면 값이 바뀌므로 사용자 ID로 쓰면 안 됩니다. 사용자를 식별하려면 6장의 인증 단계에서 얻은 사용자 정보를 socket.data에 저장해 두고 씁니다.
클라이언트
// client.ts
// 필요한 모듈 import
import { io } from 'socket.io-client';
const socket = io('http://localhost:3001');
socket.on('connect', () => {
console.log('Connected:', socket.id);
});
socket.emit('message', 'Hello Server!');
socket.on('message', (data) => {
console.log('Received:', data);
});
socket.on('disconnect', () => {
console.log('Disconnected');
});
io()를 호출하는 순간 연결을 시작하지만, socket.emit()은 연결이 완료되기 전에 호출해도 됩니다. 클라이언트가 보낼 이벤트를 버퍼에 담아 두었다가 연결되면 전송하기 때문입니다. 이 동작은 편리하지만, 오래 끊겨 있던 클라이언트가 다시 연결될 때 쌓인 이벤트가 한꺼번에 전송되는 부작용도 있습니다. 실시간성이 중요한 이벤트(예: 현재 마우스 위치)라면 socket.volatile.emit()으로 보내 연결되지 않은 동안의 이벤트를 버리게 할 수 있습니다.
요청에 대한 응답을 받고 싶다면 이벤트를 따로 주고받는 대신 acknowledgement를 씁니다. socket.emit('message', data, (res) => { ... })처럼 마지막 인자로 콜백을 넘기면 서버 핸들러가 받은 콜백을 호출할 때 그 결과가 클라이언트로 돌아옵니다. v4.4 이후에는 socket.timeout(5000).emit(...)으로 응답 시간 제한도 걸 수 있습니다. 반대로 Socket.IO는 기본적으로 메시지 전달을 보장하지 않습니다. 연결이 끊긴 동안 서버가 보낸 이벤트는 사라지므로, 채팅처럼 누락이 허용되지 않는 서비스는 메시지를 DB에 저장하고 재연결 시 마지막으로 받은 메시지 이후분을 다시 가져오는 로직이 필요합니다.
Room
서버
io.on('connection', (socket) => {
// Room 참가
socket.on('join-room', (roomId) => {
socket.join(roomId);
console.log(`${socket.id} joined room ${roomId}`);
// Room에 메시지 전송
io.to(roomId).emit('user-joined', socket.id);
});
// Room에 메시지 전송
socket.on('room-message', ({ roomId, message }) => {
io.to(roomId).emit('message', {
userId: socket.id,
message,
});
});
// Room 나가기
socket.on('leave-room', (roomId) => {
socket.leave(roomId);
io.to(roomId).emit('user-left', socket.id);
});
});
Room은 서버에만 존재하는 개념입니다. 클라이언트는 자신이 어떤 Room에 속해 있는지 알 수 없고, 직접 Room에 들어갈 수도 없습니다. 그래서 예제처럼 클라이언트가 join-room 이벤트로 요청하면 서버가 socket.join()을 호출하는 구조가 됩니다. 이 예제는 요청받은 roomId를 그대로 믿기 때문에, 누구든 임의의 방 이름을 보내 다른 사람의 대화방에 들어갈 수 있습니다. 실제 서비스에서는 join 전에 이 사용자가 해당 방의 멤버인지 DB로 확인해야 합니다. room-message도 마찬가지로 보낸 사람이 그 Room에 실제로 속해 있는지(socket.rooms.has(roomId)) 확인하지 않으면, 방에 들어가지 않고도 메시지를 뿌릴 수 있습니다.
io.to(roomId).emit()은 보낸 사람 자신에게도 메시지가 전달되고, socket.to(roomId).emit()은 자신을 제외합니다. 채팅에서 내 메시지가 두 번 표시되는 문제는 보통 입력 즉시 화면에 추가한 뒤 io.to()로 돌아온 메시지를 한 번 더 추가해서 생깁니다. 모든 소켓은 연결 즉시 자신의 socket.id와 같은 이름의 Room에 자동으로 들어가 있으므로, io.to(socketId).emit()으로 특정 사용자 한 명에게 보낼 수도 있습니다.
Broadcasting
// 모든 클라이언트에게
io.emit('broadcast', 'Hello everyone!');
// 자신 제외 모든 클라이언트에게
socket.broadcast.emit('broadcast', 'Hello others!');
// 특정 Room에게
io.to('room1').emit('message', 'Hello room1!');
// 여러 Room에게
io.to('room1').to('room2').emit('message', 'Hello!');
// 자신 제외 Room에게
socket.to('room1').emit('message', 'Hello room1!');
io로 시작하는 호출은 서버 전체(정확히는 기본 Namespace) 기준이고, socket으로 시작하는 호출은 “이 소켓을 제외하고”라는 의미가 붙습니다. io.to('room1').to('room2')는 두 Room의 합집합에 보내며, 두 방에 모두 속한 소켓도 메시지를 한 번만 받습니다. 특정 Room을 제외하려면 io.except('room1').emit()을 씁니다.
io.emit()은 연결된 모든 클라이언트에게 보내므로 접속자가 많을 때 비용이 큽니다. 1만 명이 접속한 상태에서 누군가 타이핑할 때마다 전체 브로드캐스트를 하면 초당 수십만 개의 메시지가 나갑니다. 브로드캐스트 대상은 가능한 한 Room으로 좁히고, 타이핑 표시나 커서 위치처럼 빈번한 이벤트는 클라이언트에서 쓰로틀링해 보내는 것이 기본입니다.
Namespace
// 서버
const chatNamespace = io.of('/chat');
const adminNamespace = io.of('/admin');
chatNamespace.on('connection', (socket) => {
console.log('Chat connected:', socket.id);
});
adminNamespace.on('connection', (socket) => {
console.log('Admin connected:', socket.id);
});
// 클라이언트
const chatSocket = io('http://localhost:3001/chat');
const adminSocket = io('http://localhost:3001/admin');
Namespace는 하나의 연결 위에서 논리적으로 분리된 통신 채널입니다. 클라이언트가 /chat과 /admin에 동시에 접속해도 실제 연결(WebSocket)은 하나만 열리고 그 위에서 다중화됩니다. Room과 다른 점은 Namespace마다 미들웨어와 이벤트 핸들러를 따로 둘 수 있다는 것입니다. 예를 들어 /admin Namespace에만 관리자 권한 검사 미들웨어를 걸면, 일반 사용자는 연결 단계에서 거부됩니다. 반면 Room은 같은 Namespace 안에서 전송 대상을 나누는 용도라, 권한 경계에는 Namespace를, 채팅방·문서 같은 동적 그룹에는 Room을 쓰는 것이 일반적입니다.
주의할 점은 io.use()로 등록한 미들웨어가 기본 Namespace(/)에만 적용된다는 것입니다. 6장의 인증을 io.use()로만 걸어 두고 /admin Namespace를 추가하면 그 Namespace는 인증 없이 접속됩니다. 각 Namespace에 adminNamespace.use(...)를 따로 등록해야 합니다.
인증
서버
io.use((socket, next) => {
const token = socket.handshake.auth.token;
if (verifyToken(token)) {
next();
} else {
next(new Error('Authentication error'));
}
});
io.on('connection', (socket) => {
console.log('Authenticated user:', socket.id);
});
미들웨어는 연결이 확립되기 전, 핸드셰이크 단계에서 실행됩니다. next()를 호출하면 연결이 허용되고 next(new Error(...))를 호출하면 거부되며, 그 에러 메시지가 클라이언트의 connect_error 이벤트로 전달됩니다. 검증에 성공했다면 socket.data.user = decoded처럼 사용자 정보를 저장해 두어야 이후 이벤트 핸들러에서 “누가 보냈는지”를 알 수 있습니다.
토큰을 URL 쿼리 문자열(?token=...)로 보내는 방식도 가능하지만, 쿼리 문자열은 프록시와 서버 액세스 로그에 그대로 기록되므로 auth 옵션을 쓰는 편이 안전합니다. 또 인증은 연결 시점에 한 번만 실행된다는 점을 기억해야 합니다. 연결이 몇 시간 유지되는 동안 JWT가 만료되거나 사용자가 차단되어도 기존 연결은 그대로 살아 있으므로, 민감한 이벤트 핸들러에서는 권한을 다시 확인하거나 토큰 만료 시각에 맞춰 서버가 socket.disconnect()로 연결을 끊는 처리가 필요합니다.
클라이언트
const socket = io('http://localhost:3001', {
auth: {
token: 'your-jwt-token',
},
});
socket.on('connect_error', (error) => {
console.error('Connection error:', error.message);
});
미들웨어에서 연결을 거부하면 클라이언트는 자동으로 재연결하지 않습니다. 토큰을 갱신한 뒤 다시 연결하려면 socket.auth.token = newToken; socket.connect();처럼 직접 호출해야 합니다. auth를 함수(auth: (cb) => cb({ token: getToken() }))로 넘기면 재연결할 때마다 최신 토큰을 읽어 가므로 토큰 갱신이 있는 앱에서는 이 형태가 편합니다.
React 통합
// hooks/useSocket.ts
import { useEffect, useState } from 'react';
import { io, Socket } from 'socket.io-client';
export function useSocket() {
const [socket, setSocket] = useState<Socket | null>(null);
useEffect(() => {
const newSocket = io('http://localhost:3001');
setSocket(newSocket);
return () => {
newSocket.close();
};
}, []);
return socket;
}
// components/Chat.tsx
export default function Chat() {
const socket = useSocket();
const [messages, setMessages] = useState<string[]>([]);
const [input, setInput] = useState('');
useEffect(() => {
if (!socket) return;
socket.on('message', (message) => {
setMessages((prev) => [...prev, message]);
});
return () => {
socket.off('message');
};
}, [socket]);
const sendMessage = () => {
socket?.emit('message', input);
setInput('');
};
return (
<div>
<div>
{messages.map((msg, i) => (
<div key={i}>{msg}</div>
))}
</div>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button onClick={sendMessage}>Send</button>
</div>
);
}
useSocket 훅은 컴포넌트가 마운트될 때 연결을 만들고 언마운트될 때 닫습니다. 이 구조의 한계는 훅을 쓰는 컴포넌트마다 연결이 하나씩 생긴다는 점입니다. 여러 컴포넌트가 같은 소켓을 써야 한다면 모듈 최상위에서 소켓을 하나 만들어 두거나(autoConnect: false로 만들고 필요할 때 connect()), Context로 공유하는 편이 낫습니다. 개발 모드의 React StrictMode는 effect를 의도적으로 두 번 실행하므로, 서버 로그에 연결과 해제가 한 번씩 더 찍히는 것은 정상입니다. 정리 함수에서 close()를 제대로 호출하고 있다는 신호이기도 합니다.
Chat 컴포넌트의 정리 함수에 있는 socket.off('message')는 핸들러를 지정하지 않았기 때문에, 다른 컴포넌트가 등록한 message 리스너까지 모두 제거합니다. 핸들러를 변수로 빼서 socket.off('message', handler)로 해제하는 것이 안전합니다. 반대로 정리 함수를 빠뜨리면 컴포넌트가 다시 마운트될 때마다 리스너가 쌓여, 메시지 하나가 화면에 두세 번씩 추가되는 증상이 나타납니다. Socket.IO를 React에 붙일 때 가장 자주 보는 버그가 이 리스너 중복입니다. 또 예제는 메시지 배열 인덱스를 key로 쓰는데, 목록 앞쪽에 항목이 삽입되는 구조라면 서버가 준 메시지 ID를 쓰는 것이 좋습니다.
Redis Adapter (확장성)
서버가 한 대일 때는 모든 소켓이 같은 프로세스 메모리에 있으므로 io.to(room).emit()이 그 방의 모든 사용자에게 도달합니다. 서버를 두 대로 늘리면 A 서버에 연결된 사용자가 보낸 메시지는 A 서버의 소켓에게만 전달되고, B 서버에 연결된 같은 방 사용자는 받지 못합니다. Adapter는 이 브로드캐스트를 다른 서버로 전파하는 계층이고, Redis Adapter는 Redis Pub/Sub을 통해 모든 서버에 이벤트를 중계합니다.
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
const pubClient = createClient({ url: 'redis://localhost:6379' });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
클라이언트 두 개를 만드는 이유는 Redis 연결이 SUBSCRIBE 상태가 되면 일반 명령을 보낼 수 없기 때문입니다. 발행용과 구독용 연결을 분리해야 합니다. 최상위 await는 ES 모듈 환경에서만 동작하므로, CommonJS라면 async 함수 안에서 연결한 뒤 어댑터를 설정합니다.
Redis Adapter만으로 확장이 끝나지는 않습니다. 앞에서 말한 롱폴링 단계 때문에, 로드밸런서가 같은 클라이언트의 요청을 매번 다른 서버로 보내면 Session ID unknown 에러와 함께 연결이 반복해서 끊깁니다. Nginx의 ip_hash나 쿠키 기반 스티키 세션으로 한 클라이언트를 한 서버에 고정하거나, 클라이언트를 transports: ['websocket']으로 설정해 폴링을 쓰지 않아야 합니다. Nginx를 앞에 둘 때는 proxy_set_header Upgrade $http_upgrade;와 Connection "upgrade" 헤더 설정이 없으면 WebSocket 업그레이드가 실패하고 폴링으로만 동작하는데, 에러 없이 조용히 느려지기 때문에 놓치기 쉽습니다.
정리 및 체크리스트
핵심 요약
- Socket.IO: 실시간 양방향 통신
- Room: 그룹 통신
- Broadcasting: 다중 전송
- Namespace: 논리적 분리
- 인증: 토큰 기반
- Redis Adapter: 확장성
구현 체크리스트
- Socket.IO 설치
- 서버 구현
- 클라이언트 구현
- Room 구현
- Broadcasting 구현
- 인증 구현
- React 통합
같이 보면 좋은 글
자주 묻는 질문 (FAQ)
Q. WebSocket과 비교하면 어떤가요?
A. Socket.IO는 재연결, Room, acknowledgement, 폴링 폴백을 제공하는 대신 자체 프로토콜을 쓰므로 양쪽 모두 Socket.IO 라이브러리가 필요합니다. 순수 WebSocket은 표준이라 어떤 언어의 클라이언트와도 통신할 수 있고 메시지 오버헤드가 작지만, 위 기능은 직접 만들어야 합니다.
Q. 확장성은 어떤가요?
A. Redis Adapter로 서버 간 브로드캐스트를 공유하고, 롱폴링을 쓴다면 로드밸런서에 스티키 세션을 설정해야 여러 서버로 늘릴 수 있습니다.
Q. 모바일 앱에서도 사용할 수 있나요?
A. React Native에서는 socket.io-client를 그대로 쓸 수 있고, Swift·Kotlin·Dart 등에도 클라이언트 라이브러리가 있습니다. 앱이 백그라운드로 가면 운영체제가 연결을 끊는 경우가 많으므로, 백그라운드 알림은 푸시 알림으로 처리해야 합니다.
Q. Socket.IO 서버를 배포하기 전에 무엇을 점검해야 하나요?
A. Room 참가 권한 검사, Namespace별 인증 미들웨어, 메시지 누락 대비 저장·재조회, 프록시의 Upgrade 헤더와 타임아웃 설정을 배포 전에 점검해야 합니다.