네트워크 요청(웹 소켓)

Websocket
Dragon C's avatar
Aug 16, 2024
네트워크 요청(웹 소켓)
  • 웹소켓(WebSocket)프로토콜을 사용하면 서버와 브라우저 간 연결을 유지한 상태로 데이터를 교환할 수 있다. 데이터는 ‘패킷(packet)’ 형태로 전달되며, 전송은 커넥션 중단과 추가 HTTP 요청 없이 양방향으로 이뤄진다.

  • 웹소켓은 온라인 게임이나 주식 트레이딩 시스템같이 데이터 교환이 지속적으로 이뤄져야 하는 서비스에 적합하다.

  • 간단한 예시

    • 웹소켓 커넥션은 new WebSocket을 호출하여 만들 수 있고, ws라는 특수 프로토콜을 사용한다.

    let socket = new WebSocket("ws://javascript.info");
    
    • ws 와wss://프로토콜이 있는데, 두 프로토콜의 관계는 HTTP와 HTTPS의 관계와 유사하다.

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

    • 소켓이 정상적으로 만들어지면 네 개의 이벤트를 사용할 수 있게 됩니다.

      • open – 커넥션이 제대로 만들어졌을 때 발생

      • message – 데이터를 수신하였을 때 발생

      • error – 에러가 생겼을 때 발생

      • close – 커넥션이 종료되었을 때 발생

    • 커넥션이 만들어진 상태에서 무언가를 보내고 싶으면 socket.send(data)를 사용한다.

let socket = new WebSocket("wss://javascript.info/article/websocket/demo/hello");

socket.onopen = function(e) {
  alert("[open] 커넥션이 만들어졌습니다.");
  alert("데이터를 서버에 전송해봅시다.");
  socket.send("My name is Bora");
};

socket.onmessage = function(event) {
  alert(`[message] 서버로부터 전송받은 데이터: ${event.data}`);
};

socket.onclose = function(event) {
  if (event.wasClean) {
    alert(`[close] 커넥션이 정상적으로 종료되었습니다(code=${event.code} reason=${event.reason})`);
  } else {
    // 예시: 프로세스가 죽거나 네트워크에 장애가 있는 경우
    // event.code가 1006이 됩니다.
    alert('[close] 커넥션이 죽었습니다.');
  }
};

socket.onerror = function(error) {
  alert(`[error]`);
};

웹소켓 핸드셰이크

  • 소켓을 생성하면 즉시 연결이 시작된다. 커넥션이 유지되는 동안, 브라우저는 헤더를 사용하여 서버에 '웹소켓을 지원하나요?'라고 물어본다. 서버가 '네’라는 응답을 하면 서버-브라우저간 통신은 HTTP가 아닌 웹소켓 프로토콜을 사용해 진행된다.

  • new WebSocket("wss://javascript.info/chat") 호출해 최초 요청을 전송했다고 가정하고, 이때의 요청 헤더를 살펴본다.

    GET /chat
    Host: javascript.info
    Origin: https://javascript.info
    Connection: Upgrade
    Upgrade: websocket
    Sec-WebSocket-Key: Iv8io/9s+lYFgZWcXczP8Q==
    Sec-WebSocket-Version: 13
  • Origin – 클라이언트 오리진을 나타낸다. 서버는 Origin 헤더를 보고 어떤 웹사이트와 소켓 통신을 할지 결정한다. 웹소켓 객체는 기본적으로 크로스 오리진(cross-origin) 요청을 지원한다. 웹소켓 통신만을 위한 전용 헤더나 제약도 없다. 오래된 서버는 웹소켓 통신을 지원하지 못하기 때문에 웹소켓 통신은 호환성 문제도 없다.

  • Connection: Upgrade – 클라이언트 측에서 프로토콜을 바꾸고 싶다는 신호를 보냈다는 것을 의미한다.

  • Upgrade: websocket – 클라이언트측에서 요청한 프로토콜은 'websocket’이라는걸 의미한다.

  • Sec-WebSocket-Key – 보안을 위해 브라우저에서 생성한 키로, 서버가 웹소켓 프로토콜을 지원하는지를 확인하는데 사용된다. 프록시가 다음 통신을 캐싱하지 못하도록 하기 위해 무작위로 설정된다.

  • Sec-WebSocket-Version – 웹소켓 프로토콜 버전이 명시된다.

  • 서버는 클라이언트 측에서 보낸 웹소켓 통신 요청을 최초로 받고 이에 동의하면, 상태 코드 101이 담긴 응답을 클라이언트에 전송한다.

101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: hsBlbuDTkk24srzEOTBUlZAlC2g=
  • Sec-WebSocket-Accept값은 특별한 알고리즘을 사용해 만든 Sec-WebSocket-Key 이다. 이 값을 보고 브라우저는 서버가 진짜 웹소켓 프로토콜을 지원하는지 확인한다.

  • 핸드셰이크가 끝나면 HTTP 프로토콜이 아닌 웹소켓 프로토콜을 사용해 데이터가 전송되기 시작한다.

    ⚠️

    웹소켓 핸드셰이크는 모방이 불가능하다. 바닐라 자바스크립트로 헤더를 설정하는 건 기본적으로 막혀있기 때문에 XMLHttpRequest나 fetch로 위 예시와 유사한 헤더를 가진 HTTP 요청을 만들 수 없다.

Extensions와 Subprotocols 헤더

  • 웹소켓 통신은 Sec-WebSocket-Extensions와 Sec-WebSocket-Protocol 헤더를 지원한다. 각각 웹소켓 프로토콜 기능을 확장(extension)할 때와 서브 프로토콜(subprotocal)을 사용해 데이터를 전송할 때 사용한다.

  • 예시

    • Sec-WebSocket-Extensions: deflate-frame – 이 헤더는 브라우저에서 데이터 압축(deflate)을 지원한다는 것을 의미한다. Sec-WebSocket-Extensions은 브라우저에 의해 자동 생성되는데, 그 값엔 데이터 전송과 관련된 것이나 웹소켓 프로토콜 기능 확장과 관련된 것이 나열된다.

    • Sec-WebSocket-Protocol: soap, wamp – 평범한 데이터가 아닌 SOAP나 WAMP(The WebSocket Application Messaging Protocol) 프로토콜을 준수하는 데이터를 전송하겠다는 것을 의미한다. 웹소켓에서 지원하는 서브 프로토콜 목록은 IANA 카탈로그에서 확인할 수 있다.

  • new WebSocket의 두 번째 매개변수에 값을 넣어서 설정할 수 있다.

let socket = new WebSocket("wss://javascript.info/chat", ["soap", "wamp"]);
  • 서버는 지원 가능한 익스텐션과 프로토콜을 응답 헤더에 담아 클라이언트에 전달해야 한다.

  • 요청 예시

    GET /chat
    Host: javascript.info
    Upgrade: websocket
    Connection: Upgrade
    Origin: https://javascript.info
    Sec-WebSocket-Key: Iv8io/9s+lYFgZWcXczP8Q==
    Sec-WebSocket-Version: 13
    Sec-WebSocket-Extensions: deflate-frame
    Sec-WebSocket-Protocol: soap, wamp
  • 응답 예시

    101 Switching Protocols
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Accept: hsBlbuDTkk24srzEOTBUlZAlC2g=
    Sec-WebSocket-Extensions: deflate-frame
    Sec-WebSocket-Protocol: soap
  • 서버에선 'deflate-frame’이라는 익스텐션과 요청 프로토콜 중 SOAP라는 서브 프로토콜만 지원한다는 사실을 알 수 있다.

데이터 전송

  • 웹소켓 통신은 '프레임(frame)'이라 불리는 데이터 조각을 사용한다. 프레임은 서버와 클라이언트 양측 모두에서 보낼 수 있는데, 프레임 내 담긴 데이터 종류에 따라 다음과 같이 분류한다.

    • 텍스트 프레임(text frame) – 텍스트 데이터가 담긴 프레임

    • 이진 데이터 프레임(binary data frame) – 이진 데이터가 담긴 프레임

    • 핑·퐁 프레임(ping/pong frame) – 커넥션이 유지되고 있는지 확인할 때 사용하는 프레임으로 서버나 브라우저에서 자동 생성해서 보내는 프레임

    • 커넥션 종료 프레임(connection close frame) 등 다양한 프레임이 있다.

  • 브라우저 환경에서는 텍스트나 이진 데이터 프레임만 다룬다. WebSocket .send() 메서드는 텍스트나 이진 데이터만 보낼 수 있기 때문이다.

  • socket.send(body)를 호출할 때, body엔 문자열이나 BlobArrayBuffer등의 이진 데이터만 들어갈 수 있다.

  • 데이터를 받을 때 텍스트 데이터는 항상 문자열 형태로 온다. 이진 데이터를 받을 때엔 Blob이나 ArrayBuffer 포맷 둘 중 하나를 고를 수 있다. socket.binaryType 프로퍼티를 사용하면 Blob이나 ArrayBuffer 포맷 둘 중 하나를 고를 수 있는데, 프로퍼티 기본값은 "blob"이라서 이진 데이터는 기본적으로 Blob 객체 형태로 전송받게 된다.

  • Blob은 고차원(high-level)의 이진 객체인데, <a>나 <img> 등의 태그와 바로 통합할 수 있어서 기본값을 blob으로 한 것이다. 하지만 이진 데이터를 처리하는 과정에 개별 데이터 바이트에 접근해야 한다면 프로퍼티 값을 "arraybuffer"로 바꿀 수도 있다.

    socket.binaryType = "arraybuffer";
    socket.onmessage = (event) => {
      // event.data는 (텍스트인 경우) 문자열이거나 (이진 데이터인 경우) arraybuffer 입니다.
    };

전송 제한

  • 데이터 전송량이 상당한 앱을 개발하고 있는데 앱의 사용자는 모바일이나 시골같이 네트워크 속도가 느린 곳에서 앱을 사용하고 있다고 가정한다.

  • 앱 쪽에서 socket.send(data)를 계속해서 호출할 수 있으나 데이터가 메모리에 쌓이게 되고(버퍼) 네트워크 속도가 데이터를 송신하기에 충분할 때만 송신될 것이다.

  • socket.bufferedAmount 프로퍼티는 송신 대기 중인 현재 시점에서 얼마나 많은 바이트가 메모리에 쌓여있는지 정보를 담고 있다.  socket.bufferedAmount 프로퍼티 값을 확인하면 소켓을 전송에 사용할 수 있는지 아닌지를 판단할 수 있다.

    // 100ms마다 소켓을 확인해 쌓여있는 바이트가 없는 경우에만
    // 데이터를 추가 전송합니다.
    setInterval(() => {
      if (socket.bufferedAmount == 0) {
        socket.send(moreData());
      }
    }, 100);

커넥션 닫기

  • 연결 주체(브라우저나 서버) 중 한쪽에서 커넥션 닫기(close)를 원하는 경우에는 보통 숫자로 된 코드와 문자로 된 사유가 담긴 '커넥션 종료 프레임’을 전송한다.

socket.close([code], [reason]);
  • code – 커넥션을 닫을 때 사용하는 특수 코드(옵션)

  • reason – 커넥션 닫기 사유를 설명하는 문자열(옵션)

// 닫기를 요청한 주체:
socket.close(1000, "Work complete");

// 다른 주체:
socket.onclose = event => {
  // event.code === 1000
  // event.reason === "작업 완료"
};

  • 많이 사용하는 코드

    • 1000 – 기본값으로 정상 종료를 의미함(code값이 주어지지 않을 때 기본 세팅됨)

    • 1006 – 1000 같은 코드를 수동으로 설정할 수 없을 때 사용하고, 커넥션이 유실(no close frame)되었음을 의미함

  • 그 외 코드

    • 1001 – 연결 주체 중 한쪽이 떠남(예: 서버 셧다운, 브라우저에서 페이지 종료)

    • 1009 – 메시지가 너무 커서 처리하지 못함

    • 1011 – 서버 측에서 비정상적인 에러 발생

  • 코드 전체 목록은 RFC6455, §7.4.1에서 확인할 수 있다.

  • 웹소켓 코드는 언뜻 보기엔 HTTP 코드 같아 보이지만 실제로는 다르며, 특히 1000보다 작은 값은 예약 값이어서 작은 숫자를 설정하려 하면 에러가 발생한다.

    // 사례: 커넥션 유실
    socket.onclose = event => {
      // event.code === 1006
      // event.reason === ""
      // event.wasClean === false (no closing frame)
    };

커넥션 상태

  • 커넥션 상태를 알고 싶다면 socket.readyState 프로퍼티의 값을 확인하면 된다

    • 0 – “CONNECTING”: 연결 중

    • 1 – “OPEN”: 연결이 성립되고 통신 중

    • 2 – “CLOSING”: 커넥션 종료 중

    • 3 – “CLOSED”: 커넥션이 종료됨

채팅 앱 만들기

  • HTML 코드

    <!-- 메시지 폼 -->
    <form name="publish">
      <input type="text" name="message">
      <input type="submit" value="전송">
    </form>
    
    <!-- 수신받을 메시지가 노출될 div -->
    <div id="messages"></div>
  • Javascript

    • 커넥션 생성

    • Form 제출 - socket.send(message)를 사용해 message 전송

    • 메시지 수신 - div#messages에 추가

      let socket = new WebSocket("wss://javascript.info/article/websocket/chat/ws");
      
      // 폼에 있는 메시지를 전송합니다.
      document.forms.publish.onsubmit = function() {
        let outgoingMessage = this.message.value;
      
        socket.send(outgoingMessage);
        return false;
      };
      
      // 메시지를 수신하고, 수신한 메시지를 div#messages에 보여줍니다.
      socket.onmessage = function(event) {
        let message = event.data;
      
        let messageElem = document.createElement('div');
        messageElem.textContent = message;
        document.getElementById('messages').prepend(messageElem);
      }
  • Server Side

    • clients = new Set() - 소켓 집합을 생성한다.

    • 수락된 각 웹소켓에 대해 clients.add(socket) 를 사용하여 집합에 추가하고 메시지 이벤트 리스너를 설정하여 해당 메시지를 받는다.

    • 메시지가 수신되면 클라이언트에 대해 반복하여 모든 사람에게 메시지를 보낸다.

    • 연결이 닫히면 clients.delete(socket)을 호출한다.

    const ws = new require('ws');
    const wss = new ws.Server({noServer: true});
    
    const clients = new Set();
    
    http.createServer((req, res) => {
      // here we only handle websocket connections
      // in real project we'd have some other code here to handle non-websocket requests
      wss.handleUpgrade(req, req.socket, Buffer.alloc(0), onSocketConnect);
    });
    
    function onSocketConnect(ws) {
      clients.add(ws);
    
      ws.on('message', function(message) {
        message = message.slice(0, 50); // max message length will be 50
    
        for(let client of clients) {
          client.send(message);
        }
      });
    
      ws.on('close', function() {
        clients.delete(ws);
      });
    }

Share article

cmun2