Socket.IO에서 가장 기본이 되는 통신 방식은 이벤트를 주고받는 것이다. 한쪽에서 emit()으로 이벤트를 보내고, 다른 쪽에서는 on()으로 해당 이벤트를 수신해 처리한다.
// 서버 -> 클라이언트io.on('connection', (socket) => {socket.emit('hello', 'world');});// 클라이언트socket.on('hello', (message) => {console.log(message); // 'world'});
이벤트를 발송할 때는 여러 인수를 함께 전달할 수 있으며, 숫자 / 문자열 / 객체는 물론 Buffer, TypedArray 같은 바이너리 데이터도 지원된다.
socket.emit('user:action', 'move', { x: 10, y: 20 }, 3);
실시간 통신에서도 전통적인 요청-응답 패턴이 필요한 경우가 있다. Socket.IO에서는 이를 Acknowledgements 라고 부르며, emit() 메서드의 마지막 인수로 콜백 함수를 전달해 상대방이 이벤트를 처리했는지 확인할 수 있다.
채팅 메세지 알림처럼 다소 유실되어도 되는 이벤트보다는, 주문 생성, 결제 처리, 중요한 상태 변경 등 실제로 처리되었는지를 확인해야 하는 시나리오에 적합하다.
// 클라이언트socket.timeout(5000).emit('request', (err, response) => {if (err) {// 서버가 지정된 시간 내에 응답하지 않음return;}console
timeout() 메서드를 사용하면 일정 시간 내에 응답이 오지 않을 경우 에러를 분기 처리할 수 있으며, 이는 네트워크 지연이나 서버 문제로 인해 응답이 없는 상황에 대비하기 위한 안전장치로 쓰인다.
Promise 스타일을 선호한다면 emitWithAck() 메서드를 사용할 수 있다. 이 메서드는 상대방이 이벤트를 확인하면 resolve되고, 타임아웃 시 reject되는 Promise를 반환한다.
// 클라이언트try {const response = await socket.timeout(5000).emitWithAck('request');console.log(response.status); // 'ok'} catch (
async/await 문법을 사용하면 비동기 흐름을 동기 코드처럼 읽기 쉬운 형태로 작성할 수 있고, 에러 처리도 try-catch로 일관성 있게 관리할 수 있다. 특히 서비스 레이어나 테스트 코드에서는 emitWithAck 패턴을 기본으로 두면, 여러 단계의 비동기 로직을 순차적인 비즈니스 로직처럼 표현할 수 있어 가독성이 크게 향상된다.
Socket.IO 클라이언트 소켓은 연결 과정에서 connect, connect_error, disconnect 세 가지 핵심 이벤트를 발생시킨다. 이 이벤트들을 적절히 처리하면 일시적인 네트워크 문제와 영구적인 종료를 구분하여 안정적인 실시간 통신 흐름을 만들 수 있다.

connect 이벤트는 소켓이 서버와 성공적으로 연결되었을 때 발생하며, 최초 연결뿐 아니라 재연결 시에도 다시 발생한다. 이 시점에 사용자 정보 전송, Room 참여, 초기 데이터 패칭 등 연결 직후 한 번 수행해야 하는 초기화 작업을 실행할 수 있다.
socket.on('connect', () => {console.log('Connected to server');});
다만 이벤트 핸들러(socket.on('data', ...) 등)를 connect 내부에서 등록하면 재연결 때마다 중복 등록되므로, 핸들러는 바깥에서 한 번만 등록해야 한다.
/** 잘못된 예시 */socket.on('connect', () => {socket.on('data', () => {// 재연결할 때마다 새로운 핸들러가 추가됨});});
연결에 실패하면 connect_error 이벤트가 발생하며, 이유에 따라 자동 재연결 여부가 달라진다.
| 이유 | 설명 | 자동 재연결 |
| 저수준 연결을 설정할 수 없는 경우 | 일시적 네트워크 오류, 서버 일시 다운 등 | O |
| 서버가 미들웨어에서 연결을 명시적으로 거부한 경우 | 인증 토큰 만료, 권한 부족 등 | X |
socket.active 값이 true면 짧은 랜덤 지연 후 자동 재연결을 시도하고, false면 서버가 연결을 거부한 상태이므로 필요 시 socket.connect()를 직접 호출해야 한다.
socket.on('connect_error', (error) => {if (socket.active) {// 일시적 오류, 소켓이 자동으로 재연결 시도console.log('일시적 연결 오류, 재시도 중...');} else {
연결이 끊어지면 disconnect 이벤트가 발생하며, reason 인자로 끊어진 이유를 전달한다.
socket.on('disconnect', (reason, details) => {console.log('Disconnected:', reason);});
연결이 끊어지는 이유는 다양하다. 각 이유에 따라 자동 재연결 여부가 달라진다.
| 이유 | 설명 | 자동 재연결 |
| io server disconnect | 서버가 socket.disconnect()로 소켓을 강제로 종료 | X |
| io client disconnect | 클라이언트가 socket.disconnect()를 호출해 수동 종료 | X |
| ping timeout | pingInterval + pingTimeout 내에 ping/pong 교환 실패 (네트워크 문제, 서버 과부하 등) | O |
| transport close | 물리적 연결이 끊김 (네트워크 단절, Wi‑Fi ↔ LTE 전환 등) | O |
| transport error |
socket.active 속성을 통해 자동 재연결 여부를 판단하는 패턴은 connect_error 이벤트와 동일하며, 이를 활용하면 일시적인 장애와 영구적인 종료를 구분해 적절히 대응할 수 있다.
socket.on('disconnect', (reason) => {if (socket.active) {// 일시적 연결 끊김, 자동으로 재연결 시도console.log('일시적 연결 끊김:', reason);} else {
각 새 연결에는 무작위 20자 길이의 ID가 할당되며, 서버와 클라이언트에서 동일한 값으로 동기화된다.
console.log(socket.id); // "G5p5..."
다만 연결 상태 복구 기능을 사용하지 않는 한, 이 ID는 재연결할 때마다 바뀌는 매우 일시적인 값이다. WebSocket 연결이 끊기거나 사용자가 페이지를 새로고침하면 완전히 다른 ID가 부여되며, 같은 사용자라도 브라우저 탭이 다르면 서로 다른 ID를 갖는다.
서버는 특정 ID에 대한 메시지 큐를 유지하지 않으므로, 클라이언트가 끊긴 뒤 그 ID로 전송된 메시지는 모두 손실된다. 따라서 socket.id는 디버깅이나 현재 연결 세션 범위에서만 사용하고, 사용자 식별에는 JWT, 세션 쿠키, 별도의 유저 ID 같은 다른 식별자를 사용하는 것이 좋다.
현재 소켓이 서버와 연결되어 있는지 나타내는 boolean 값이다.
if (socket.connected) {socket.emit('message', data);} else {console.log('연결되지 않음');}
기본적으로 연결이 끊긴 상태에서 emit을 호출하면 이벤트가 내부 버퍼에 쌓였다가 재연결 시 한꺼번에 전송된다. 이런 버퍼링을 피하고 싶다면 socket.connected를 먼저 확인한 뒤, 끊겨 있을 때는 전송을 건너뛰거나, 애플리케이션 레벨 큐에 저장해 두었다가 사용자가 재시도 버튼을 눌렀을 때만 다시 보내는 식으로 제어할 수 있다.
소켓이 자동으로 재연결을 시도할지 여부를 나타낸다. connect_error나 disconnect 이벤트 핸들러 안에서 이 값을 확인하면 일시적 문제인지, 서버/클라이언트가 의도적으로 연결을 끊은 영구적 종료인지 구분할 수 있다.
socket.on('disconnect', (reason) => {if (socket.active) {// 일시적 끊김, 자동 재연결 시도} else {// 서버 또는 클라이언트가 강제로 종료// 재연결하려면 socket.connect()를 수동으로 호출해야 함}
Socket.IO에서는 클라이언트가 항상 연결된 상태가 아니며, 서버도 기본적으로 이벤트를 저장하지 않는다. 따라서 클라이언트가 잠시 끊겼다가 다시 연결되면 그 사이에 발생한 이벤트는 그대로 손실된다.
이 문제를 완화하기 위해 Connection State Recovery 기능을 제공한다. 이 기능을 활성화하면 서버가 일정 시간 동안 세션과 패킷을 임시로 저장하고, 클라이언트가 다시 연결될 때 상태를 복원하려 시도한다.
const io = new Server(server, {connectionStateRecovery: {}});
이 기능이 켜져 있으면, 일시적인 연결 끊김 후 재연결 시 아래와 같은 복원이 가능하다.
# 참고# socket.disconnect()- 클라이언트가 의도적으로 연결을 끊은 것이므로 상태가 복구되지 않는다.# socket.io.engine.close- 네트워크 연결이 끊긴 것처럼 시뮬레이션되어 복구가 이루어진다.
connectionStateRecovery에는 몇 가지 옵션을 줄 수 있다.
const io = new Server(server, {connectionStateRecovery: {maxDisconnectionDuration: 2 * 60 * 1000, // 2분skipMiddlewares: true, // 복구가 성공한 재연결 시 미들웨어 건너뛰기},
maxDisconnectionDuration
세션과 패킷을 얼마나 오래 보관할지, 즉 최대 몇 ms까지 끊긴 세션을 복구 대상으로 볼지 지정한다. 값이 너무 크면 어댑터(메모리/Redis)에 상태가 과도하게 쌓이고, 너무 작으면 조금만 오래 끊겨도 복구에 실패하므로 서비스 특성에 맞게 조정해야 한다.
skipMiddlewares
복구에 성공했을 때 서버 미들웨어(인증, 권한 체크 등)를 다시 타지 않고 바로 이전 상태를 이어갈지 여부를 결정한다. 민감한 서비스라면 false로 두고 재검증하는 것도 고려해야 한다.
복구가 실제로 성공했는지는 socket.recovered로 확인할 수 있다.
// 서버io.on('connection', (socket) => {if (socket.recovered) {// 복구 성공: socket.id, socket.rooms, socket.data가 복원됨} else {// 신규 세션 또는 복구 불가 세션}
이 기능 덕분에 사용자가 엘리베이터나 지하철 등으로 인해 잠시 네트워크가 끊겨도, 허용된 시간(maxDisconnectionDuration) 안에 다시 연결되면 놓쳤던 메시지를 이어받을 수 있어 채팅 초기화나 메시지 누락을 크게 줄일 수 있다.
다만 서버 크래시 / 재시작처럼 세션 자체가 사라지는 경우에는 복구가 항상 보장되지는 않으므로, 최종 상태 동기화(예: REST로 최신 스냅샷 재요청) 로직은 별도로 두는 것이 권장된다.
클라이언트 연결이 끊어진 상태에서 socket.emit()을 호출하면, 기본 설정에서는 그 이벤트가 내부 버퍼에 쌓였다가 재연결 시 한꺼번에 전송된다.
// 연결이 끊긴 상태에서도 emit 가능socket.emit('message', 'Hello'); // 버퍼에 저장됨socket.emit('message', 'World'); // 버퍼에 저장됨// 재연결되면 두 메시지가 순서대로 전송됨
이 동작은 재연결 지연이 짧은 대부분의 애플리케이션에는 충분하지만, 다음과 같은 경우에는 메시지가 여전히 손실될 수 있다.
또한 연결이 복구될 때 버퍼에 쌓여 있던 이벤트가 한꺼번에 전송되면서 스파이크가 생길 수 있다. 연결 상태를 보고 직접 제어하고 싶다면 socket.connected를 사용할 수 있다.
if (socket.connected) {socket.emit('message', data);} else {// 연결이 끊긴 상태에서는 발송하지 않음console.log('연결 끊김, 메시지 저장 필요');}
이 밖에도, 상황에 따라 다음과 같은 전략을 쓸 수 있다.
Socket.IO 클라이언트를 사용하려면 먼저 패키지를 설치한다.
npm install socket.io-client
socket.io-client 패키지에는 TypeScript 타입 정의가 포함되어 있으므로, 예전처럼 @types/socket.io-client를 따로 설치할 필요가 없다. 오히려 둘을 함께 설치하면 타입이 중복되어 컴파일 오류가 발생할 수 있으니 @types/socket.io-client는 설치하지 않는 것이 좋다.
프론트엔드와 서버가 같은 도메인에서 제공된다면 URL을 생략하고 바로 초기화할 수 있다. Socket.IO는 window.location을 기반으로 자동으로 서버 주소를 추론하며, 페이지가 https://example.com에서 제공되면 동일한 호스트로 연결을 시도한다. 포트만 달라도 호스트명이 같다면 이 방식을 그대로 사용할 수 있다.
const socket = io();
프론트엔드와 백엔드가 다른 도메인에 있다면 서버 URL을 명시해야 한다.
const socket = io('https://server-domain.com');
이때 서버 측에서는 CORS를 활성화해야 브라우저가 연결을 허용한다.
// 서버 측const io = new Server(server, {cors: {origin: 'https://client-domain.com',methods: ['GET', 'POST']}});
프로토콜은 https와 wss 둘 다 사용할 수 있으며, 둘은 보안 연결이라는 점에서 동일하게 동작한다.
// 다음 방식들은 모두 동일하게 작동const socket = io('https://server-domain.com');const socket = io('wss://server-domain.com');const socket = io('server-domain.com'); // 페이지가 https로 제공될 때만 가능
세 번째처럼 프로토콜을 생략하면 현재 페이지의 프로토콜을 따른다. 이 방식은 브라우저 환경에서만 동작하며, Node.js 환경에서는 명시적인 URL을 사용하는 것이 필요하다. 또한 페이지가 https로 제공될 때만 wss 업그레이드가 가능하고, http 페이지에서는 ws로 연결된다.
| 전송 계층에서 오류 발생 (예: HTTP 롱폴링 중 서버 종료) |
| O |