junyeokk
Blog
Socket·2025. 12. 13·4

Socket.IO의 전송 계층 관리

Socket.IO를 WebSocket을 추상화해서 쉽게 쓰게 해주는 라이브러리로 이해하고 있었다. 틀린 말은 아니지만, Socket.IO의 작동 원리를 더 깊게 알아보니 단순히 추상화만 해주는 라이브러리가 아니었다. Socket.IO는 WebSocket만 쓰는 게 아니라 전송 방식(transport) 자체를 직접 관리한다.

polling에서 WebSocket으로 업그레이드

Socket.IO의 기본 연결 흐름은 이렇다.

다이어그램을 보면 Socket.IO가 WebSocket을 바로 쓰지 않고 HTTP long-polling을 먼저 거치는 것을 알 수 있다. 왜 처음부터 WebSocket으로 연결하지 않을까? WebSocket은 독립적인 프로토콜이 아니라, HTTP 요청으로 시작해서 "이제부터 WebSocket으로 통신하자"고 서버에 요청하는 업그레이드 핸드셰이크를 거쳐야 한다.

핸드셰이크(handshake)는 TCP 3-way handshake만을 뜻하는 게 아니라, 두 당사자가 통신을 시작하기 전에 조건을 합의하는 과정을 통칭한다. TCP 핸드셰이크는 연결 수립을 합의하고, TLS 핸드셰이크는 암호화 방식을 합의하고, WebSocket 핸드셰이크는 프로토콜 전환을 합의한다. 이 과정에서 프록시, 방화벽, 로드 밸런서가 WebSocket을 차단하는 환경이 존재한다. 기업 내부 네트워크나 오래된 프록시 서버가 대표적이다.

Socket.IO는 이 문제를 일단 확실히 되는 방식(HTTP long-polling)으로 먼저 연결을 수립하고, 연결 직후 바로 WebSocket 업그레이드를 시도하는 방향으로 해결한다. long-polling은 WebSocket이 수립될 때까지의 임시적인 방법이다.

HTTP long-polling이란

일반 HTTP 요청은 클라이언트가 요청 → 서버가 응답 → 연결 종료의 흐름이다. 서버가 먼저 데이터를 보낼 수 없다.

실시간 서비스에서는 서버가 먼저 클라이언트에게 데이터를 보내야 하는 상황이 있다. 새 댓글이 달렸을 때, 다른 사용자가 문서를 수정했을 때가 그렇다. 하지만 HTTP는 클라이언트가 먼저 요청해야만 응답할 수 있다. Long-polling은 이 제약을 우회하는 방법이다.

HTTP 프로토콜을 그대로 사용하기 때문에 프록시나 방화벽에 차단될 일이 없다. 하지만 요청/응답 사이클이 반복되면서 HTTP 헤더가 매번 오가고, 서버에 연결이 계속 열려 있어야 하니 자원 소모가 크다. WebSocket은 한 번 연결하면 HTTP 헤더 없이 프레임 단위로 양방향 통신이 가능하기 때문에, Socket.IO가 long-polling을 임시 통로로 쓰고 최종적으로 WebSocket으로 전환하는 것이다.

long-polling 없이 바로 WebSocket으로 연결하기

Socket.IO가 long-polling을 먼저 거치는 이유는 WebSocket을 지원하지 않는 환경에 대응하기 위해서였다. 하지만 요즘 브라우저는 WebSocket을 모두 지원하기 때문에 이 폴백 과정이 불필요한 경우가 대부분이다. transports 옵션으로 처음부터 WebSocket만 사용하도록 설정할 수 있다.

typescript
const socket = io(url, {
  transports: ["websocket"], // long-polling 단계를 건너뜀
});

이렇게 함으로써 아래와 같은 이점을 얻을 수 있다.

  • long-polling에서 WebSocket으로 전환하는 과정이 생략되어 초기 연결 시간이 단축된다.
  • 연결 시작부터 WebSocket만 사용하기 때문에 HTTP 헤더가 반복적으로 오가는 오버헤드가 없다.
  • 서버 측에서도 long-polling 핸들러를 별도로 유지할 필요가 없어진다.

단점은 WebSocket이 차단되는 환경에서 연결 자체가 실패한다는 것이다. 불특정 다수가 사용하는 서비스라면 폴백을 유지하는 게 안전하고, 사용 환경을 통제할 수 있는 서비스라면 WebSocket만 사용하는 게 효율적이다.

Socket.IO ≠ WebSocket

Socket.IO는 WebSocket 위에 자체 메시지 형식(프로토콜)을 얹어서 동작한다. 내부적으로는 Engine.IO가 전송 계층을 관리하고, 그 위에서 Socket.IO가 이벤트, Room, 네임스페이스 같은 고수준 기능을 제공하는 구조다.

text
┌─────────────────────────┐
│       Socket.IO         │  이벤트, Room, 네임스페이스
├─────────────────────────┤
│       Engine.IO         │  전송 계층 관리, 핸드셰이크, 하트비트
├─────────────────────────┤
│  WebSocket / Polling    │  실제 데이터 전송
└─────────────────────────┘

Engine.IO는 연결 수립, long-polling ↔ WebSocket 전환, 하트비트(연결이 살아있는지 주기적으로 확인), 세션 ID 관리를 담당한다. 개발자가 Engine.IO를 직접 다룰 일은 없지만, Socket.IO의 연결 동작을 이해하려면 이 계층이 존재한다는 것을 알아두는 게 좋다.

이 자체 프로토콜 때문에 Socket.IO 클라이언트는 일반 WebSocket 서버에 연결할 수 없고, 반대도 마찬가지다. Socket.IO 클라이언트가 서버에 연결할 때 Engine.IO 프로토콜로 핸드셰이크를 시도하는데, 일반 WebSocket 서버는 이 메시지를 이해하지 못한다. 서버 입장에서는 알 수 없는 데이터가 들어온 거라 연결을 거부하거나 무시한다.

javascript
// 일반 WebSocket 서버
const ws = new WebSocket.Server({ port: 3000 });

// Socket.IO 클라이언트로 연결 시도 → 연결 실패
const socket = io('http://localhost:3000');

반대도 마찬가지다. 일반 WebSocket 클라이언트가 Socket.IO 서버에 연결하면, 서버가 Engine.IO 핸드셰이크를 기대하는데 클라이언트는 raw 메시지를 보내니까 서로 대화가 안 된다.

javascript
// Socket.IO 서버
const io = new Server(3000);

// 일반 WebSocket 클라이언트로 연결 시도 → 연결은 되지만 통신 불가
const ws = new WebSocket('ws://localhost:3000');

결국 Socket.IO는 양쪽 다 Socket.IO를 써야 통신이 된다.

참고 자료