본문으로 건너뛰기
pjh-climb-dev

WebSocket과 Socket.IO의 Handshake

개요

흔히 Socket.IO는 Websocket에서 Room 기능이 추가되고, 자동 재연결, Broadcasting 등 편의 기능을 추가하여 더 쉽게 실시간 통신을 구현한다고 알려져 있다.

하지만, Websocket 기반이기 때문에 Handshake도 같다고 생각할 수 있는데, 조금 다르다. 이 차이점을 알아두어야, 나중에 Socket.IO 기반 서버를 확장할 때 실수없이 잘 다룰 수 있을 것이다.


1. WebSocket Handshake

처음에는 HTTP로 Handshake를 한 후 Websocket으로 프로토콜을 변경한다. 이때 응답으로 받는 HTTP Status Code는 101 Switching Protocols이다.

101 Switching Protocols 상태 코드는 MDN에서 다음과 같이 설명한다.

서버가 전환되는 프로토콜을 가리킵니다.
프로토콜은 클라이언트로부터 받은 Upgrade 헤더에 명시되어 있습니다. https://developer.mozilla.org/ko/docs/Web/HTTP/Reference/Status/101

여기서 클라이언트는 Websocket 연결을 위해 Handshake 과정에서 Upgrade 헤더를 포함하여 전송한다는 것을 알 수 있다.


1-1. Handshake 요청

GET / HTTP/1.1
Host: localhost:8080
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: /sJ4D9yaOhXda7y33UEUJg==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits

요청 헤더는 위와 같이 되어 있다.

MDN의 내용에서는 HTTP/2는 프로토콜 업그레이드 메커니즘의 사용을 명시적으로 허용하지 않고, HTTP/1.1에서만 허용한다고 한다.

  • Upgrade : 클라이언트가 서버에게 우선순위가 내림차순으로 나열된 프로토콜 중 하나로 전환할 것을 요청
    • 콤마를 넣어 업그레이드할 프로토콜을 여러 개 넣을 수 있다.
      ex) Upgrade: websocket, example/1, foo/2
  • Connection : 현재의 전송이 완료된 후 네트워크 접속을 유지할지 말지 제어
    • 만약 전송된 값이 keep-alive면, 연결은 지속되고 끊기지 않고 후속 요청 수행 가능
    • Websocket 요청 시에는 반드시 Upgrade를 가짐
    • 만약, Upgrade나 Connection 둘 중 하나라도 없으면 Cross-protocol Attack이라고 간주
  • Sec-WebSocket-Version : 사용하고자 하는 Websocket 프로토콜 버전을 지정
  • Sec-WebSocket-Key : 클라이언트의 보안 키. 서버는 해당 연결 요청이 의도된 요청인지 확인하는 데 사용
    • 말이 Key지, 사실은 그냥 식별자 또는 Nonce 그 이상 그 이하도 아니다.
    • 16Byte 무작위로 생성된 값을 Base64로 인코딩한 값
    • 어떻게 사용하는지는 Handshake 응답에서 설명
  • Sec-WebSocket-Extensions : 양 측 엔드간, 추가적인 기능 확장 요청. 필수는 아님
  • Sec-WebSocket-Protocol : 사용하고자 하는 하나 이상의 Websocket 프로토콜 지정. 필수는 아님

1-2. Handshake 응답

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: Os9sl/aBLSQq5xY87biSEiNAIhU=

성공적으로 연결이 됐을 경우, 101 응답이 온다. 만약 Websocket을 사용할 수 없거나, 모종의 이유로 실패한다면 Upgrade 헤더 없이 200 OK 응답을 보낸다.

상황에 따라 426 응답을 보내기도 한다. 426 Upgrade Required : https://developer.mozilla.org/ko/docs/Web/HTTP/Reference/Status/426

  • Sec-WebSocket-Accept : Sec-WebSocket-Key 값을 이용해 계산한 값. 클라이언트와 서버 간의 명시적인 의도와 프로토콜 전환의 유효성을 확인
    • Magic String을 사용하여, Sec-WebSocket-Key와 함께 결합하고 SHA-1 해시 알고리즘으로 처리한 다음, Base64로 인코딩한다.
    • Magic String은 RFC-6455 Websocket 표준에 정의된 문자열이다. (“258EAFA5-E914-47DA-95CA-C5AB0DC85B11”)
      RFC-6455 : https://datatracker.ietf.org/doc/html/rfc6455#section-1.3
    • 클라이언트는 이 값을 검증한다.

아래는 Pseudocode이다.

/**
 * 클라이언트에서 수행
 */
function generateWebSocketKey() {
    // 16바이트의 무작위 데이터를 생성합니다. (브라우저 환경의 Crypto API 사용 가정)
    const randomBytes = new Uint8Array(16);
    // 실제 구현에서는 안전한 난수 생성기를 사용합니다.
    window.crypto.getRandomValues(randomBytes);

    // 이 바이트 배열을 Base64 문자열로 변환합니다.
    // 이 변환된 문자열이 'Sec-WebSocket-Key' 헤더 값이 됩니다.
    const base64Key = btoa(String.fromCharCode(...randomBytes));
    
    return base64Key;
}

// 예시: WebSocket 요청 헤더에 포함될 값
// const secWebSocketKey = generateWebSocketKey();
// Headers: { "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==" } 

/**
 * 서버에서 실행
 */
// 웹소켓 표준에 정의된 고정된 "매직 문자열"
const MAGIC_STRING = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";

async function calculateWebSocketAccept(clientKey) {
    // 1. 클라이언트의 키와 매직 문자열을 합칩니다.
    const combinedString = clientKey + MAGIC_STRING;

    // 2. 결합된 문자열을 SHA-1 해시 알고리즘으로 처리합니다.
    // (실제 서버 구현에서는 라이브러리 사용)
    // const sha1Hash = crypto.createHash('sha1').update(combinedString).digest();

    // 3. 해시된 결과(바이트)를 다시 Base64로 인코딩합니다.
    // const acceptValue = sha1Hash.toString('base64');

    // 이 값이 'Sec-WebSocket-Accept' 응답 헤더 값이 됩니다.
    return acceptValue;
}

// 예시: 클라이언트 키 "dGhlIHNhbXBsZSBub25jZQ==" 에 대한 응답
// const accept = await calculateWebSocketAccept("dGhlIHNhbXBsZSBub25jZQ==");
// Response Headers: { "Sec-WebSocket-Accept": "s3pPLMBiTxaQ9kYGzzhZRbK+aOo=" }

2. Socket.IO Handshake

2-1. Socket.IO만의 전략

기본적으로 “의심이 많은” 전략을 취한다. “이 네트워크 환경이 Websocket을 지원하는지 믿을 수 없다.”라는 가정하에 동작하기 때문이다.

이게 무슨 말이냐면, Websocket에서는 한 번의 Handshake로 프로토콜을 업그레이드하고 통신한다고 했지만, Websocket을 지원하지 않는 네트워크 환경 속, 사용자 입장에서 연결을 실패한다는 것은 사용자 경험을 저해하는 것이다.

즉, Establish first, Upgrade Later 방식을 취한다.

그래서 Socket.IO 만으로 동작하는 것이 아니라, Engine.IO라는 하위 계층에서 HTTP Long Polling 방식으로 먼저 통신하게 된다.

HTTP Long Polling에서 프로토콜을 업데이트한다면, 클라이언트는 다음과 같은 상태이어야 한다.

  1. 나가는 버퍼가 비어있어야 한다.
  2. 현재 전송을 읽기전용 모드로 변경한다.
  3. 다른 전송으로 연결을 시도한다. (프로토콜 업그레이드)
  4. 성공하면, 처음 전송을 닫는다. (HTTP 종료)

2-2. Handshake

socketio handshake.png

Handshake는 위와 같은 순서로 진행된다. (favicon.ico는 무시) 각각의 요청 응답이 어떤 역할을 수행하는지 알아보도록 한다.

1단계 : Engine.IO HTTP Long Polling

GET /socket.io/?EIO=4&transport=polling&t=PhjbHfa HTTP/1.1
Connection: keep-alive
Host: localhost:8080
...
  • EIO=4 : Engine.IO 프로토콜 버전 4를 사용
  • transport=polling : HTTP polling 방식을 사용
Response Header
HTTP/1.1 200 OK
Content-Type: text/plain; charset=UTF-8
Content-Length: 118
Connection: keep-alive
Keep-Alive: timeout=5
...

Response Body
0{
"sid": "BaC8BqodRcwzKSkUAAAG",
"upgrades": [
"websocket"
],
"pingInterval": 25000,
"pingTimeout": 20000,
"maxPayload": 1000000
}
  • 0 : Open Packet. 연결 완료
  • sid : 세션 ID. 이후 모든 HTTP 요청에 이 ID가 Query Param으로 붙어 세션을 유지함
  • upgrades: [“websocket”] : 서버가 Websocket도 지원한다는 의미. 핵심 필드
  • pingInterval, pingTimeout : 연결 유지를 위한 하트비트 설정
  • maxPayload : 전송 가능한 payload 크기

2단계 : Socket.IO 연결 요청 (Namespace Connect)

Engine.IO 연결은 되었으니, Socket.IO 연결을 시도한다.

POST /socket.io/?EIO=4&transport=polling&t=PhjbHfm&sid=BaC8BqodRcwzKSkUAAAG HTTP/1.1
Connection: keep-alive
Content-Length: 2
Content-type: text/plain;charset=UTF-8
Host: localhost:8080
...

HTTP Polling 방식에서 클라이언트가 서버로 데이터를 보낼 때는 POST를 사용한다.

Response Header
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 2
Connection: keep-alive
Keep-Alive: timeout=5
...

Response Body
ok

응답 Body에는 “ok”라는 것이 왔다. 이것은 서버가 클라이언트의 연결 요청을 잘 받았다는 ACK 메시지인 것이다.


3단계 : Socket.IO 연결 확정 (Connect Response)

서버가 클라이언트의 연결 요청을 수락하고, 클라이언트에게 알린다.

GET /socket.io/?EIO=4&transport=polling&t=PhjbHfn&sid=BaC8BqodRcwzKSkUAAAG HTTP/1.1
Connection: keep-alive
Host: localhost:8080
...

클라이언트가 서버로부터 받을 데이터가 있는지 묻는, Long Polling 요청이다.

Response Header
HTTP/1.1 200 OK
Content-Type: text/plain; charset=UTF-8
Content-Length: 32
Connection: keep-alive
Keep-Alive: timeout=5
...

Response Body
40{
"sid":"7U5N-J3oh6o-bWfKAAAH"
}
  • 4 : Message Packet (Engine.IO)
  • 0 : Connect Packet (Socket.IO)
  • 40 : ”/” 네임스페이스 연결에 성공했다는 의미
  • sid : 1단계의 Engine.IO의 sid와는 다른, Socket.IO의 소켓 ID. 코드에서 socket.id로 접근하는 값

4단계 : 프로토콜 업그레이드

HTTP에서 Websocket으로 프로토콜을 변경하는 단계이다. 1단계의 upgrades 필드 덕분에 websocket으로 업그레이드할 수 있는 것이다.

GET ws://localhost:8080/socket.io/?EIO=4&transport=websocket&sid=BaC8BqodRcwzKSkUAAAG HTTP/1.1
Host: localhost:8080
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: wpGkmpdkU6fArbXKI0kxWA==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
...
  • transport=websocket : polling이 아니라 websocket이라고 명시
  • Connection, Upgrade, Sec-WebSocket-* : 위에서 알아본 Websocket Handshake를 진행하는 헤더들이다.
Response Header
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: 74e0vSbW1LIx9K07vvd85J8GfTs=
...

Status Code는 101 Switching Protocols가 응답으로 왔다. 성공적으로 연결이 되었음을 알 수 있다.


5단계 : Polling 전송 종료 (Polling Cleanup)

Websocket에 연결되었으니, 기존에 열려있던 HTTP 통신을 안전하게 닫는 단계이다.

GET /socket.io/?EIO=4&transport=polling&t=PhjbHfw&sid=BaC8BqodRcwzKSkUAAAG HTTP/1.1
Connection: keep-alive
Host: localhost:8080
...

이미 소켓 연결이 되었지만, 기존에 걸어놓았던 Long Polling 요청이 남아있을 수 있다.

Response Header
HTTP/1.1 200 OK
Content-Type: text/plain; charset=UTF-8
Content-Length: 1
Connection: keep-alive
Keep-Alive: timeout=5
...

Response Body
6

Body 데이터가 “6”이다. 6은 Noop (No Operation) 패킷이다.

서버가 이제 Websocket으로 통신할거니까, HTTP 요청은 닫겠다고 처리하는 것이다. 이를 통해 불필요한 HTTP 연결이 계속 남는 것을 방지한다.


2-3. 그렇다면 Engine.IO와 Socket.IO는 별개의 Connection인가?

Socket.IO 시스템에서는 Engine.IO가 뼈대이다. 즉, Socket.IO는 Engine.IO 위에서 돌아가는 구조이다.
HTTP Polling이든 Websocket이든 상관없이, 바이트를 전송하고 연결 상태를 관리하는 것은 Engine.IO의 몫이다.

Socket.IO는 그 위에서

  • 어떤 Room에 보낼지
  • 어떤 Event인지
  • 재접속은 언제 할지

와 같은 고급 기능을 담당한다.


참조