websocket은 HTML5 표준 기술로, 서버와 클라이언트 간 connection을 유지하며 "패킷" 형태로 양방향 통신, 데이터 전송이 가능하도록 하는 기술입니다.

웹 소켓은 다음과 같은 특징을 갖고 있습니다.

양방향 통신

통산적인 HTTP 통신이 요청에 대한 응답의 단방향 통신인 반면, websocket은 클라이언트와 서버가 서로에게 원할 때 데이터를 주고 받을 수 있습니다. 데이터 송수신을 동시에 처리할 수 있다는 특징이 있습니다.

WebSocket API를 사용하면 응답을 위해 서버를 폴링하지 않아도 서버로 메시지를 보내고 이벤트 기반(event-driven) 한 응답을 서버로부터 전달받을 수 있습니다.

Websocket은 웹 환경에서 연속된 데이터를 빠르게 노출하거나, 여러 단말기에 데이터를 빠르게 교환해야 하는 실시간 네트워킹 환경에서 사용될 수 있습니다. 채팅, 주식, 라이브 비디오 등이 대표적인 예시 입니다.

WebSocket 이전의 기술

  • Polling - 클라이언트에서 일정 주기로 서버에 요청을 보내는 기술입니다. 실시간 네트워킹에서 언제 통신이 발생할지 예측할 수 없으므로 서버에게 계속해서 요청을 보내며 응답을 받는 구조입니다. 불필요한 요청과 연결이 생성됩니다.

  • Long Poling - Polling의 단점을 해소하기 위해 요청을 보낸 뒤 서버에서 조금 더 대기하면서 이벤트가 발생할 때 응답하는 방식입니다. 응답을 받으면 연결이 끊어지며 재요청합니다. 많은 양의 메시지가 쏟아지는 경우 Polling이상의 문제가 발생합니다.

  • Streaming - 서버에 요청을 보내고 끊기지 않은 연결상태에서 끊임없이 데이터를 수신합니다. 클라이언트에서 서버로의 데이터 송신이 어렵습니다.

위 방식 모두 HTTP를 이용해 통신하기 때문에 요청/응답 모두 헤더가 불필요하게 크다는 단점이 있습니다. (HTTP는 메타데이터가 많아 헤더가 엄청 큰 방식입니다)

웹 소켓의 동작 방법

웹 소켓은 http(80), https(443)과 동일한 소켓을 이용해 통신합니다.

웹소켓 프로토콜은 http, https와 같이 ws, wss 프로토콜이 존재합니다.

wss:// 는 보안 이외에도 신뢰성 측면에서 ws보다 좀 더 신뢰할 만한 프로토콜 입니다.
ws://를 사용해 데이터를 전송하면 데이터가 암호화되지 않은 채로 전송되기 때문에 데이터가 그대로 노출됩니다. 아주 오래된 프록시 서버는 웹 소켓이 무엇인지 몰라 이상한 헤더가 붙은 요청이 들어왔다고 간주해 연결을 끊어버릴 수 있습니다.
반면 wss://는 TSL(전송 계층 보안(Transport Layer Security))라는 보안 계층을 통과해 전달되므로 송신자 측에서 데이터가 암호화되고, 복호화는 수신자 측에서 이뤄지게 됩니다. 따라서 데이터가 담긴 패킷이 함호화된 상태로 프록시 서버를 통과하므로 프록시 서버는 패킷 내부를 볼 수 없게 됩니다.

Hand Shaking

웹 소켓은 연속적인 데이터 전송의 신뢰성을 보장하기 위해 Handshake 과정을 진행합니다.

기존의 다른 TCP 기반의 프로토콜은 TCP layer에서의 Handshake를 통해 연결을 수립하는 반면, 웹 소켓은 HTTP 요청 기반으로 Handshake 과정을 거쳐 연결을 수립합니다.

웹 소켓 요청 HTTP 헤더의 예시를 살펴봅시다.

GET /example/chat HTTP/1.1
Host: example.com:8080
Upgrade: websocket
Connection: Upgrade

Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: https://example.com

요청은 반드시 HTTP Get 메서드를 사용해야 하며 HTTP 버전은 1.1이상 이어야 합니다.

웹 소켓 요청시 Upgrade헤더를 반드시 websocket으로 지정해야 합니다. Upgrade헤더는 현재 클라이언트, 서버, 전송 프로토콜 연결에서 다른 프로토콜로 업그레이드/변경 하기 위한 규칙입니다.

Connection 헤더는 반드시 Upgrade를 명시해주어야 합니다. Upgrade 헤더가 명시되었을 경우, 송신자는 반드시 Upgrade 옵션을 지정한 Connection헤더 필드도 전송합니다.

Sec-WebSocket-Key는 클라이언트와 서버 간 서로의 신원을 인증하기 위해 사용됩니다.

Sec-WebSocket-Protocol은 클라이언트가 요청하는 여러 서브프로토콜을 의미합니다. 공백문자를 구분하며 순서에 따라 우선권이 부여됩니다. 서버에서 여러 프로토콜 혹은 프로토콜 버전을 나눠 서비스 하는 경우 필요한 정보입니다.

이렇게 요청을 보낸 뒤 받는 응답은 다음과 같습니다.

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

101 Switching Protocols 응답이 오면 웹 소켓이 연결되었다는 의미입니다. Upgrade/ Connection헤더는 위에서 살펴본 그대로 있죠?

Sec-WebSocket-Accept 헤더는 클라이언트로 부터 받은 Sec-Web-Socket-Key를 미리 정의된 문자열인 "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"을 연결해 새로운 문자열을 만듭니다. 그리고 생성된 문자열의 SHA-1 hash 해시값을 구한 후 base64로 인코딩한 결과입니다.

이는 서로의 신원을 인증하는 과정에 필요한 헤더로 클라이언트에서 계산한 값과 일치하지 않으면 연결하지 않습니다.

데이터 전송

Hand Shake 이후 데이터는 ws(80), wss(443)프로토콜을 이용해 통신합니다.

  • Message: 여러 Frame이 모여 구성하는 하나의 논리적 메시지 단위입니다. ws 프로토콜을 통해 주고 받는 단위라고 볼 수 있습니다.
  • Frame: Communication에서 가장 작은 단위의 데이터 입니다. 작은 헤더 + Payload로 구성되어 기존 무거운 헤더의 단점을 보완합니다.

웹 소켓 통신에 사용되는 데이터는 UTF-8 인코딩을 통해서만 지원됩니다.
0x00{보낼 데이터}0xff 와 같은 형태로 데이터를 주고 받습니다.

프레임

  • FIN: 전체 메시지의 끝을 나타내는 플래그
  • OPCODE
    - Continue(0x0): 전체 메시지의 일부임을 의미
    • Text(0x1): 포함된 데이터가 UTF-8 텍스트라는 의미
    • Binary(0x2): 포함된 데이터가 이진 데이터라는 의미
    • close(0x8): Close 핸드쉐이크를 시작한다는 의미
  • Length: 이 프레임에 포함된 데이터의 총 길이를 나타내는 단위
  • RSV 1~3: 프로토콜 별로 사용할 수 있고 사용안하기도 하는 데이터

정리

핸드 쉐이크로 ws프로토콜로 업그레이드 하고, 양방향 데이터를 전송하며, 전송이 완료되면 Close Frame을 주고 받으며 종료됩니다.

1. HTTP를 이용한 HandShake
2. ws프로토콜로 양방향 통신 (0x00{UTF8 payload}0xff 형식)
3. Close Frame을 이용한 연결 종료

메시지에 포함될 수 있는 교환 가능한 메시지는 오직 텍스트와 바이너리 입니다.

STOMP

웹 소켓은 문자열을 주고 받을 뿐 그 이상의 일은 하지 않습니다. 즉 주고 받는 문자열은 온전히 에플리케이션에서 담당하죠. HTTP는 정해진 형식이 있으므로 해석이 쉽지만, ws는 그렇지가 않습니다.

따라서 ws방식은 Sub Protocol을 사용해서 주고 받는 메시지의 형태를 약속하는 경우가 많습니다. 이러한 프로토콜 중 하나가 바로 STOMP(Simple/Stream Text Oriented Message Protocol)입니다.

STOMP는 채팅 통신을 하기 위한 형식을 정의합니다. HTTP와 유사하게 간단히 정의되어 해석하기 편하며 일반적으로 웹 소켓 위에서 사용됩니다.

일반적인 프레임 구조는 다음과 같습니다.

COMMAND
header1:value1
header2:value2

Body^@

STOMP는 프레임 기반의 프로토콜로 프레임은 명령, 헤더, 바디로 구성됩니다.
자주 사용되는 명령은 CONNECT, SEND, SUBSCRIBE, DISCONNECT등이 있죠. 헤더와 바디는 빈 라인으로 구분하며 바디의 끝은 NULL 문자로 설정합니다.

ws를 사용해보신 분들은 알겠지만, 단순한 문자열로 주고 받기에 해석이 어려워 주고 받는 문자의 형식을 정의한 경험이 있을거에요. STOMP는 그런 형식을 정할때 사용할 수 있는 좋은 방법입니다.

// 실제 STOMP 구조 예시

CONNECT
notice:1
accept-version:1.2
heart-beat:10000.10000

SUBSCRIBE
id:sub-1600335610276_227
destination:/channel/1

MESSAGE
destination:/channel/1
content-type:application/json
subscription:sub_1600335610276-227
message-id:zowit0sl-2
content-length:103

자바스크립트를 사용해 WebSocket을 사용하는 예시를 살펴보겠습니다.

server

import { WebSocketServer } from "ws";

const server = new WebSocketServer({ port: 3000 });

server.on("connection", (socket) => {
  // send a message to the client
  socket.send(JSON.stringify({
    type: "hello from server",
    content: [ 1, "2" ]
  }));

  // receive a message from the client
  socket.on("message", (data) => {
    const packet = JSON.parse(data);

    switch (packet.type) {
      case "hello from client":
        // ...
        break;
    }
  });
});

client

const socket = new WebSocket("ws://localhost:3000");

socket.addEventListener("open", () => {
  // send a message to the server
  socket.send(JSON.stringify({
    type: "hello from client",
    content: [ 3, "4" ]
  }));
});

// receive a message from the server
socket.addEventListener("message", ({ data }) => {
  const packet = JSON.parse(data);

  switch (packet.type) {
    case "hello from server":
      // ...
      break;
  }
});

Socket.io

Socket.io는 JavaScript를 이용하여 브라우저 종류에 상관 없이 실시간 웹 서비스를 구현할 수 있도록 도와주는 기술입니다.

브라우저와 웹 서버의 종류, 버전을 파악하여 가장 적합한 기술을 선택하여 사용하도록 도와주죠. 따라서 개발자가 각 기술을 깊이 이해하지 못하거나 구현 방법을 알지 못해도 사용할 수 있습니다.

Socket.io는 클라이언트와 서버 간의 짧은 대기 시간, 양방향성, 이벤트 기반 통신을 가능하게 해주는 라이브러리 입니다.

기본적으로 WebSocket 프로토콜을 기반으로 구축되었으며, HTTP 롱 폴링 또는 재연결에 대한 폴백과 같은 추가 보장을 제공합니다.

만약 WebSocket 연결을 설정할 수 없는 경우 연결이 HTTP 롱 폴링으로 대체됩니다.

현재 대부분의 브라우저가 WebSocket을 지원하지만(97% 이상), 일부 잘못 구성된 프록시 뒤에 있기 때문에 연결을 설정할 수 없는 사용자들도 존재합니다. 이런 경우에도 Socket.io를 사용할 수 있도록 보장해주죠.

또 Socket.io에는 연결 상태를 주기적으로 확인하는 하트비트 매커니즘이 포함되어 있습니다. 따라서 클라이언트가 연결이 끊어지면 자동으로 다시 연결합니다.

일반적인 예시를 살펴보겠습니다.

server

import { Server } from "socket.io";

const io = new Server(3000);

io.on("connection", (socket) => {
  // send a message to the client
  socket.emit("hello from server", 1, "2", { 3: Buffer.from([4]) });

  // receive a message from the client
  socket.on("hello from client", (...args) => {
    // ...
  });
});

client

import { io } from "socket.io-client";

const socket = io("ws://localhost:3000");

// send a message to the server
socket.emit("hello from client", 5, "6", { 7: Uint8Array.from([8]) });

// receive a message from the server
socket.on("hello from server", (...args) => {
  // ...
});

Socket.io는 내부적으로 프로덕션 환경에서 WebSocket을 기반으로 애플리케이션을 작성하는 복잡성을 줄여줍니다.

또 이벤트를 보내고, 응답을 받는 편리한 방법을 제공합니다

sender

// eventInfo, data, callback function
socket.emit("hello", "world", (response) => {
  console.log(response); // "got it"
});

receiver

socket.on("hello", (arg, callback) => {
  console.log(arg); // "world"
  callback("got it");
}); 

서버 측에서는 연결된 모든 클라이언트, 특정 클라이언트의 집합(room)에 이벤트를 보낼 수 있습니다.

// to all connected clients
io.emit("hello");

// to all connected clients in the "news" room
io.to("news").emit("hello");

서버가 여러 노드로 확장할 때도 작동합니다.

네임스페이스를 사용하면 단일 공유 연결을 통해 애플리케이션의 논리를 분할할 수 있습니다. 승인된 사용자만 참여할 수 있는 관리자 채널을 만드려는 경우 등에 유용합니다.

io.on("connection", (socket) => {
  // classic users
});

io.of("/admin").on("connection", (socket) => {
  // admin users
});

이러한 방식을 이용해 단일 서버로 동작하는 채팅 기능 등을 구현할 수 있습니다.

또한 메시지 동기화 큐 등을 사용하면 서버 노드를 확장한(scale out) 대규모 채팅 어플리케이션 설계도 가능할 것으로 보입니다.

출처

profile
웹 개발을 공부하고 있는 윤석주입니다.

0개의 댓글