Socket.IO는 Engine.IO 엔진을 기반으로, 환경에 구애받지 않는 안정적인 실시간 양방향 통신을 제공한다. Socket.IO는 역할을 명확히 분리하여 유연성과 이식성을 동시에 확보한다.
Engine.IO (Low-level)
Engine.IO는 서버와 클라이언트 사이의 저수준 연결을 설정하는 역할을 한다. 다양한 전송 방식과 업그레이드 메커니즘을 처리하며, 연결 수립 / 유지 / 종료까지의 생명주기를 책임진다.
또한 서버와 클라이언트 간의 저수준 연결, 연결 상태 감지(Heartbeat)를 담당해 상위 레이어가 전송 방식 세부 구현을 의식하지 않고 동작할 수 있게 해 준다.
Socket.IO (High-level)
Socket.IO는 Engine.IO 위에서 동작하는 고수준 추상화 레이어다. 자동 재연결, 이벤트 기반 통신, 네임스페이스와 룸을 이용한 멀티플렉싱, 브로드캐스트, ack 등 사용자 편의 기능을 제공한다. 덕분에 개발자는 어떤 전송 방식으로 어떻게 보내는지 보다 어떤 이벤트를 누구에게 보낼지 에만 집중할 수 있다.
환경에 따라 최적의 통신 수단을 선택한다. 같은 API(socket.emit, socket.on)를 사용하더라도, 실제로는 Long-polling과 WebSocket 중 상황에 맞는 방식이 내부적으로 선택된다.
HTTP long-polling
HTTP long-polling 전송은 연속적인 HTTP 요청으로 구성된다. 이 전송 방식의 특성상, 여러 번의 emit이 하나의 HTTP 요청에 합쳐져 전송될 수 있다. 전송은 비효율적일 수 있지만, 대부분의 프록시 / 방화벽 환경에서 동작한다는 장점이 있어 안전한 기본값 역할을 한다.
WebSocket
WebSocket 전송은 서버와 클라이언트 사이에 열리는 하나의 WebSocket 연결로 이루어지며, 양방향 / 저지연 통신 채널을 제공한다. 이 전송 방식의 특성상, 각 emit은 자체 WebSocket 프레임으로 전송된다. 일부 emit은 바이너리 데이터 분리 등으로 두 개의 WebSocket 프레임으로 나뉘어 전송되기도 한다.
실시간성이 중요한 채팅, 게임, 실시간 대시보드 같은 시나리오에서 최종적으로 목표로 하는 전송 방식이 WebSocket이다.
Engine.IO 연결이 시작될 때 서버는 다음과 같은 정보를 클라이언트에 보낸다.
{"sid": "FSDjX-WRwSA4zTZMALqx","upgrades": ["websocket"],"pingInterval": 25000,"pingTimeout": 20000,"maxPayload": 1000000}
이 핸드셰이크 응답 하나로 어떤 세션인지 식별할 수 있는 정보와 이 클라이언트가 어떤 전송 방식으로 업그레이드될 수 있는지 를 동시에 전달한다. 브라우저 네트워크 패널에서 최초 polling 요청의 응답 바디를 보면 위와 유사한 JSON 페이로드를 직접 확인할 수 있다.
처음부터 WebSocket을 시도하지 않고, HTTP Long-polling으로 먼저 연결한 뒤 점진적으로 업그레이드한다. 이는 방화벽이나 프록시로 인해 WebSocket이 차단된 환경에서도 즉각적인 통신을 보장하기 위함이다.
브라우저의 Network Monitor에서 다음과 같이 확인할 수 있다.

Engine.IO 연결은 다음 경우에 닫힌 것으로 간주된다.
서버와 클라이언트 사이의 연결이 여전히 살아 있는지를 확인하기 위한 heartbeat 메커니즘이 있다.
이 덕분에 일시적인 네트워크 문제(예: 와이파이 전환, 모바일 네트워크 끊김)를 빠르게 감지하고, 상위 Socket.IO 레이어에서 재연결 로직을 트리거할 수 있다.
Socket.IO는 Engine.IO 연결 위에서 다음과 같은 추가 기능들을 제공한다.
Socket.IO는 Long-polling과 WebSocket을 오가더라도 애플리케이션 레벨에서의 순서를 보장한다. 이는 각 연결이 단일 TCP 스트림 위에서 동작하고, 업그레이드 과정에서도 기존 연결에서 처리 중인 패킷을 모두 비운 뒤 새 전송 방식으로 넘어가도록 설계되어 있기 때문이다.
socket.emit("event1");socket.emit("event2");socket.emit("event3");
위 예시처럼 같은 연결에서 연속으로 emit한 이벤트는, 네트워크 환경이나 전송 방식 변경과 무관하게 항상 event1 → event2 → event3 순서로 상대방에게 전달된다. 같은 소켓에서 보낸 이벤트에 대해서만 적용되며, 서로 다른 소켓이나 다른 서버 인스턴스에서 오는 이벤트까지 전역 순서를 맞춰 주는 것은 아니다.
최대 한 번 (At most once)
기본적으로 Socket.IO는 최대 한 번(at most once)의 전송 보장을 제공한다. 즉, 한 번 보낸 이벤트가 중복으로 전달되지는 않지만, 네트워크 오류나 연결 끊김 상황에서 아예 도착하지 않을 수는 있다.
최소 한 번 (At least once)
클라이언트에서 최소 한 번 보장을 원한다면, retries 옵션을 통해 설정할 수 있다.
const socket = io({retries: 3,ackTimeout: 10000});
이 설정은 특정 이벤트에 대해 서버로부터 ack(응답)를 지정된 시간(ackTimeout) 안에 받지 못하면, 최대 retries + 1번까지 동일 이벤트를 다시 전송한다. 이 패턴은 최소 한 번이라는 특성을 주지만, 브라우저 새로고침 / 탭 종료 시 대기 중인 이벤트는 여전히 사라진다는 점에 유의해야 한다.
서버에서 클라이언트로 가는 이벤트는 라이브러리 기본 기능만으로는 “최소 한 번”이나 “정확히 한 번”을 보장하지 못한다. 대신 아래와 같은 애플리케이션 레벨 패턴으로 보강할 수 있다.
// 클라이언트const socket = io({auth: { offset: undefined }});socket.on("my-event", ({ id, data })
// 서버io.on("connection", async (socket) => {const offset = socket.handshake.auth.offset;if (offset) {// 재연결된 경우for
연결 상태 복구는 자동 재연결과는 다른, 세션 단위의 상태 복원 기능이다. 단순히 소켓이 다시 붙는 것을 넘어서, 같은 세션으로 간주할 수 있을 때만 방 참여, socket.data, 놓친 패킷까지 이어받을 수 있다.
현실적으로, 연결 품질이 아무리 좋아도 Socket.IO 클라이언트는 일시적인 연결 끊김을 피할 수 없다. 이 기능은 이러한 상황을 완화하기 위한 것이지만, 복구가 항상 성공하는 것은 아니다.
서버 측에서 명시적으로 연결 상태 복구 기능을 활성화해야 한다. connectionStateRecovery는 간헐적인 연결 해제를 처리하기 위한 기능이며, maxDisconnectionDuration은 상태를 얼마 동안 유지할지에 대한 TTL이라고 보면 된다.
const io = new Server(httpServer, {connectionStateRecovery: {// 세션 및 패킷 백업 유지 시간 (예: 2분)maxDisconnectionDuration: 2 * 60 * 1000,// 복구 성공 시 connection 미들웨어를 다시 태우지 않을지 여부skipMiddlewares: true,
// 서버io.on("connection", (socket) => {if (socket.recovered) {// 복구 성공: socket.id, socket.rooms, socket.data 복원됨} else {// 새 연결이거나 복구 불가한 세션}
// 클라이언트socket.on("connect", () => {if (socket.recovered) {// 끊김 동안 놓친 이벤트를 지금 모두 수신함} else {// 새 연결이거나 복구 불가 세션}
복구 여부에 따라 UI를 다르게 처리 하는 식으로 구분하는 것이 좋다.
강제로 엔진 레벨에서 연결을 끊어 복구가 작동하는지 확인할 수 있다.
import { io } from "socket.io-client";const socket = io({reconnectionDelay: 10000, // 기본값 1000msreconnectionDelayMax: 10000 // 기본값 5000ms});
핸드셰이크 중 서버가 세션 ID 전송
서버는 클라이언트 연결 시 내부적으로 두 종류의 세션 ID를 전송한다. 하나는 기존에 공개적으로 쓰이던 sid(공개용 세션 ID), 다른 하나는 외부에 노출되지 않는 pid(비공개 세션 ID)다.
40{"sid":"GNpWD7LbGCBNCr8GAAAB","pid":"YHcX2sdAF1z452-HAAAW"}where4 => Engine.IO 메시지 타입0 => Socket.IO CONNECT 타입GN...AB => 공개용 세션 ID
서버가 각 패킷에 offset을 부여
서버는 각 이벤트 패킷마다 증가하는 offset을 부여해, 클라이언트와의 동기화 지점을 나타낸다. 이 값은 이벤트 데이터 배열의 끝에 추가되므로, 예전 버전의 클라이언트와도 호환성을 유지할 수 있다.
42["foo","MzUPkW0"]where4 => Engine.IO 메시지 타입2 => Socket.IO EVENT 타입foo => 이벤트 이름 (socket.emit("foo")
클라이언트는 마지막으로 처리한 오프셋을 기억해 두었다가, 재연결 시 이 값을 서버에 알려 줌으로써 어디부터 다시 보내줘야 하는지를 정확히 지정할 수 있다.
임시 연결 해제 시
클라이언트가 네트워크 문제 등으로 잠시 끊기면, 서버는 어댑터 수준에서 다음 정보를 일정 시간 동안 유지한다.
서버는 클라이언트 상태를 어댑터 수준에서 일정 시간(maxDisconnectionDuration) 동안 저장한다. 이 보존 기간은 maxDisconnectionDuration 설정값에 의해 결정되며, 이 시간을 넘기면 세션은 완전히 잊혀져 복구 대상이 될 수 없다.
재연결 시 복구 절차
클라이언트가 다시 연결을 시도할 때, 이전에 부여받았던 pid와 마지막 처리 오프셋을 함께 전송한다.
40{"pid":"YHcX2sdAF1z452-HAAAW","offset":"MzUPkW0"}where4 => Engine.IO 메시지 타입0 => Socket.IO CONNECT 타입YH...AW => 비공개 세션 ID
서버는 이 정보를 바탕으로 다음을 수행한다.
반대로, pid를 찾을 수 없거나, maxDisconnectionDuration이 지나 상태가 정리된 경우에는 복구가 실패하고, 이 연결은 완전히 새로운 세션으로 취급된다.
각 모듈은 고유한 네임스페이스(예: socket.io:server, socket.io:client, engine.io:socket)를 가지며, 이 네임스페이스가 곧 디버깅 스코프 역할을 한다.
모든 디버그 정보 확인
// Node.jsDEBUG=* node yourfile.js// 브라우저localStorage.debug = '*';
이렇게 설정하면 Socket.IO와 Engine.IO, 어댑터 등에서 발생하는 모든 디버그 스코프가 콘솔에 출력되며, 어떤 스코프 이름들이 존재하는지 파악하는 데도 유용하다.
특정 스코프만 필터링
// Socket.IO 클라이언트 로그만DEBUG=socket.io:client* node yourfile.js// Engine.IO + Socket.IO 전체 로그DEBUG=engine,socket.io* node yourfile.js// 여러 스코프 조합DEBUG=socket.io:client
브라우저에서도 같은 패턴을 사용할 수 있다.
// 예: 클라이언트 측 socket.io 로그만 보고 싶을 때localStorage.debug = 'socket.io-client:*';
페이지를 새로고침하면 설정이 반영된다.
다음과 같은 브라우저 자체 에러들은 Socket.IO 라이브러리에서 발생한 것이 아니다. 이들은 브라우저 네트워크 레이어에서 발생하며, Socket.IO가 제어할 수 없다.
net::ERR_INTERNET_DISCONNECTEDnet::ERR_CONNECTION_REFUSEDWebSocket is already in CLOSING or CLOSED stateCross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at xxx. (Reason: CORS header ‘Access-
// vitestimport { beforeAll, afterAll, describe, it, expect } from "vitest";import { createServer } from "node:http";import { io as ioc } from "socket.io-client"