Socket.IO 실시간 통신: Room과 Broadcasting, Namespace, 인증, React 연동, Redis Adapter
이 글의 핵심
Socket.IO로 이벤트를 주고받는 기본부터 Room과 Namespace로 대상을 나누는 법, 연결 시 인증, React에서 소켓 수명 관리, 서버를 여러 대로 늘릴 때 Redis Adapter가 필요한 이유를 다룹니다.
실무에서 마주치는 문제들
클라이언트가 몇 초마다 “새 메시지 있나요?”라고 묻는 폴링은 대부분의 요청이 빈 응답으로 끝나면서도 매번 HTTP 헤더와 인증 처리 비용을 치릅니다. 간격을 줄이면 서버 부하가 늘고, 늘리면 메시지가 늦게 도착합니다. 연결을 하나 열어 두고 서버가 필요할 때 먼저 보내는 방식이 이 딜레마를 없앱니다.
브라우저의 WebSocket API 자체는 단순하지만, 실제 서비스에 쓰려면 연결이 끊겼을 때의 재연결과 지수 백오프, 끊긴 연결을 감지하는 하트비트, “어떤 메시지에 대한 응답인지” 짝을 맞추는 요청-응답 구조, 사용자 그룹별 전송을 전부 직접 만들어야 합니다. Socket.IO는 이런 기능을 이벤트 이름 기반 API로 감싸 제공합니다.
채팅방마다 참가자 연결 목록을 관리하고, 연결이 끊기면 목록에서 빼는 코드를 직접 짜면 누락되기 쉽습니다. Socket.IO의 Room은 서버 쪽에서 소켓을 이름 붙은 그룹에 넣고 빼는 기능이며, 연결이 끊기면 자동으로 모든 Room에서 빠집니다.
한 가지 오해를 먼저 풀어야 합니다. Socket.IO는 WebSocket 구현체가 아니라 WebSocket(또는 HTTP 롱폴링) 위에서 돌아가는 자체 프로토콜입니다. 그래서 Socket.IO 서버에 브라우저의 순수 new WebSocket()이나 다른 언어의 표준 WebSocket 클라이언트로 접속하면 연결은 되더라도 메시지를 주고받을 수 없습니다. 반드시 Socket.IO 클라이언트 라이브러리를 써야 하고, 서버와 클라이언트의 메이저 버전도 맞아야 합니다.
Socket.IO의 연결 방식: 롱폴링에서 WebSocket으로
Socket.IO는 실시간 양방향 통신 라이브러리입니다.
그림: Socket.IO는 먼저 HTTP로 연결하고 WebSocket이 가능하면 올라가며, 불가능하면 롱폴링으로 계속 동작한다.
연결 과정을 보면 Fallback의 의미가 드러납니다. Socket.IO 클라이언트는 기본적으로 먼저 HTTP 롱폴링으로 연결을 맺고, 가능하면 WebSocket으로 업그레이드합니다. 회사 프록시나 방화벽이 WebSocket을 막는 환경에서도 연결 자체는 유지되는 이유입니다. 대신 폴링 단계가 있기 때문에 서버를 여러 대로 늘리면 같은 클라이언트의 요청이 항상 같은 서버로 가도록 하는 스티키 세션이 필요해집니다(‘Redis Adapter (확장성)’ 절에서 다룹니다). WebSocket만 쓰도록 transports: ['websocket']을 지정하면 이 제약은 사라지지만 폴백도 함께 사라집니다.
이 동작을 Socket.IO 4.8.4(Node 26)로 서버와 클라이언트를 한 프로세스에 띄워 확인했습니다. 서버의 connection 시점에 socket.conn.transport.name은 polling이었고 곧이어 upgrade 이벤트로 websocket이 되었으며, 1.5초 뒤 클라이언트의 전송 방식도 websocket이었습니다. transports: ['polling']으로 제한하면 업그레이드 없이 polling에 머물렀습니다. 연결 전에 emit('message', 'early', callback)을 호출했을 때 c.connected는 false였지만 연결 후 이벤트가 전송되어 ack 콜백이 {ok: true, echo: 'early'}로 돌아왔습니다. Room은 서버에만 있다는 설명도 맞았습니다. 클라이언트가 join-room을 보내자 서버의 adapter.rooms에 r1이 생겼고, 소켓 ID 이름의 Room도 하나씩 자동으로 있었으며, 클라이언트 객체에는 rooms 속성이 없었습니다. (환경: macOS 27.0.1, localhost. 프록시나 로드밸런서 뒤의 동작은 확인하지 않았습니다.)
설치 및 기본 사용
설치
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로 쓰면 안 됩니다. 사용자를 식별하려면 ‘인증’ 절의 인증 단계에서 얻은 사용자 정보를 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(/)에만 적용된다는 것입니다. ‘인증’ 절에서 설정한 인증을 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() }))로 넘기면 재연결할 때마다 최신 토큰을 읽어 가므로 토큰 갱신이 있는 앱에서는 이 형태가 편합니다.
Room·Namespace·인증 동작 확인
앞 절의 설명을 socket.io 4.8.3과 socket.io-client 4.8.3(Node 26, 한 프로세스에 서버와 클라이언트를 띄움)으로 직접 실행해 대조했습니다.
io.to('r1').emit()은 보낸 소켓 자신에게도 전달됐고,socket.to('r1').emit()은 자신을 제외했습니다. 서버에서socket.rooms를 출력하면 항목이 2개였고 자신의socket.id와r1이 들어 있었습니다.io.to('r1').to('r2').emit()은 두 방에 모두 속한 소켓에 한 번만 도착했고,io.except('r1').emit()은r1에 속한 소켓을 빼고 보냈습니다. 재연결하면socket.id가 바뀌었습니다.- 연결 전에 호출한
emit('echo', 'early', 콜백)은 연결 뒤에 전송되어 콜백이Echo early를 받았습니다. 응답하지 않는 이벤트에socket.timeout(300).emit(...)을 쓰면 콜백의 첫 인자로operation has timed out이 왔습니다. io.use()로 토큰 검사를 걸어도/adminNamespace에는 적용되지 않아, 토큰 없이{"token":"whatever"}만 보낸 클라이언트의 연결이 그대로 성공했습니다./chat과/admin에 각각io()를 호출한 두 클라이언트 소켓은 같은Manager를 공유했고(chat.io === admin.io), 서버의io.engine.clientsCount는 1이었습니다.forceNew: true로 하나를 더 만들면 2가 됐습니다.- 기본 Namespace 미들웨어가
next(new Error('Authentication error'))로 거부하면 클라이언트의connect_error로 같은 메시지가 왔고, 2.5초 동안 자동 재연결이 일어나지 않았으며(socket.active가false),socket.auth.token을 바꾸고socket.connect()를 호출하자 연결됐습니다.auth를 함수로 넘기면 엔진 연결을 강제로 끊은 뒤 재연결할 때 함수가 다시 호출됐습니다(총 2회). - 브라우저에서의 CORS 오류와
socket.volatile, Redis Adapter는 이 실험에서 실행하지 않았습니다.
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 업그레이드가 실패하고 폴링으로만 동작하는데, 에러 없이 조용히 느려지기 때문에 놓치기 쉽습니다.
참고 자료
자주 묻는 질문 (FAQ)
WebSocket과 비교하면 어떤가요?
Socket.IO는 재연결, Room, acknowledgement, 폴링 폴백을 제공하는 대신 자체 프로토콜을 쓰므로 양쪽 모두 Socket.IO 라이브러리가 필요합니다. 순수 WebSocket은 표준이라 어떤 언어의 클라이언트와도 통신할 수 있고 메시지 오버헤드가 작지만, 위 기능은 직접 만들어야 합니다.
직접 확인한 와이어 수준의 차이입니다. macOS의 Node.js 26.8, socket.io·socket.io-client 4.8.4, ws로 로컬 서버를 만들어 보면 이렇습니다.
- 자체 프로토콜: 순수
ws클라이언트로ws://localhost:3200/socket.io/?EIO=4&transport=websocket에 접속하면 서버가 먼저0{"sid":"…","upgrades":[],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000}형태의 텍스트 프레임을 보냈습니다. 네임스페이스 연결은40을 보내야40{"sid":"…"}로 완료되고, 이벤트는42["chat","hello"]처럼 숫자 접두사와 JSON 배열로 오갔습니다. 순수 WebSocket에서hello는 5바이트인데 이 이벤트 프레임은chat이름과 따옴표·대괄호까지 18바이트였습니다. 이벤트 이름과 JSON 형식을 쓰는 대가입니다. - 핸드셰이크 순서: 기본
transports로 접속하면 서버가 본 요청이GET /socket.io/?EIO=4&transport=polling,POST ...&sid=…,UPGRADE(WebSocket) ...&sid=…,GET ...polling&sid=…순서였고 최종 transport는websocket이었습니다. 처음에는 HTTP 롱 폴링으로 시작해 WebSocket으로 올라간 것입니다.transports: ['websocket']로 지정하면 WebSocket 업그레이드 요청 하나만 왔습니다. - acknowledgement:
emit('chat', 'hi', callback)에 서버가ack('got hi')로 응답하자 콜백이got hi를 받았습니다. 이런 요청·응답 형태는 순수 WebSocket에서는 직접 요청 ID를 설계해야 합니다.
확장성은 어떤가요?
Redis Adapter로 서버 간 브로드캐스트를 공유하고, 롱폴링을 쓴다면 로드밸런서에 스티키 세션을 설정해야 여러 서버로 늘릴 수 있습니다.
직접 확인했습니다. macOS의 Node.js 26.8, socket.io·socket.io-client 4.8.4, ws로 서버 두 대(포트 3200, 3201)를 띄우고, 3200에서 폴링으로 받은 sid를 3201에 보내 보면 400과 {"code":1,"message":"Session ID unknown"}이 돌아왔습니다. 폴링 연결은 서버가 메모리에 세션을 들고 있어서, 같은 클라이언트의 요청이 다른 서버로 가면 이런 오류가 납니다. 그래서 폴링을 허용한다면 로드밸런서에서 스티키 세션이 필요하고, transports: ['websocket']로 제한하면 연결 하나가 한 서버에 계속 붙어 있으므로 이 문제가 줄어듭니다. Redis 어댑터로 서버 간 브로드캐스트를 공유하는 동작은 이번에 실행하지 않았습니다.
모바일 앱에서도 사용할 수 있나요?
React Native에서는 socket.io-client를 그대로 쓸 수 있고, Swift·Kotlin·Dart 등에도 클라이언트 라이브러리가 있습니다. 앱이 백그라운드로 가면 운영체제가 연결을 끊는 경우가 많으므로, 백그라운드 알림은 푸시 알림으로 처리해야 합니다.
Socket.IO 서버를 배포하기 전에 무엇을 점검해야 하나요?
Room 참가 권한 검사, Namespace별 인증 미들웨어, 메시지 누락 대비 저장·재조회, 프록시의 Upgrade 헤더와 타임아웃 설정을 배포 전에 점검해야 합니다.