Socket.IO 클라이언트 채팅
웹 애플리케이션에서 실시간 채팅을 구현하려면, 클라이언트와 서버가 양방향으로 즉시 데이터를 주고받을 수 있어야 한다. HTTP는 기본적으로 요청-응답 모델이라, 서버가 먼저 클라이언트에게 데이터를 보낼 수 없다. 이 한계를 극복하기 위해 WebSocket이 등장했고, Socket.IO는 WebSocket 위에 더 편리한 추상화 계층을 제공하는 라이브러리다.
WebSocket vs Socket.IO
WebSocket은 브라우저 내장 API로, new WebSocket('ws://...')만으로 양방향 통신을 열 수 있다. 그런데 실제 프로덕션에서 순수 WebSocket만 쓰면 몇 가지 문제가 생긴다.
// 순수 WebSocket
const ws = new WebSocket('ws://example.com/chat');
ws.onopen = () => { /* 연결됨 */ };
ws.onmessage = (event) => { /* 메시지 수신 */ };
ws.onclose = () => { /* 연결 끊김 — 재연결은? */ };
- 자동 재연결이 없다. 네트워크가 잠깐 끊기면 수동으로 재연결 로직을 구현해야 한다.
- 이벤트 구분이 없다. 모든 메시지가
onmessage하나로 들어오므로, 메시지 종류별 분기를 직접 처리해야 한다. - 폴백(fallback)이 없다. WebSocket을 지원하지 않는 환경(구형 프록시, 기업 방화벽 등)에서는 연결 자체가 실패한다.
Socket.IO는 이 문제들을 해결한다.
| 기능 | WebSocket | Socket.IO |
|---|---|---|
| 자동 재연결 | ❌ 직접 구현 | ✅ 내장 |
| 이벤트 기반 통신 | ❌ 단일 onmessage | ✅ on('eventName') |
| 전송 폴백 | ❌ WebSocket only | ✅ HTTP long-polling → WebSocket |
| 룸/네임스페이스 | ❌ 없음 | ✅ 내장 |
| 바이너리 전송 | ✅ 가능 | ✅ 자동 감지 |
다만 Socket.IO는 순수 WebSocket이 아니다. 자체 프로토콜을 사용하므로, Socket.IO 클라이언트는 반드시 Socket.IO 서버와 통신해야 한다. 순수 WebSocket 서버와는 호환되지 않는다.
설치와 기본 연결
npm install socket.io-client
import { io, Socket } from 'socket.io-client';
// 기본 연결
const socket: Socket = io('http://example.com', {
path: '/chat', // 서버의 Socket.IO 경로
transports: ['websocket'], // 전송 방식 지정
});
socket.on('connect', () => {
console.log('연결됨:', socket.id);
});
socket.on('disconnect', (reason) => {
console.log('연결 해제:', reason);
});
연결 옵션 상세
io() 함수의 두 번째 인자로 다양한 옵션을 설정할 수 있다.
const socket = io('http://example.com', {
path: '/chat',
transports: ['websocket'],
reconnection: true, // 자동 재연결 활성화 (기본값: true)
reconnectionAttempts: 5, // 최대 재연결 시도 횟수 (기본값: Infinity)
reconnectionDelay: 1000, // 첫 재연결까지 대기 시간 (ms)
reconnectionDelayMax: 5000, // 최대 재연결 대기 시간
timeout: 20000, // 연결 타임아웃
});
transports 옵션이 중요하다. 기본값은 ['polling', 'websocket']인데, 이 경우 처음에 HTTP long-polling으로 연결한 뒤 WebSocket으로 업그레이드한다. WebSocket을 지원하는 환경이 확실하다면 ['websocket']만 지정하는 게 초기 연결이 더 빠르다. 단, WebSocket이 막힌 환경에서는 연결이 실패하게 된다.
path 옵션은 HTTP 요청 경로를 지정한다. 기본값은 /socket.io/인데, 서버에서 다른 경로를 설정했다면 반드시 맞춰줘야 한다. Nginx 같은 리버스 프록시를 사용할 때도 이 경로로 WebSocket 업그레이드를 프록시해야 한다.
이벤트 기반 통신
Socket.IO의 핵심은 이벤트 기반 통신이다. emit으로 이벤트를 보내고, on으로 이벤트를 수신한다.
// 메시지 전송
socket.emit('message', { message: 'Hello!' });
// 메시지 수신
socket.on('message', (data) => {
console.log('받은 메시지:', data);
});
// 여러 이벤트를 각각 처리
socket.on('updateUserCount', (data) => {
console.log('현재 접속자:', data.userCount);
});
socket.on('chatHistory', (data) => {
console.log('이전 채팅 기록:', data);
});
순수 WebSocket에서는 ws.send(JSON.stringify({ type: 'message', data: ... }))처럼 보내고, onmessage에서 JSON.parse 후 type별로 분기해야 한다. Socket.IO는 이걸 이벤트 이름으로 깔끔하게 분리해준다.
요청-응답 패턴 (Acknowledgement)
Socket.IO는 HTTP의 요청-응답처럼, 이벤트에 대한 응답을 받을 수도 있다.
// 클라이언트: 콜백 함수를 세 번째 인자로 전달
socket.emit('getHistory', { page: 1 }, (response) => {
console.log('서버 응답:', response);
});
// 서버: 콜백을 호출해서 응답
socket.on('getHistory', (data, callback) => {
const history = getHistoryFromDB(data.page);
callback(history); // 클라이언트의 콜백이 실행됨
});
이 패턴은 채팅 기록 요청처럼 "보내고 → 응답받기"가 필요한 경우에 유용하다. 다만 단방향 이벤트(on/emit)와 달리, 서버가 응답하지 않으면 콜백이 영원히 호출되지 않으므로 타임아웃 처리를 고려해야 한다.
React에서 Socket.IO 관리
React에서 Socket.IO를 사용할 때 가장 중요한 문제는 소켓 인스턴스의 생명주기다. 컴포넌트가 마운트될 때 연결하고, 언마운트될 때 연결을 끊어야 한다. 이걸 제대로 안 하면 소켓이 중복 생성되거나, 언마운트 후에도 이벤트 리스너가 남아서 메모리 누수가 발생한다.
방법 1: Zustand 스토어로 관리
전역 상태 관리 라이브러리에 소켓 인스턴스와 채팅 상태를 함께 관리하는 방법이다.
import { io, Socket } from 'socket.io-client';
import { create } from 'zustand';
interface ChatMessage {
id: string;
message: string;
nickname: string;
timestamp: string;
}
interface ChatStore {
chatHistory: ChatMessage[];
userCount: number;
isLoading: boolean;
connect: () => void;
disconnect: () => void;
getHistory: () => void;
sendMessage: (message: string) => void;
}
export const useChatStore = create<ChatStore>((set) => {
let socket: Socket | null = null;
const initializeSocket = () => {
if (socket) return socket;
socket = io('http://example.com', {
path: '/chat',
transports: ['websocket'],
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
});
socket.on('connect', () => {
console.log('채팅 서버 연결됨');
});
socket.on('message', (data: ChatMessage) => {
set((state) => ({
chatHistory: [...state.chatHistory, data],
}));
});
socket.on('updateUserCount', (data: { userCount: number }) => {
set({ userCount: data.userCount });
});
socket.on('disconnect', () => {
console.log('채팅 서버 연결 해제');
});
return socket;
};
return {
chatHistory: [],
userCount: 0,
isLoading: true,
connect: () => {
if (socket) return;
initializeSocket();
},
disconnect: () => {
socket?.disconnect();
socket = null;
},
getHistory: () => {
if (!socket) {
initializeSocket();
}
socket!.emit('getHistory');
socket!.on('chatHistory', (data: ChatMessage[]) => {
set({ chatHistory: data, isLoading: false });
});
},
sendMessage: (message: string) => {
if (!socket) {
const newSocket = initializeSocket();
newSocket.on('connect', () => {
newSocket.emit('message', { message });
});
return;
}
socket.emit('message', { message });
},
};
});
이 방식의 장점은 소켓 인스턴스가 스토어 클로저 안에 하나만 존재한다는 것이다. 어떤 컴포넌트에서 connect()를 호출하든 같은 소켓을 사용하고, disconnect()로 명시적으로 정리할 수 있다.
컴포넌트에서는 이렇게 사용한다.
function ChatRoom() {
const { chatHistory, userCount, connect, disconnect, getHistory, sendMessage } = useChatStore();
const [input, setInput] = useState('');
useEffect(() => {
connect();
getHistory();
return () => disconnect();
}, []);
const handleSend = () => {
if (input.trim()) {
sendMessage(input);
setInput('');
}
};
return (
<div>
<div>접속자: {userCount}명</div>
<div>
{chatHistory.map((msg, i) => (
<div key={i}>
<strong>{msg.nickname}</strong>: {msg.message}
</div>
))}
</div>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button onClick={handleSend}>전송</button>
</div>
);
}
방법 2: 커스텀 훅으로 관리
스토어 없이 훅 안에서 소켓을 관리하는 방법도 있다.
import { useEffect, useRef, useState, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
interface ChatMessage {
message: string;
nickname: string;
}
function useChat(serverUrl: string) {
const socketRef = useRef<Socket | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [userCount, setUserCount] = useState(0);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
const socket = io(serverUrl, {
path: '/chat',
transports: ['websocket'],
});
socketRef.current = socket;
socket.on('connect', () => setIsConnected(true));
socket.on('disconnect', () => setIsConnected(false));
socket.on('message', (data: ChatMessage) => {
setMessages((prev) => [...prev, data]);
});
socket.on('updateUserCount', (data: { userCount: number }) => {
setUserCount(data.userCount);
});
// 클린업: 언마운트 시 연결 해제 + 리스너 정리
return () => {
socket.off('connect');
socket.off('disconnect');
socket.off('message');
socket.off('updateUserCount');
socket.disconnect();
};
}, [serverUrl]);
const sendMessage = useCallback((message: string) => {
socketRef.current?.emit('message', { message });
}, []);
return { messages, userCount, isConnected, sendMessage };
}
이 방식은 더 간결하지만, 훅을 사용하는 컴포넌트가 언마운트되면 소켓도 끊긴다. 채팅 UI가 하나의 페이지에만 있다면 이 방법이 더 적합하고, 여러 컴포넌트에서 같은 소켓 상태를 공유해야 한다면 Zustand 방식이 낫다.
이벤트 리스너 정리의 중요성
React에서 Socket.IO를 쓸 때 가장 흔한 실수는 이벤트 리스너를 정리하지 않는 것이다.
// ❌ 잘못된 예: 리스너가 계속 쌓임
useEffect(() => {
socket.on('message', (data) => {
setMessages(prev => [...prev, data]);
});
// cleanup 없음!
}, []);
// ✅ 올바른 예: 언마운트 시 리스너 제거
useEffect(() => {
const handleMessage = (data: ChatMessage) => {
setMessages(prev => [...prev, data]);
};
socket.on('message', handleMessage);
return () => {
socket.off('message', handleMessage);
};
}, []);
socket.on()은 호출할 때마다 새 리스너를 추가한다. cleanup 없이 컴포넌트가 리렌더링되면 같은 이벤트에 리스너가 여러 개 등록되어 메시지가 중복 처리된다. socket.off()로 특정 리스너를 제거하거나, socket.off('message')로 해당 이벤트의 모든 리스너를 제거할 수 있다.
재연결 전략
네트워크가 불안정한 환경에서는 재연결 설정이 중요하다.
const socket = io('http://example.com', {
reconnection: true,
reconnectionAttempts: 10, // 최대 10번 시도
reconnectionDelay: 1000, // 첫 재연결 1초 후
reconnectionDelayMax: 10000, // 최대 10초까지 간격 증가
randomizationFactor: 0.5, // 지터(jitter) 추가
});
reconnectionDelay와 reconnectionDelayMax 사이에서 지수 백오프(exponential backoff)가 적용된다. 첫 시도는 1초 후, 두 번째는 2초, 세 번째는 4초… 이런 식으로 간격이 늘어나며 reconnectionDelayMax에서 멈춘다. randomizationFactor는 여러 클라이언트가 동시에 재연결하는 "thundering herd" 문제를 완화한다.
재연결 관련 이벤트를 활용하면 사용자에게 상태를 보여줄 수 있다.
socket.io.on('reconnect_attempt', (attempt) => {
console.log(`재연결 시도 #${attempt}`);
showToast('서버 연결 중...');
});
socket.io.on('reconnect', (attempt) => {
console.log(`${attempt}번 만에 재연결 성공`);
showToast('연결 복구됨!');
// 재연결 후 채팅 기록 재요청
socket.emit('getHistory');
});
socket.io.on('reconnect_failed', () => {
console.log('재연결 포기');
showToast('서버에 연결할 수 없습니다');
});
주의할 점은, 재연결 이벤트는 socket.io (Manager 객체)에서 발생한다는 것이다. socket.on('reconnect')가 아니라 socket.io.on('reconnect')이다.
네임스페이스
Socket.IO는 네임스페이스로 하나의 서버에서 여러 통신 채널을 분리할 수 있다.
// 채팅용 소켓
const chatSocket = io('http://example.com/chat');
// 알림용 소켓
const notifSocket = io('http://example.com/notifications');
네임스페이스가 다르면 이벤트가 서로 간섭하지 않는다. 채팅 소켓의 message 이벤트와 알림 소켓의 message 이벤트는 완전히 별개다. 같은 서버에 여러 네임스페이스 연결을 만들어도, 내부적으로 하나의 WebSocket 연결을 공유하므로 추가 연결 비용이 들지 않는다(멀티플렉싱).
디버깅 팁
개발 중 Socket.IO의 내부 동작을 확인하려면 디버그 모드를 활성화한다.
// 브라우저 콘솔에서
localStorage.debug = 'socket.io-client:socket';
// 모든 로그 보기
localStorage.debug = '*';
이렇게 하면 콘솔에서 패킷 전송/수신, 연결 상태 변화, 재연결 시도 등을 실시간으로 확인할 수 있다.
크롬 개발자 도구의 Network 탭에서 "WS" 필터를 적용하면 WebSocket 프레임을 직접 확인할 수 있다. Socket.IO가 보내는 실제 패킷 형식을 보면 42["message",{"text":"hello"}] 같은 형태인데, 앞의 4는 Engine.IO의 메시지 타입, 2는 Socket.IO의 이벤트 타입을 나타낸다. 이 구조를 알면 통신 문제를 디버깅할 때 도움이 된다.
CORS 설정
프론트엔드와 백엔드의 도메인이 다르면 CORS 설정이 필요하다. 이건 서버 측 설정이지만, 클라이언트에서 연결이 안 될 때 가장 먼저 확인해야 할 부분이다.
// 서버 (NestJS 예시)
@WebSocketGateway({
cors: {
origin: ['http://localhost:3000', 'https://my-app.com'],
credentials: true,
},
})
export class ChatGateway { ... }
클라이언트에서 쿠키를 전송해야 하는 경우(예: 세션 기반 인증), withCredentials 옵션을 추가한다.
const socket = io('http://example.com', {
withCredentials: true,
});
정리
Socket.IO 클라이언트를 React에서 사용할 때 핵심은 세 가지다.
- 소켓 인스턴스를 하나만 유지하고, 컴포넌트 생명주기에 맞게 연결/해제한다.
- 이벤트 리스너를 반드시 정리해서 메모리 누수와 중복 처리를 방지한다.
- 재연결 전략을 설정해서 네트워크 불안정 상황에 대비한다.
전역 상태(Zustand, Context 등)로 소켓을 관리하면 여러 컴포넌트에서 안전하게 소켓 상태를 공유할 수 있고, 커스텀 훅으로 관리하면 단일 컴포넌트에서 간결하게 사용할 수 있다. 프로젝트의 채팅 구조 복잡도에 따라 선택하면 된다.