컴퓨터 공학(Computer Science & Engineering))/네트워크

[네트워크] TCP와 HTTP 레이어에서 직접 구현하는 웹소켓 그리고 socket.io

멍개. 2022. 8. 28. 15:20

지난번 글에서 TCP와 HTTP가 무엇인지 그리고 어떻게 동작하는지 살펴보았습니다.

 

2022.08.28 - [컴퓨터 공학(Computer Science & Engineering))/네트워크] - [네트워크] TCP? HTTP? 그것이 알고싶다

 

[네트워크] TCP? HTTP? 그것이 알고싶다

TCP와 HTTP를 다뤄보도록 하겠습니다. 근데 사실 어렵지 않아요!!!! 이걸 어렵게 설명하니깐 어려운거지..... 어렵지 않은 이유를 알려드릴게요 ​ 사실 어려운 개념은 맞습니다. 왜냐고요? 우리 눈

meongae.tistory.com

해당 내용에서 웹소켓은 HTTP 위에서 동작하는 프로토콜이라고 언급했습니다. 이번글에서는 웹소켓이 무엇인지 그리고 이를 직접 구현해보도록 하곘습니다. 웹소켓이 HTTP위에서 동작한다는 점은 TCP 위에서 동작한다는 것과 같은 말입니다.

특히 웹소켓은 로우레벨을 이해하면 좋은점이 있습니다. 만약 거래소 또는 채팅과같이 실시간 데이터를 빠르게 처리해야 한다면 웹소켓을 직접 옵티마이징(최적화) 작업을 해야하거나 직접 구현해야하는 상황이 있습니다.

멍선생의 네트워크 강의는 단순히 이론만 설명하지 실제 동작하는 코드를 작성하여 추상적인 개념을 최대한 구체화하는 것을 목표로 하고 있습니다.

 

● 웹소켓(websocket)

웹소켓은 HTTP를 기반으로 양방향 통신을 제공하는 프로토콜입니다.

양방향 통신이라는 것은 클라이언트의 요청없이 서버가 클라이언트에게 메시지를 전달할 수 있다는 것을 의미합니다. 즉, 클라이언트는 최신 데이터를 받기위해 주기적으로 서버에게 요청하는 polling, long polling, stream등을 사용하지 않아도 됩니다.

웹소켓의 개념은 아주 간단합니다. 앞에서 우리는 TCP로 연결된 두 응용프로그램은 TCP 연결을 끊지 않는다면 즉, socket.end()를 하지 않는다면 서버든 클라이언트든 데이터를 전달하고 전달 받을수 있다는 점을 인지하고 있을것입니다. 여기서 HTTP와 웹소켓의 차이점이 바로 이 부분입니다. 물론 HTTP/1.1은 일정시간 연결을 끊진 않지만 결국 끊기게 됩니다.

웹소켓은 HTTP 요청을 통해 socket.end를 하지 말라고 합니다. 그리고 자체적으로 정의한 데이터프레임 형식에 맞춰 데이터를 주고 받습니다. 이때 socket.end를 하지 말라고 하는 과정을 핸드쉐이크라고 하며 핸드쉐이크 시 HTTP 프로토콜을 이용합니다.

· 핸드쉐이킹 과정

▶ handshake request

 
{
  upgrade: 'websocket',
  connection: 'Upgrade',
  'sec-websocket-version': '13',
  'sec-websocket-key': 'VEX/yDINihxNvSKmffJF+Q==',
  host: 'localhost:5000'
}

▶ handshake response

101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: hsBlbuDTkk24srzEOTBUlZAlC2g=

· 핸드쉐이크 구현

웹소켓의 핸드쉐이크는 HTTP를 이용합니다.

▶ 클라이언트 -> 서버 연결요청

웹소켓 사용시 클라이언트는 서버에게 다음과 같이 HTTP를 요청합니다.

GET / HTTP/1.1
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: xJayu5kgUOIvPXQQpegBXQ==
Sec-WebSocket-Version: 13
Host: localhost:5000

HTTP 헤더의 Connection이 Upgrade가 되면 HTTP 서버는 해당 요청이 HTTP라고 인식하지 않습니다. 즉, HTTP가 아닌 상위 수준의 프로토콜을 구현할 수 있게 됩니다. upgrade: websocket은 업그레이드 할 대상이 websocket이라는 의미입니다. 즉, 해당 요청은 Upgrade이므로 HTTP 프로토콜에서 커네션을 끊거나 유지하거나 관리를 더이상 하지 않습니다.

Sec-Websocket-KeySec-WebSocket-Version은 웹소켓 프로토콜에서 사용하는 정보이며 버전은 13이 최신버전 입니다.

nodejs의 HTTP 라이브러리는 connection이 upgrade가 되었을 때 별도로 처리할 수 있도록 핸들링할 수 있도록 제공합니다.

const http = require("http");

const server = http.createServer(function (req, res) {});

server.on("upgrade", function (req, socket, head) {
  console.log(req.headers)
  socket.on("connect", function () { console.log("success"); });
  socket.on("data", function (data) {
    console.log(data);
    socket.write(data)
  });
});

server.listen(5000, function () {
  console.log("server is on 5000");
});

해당 서버를 실행 후 웹 브라우저로 http://localhost:5000을 접속해보세요 on('upgrade')가 반응하지 않습니다. 왜냐하면 브라우저는 HTTP 요청을 하기 때문에 Connection: upgrade가 아닙니다.

 
<!DOCTYPE html>
<html>
  <head></head>
  <body>
    <script>
      const ws = new WebSocket('ws://localhost:5000');
    </script>
  </body>
</html>

웹소켓 연결을 시도하는 브라우저를 실행해보세요 해당 서버에서 헤더를 출력할겁니다. 그리고 헤더 정보를 보면 다음과 같습니다.

{
  upgrade: 'websocket',
  connection: 'Upgrade',
  'sec-websocket-version': '13',
  'sec-websocket-key': 'VEX/yDINihxNvSKmffJF+Q==',
  host: 'localhost:5000'
}

서버에서 별도의 데이터를 주고있지 않기 때문에 요청 헤더만 존재하고 응답 헤더는 보이지 않습니다.

websocket 라이브러리를 활용하여 해당 서버에 접속해보겠습니다.

const WebSocketClient = require('websocket').client;
const client = new WebSocketClient();

client.connect('ws://localhost:5000/');

마찬가지로 서버는 반응합니다. 그렇다면 우리가 직접 net 모듈을 통해 TCP 수준에서 요청을 해본다면?

const net = require('net');

// 서버 5000번 포트로 접속 
// TCP level: 3 way-handshake
const socket = net.connect({ port: 5000 });

socket.on('connect', function () {
  console.log('connected to server!');
  
  const msg = [
    'GET / HTTP/1.1\r\n',
    'Connection: Upgrade\r\n',
    'Upgrade: websocket\r\n',
    'Sec-WebSocket-Version: 13\r\n',
    'Sec-WebSocket-Key: s+92vaV9BEsiNRk0rVmKDA==\r\n',
    'Host: localhost:5000\r\n',
    '\r\n'
  ].join('')

  // HTTP level: 2 way-handshake
  socket.write(msg);
});

해당 클라이언트 프로그램을 실행하면 서버가 반응합니다. 지금까지 HTTP 프로토콜의 Connection: Upgrade를 알아보았습니다.

▶ 서버 -> 클라이언트 연결수락

앞에서 Connection: Upgrade, Upgrade: websocket,을 기반으로 Sec-WebSocket-Version과 Sec-WebSocket-Key를 핸드쉐이크를 위해 서버에게 전송했습니다. 서버는 Sec-WebSocket-Key를 이용하여 Sec-WebSocket-Accept를 만듭니다.

101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: hsBlbuDTkk24srzEOTBUlZAlC2g=

Sec-WebsSocket-Accept는 클라이언트가 전달한 Sec-WebSocket-Key와 258EAFA5-E914-47DA-95CA-C5AB0DC85B11를 하나의 문자열로 만듭니다. 그리고 sha1로 해싱한 후 base64로 인코딩한 결과가 Sec-WebSocket-Accept입니다. 그리고 이때 중요한 점은 상태 코드를 101로 해야합니다.

이번의 서버는 http가 아닌 net 모듈을 이용하여 구축을 해보겠습니다. 이왕 로우한 영역으로 내려온거 화끈하게 내려가보겠습니다.

 
const net = require('net');
const crypto = require('crypto');

function handshakeMsg(msg) {
  const websocketKey = msg.split('\n').find(item => item.includes('Sec-WebSocket-Key'));
  const secWebsocketAccept = websocketKey.split(': ')[1].replace('\n', '').replace('\r', '');
  const salt = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'

  // sha1 해싱된 결과 base64 인코딩
  const sha1 = crypto.createHash('sha1')
  const hashing = sha1.update(secWebsocketAccept + salt).digest('base64');
  
  return 'HTTP/1.1 101 Switching Protocols\r\n' +
         'Upgrade: websocket\r\n' +
         'Connection: Upgrade\r\n' +
         'Sec-WebSocket-Accept: ' + hashing + '\r\n' +
         '\r\n';
}

const server = net.createServer(socket => {
  console.log(`cnnected address: ${socket.address().address}`);
  socket.on('data', (data) => { 
    
    console.log('====== handshake start ===== ')
    console.log('request headers')
    console.log(data.toString());

    const msg = handshakeMsg(data.toString());
    console.log('response headers')
    console.log(msg)
    socket.write(msg)
    console.log('====== handshake end ===== ')
  
  });
})

server.listen(5000, function() {
  console.log(`listen on port 5000`)
})

handshakeMsg 함수는 클라이언트가 전달한 Connection: Upgrade, Upgrade: websocket, Sec-WebSocket-Version, Sec-WebSocket-Key 문자열 정보를 파라미터로 넘기면 Sec-WebSocket-Accept를 포함한 결과를 만들어 줍니다. 해당 정보를 클라이언트에게 전달하면 핸드쉐이크가 성공적으로 완료하며 HTTP의 역할은 종료합니다.

const WebSocketClient = require('websocket').client;

const client = new WebSocketClient();
client.connect('ws://localhost:5000');

client.on('connect', function(connection) {
    console.log('WebSocket Client Connected');
 });

on('connect')는 웹소켓 핸드쉐이크가 성공적으로 완료되었을 때 호출됩니다.

※ 주의사항

여기서 한 가지 해깔릴만한 부분을 알려드리겠습니다. 지금 우리는 2개의 라이브러리를 이용하여 클라이언트를 직접 구현해보고 있습니다. 바로 TCP 레이어인 net과 웹소켓 레이어인 websocket입니다.

const net = require('net');

// 서버 5000번 포트로 접속 
const socket = net.connect({ port: 5000 });

// TCP level: 3 way-handshake 완료시
socket.on('connect', function () {
  console.log('connected to server!');
})
const WebSocketClient = require('websocket').client;

const client = new WebSocketClient();
client.connect('ws://localhost:5000');

// HTTP level: 2 way-handshake 완료시
client.on('connect', function(connection) {
    console.log('WebSocket Client Connected');
})

코드의 형태가 유사하죠? 하지만 on('connect')가 호출되는 시점이 다릅니다 각 레이어마다 connect라고 인지하는 시점이 다르기 때문입니다. net에서의 on('connect')는 TCP 수준에서 발생하는 3 way-handshake입니다. 하지만 websocket은 HTTP로 Sec-WebSocket-Key와 Sec-WebSocket-Accept를 성공적으로 주고받았고 클라이언트가 101응답을 받았을 때 on('connect')를 호출합니다.

웹 브라우저에서도 우리가 만든 웹소켓 서버와 연결을 할 수 있는지 확인해보겠습니다.

HTTP 기반으로 핸드쉐이킹을 완료하여 웹소켓 연결이 되었습니다.

이렇게 연결한 두 프로그램은 웹소켓 데이터프레임을 이용하여 데이터를 주고받을 수 있습니다.

만약 http위에서 구현한다면 다음과 같이 구현할 수 있습니다. 사실상 코드가 거의 유사합니다.

const http = require("http");
const crypto = require("crypto");

function handshakeMsg(msg) {
  const secWebsocketAccept = msg;
  const salt = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'

  // sha1 해싱된 결과 base64 인코딩
  const sha1 = crypto.createHash('sha1')
  const hashing = sha1.update(secWebsocketAccept + salt).digest('base64');
  
  return 'HTTP/1.1 101 Switching Protocols\r\n' +
         'Upgrade: websocket\r\n' +
         'Connection: Upgrade\r\n' +
         'Sec-WebSocket-Accept: ' + hashing + '\r\n' +
         '\r\n';
}

const server = http.createServer(function (req, res) {});

server.on("upgrade", function (req, socket, head) {
  socket.write(headersReturn);
});

server.listen(5000, function () {
  console.log("server is on 5000");
});

· 데이터프레임

웹소켓은 다음과 같이 데이터 프레임 포맷을 맞춰야 합니다.

 0               1               2               3
 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len |    Extended payload length    |
|I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
|N|V|V|V|       |S|             |   (if payload len==126/127)   |
| |1|2|3|       |K|             |                               |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
 4               5               6               7
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
|     Extended payload length continued, if payload len == 127  |
+ - - - - - - - - - - - - - - - +-------------------------------+
 8               9               10              11
+ - - - - - - - - - - - - - - - +-------------------------------+
|                               |Masking-key, if MASK set to 1  |
+-------------------------------+-------------------------------+
 12              13              14              15
+-------------------------------+-------------------------------+
| Masking-key (continued)       |          Payload Data         |
+-------------------------------- - - - - - - - - - - - - - - - +
:                     Payload Data continued ...                :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
|                     Payload Data continued ...                |
+---------------------------------------------------------------+

웹소켓의 데이터 프레임은 가변적입니다. 데이터가 가변되는 상황은 다음과 같습니다.

▶ 첫 번째 가변상황

서버 -> 클라이언트 데이터 전송
클라이언트 -> 서버 데이터 전송

이 둘은 동일한 데이터를 전송하더라도 데이터 프레임의 모양이 조금 다릅니다. 클라이언트에서 서버로 데이터를 전송할 땐 마스킹 작업을 합니다. 이를 위해 마스킹 활성비트 1로 활성화하며 마스킹 비트를 포함합니다. 이후 페이로드(실제 데이터)를 마시킹 비트와 XOR 연산 결과를 utf-8 인코딩합니다. 하지만 서버에서 클라이언트로 데이터를 전송할 땐 마스킹 처리를 하지 않습니다. 그렇기 때문에 마스킹 활성 비트는 0으로 설정되며 마스킹 비트는 존재하지 않고 페이로드는 utf-8로만 인코딩처리 합니다.

▶ 두 번째 가변상황

데이터 프레임은 페이로드(전달할 데이터)의 길이를 명시합니다. 하지만 데이터 크기에 따라 크기로 사용하는 프레임의 길이가 달라집니다. 기본적으로 페이로드 길이를 표현하기 위해 7비트를 사용하지만 페이로드 길이에 따라 최대 4바이트(32비트)까지 사용합니다. 만약 페이로드의 길이가 7비트보다 작다면 4바이트는 사용하지 않습니다.

데이터 프레임이 가변이 되는 2가지 상황을 알아보았습니다. 이제 데이터 프레임의 각 항목이 의미하는 것이 무엇인지 살펴보겠습니다.

▶ 데이터 프레임 분석

 0               1               2               3
 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len |    Extended payload length    |
|I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
|N|V|V|V|       |S|             |   (if payload len==126/127)   |
| |1|2|3|       |K|             |                               |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +

여기서 맨 윗줄은 바이트를 의미합니다. 그 디음 0~7은 비트를 의미합니다. 그다음 문자는 패킷의 역할입니다.

데이터프레임 4바이트는 FIN, RSV1, RSV2, RSV3, opce, MASK, payload len, Extended payload length를 포함합니다.

 4               5               6               7
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
|     Extended payload length continued, if payload len == 127  |
+ - - - - - - - - - - - - - - - +-------------------------------+
 8               9               
+ - - - - - - - - - - - - - - - +
|                               |
+-------------------------------+

Extended payload leng continued는 10번째 바이트인 9라고 적혀있는 부분까지 확장될 수 있습니다. Payload Len은 항상 존재하지만 Extended payload length는 있을수도 없을수도 있습니다.

FIN: 해당 데이터 프레임이 마지막임을 알립니다.

RSV1, RSV2, RSV3: 사용되지 않음, 추후 확장될 것을 대비하여 존재

opcode: opcode에 따라 데이터 형태가 text(0x1 항상 UTF-8입니다)/blob(0x2)인지 구분합니다.

MASK: 앞에서 가변 상황 첫번째에서 마스크 활성 비트입니다. 해당 비트가 1일 땐 마스킹 처리가 되어있음을 알립니다.

payload length: 페이로드 길이입니다. 해당 값이 126일 땐 2바이트(2~3)를 데이터 길이표현으로 추가적으로 사용하며, 127일 땐 6바이트를 추가적으로 사용합니다.

                                +-------------------------------+
                                 10              11
                                +-------------------------------+
                                |Masking-key, if MASK set to 1  |
+-------------------------------+-------------------------------+
 12              13              
+-------------------------------+
| Masking-key (continued)       |   
+--------------------------------

masking-key는 MASK가 1로 활성화되었을 때, 즉 클라이언트가 서버로 데이터 프레임을 전달할 때 Masking-key를 이용하여 페이로드를 마스킹처리 합니다.

14(15번째) 바이트 부터 페이로드 입니다.

                                +-------------------------------+
                                 14              15
                                +-------------------------------+
                                |          Payload Data         |
+-------------------------------- - - - - - - - - - - - - - - - +
:                     Payload Data continued ...                :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
|                     Payload Data continued ...                |
+---------------------------------------------------------------+

여기서 중요한 점은 앞에서 payload length가 7비트 고정 8바이트가 가변이기 때문에 Masking-key와 Payload의 위치(offset)는 변할 수 있습니다. 그렇기 때문에 데이터프레임 인코딩/디코딩 시 이 부분을 적절히 신경써줘야 합니다.

Masking-key와 payload가 다음과 같을때 실제 페이로드는 다음과 같은 연산 작업을 진행하여 만들어 집니다.

Masking-Key: 00000001 00000002 00000003 00000004
payload data: hello world
h XOR 00000001 연산결과 utf8
e XOR 00000002 연산결과 utf8
l XOR 00000003 연산결과 utf8
l XOR 00000004 연산결과 utf8
o XOR 00000001 연산결과 utf8
띄어쓰기  XOR 00000002 연산결과 utf8
w  XOR 00000003 연산결과 utf8
o  XOR 00000004 연산결과 utf8
r  XOR 00000001 연산결과 utf8
l  XOR 00000002 연산결과 utf8
d  XOR 00000003 연산결과 utf8

· 데이터 프레임 디코딩 구현

앞서서 우리는 데이터 프레임이 가변적인 상황을 알아보았습니다. 여기선 클라이언트가 요청할 땐데이터의 길이가 7비트로 표현할 수 있는 126 이하의 데이터 사이즈만 다룹니다.

우리가 디코딩을 다루는 케이스는 클라이언트가 서버로 데이터를 전달하는 경우입니다. 이때 데이터 프레임은 마스크 활성비트가 1로 활성되었기 때문에 Masking-key로 payload를 xor 연산해야 합니다.

function decode(data) {
  let FIN = data[0] >> 7 == 1;
  let RSV1 = (data[0] >> 6 & 1) == 1;
  let RSV2 = (data[0] >> 5 & 1) == 1;
  let RSV3 = (data[0] >> 4 & 1) == 1;
  let OPCODE = data[0] & 0xf;

  console.log('FIN, RSV1, RSV2, RSV3, OPCODE', convertToBinary1(data[0]))
  console.log(FIN, RSV1, RSV2, RSV3, OPCODE)
  console.log()
  
  const IS_MASK = data[1] >> 7 == 1;
  const PAYLOAD_LENGTH = data[1] & 0x7f;

  console.log(`IS_MASK: ${IS_MASK}, PAYLOAD_LENGTH: ${PAYLOAD_LENGTH}`)

  const MASK = data.slice(2, 6);
  const payload = data.slice(8);
  console.log('mask', MASK)
  console.log('payload', payload)
   
  const buffer = Buffer.alloc(PAYLOAD_LENGTH); //  payload 저장용 버퍼생성

  for (let i = 0 ; i < PAYLOAD_LENGTH ; i++) {
    buffer[i] = data[6 + i] ^ MASK[i % 4]
  }

  return buffer.toString('utf-8')
}
 

클라이언트와 서버는 데이터 프레임을 바이너리를 처리하기 위해 버퍼로 관리합니다. 이 부분은 사용하는 언어 및 라이브러리에 따라 달라질겁니다. 여기서 프레임의 값을 좀 더 편하게 보기위해 2진형태로 변환하는 함수가 convertToBinary1입니다.

function convertToBinary1 (number) {
  let num = number;
  let binary = (num % 2).toString();
  for (; num > 1; ) {
      num = parseInt(num / 2);
      binary =  (num % 2) + (binary);
  }
  return binary;
}
  const IS_MASK = data[1] >> 7 == 1;
  const PAYLOAD_LENGTH = data[1] & 0x7f;

IS_MASK에 따라 xor 연산을 할지 말지 판단하며, PAYLOAD_LENGTH에 따라 추가적인 데이터 길이를 위해 필요한 부분이 있는지 달라집니다.

  const MASK = data.slice(2, 6);
  const payload = data.slice(8);

PAYLOAD_LENGTH에 따라 masking-key를 가져오는 부분과 payload를 가져오는 기준점이 달라집니다.

MASK가 활성되었기 때문에 xor 연산을 합니다.

  const buffer = Buffer.alloc(PAYLOAD_LENGTH); //  payload 저장용 버퍼생성
  for (let i = 0 ; i < PAYLOAD_LENGTH ; i++) {
    buffer[i] = data[6 + i] ^ MASK[i % 4]
  }

이렇게 말이죠 물론 xor 연산 결과를 buffer에 넣어줍니다. 버퍼의 사이즈는 미리 정해야 하기 때문에 페이로드 사이즈는 중요합니다. 물론 이를 별도의 버퍼공간을 만들어서 concat하는 방법도 있지만 이럴경우 성능이 떨어집니다.

아마 코드를 보면서 >>, &의 연산자가 많이 나오는것을 보실텐데... 이게 최적화를 위해 비트연산자를 활용한 사례입니다.... 이 부분은 최적화가 정말 중요한 문제라 최대한 비트연산자를 많이 사용합니다.

const net = require('net');
const crypto = require('crypto');

function handshakeMsg(msg) {
  const websocketKey = msg.split('\n').find(item => item.includes('Sec-WebSocket-Key'));
  const secWebsocketAccept = websocketKey.split(': ')[1].replace('\n', '').replace('\r', '');
  const salt = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'

  // sha1 해싱된 결과 base64 인코딩
  const sha1 = crypto.createHash('sha1')
  const hashing = sha1.update(secWebsocketAccept + salt).digest('base64');
  
  return 'HTTP/1.1 101 Switching Protocols\r\n' +
         'Upgrade: websocket\r\n' +
         'Connection: Upgrade\r\n' +
         'Sec-WebSocket-Accept: ' + hashing + '\r\n' +
         '\r\n';
}

function isHandshake(data) {
  return data.split('\n').length > 2;
}

function convertToBinary1 (number) {
  let num = number;
  let binary = (num % 2).toString();
  for (; num > 1; ) {
      num = parseInt(num / 2);
      binary =  (num % 2) + (binary);
  }
  return binary;
}

function decode(data) {
  let FIN = data[0] >> 7 == 1;
  let RSV1 = (data[0] >> 6 & 1) == 1;
  let RSV2 = (data[0] >> 5 & 1) == 1;
  let RSV3 = (data[0] >> 4 & 1) == 1;
  let OPCODE = data[0] & 0xf;

  console.log('FIN, RSV1, RSV2, RSV3, OPCODE', convertToBinary1(data[0]))
  console.log(FIN, RSV1, RSV2, RSV3, OPCODE)
  console.log()
  
  const IS_MASK = data[1] >> 7 == 1;
  const PAYLOAD_LENGTH = data[1] & 0x7f;

  console.log(`IS_MASK: ${IS_MASK}, PAYLOAD_LENGTH: ${PAYLOAD_LENGTH}`)

  const MASK = data.slice(2, 6);
  const payload = data.slice(8);
  console.log('mask', MASK)
  console.log('payload', payload)
   
  const buffer = Buffer.alloc(PAYLOAD_LENGTH); //  payload 저장용 버퍼생성

  for (let i = 0 ; i < PAYLOAD_LENGTH ; i++) {
    buffer[i] = data[6 + i] ^ MASK[i % 4]
  }

  return buffer.toString('utf-8')
}

const server = net.createServer(socket => {
  console.log(`cnnected address: ${socket.address().address}`);
  socket.on('data', (data) => { 
    
    if(isHandshake(data.toString())) {
      console.log('====== handshake start ===== ')
      console.log('request headers')
      console.log(data.toString());
  
      const msg = handshakeMsg(data.toString());
      console.log('response headers')
      console.log(msg)
      socket.write(msg)
      console.log('====== handshake end ===== ')
    } else {
      console.log('========= dataframe parse start =========')
      console.log(data)
      console.log('decode: ', decode(data));
      console.log('========= dataframe parse end =========')
    }
  });
})

// 클라이언트 -> 서버 마스킹 해야 함
// 서버 -> 클라이언트 마스킹 안해야 함
server.listen(5000, function() {
  console.log(`listen on port 5000`)
})

서버는 연결을 목적으로 하는 핸드쉐이크와 데이터 전달을 목적으로 하는 데이터 프레임의 목적을 구분하기 위해 isHandshake 함수를 이용하여 구분합니다. 연결목적인 경우 HTTP 헤더라 엔터가 존재하므로 엔터존재 유무로 구분하고 있습니다. 실제로는 이렇게 안합니다.

서버를 구동 후 간략하게 클라이언트를 실행하겠습니다.

const WebSocketClient = require('websocket').client;

const client = new WebSocketClient();
client.connect('ws://localhost:5000');

client.on('connect', function(connection) {
    console.log('WebSocket Client Connected');
    
    function sendNumber() {
        if (connection.connected) {
            const data0 = {a: 10};
            console.log('send data', data0)
            connection.send(JSON.stringify(data0))
            setTimeout(sendNumber, 3000);
        }
    }
    sendNumber();
});

연결이 성공적으로 되었으면 json 형태의 데이터를 문자열로 변환하여 3000ms마다 서버로 전송합니다.

<!DOCTYPE html>
<html>
  <head></head>
  <body>
    <script>
      const ws = new WebSocket('ws://localhost:5000');
      setInterval(() => {
        ws.send('hello world');
      }, 3000)
    </script>
  </body>
</html>

해당 브라우저를 실행하면 브라우저에서 우리가 구현한 서버로 인코딩 된 hello world를 전송합니다.

· 데이터 프레임 인코딩 구현

드디어 이번 여정의 마지막입니다. 우선 중요한 점! 서버에서 클라이언트로 요청할 땐 MASK 처리하지 않습니다. 그러므로 MASK 비트는 0이 됩니다. 그렇다면 Masking-Key는 존재하지 않습니다.

function encode(msg) {
  const RES_PAYLOAD_LENGTH = msg.length.toString(2).padStart(8, 0); // 2진수 변환

  const responseBuffer =  Buffer.concat([
    // FIN(1), RSV1(0), RSV2(0), RSV3(0), OPCODE(0001) 
    // 10000001 => 0x81
    Buffer.from('81'.toString(16), 'hex'),
    // mask + payload_length 서버 -> 클라이언트 시 mask는 0이므로 payload 길이만 가지고 만든다.
    Buffer.from(parseInt(RES_PAYLOAD_LENGTH, 2).toString(16).padStart(2, 0), 'hex'), 
    // 페이로드 메시지 인코딩(UTF-8)
    Buffer.from(msg)
  ])

  return responseBuffer;
}

우리가 원하는 패킷 모양을 만들어 줍니다. MASK는 이며, 해당 형태는 데이터의 길이가 126이하여야만 합니다. 이 이상이면 데이터 길이를 포함해야 하는 패킷을 더 추가해야하기 때문입니다.

이제 서버 기존에 클라이언트가 데이터를 보내면 서버는 '멍개는 잘 생겼다!!'라는 문구를 해당 클라이언트에게 2번 전송합니다.

만약 클라이언트가 서버로 보내는 데이터 프레임이라면 MASK는 1이여야 하며 Masking-Key가 추가되고 페이로드와 XOR 연산 후 UTF-8로 인코딩 되어야 합니다.

const net = require('net');
const crypto = require('crypto');

function handshakeMsg(msg) {
  const websocketKey = msg.split('\n').find(item => item.includes('Sec-WebSocket-Key'));
  const secWebsocketAccept = websocketKey.split(': ')[1].replace('\n', '').replace('\r', '');
  const salt = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'

  // sha1 해싱된 결과 base64 인코딩
  const sha1 = crypto.createHash('sha1')
  const hashing = sha1.update(secWebsocketAccept + salt).digest('base64');
  
  return 'HTTP/1.1 101 Switching Protocols\r\n' +
         'Upgrade: websocket\r\n' +
         'Connection: Upgrade\r\n' +
         'Sec-WebSocket-Accept: ' + hashing + '\r\n' +
         '\r\n';
}

function isHandshake(data) {
  return data.split('\n').length > 2;
}

function convertToBinary1 (number) {
  let num = number;
  let binary = (num % 2).toString();
  for (; num > 1; ) {
      num = parseInt(num / 2);
      binary =  (num % 2) + (binary);
  }
  return binary;
}

function decode(data) {
  let FIN = data[0] >> 7 == 1;
  let RSV1 = (data[0] >> 6 & 1) == 1;
  let RSV2 = (data[0] >> 5 & 1) == 1;
  let RSV3 = (data[0] >> 4 & 1) == 1;
  let OPCODE = data[0] & 0xf;

  console.log('FIN, RSV1, RSV2, RSV3, OPCODE', convertToBinary1(data[0]))
  console.log(FIN, RSV1, RSV2, RSV3, OPCODE)
  console.log()
  
  const IS_MASK = data[1] >> 7 == 1;
  const PAYLOAD_LENGTH = data[1] & 0x7f;

  console.log(`IS_MASK: ${IS_MASK}, PAYLOAD_LENGTH: ${PAYLOAD_LENGTH}`)

  const MASK = data.slice(2, 6);
  const payload = data.slice(8);
  console.log('mask', MASK)
  console.log('payload', payload)
   
  const buffer = Buffer.alloc(PAYLOAD_LENGTH); //  payload 저장용 버퍼생성

  for (let i = 0 ; i < PAYLOAD_LENGTH ; i++) {
    buffer[i] = data[6 + i] ^ MASK[i % 4]
  }

  return buffer.toString('utf-8')
}

function encode(msg) {
  const RES_PAYLOAD_LENGTH = msg.length.toString(2).padStart(8, 0); // 2진수 변환

  const responseBuffer =  Buffer.concat([
    // FIN(1), RSV1(0), RSV2(0), RSV3(0), OPCODE(0001) 
    // 10000001 => 0x81
    Buffer.from('81'.toString(16), 'hex'),
    // mask + payload_length 서버 -> 클라이언트 시 mask는 0이므로 payload 길이만 가지고 만든다.
    Buffer.from(parseInt(RES_PAYLOAD_LENGTH, 2).toString(16).padStart(2, 0), 'hex'), 
    // 페이로드 메시지 인코딩(UTF-8)
    Buffer.from(msg)
  ])

  return responseBuffer;
}

const server = net.createServer(socket => {
  console.log(`cnnected address: ${socket.address().address}`);
  socket.on('data', (data) => { 
    
    if(isHandshake(data.toString())) {
      console.log('====== handshake start ===== ')
      console.log('request headers')
      console.log(data.toString());
  
      const msg = handshakeMsg(data.toString());
      console.log('response headers')
      console.log(msg)
      socket.write(msg)
      console.log('====== handshake end ===== ')
    } else {
      console.log('========= dataframe parse start =========')
      console.log(data)
      console.log('decode: ', decode(data));
      const msgBuffer = encode('abcdefghijk');

      console.log('encode: ', msgBuffer);
      socket.write(msgBuffer)
      socket.write(msgBuffer)
      console.log('========= dataframe parse end =========')
    }
  });
})

// 클라이언트 -> 서버 마스킹 해야 함
// 서버 -> 클라이언트 마스킹 안해야 함
server.listen(5000, function() {
  console.log(`listen on port 5000`)
})

클라이언트가 한 번요청하면 서버는 2개의 데이터를 응답합니다.

<!DOCTYPE html>
<html>
  <head></head>
  <body>
    <script>
      const ws = new WebSocket('ws://localhost:5000');
      setInterval(() => {
        ws.send('hello world');
      }, 3000)

      ws.on('data', data => {
        console.log(data);
      })
    </script>
  </body>
</html>

3000ms마다 한 번씩 서버로 hello world데이터를 전달합니다.ws.on('data')는 서버가 전달한 데이터가 있을 때 콜백을 호출합니다.

websocket으로 구현한 클라이언트라면 다음과 같이 처리합니다.

const WebSocketClient = require('websocket').client;

const client = new WebSocketClient();
client.connect('ws://localhost:5000');

client.on('connect', function(connection) {
    console.log('WebSocket Client Connected');

    connection.on('message', function(message) {
        console.log(message)
        if (message.type === 'utf8') {
            console.log("Received: '" + message.utf8Data + "'");
        }
    });
    
    function sendNumber() {
        if (connection.connected) {
            const data0 = {a: 10};
            console.log('send data', data0)
            connection.send(JSON.stringify(data0))
            setTimeout(sendNumber, 1000);
        }
    }
    sendNumber();
});

on('message')를 이용하여 서버가 전달한 데이터를 받을 수 있습니다.

만약 http에서 웹소켓 서버를 구현했다면 TCP 보다 훨씬 깔끔합니다. TCP 입장에선 HTTP랑 websocket의 핸드쉐이크, 데이터프레임 모두 받기 때문에 이를 구분해야 합니다. 하지만 HTTP 위에선 핸드쉐이크가 끝났다면 HTTP 서버는 더이상 웹소켓 클라이언트의 요청을 직접 받지 않습니다.

 
const http = require("http");
const crypto = require("crypto");

function handshakeMsg(msg) {
  const secWebsocketAccept = msg;
  const salt = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'

  // sha1 해싱된 결과 base64 인코딩
  const sha1 = crypto.createHash('sha1')
  const hashing = sha1.update(secWebsocketAccept + salt).digest('base64');
  
  return 'HTTP/1.1 101 Switching Protocols\r\n' +
         'Upgrade: websocket\r\n' +
         'Connection: Upgrade\r\n' +
         'Sec-WebSocket-Accept: ' + hashing + '\r\n' +
         '\r\n';
}

// encode, decode, convertToBinary1 함수는 동일하게 사용가능

const server = http.createServer(function (req, res) {
  console.log('123')
});

server.on("upgrade", function (req, socket, head) {
  console.log(req.headers)
  let headersReturn = handshakeMsg(req.headers["sec-websocket-key"])
  socket.write(headersReturn);

  socket.on('data', (data) => {
    console.log('========= dataframe parse start =========')
    console.log(data)
    console.log('decode: ', decode(data));
    const msgBuffer = encode('abcdefghijk');

    console.log('encode: ', msgBuffer);
    socket.write(msgBuffer)
    socket.write(msgBuffer)
    console.log('========= dataframe parse end =========')
  })
});

server.listen(5000, function () {
  console.log("server is on 5000");
});

또한 핸드쉐이크 시 http 라이브러리는 json으로 파싱된 헤더를 사용할 수 있습니다.

HTTP 프로토콜이 아니라 웹소켓 데이터프레임 프로토콜에 맞춰서 통신하기 때문에 http로 동작중인 server.on은 동작하지 않고 socket이 데이터를 수신합니다.

만약 클라이언트를 TCP로 직접 구현했다면 서버에서 구현했던 데이터프레임 생성을 그대로 가져와야 하지만 가변 데이터 프레임 정책에 따라 인코딩 디코딩을 해야합니다.

const net = require('net');

// 서버 5000번 포트로 접속 
// TCP level: 3 way-handshake
const socket = net.connect({ port: 5000 });

let isHandshake = false;

socket.on('connect', function () {
  console.log('connected to server!');
  
  const msg = [
    'GET / HTTP/1.1\r\n',
    'Connection: Upgrade\r\n',
    'Upgrade: websocket\r\n',
    'Sec-WebSocket-Version: 13\r\n',
    'Sec-WebSocket-Key: s+92vaV9BEsiNRk0rVmKDA==\r\n',
    'Host: localhost:5000\r\n',
    '\r\n'
  ].join('')

  // HTTP level: 2 way-handshake
  socket.write(msg);

  setInterval(() => {
    if (isHandshake) { // handshake 되었을 때만 실행
      socket.write(); // TODO: 인코딩
    }
  }, 1000);
});

// 서버로부터 받은 데이터를 화면에 출력 
socket.on('data', function (data) {
  // TODO: 디코딩 필요
  // 핸드쉐이크 응답과 dataframe을 전달받음

  // 핸드쉐이크 응답을 전달받은 것에 따라 isHandshake를 true로 바꿔준다
  isHandshake = true; 
  console.log(data); 
});

// // 접속이 종료됬을때 메시지 출력 
// // 4 way-handshake
// socket.on('end', function () {
//   console.log('disconnected.');
// });

// // 에러가 발생할때 에러메시지 화면에 출력 
// socket.on('error', function (err) {
//   console.log(err);
// });

// // connection에서 timeout이 발생하면 메시지 출력 
// socket.on('timeout', function () {
//   console.log('connection timeout.');
// });

코드의 레이아웃은 대략 이런 모습이 됩니다.

지금까지 TCP와 HTTP 레이어에서 웹소켓을 구현해보았습니다.

● socketio

socketio는 websocket위에서 동작하는 프로토콜 입니다. 또한 socketio는 커넥션이 끊기면 재연결을 시도하거나 웹소켓을 지원하지 않는 환경에서는 polling과 같은 기술을 사용하여 웹소켓을 대체합니다. transports는 연결을 시도할 프로토콜을 명시합니다.

const { io } = require("socket.io-client");

const socket = io("ws://localhost:5000", {
  transports: ["websocket", "polling"],
});

socket.io-client 또는 socket.io의 CDN을 이용하면 socket.io를 사용할 수 있습니다. socket.io는 transports로 websocket, polling 등을 넣을 수 있습니다. transports에 명시한 순서대로 클라이언트는 연결을 시도합니다.

transports는 다음과 같은 종류를 포함할 수 있습니다.

websocket
flashsocket
htmlfile
xhr-polling
jsonp-polling
polling

(별의 별 방식이 다 있죠?)

▶ websocket으로 연결 시도시 헤더

GET /socket.io/?EIO=4&transport=websocket HTTP/1.1
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: IogOLF9O3PcDxEVa1yCW7A==
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Host: localhost:5000

socket.io는 sec-websocket-extensions을 포함합니다. sec-websocket-extensions은 클라이언트가 제공하는 확장 기능을 의미합니다.

▶ polling으로 연결 시도시 헤더

 
GET /socket.io/?EIO=4&transport=polling&t=NwOl9RS&b64=1 HTTP/1.1
User-Agent: node-XMLHttpRequest
Accept: */*
Host: localhost:5000
Connection: close

또한 socket.io는 웹소켓의 단점 중 하나인 서버와 클라이언트간 주고받는 메시지를 on('message') 하나의 이벤트로만 처리를 해야합니다. 하지만 서버와 클라이언트는 다양한 형태의 이벤트(즉 엔드포인트)를 구분하는데 이를 편하게 할 수 있도록 개선한 프로토콜이 socket.io입니다. 데이터를 보낼 때 emit('이벤트 명', 전달 할 데이터)를 호출하면 수신자 측에선 on('이벤트 명', 전달받은 데이터)로 데이터를 받을 수 있습니다. emit과 on은 서버와 클라이언트 구분없이 사용할 수 있습니다.

socket.io의 프로토콜은 다음과 같습니다. 해당 프로토콜은 Namespace(nsp), type, data, id가 포함된 json 구조의 포맷으로 만들어집니다. 확실히 점점 프로토콜의 모습도 발전하고 있는 모습이 보입니다.

https://github.com/socketio/socket.io-protocol

 

GitHub - socketio/socket.io-protocol: Socket.IO Protocol specification

Socket.IO Protocol specification. Contribute to socketio/socket.io-protocol development by creating an account on GitHub.

github.com