[모던JS: 심화] 네트워크 요청 (3)

KG·2021년 7월 1일
3

모던JS

목록 보기
42/47
post-thumbnail

Intro

본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.

파일 업로드 재개하기

fetch 메서드를 이용하면 쉽게 파일 업로드가 가능한 것을 앞서 살펴보았다. 이때 업로드 하려는 파일의 크기가 큰 경우 어떤 사정에 의해 도중에 연결이 끊겼다고 생각해보자. 때문에 다시 업로드 요청을 보내야 할 텐데, 처음부터 다시 시작하게 되면 이미 업로드 한 부분을 무시하게 되므로 매우 비효율적이다.

업로드가 중단되었을 때 이를 다시 이어서 시작하려면 어떻게 해야할까? 업로드 재개를 위해 지원되는 기능은 따로 없지만 부분적으로 지원되는 다른 기능을 이용해 해당 기능을 구현할 수 있다. 아쉽게도 fetch 메서드에서는 업로드를 추적할 수 있는 기능이 없다. 때문에 XMLHttpRequest 객체도 같이 사용해서 해당 기능을 구현해야 한다.

1) 별 도움 안 되는 진행률 이벤트

업로드를 재개하기 위해서 연결이 끊기기 전까지 얼마나 업로드가 되었는지 알아야 할 필요가 있다. 이전 챕터에서 xhr.upload.onprogress 프로퍼티에 핸들러를 등록하여 업로드 진행률을 추적할 수 있음을 살펴보았다.

그러나 업로드 진행률 추적은 데이터를 보낼 때 작동할 뿐, 막상 서버가 데이터를 온전하게 받았는지에 대한 여부는 브라우저 입장에서 확인할 수 없다. 때문에 해당 이벤트만으로는 파일 업로드를 재개하는데 별 도움이 되지 않는다.

업로드 진행률이 100%에 도달했더라도, 중간 과정에서 지역 네트워크 프락시 지연 또는 원격 서버 프로세스 중단으로 인해 서버가 죽어버려 온전히 데이터를 전달받지 못하는 경우는 충분히 발생할 수 있는 상황이다. 때문에 업로드 진행률을 보여주는 것은 UX 관점에서 훌륭한 수단이지만, 이 외에는 별다른 기능을 제공하기에 부족한 점이 많다.

업로드 재개를 위해서는 서버로부터 수신 받은 바이트의 정확한 숫자를 파악할 수 있어야 한다. 이때 바이트 숫자는 서버만이 전달할 수 있기 때문에 추가로 요청이 필요하다.

2) 알고리즘

  1. 먼저 업로드를 할 때, 파일에 고유값을 부여해 구분이 가능하도록 파일 아이디를 생성한다. 고유한 파일 아이디로 업로드를 재개할 때 어떤 파일이 선택되어야 할 지 구분할 수 있다.
let fileId = file.name + '-' + file.size + '-' + +file.lastModifiedDate;
  1. 서버에 요청을 보내 얼만큼의 바이트 전송이 도달했는지 질의한다. 예시에서는 서버가 X-File-Id 헤더에서 파일 업로드를 추적한다고 가정한다. 헤더의 파일 업로드 추적 작업은 서버사이드에서 따로 구현되어 있어야 한다. 만약 파일이 서버에 아직 없다면 0을 응답한다고 가정하자.
let response = await fetch('status', {
  headers: {
    'X-File-Id': fileId
  }
});

// 서버가 얼만큼 파일 바이트를 가졌는지 확인
let startByte = +await response.text();
  1. startByte에서 파일을 보내기 위해 Blobslice 메서드를 사용한다. 파일 아이디인 X-File-Id를 서버로 보내 업로드를 진행할 파일이 어떤 것인지 알리고, 시작 바이트인 X-Start-Byte를 서버에 보내 파일 업로드를 초기화하지 않고 파일 업로드를 다시 시작한다는 것을 서버에게 알린다. 그리고 서버는 기록을 확인해 파일에 업로드가 있었는지, 현재 서버에 업로드가 된 파일 크기가 정확히 X-Start-Byte인지 확인 후 서버에 업로드가 된 파일 크기의 시점부터 파일 데이터를 추가한다.
xhr.open('POST', 'upload', true);

// 파일 아이디를 통해 서버는 어떤 파일을 업로드 받을지 파악
xhr.setRequestHeader('X-File_id', fileId);

// 서버는 파일 시작 바이트를 통해 업로드 재개가 될 것을 파악
xhr.setRequestHeader('X-Start-Byte', startByte);

xhr.upload.onprogress = (e) => {
  console.log(`Uploaded ${startByte + e.loaded} of ${startByte + e.total}`);
}
  
// 업로드 할 파일은 input.files[0]이나 또 다른 출처가 될 수 있음
xhr.send(file.slice(startByte));

모던 브라우저에서 제공되는 최신 네트워킹 메서드는 기능 면에서 파일 매니저와 관련된 면모를 많이 보인다. 오버 헤더를 통제하거나 진행률을 표시 또는 파일을 부분적으로 보내는 등의 기능을 지원하기 때문이다.

Long Polling

long polling은 서버와의 연결을 유지하기 위한 여러 방법 중에서 가장 간단한 방법 중에 하나이다. 보통 HTTP 프로토콜은 stateless 하기 때문에, 어떤 요청에 대한 응답이 수신되면 그 연결을 종료되게 된다. 따라서 어떤 응답을 받기 위해서는 매번 새로운 요청을 서버에 전송해야 한다.

그러나 이러한 매커니즘이 때때로 비효율적일 수 있다. 만약 데이터가 빠른 주기로 변경되는 상황이라면, 어떤 주기로 계속 연결을 유지하고 변화되는 데이터를 계속 받아오게 하는 것이 서버입장에서 효율적일 수 있다. 이때 연결을 유지하기 위해 사용되는 방식 중엔 polling, long polling, webSocket API, Server Side Events 등이 있는데 이 중에 폴링 방식에 대해 알아보도록 하자.

1) 일반적인 폴링(Polling)

서버로부터 항상 최신 데이터를 가져오기 위한 가장 간단한 방법은 주기적으로 계속 서버에 요청을 보내는 것이다. 이러한 방식을 폴링(polling)이라고 한다. 예를 들어 10초 마다 브라우저에서 지속적으로 서버에 요청을 날리는 것을 들 수 있다.

이에 대해 서버는 먼저 클라이언트가 온라인 상태인지 체크하고, 온라인 상태가 유효할때까지 계속 메시지 패킷을 전송한다. 이는 최신 데이터를 유지하기 위해 가장 원초적이고 잘 작동하는 방법이지만 몇 가지 단점이 있다.

  1. 메시지 패킷은 설정된 주기 만큼의 지연 시간을 가진다. 위에서 10초를 주기로 설정한다면, 새로운 응답을 받기 까지의 지연시간은 항상 10초가 된다.

  2. 만약 갱신된 데이터가 없는 경우라도 서버는 10초 마다 지속적으로 요청을 받기 때문에 이에 대한 응답을 의무적으로 해야한다. 이는 데이터의 변화가 없음에도 값을 요청하는 동작이기에 매우 비효율적이고 서버 입장에서는 성능적으로 부담도 크다.

때문에 이를 개선하기 위해 고안된 방식이 롱 폴링(Long Polling) 방식이다. 폴링 방식에 비해 어떤 점이 개선되었는지 알아보자.

2) 롱 폴링(Long Polling)

롱 폴링의 핵심은 폴링 방식을 유지하되 지연시간을 없애는 것이다. 이를 위한 흐름은 아래와 같다.

  1. 클라이언트로부터 요청이 서버에게 전송된다.
  2. 서버는 전달받은 요청을 위한 커넥션을 연결한다. 이때 메시지를 클라이언트에 보낼 메시지가 생길때까지 연결을 종료하지 않는다.
  3. 전달할 메시지가 생기면 서버는 요청에 대해 응답을 전송하고 연결을 종료한다.
  4. 연결이 종료되자마자 클라이언트는 다시 새로운 요청을 전송한다.

브라우저가 요청을 전송하고 서버와의 연결이 보류 중인 상황에서 이러한 방식이 표준으로 취급될 수 있다. 오직 메시지가 전달되는 순간에만 연결이 다시 확립되게 된다. 이를 그림으로 나타내면 아래와 같다.

네트워크 오류 등으로 인해 연결이 끊기면, 브라우저가 즉시 새 요청을 전송한다. 클라이언트 사이드에서 롱 폴링 방식으로 요청을 전송하는 subscribe 함수 구현을 살펴보자.

async function subscribe() {
  let response = await fetch('/subscribe');
  
  if (response.status = 502) {
    // 502는 타임아웃 에러로 연결 보류가 너무 길어지면 발생
    // 따라서 다시 새로운 요청을 전송
    await subscribe();
  } else if (response.status !== 200) {
    // 네트워크 에러 등이 발생하는 경우
    showMessage(response.statusText);
    
    // 1초 후 다시 새로운 요청을 전송
    await new Promise(resolve => setTimeout(resolve, 1000));
    await subscribe();
  } else {
    let message = await response.text();
    showMessage(message);
    
    // 응답을 수신받는 경우도 연결이 해제되므로
    // 다시 새로운 요청을 전송
    await subscribe();
  }
}

subscribe();

보다시피 내부적으로 재귀호출을 통해 계속 새로운 요청을 서버에 전송하고 있는 모습을 볼 수 있다.

이러한 롱 폴링 방식에서는 서버 아키텍처 역시 중요하다. 서버 입장에서는 많은 연결 보류에 대처할 수 있어야 한다. PHPRuby와 같은 서버 아키텍처의 경우 연결 당 하나의 프로세스를 실행하므로 연결 수 만큼의 프로세스가 존재한다. 이때 각 프로세스는 저마다 메모리를 차지하기 때문에 많은 연결이 있다면 그 만큼 메모리 낭비가 심하다. 그러나 Node.js의 경우엔 싱글 스레드를 기반으로 요청을 처리하기 때문에 이러한 문제가 없다. 따라서 선택한 서버 아키텍처에 따라 관련 처리를 하기 위한 대응 방식이 저마다 다를 수 있다.

3) 사용 범위

롱 폴링 방식은 주로 메시지 전달이 드물게 일어나는 상황에서 적합하다. 만약 메시지가 실시간으로 빠르게 주고 받는 상황이라면 롱 폴링 방식은 사실 상 폴링 방식에서 지연시간을 매우 짧게 유지하는 것과 크게 다를 바가 없다. 이는 곧 다시 서버에게 부담으로 이어진다. 이처럼 실시간성이 중요한 경우에는 또 다른 방식을 고려해야 한다. 대표적으로 WebSocketServer Sent Events 방식이 있다.

웹소켓

RFC 6455 명세서에 웹소켓(WebSocket)은 프로토콜로 정의되어 있다. 웹소켓을 사용하면 서버와 브라우저 간 연결을 유지한 상태로 데이터를 교환할 수 있다. 이는 HTTP 프로토콜이 요청-응답이 이루어지면 연결이 해제되는 것에 비해 두드러진 차이를 보인다. 이때 웹소켓을 이용한 통신에서 데이터는 패킷(packet)형태로 전달되며, 전송은 커넥션 중단과 추가 HTTP 요청 없이 양방향으로 이루어진다.

이러한 특징 때문에 웹소켓은 온라인 게임이나 주식 트레이딩 시스템 같이 데이터 교환이 실시간으로 지속적으로 이뤄져야 하는 서비스에 매우 적합한 프로토콜이다.

1) 간단한 예시

웹소켓 연결을 만들려면 new WebSocket 생성자를 호출한다. 이때 웹소켓은 HTTP 프로토콜과 같이 자신만의 프로토콜을 사용한다. 때문에 생성자에 전달하는 경로에 ws 라는 특수 프로토콜을 사용한다.

let socket = new WebSocket('ws://javascript.info');

그러나 보통 ws 말고 wss 프로토콜을 사용한다. 이는 httphttps 프로토콜의 상관관계와 동일한 관련을 맺고 있다. 보안성을 고려한다면 ws 보다 wss를 사용해서 통신을 주고 받는 것이 좋다. 이는 https 프로토콜 처럼 TSL(전송 계층 보안) 계층을 통과해 전달되므로 송신자 측에서 데이터가 암호화되고, 수신자 측에서 복화화를 통해 데이터에 접근한다.

소켓이 정상적으로 생성되면 아래 네 개의 이벤트를 사용할 수 있다.

  • 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 Data');
};

socket.onmessage = function (e) {
  alert(`[message] 서버로부터 수신: ${e.data}`);
};

socket.onclose = function (e) {
  if (e.wasClean) {
    alert(`[close] 커넥션 정상 종료(code=${e.code} reason=${e.reason})`);
  } else {
    // 프로세스가 죽거나 네트워크 장애가 발생한 경우
    // e.code는 1006이 됨
    alert(`[close] 커넥션 비정상 종료`);
  }
};

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

위 예시는 데모 목적을 위해 만든 간이 Node.js 서버에서 돌아간다. 서버는 데이터를 받으면 이에 대해 Hello from server, Bora라는 메시지가 담긴 응답을 클라이언트에게 전송하고 5초 후에는 커넥션을 종료한다.

따라서 openmessageclose 순으로 이벤트가 발생한다. 이때 웹소켓은 HTTP 프로토콜을 사용하지 않는 새로운 프로토콜이기 때문에, CORS 정책의 제약으로부터 비교적 자유롭다. 때문에 위의 코드를 https://javascript.info 오리진이 아닌 다른 오리진에서 실행하더라도 정상적으로 응답을 받아볼 수 있음을 확인할 수 있다. 이와 관련된 내용은 밑에서 자세히 다뤄보도록 하자.

실무 수준에서 웹소켓을 활용할 수 있도록 웹소켓에 대해 좀 더 자세히 알아보도록 하자.

2) 웹소켓 핸드셰이크

new WebSocket(url)을 호출해 소켓을 생성하면 즉시 연결이 시작된다. 커넥션이 유지되는 동안, 브라우저는 헤더를 사용해 서버에 웹소켓을 지원하는지를 물어본다. 이에 서버가 지원한다는 응답을 보내면, 서버-브라우저 간 통신은 HTTP가 아닌 웹소켓 프로토콜을 이용해 진행된다.

이때 new WebSocker("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 헤더를 보고 어떤 웹사이트와 소켓통신을 할 지 결정하기 때문에 이는 웹소켓 통신에서 중요한 역할을 수행한다. 참고로 웹소켓 객체는 기본적으로 크로스 오리진 요청을 지원한다. 웹소켓 통신만을 위한 전용 헤더나 제약도 없다.

  • Connection: Upgrade : 클라이언트 측에서 프로토콜을 바꾸고 싶다는 신호를 보낸것을 나타낸다.

  • Upgrade: websocket : 클라이언트 측에서 요청한 프로토콜이 웹소켓 프로토콜임을 나타낸다.

  • Sec-WebSocket-Key : 보안을 위해 브라우저에서 생성한 키를 나타낸다.

  • Sec-WebSocket-Version : 웹소켓 프로토콜 버전을 명시한다.

웹소켓 핸드셰이크는 모방이 불가능하다. 바닐라 자바스크립트로 헤더를 설정하는 것은 일부를 제외하면 대부분 막혀있기 때문에, XMLHttpRequestfetch를 사용해 위 예시와 유사한 헤더를 가진 HTTP 요청을 만들 수 없다.

웹소켓 프로토콜은 CORS 제약으로부터 자유롭다. 이는 HTTP 프로토콜이 아닌 새로운 프로토콜이기 때문이다. 그럼에도 불구하고 웹소켓을 이용할 때 CORS 이슈가 발생할 수 있다. 이는 웹소켓 프로토콜을 사용하기 위해 사전에 요청을 전송할 땐 HTTP 프로토콜을 사용해 그 여부를 확인하기 때문이다. 이 과정에서 크로스 오리진 요청 이슈가 발생할 가능성이 있다. 때문에 사실상 브라우저에서 웹소켓 프로토콜은 비교적 CORS 제약으로부터 자유롭다고 할 수 있다.

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

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

여기서 Sec-WebSocket-Accept 헤더는 Sec-WebSocket-Key 헤더와 밀접한 관계를 맺고있다. 브라우저는 특별한 알고리즘을 사용해 만들어지는 Sec-WebSocket-Accept의 값을 서버로부터 받고, 이 응답이 자신이 보낸 요청에 대응한 응답이 맞는지를 확인한다.

이렇게 핸드셰이크 과정이 끝나면 HTTP 프로토콜이 아닌 웹소켓 프로토콜을 사용해 데이터 전송을 시작한다. 웹소켓 프로토콜을 사용한 데이터 전송은 밑에서 더 자세히 살펴보도록 하자.

3) extensions와 subprotocols 헤더

웹소켓 통신은 추가로 Sec-WebSocket-ExtensionsSec-WebSocket-Protocol 헤더를 지원한다. 두 헤더는 각각 웹소켓 프로토콜 기능을 확장할 때와 서브 프로토콜을 사용해 데이터를 전송하려 할 때 사용한다.

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

  • Sec-WebSocket-Protocol : soap, wamp : 이처럼 헤더가 설정되면 평범한 데이터가 아닌 SOAPWAMP 프로토콜을 준수하는 데이터를 전송하겠다는 것을 의미한다. 웹소켓에서 지원하는 서브프로토콜 목록은 IANA 카탈로그에서 확인할 수 있다. 개발자는 해당 헤더를 통해 앞으로 사용하게 될 데이터 포맷을 확인할 수 있다.

이 헤더들은 두 번째 매개변수에 값을 넣어 new WebSocket을 호출할 때 설정할 수 있다. 예를 들어 서브 프로토콜로 SOAP 또는 WAMP를 사용하고 싶다고 가정해보자.

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 서브 프로토콜만을 지원한다는 것을 파악할 수 있다.

4) 데이터 전송

웹소켓 통신은 프레임(frame)이라는 데이터 조각 단위로 양방향 통신을 통해 데이터를 주고 받는다. 이때 프레임은 다음과 같은 타입으로 분류될 수 있다.

  • text frames : 양방향으로 텍스트 데이터를 주고 받음
  • binary data frames : 양방향으로 바이너리 데이터를 주고 받음
  • ping/pong frames : 커넥션 체크를 위해 사용되며 서버로부터 전송되는 데이터. 브라우저는 이러한 프레임에 자동으로 응답
  • connection close frame : 커넥션 종료를 알리기 위한 프레임
  • ...

브라우저에서는 텍스트 또는 바이너리 프레임 타입으로만 직접 작업을 수행할 수 있다. 이때 WebSocket.send() 메서드는 텍스트와 이진 데이터 타입 모두 전송이 가능하다.

socket.send(body)를 호출하게 되면 body는 문자열 또는 바이너리 포맷을 허용하는데, Blob이나 ArrayBuffer와 같은 데이터 역시 모두 가능하다. 이때 별도의 설정은 필요하지 않다. 그저 어떤 형식으로든 전송하기만 하면 된다.

데이터를 전달받을 때 텍스트 데이터는 항상 텍스트로, 바이너리 데이터는 항상 바이너리 데이터로 전달된다. 바이너리 데이터의 경우엔 앞서 말한것과 같이 Blob이나 ArrayBuffer 중에 선택해야 할 수 있다. 이는 socket.binaryType 프로퍼티를 통해 설정할 수 있는데 기본값은 blob으로 설정되어 있다. 때문에 바이너리 데이터는 별도의 설정이 없다면 Blob 형식을 띄게 된다.

Blob은 상위 레벨의 바이너리 객체이기에 바로 HTML 태그인 <a> 또는 <img>와 곧장 결합할 수 있다. 그러한 연유로 blob이 기본값으로 책정되어 있다고 볼 수 있다. 그러나 만약 바이너리 데이터를 가지고 어떤 이진 처리 작업을 수행해야 한다면 ArrayBuffer가 더 적합할 수 있다.

socket.binaryType = 'arraybuffer';
socket.onmessage = (event) => {
  // event.data는 arraybuffer 타입으로 수신
};

5) 속도 제한

만약 어떤 애플리케이션이 엄청난 양의 전송 데이터를 만들고 있지만, 이를 사용하는 유저의 네트워크 환경은 매우 열악하다고 생각해보자.

이 상황에서 웹소켓을 이용해 socket.send(data)를 실시간으로 지속해서 보내게 되면, 데이터는 네트워크 환경 문제로 상대방에게 도달하지 못하고 메모리 어딘가에 차곡차곡 쌓이게 될 것이다. 이때 축적된 데이터는 네트워크 환경이 다시 양호하게 될 때 순차적으로 전송을 시작할 것이다.

socket.bufferedAmount 프로퍼티는 네트워크를 통해 전송되기를 기다리고 있는 현재 버퍼링되어 쌓여있는 바이트 수를 저장하고 있다. 이를 이용해서 해당 값이 0이 될때 다시 데이터를 전송하도록 하면, 계속해서 데이터가 버퍼링되는 현상을 어느정도 방지할 수 있다.

// 100ms를 주기로 지속적으로 웹소켓 통신을 이용해 데이터 전송
// 그러나 버퍼링 된 데이터가 없을 경우에만 실제로 데이터 전송
setInterval(() => {
  if (socket.bufferedAmount === 0) {
    socket.send(moreData());
  }
}, 100);

6) 연결 종료

일반적으로 연결을 종료하고자 하는 측에서 connection close frame을 특정 숫자 코드와 종료 이유를 함께 전송하여 연결 해제가 가능하다. 웹소켓은 양방향 통신이기 때문에 연결 종료는 클라이언트와 서버측에서 모두 가능하다.

socket.close([code], [reason]);
  • code : 웹소켓 프로토콜이 사용하는 종료 코드로 선택값
  • reason : 문자열로 종료 이유를 지정할 수 있으며 선택값

한 측에서 연결 종료를 요청하면, 다른 측에서 close 이벤트를 통해 이를 감지하고 만약 전달된 codereason이 있다면 이에 접근할 수 있다.

socket.close(1000, "work complete");

/* ----------------------------------------- */

socket.onclose = event => {
  // event.code = 1000
  // event.reason = 'work complete'
  // event.wasClean = true
};

이때 자주 사용하는 종료 코드 값으로는 다음이 있다.

  • 1000 : 기본값이며 정상 종료
  • 1006 : 연결의 손실을 의미 (네트워크 에러 등..)
  • 1001 : 통신의 한 측에 장애 발생 (서버 프로세스 중단, 브라우저가 해당 페이지를 떠남 등..)
  • 1009 : 전송 메시지의 크기가 처리하기 너무 큰 경우
  • 1011 : 서버측에서 예측하지 못한 에러를 마주한 경우
  • ...

종료 코드의 전체 목록은 다음 명세서에서 확인할 수 있다.

웹소켓의 종료 코드는 쓰임새나 모양새 모두 HTTP의 상태 코드와 유사해보이지만 이들은 서로 다른 내용을 의미하기에 같은 코드값이 아니다. 또한 1000 미만의 코드는 모두 예약되어 있기 때문에 코드를 별도로 설정하려는 경우엔 오류가 발생한다.

7) 연결 상태 (Connection state)

연결 상태값에 접근하기 위해 socket.readyState 프로퍼티를 사용할 수 있다. 상태값은 각각 다음을 의미한다.

  • 0 : CONNECTING - 연결중
  • 1 : OPEN - 통신중
  • 2 : CLOSING - 연결을 종료중
  • 3 : CLOSED - 종료

8) 채팅기능 구현 예시

웹소켓이 가장 빈번하게 사용되는 대표적인 예시가 바로 채팅 기능일 것이다. 채팅은 대화에 참여하는 사용자끼리 실시간으로 그 채널이 생성되고 메시지가 오고 가기 때문에 대화가 확실히 중단되기 전까지는 연결을 유지해야 하는 경우가 많기 때문이다. 웹소켓 통신을 이용해 채팅 기능을 구현하는 예시를 살펴보자. 여기서는 WebSocket API와 서버 사이드는 Node.js를 사용해서 데모를 구현한다.

일단 클라이언트 측에서 <form>을 사용해서 보내고자 하는 메시지를 서버에 전송할 수 있게 만들어주자. 상대방으로부터 오는 메시지는 <div> 영역에 표시할 것이다.

<form name='publish'>
  <input type='text' name='message' />
  <input type='submit' value='send' />
</form>

<div id='messages'></div>

그리고 추가적으로 자바스크립트를 이용해 다음 세 가지 작업을 처리해주자.

  1. 웹소켓 통신 연결을 열고
  2. 폼 전송 시 socket.send(message) 호출 후 기본 동작 방지 (새로고침 방지)
  3. 수신되는 메시지를 대기하며, 도착하면 이를 div#messgaes 요소에 출력
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;
};

socket.onmessage = function (event) {
  let message = event.data;
  
  let messageElem = document.createElement('div');
  messageElem.textContent = message;
  document.getElementById('messages').prepend(messageElem);
}

Node.js 서버 사이드는 브라우저의 영역 밖이지만 코드가 복잡하지 않기 때문에 간단하게 살펴보고 넘어가자. 또한 굳이 Node.js를 사용하지 않고 다른 환경으로도 웹소켓 통신을 구현할 수 있다.

서버 사이드에서는 다음의 과정이 필요하다.

  1. 응답 보낼 클라이언트를 고유하게 구분하기 위해 clients = new Set()을 선언

  2. 웹소켓 통신을 각각의 클라이언트와 연결하기 위해 clients.add(ws)를 추가하고 message 이벤트 리스너를 등록

  3. 메시지가 수신되면 연결된 모든 클라이언트에 메시지 전송

  4. 연결이 종료되면 clients.delete(ws)로 소켓 제거

const ws = new require('ws');
const wss = new ws.Server({ noServer: true });

const clients = new Set();

http.createServer((req, res) => {
  wss.handleUpgrade(req, req.socket, Buffer.alloc(0), onSocketConnect);
});

function onSocketConnet(ws) {
  clients.add(ws);
  
  ws.on('message', function (message) {
    message = message.slice(0, 50);
    
    for (let client of clients) {
      client.send(message);
    }
  });
  
  ws.on('close', function () {
    clients.delete(ws);
  });
}

웹소켓은 자체적으로 재연결이나 인증 관련된 고수준 매커니즘 기능은 지원하고 있지 않다. 따라서 웹소켓을 이용해 해당 기능을 모두 지원하려면 별도의 라이브러리 등을 이용하는 편이다. 대표적으로는 socket.io가 제일 유명하다.

때때로 웹소켓 통신과 HTTP 통신을 동시에 사용하는 프로젝트에 경우, 아예 도메인을 따로 분리시키는 경우도 있다. 예를 들어 HTTP 서버의 경우에는 https://site.com 도메인과 통신을 주고 받고, 웹소켓 프로토콜의 경우는 wss://ws.site.com과 통신을 주고 받는 식으로 설계할 수도 있다.

Server Sent Events (SSE)

HTML5 표준이 등장하고 관련 스펙이 변화를 겪으면서 WebSocket API와 같은 여러 기능이 지원되기 시작했다. 이와 유사하게 자바스크립트는 내장 클래스 EventSource를 지원하는데, 이를 이용해 HTML5 표준안 권고사항인 Server Sent Events(SSE)를 구현할 수 있다.

앞서 웹소켓 통신을 이용하면 연결을 계속 유지하며 양방향으로 통신을 할 수 있음을 살펴보았다. EventSource 역시 이와 같은 맥락을 가지고 있는데, 상대적으로 웹소켓 통신이 더 우수하다보니 약간 뒷전으로 밀린 감이 없잖아 있다.

EventSource은 웹소켓처럼 연결을 계속 유지하지만, 단방향 통신만을 지원한다. 이때 단방향은 서버에서 데이터를 전송하는 것만을 말한다. 그 외에도 웹소켓과는 다음과 같은 차이가 있다.

WebSocketEventSource
양방향 통신단방향 통신
이진 데이터와 텍스트 데이터오직 텍스트 데이터
웹소켓 프로토콜HTTP 프로토콜

이렇게 비교해보면 웹소켓 통신보다 나아보일 것이 없어 보인다. 이는 어느정도는 사실이다. EventSource는 웹소켓과 비교해 비교적 덜 강력한 것은 맞다. 그렇지만 EventSource는 다음의 경우에 오히려 웹소켓보다 적합할 수 있다.

웹소켓은 대부분의 애플리케이션에서 그 기능이 너무 무거울 수 있다. 만약 서버로부터 간단한 형식의 스트림 데이터만 받아오는 경우라면 보통 웹소켓 통신은 너무나 많은 기능을 담고 있는 경우가 많다. 예를 들어, 친구의 상태 업데이트, 주식 시세 표시기, 뉴스피드 또는 기타 자동화 된 데이터 푸시 메터니즘과 같은 경우엔 클라이언트에서 데이터를 전송하지 않고 서버 작업에서만 이를 업데이트 해주면 된다.

웹소켓은 새로운 프로토콜이기 때문에 이를 처리하기 위해서는 새로운 웹소켓 서버를 생성해야 하는 것도 부담으로 느껴질 수 있다. 반면 EventSource는 기존 HTTP 통신 프로토콜을 이용하기 때문에 별도의 서버를 또 하나 둘 필요가 없다. 그 외에도 EventSource는 자동 재연결을 지원하는 등 웹소켓에 없는 기능도 일부 지원하기 때문에, 이러한 점을 모두 고려하면 EventSource가 웹소켓 보다 더 좋은 수단이 될 수 있다.

1) 메시지 가져오기

그렇다면 EventSource을 이용해 서버가 보내는 메시지를 가져와보자. 먼저 new EventSource(url) 생성자를 호출해 EventSource 객체를 만들어주어야 한다.

let source = new EventSource(url);

new EventSource(url, [credentials])를 호출하면 브라우저는 전달받은 url과 연결을 유지하며 발생하는 이벤트를 대기한다.

이때 서버는 200 상태코드와 함께 Content-Type: text/event-stream 헤더를 응답한다. 이는 SSE에서 사용하는 특별한 형식으로, 전달되는 메시지는 다음과 같은 형식을 띈다.

data: Message 1

data: Messgae 2

data: Message 3
data: of two lines
  • 전달되는 메시지는 data: 키워드와 함께 수신된다. 콜론 뒤에 스페이스 여부는 선택사항이다.

  • 메시지는 항상 두 개의 \n 문자가 있어야 스트림을 끝낼 수 있다.

  • 메시지가 길면 여러개의 data: 행을 사용해 메시지 분할이 가능하다. 즉 data:로 시작하는 두 줄 이상의 연속 된 줄은 하나의 데이터 조각으로 간주한다. 위에서 Message 3of two lines는 따라서 한 조각으로 인식된다. 이는 스트림의 종료 여부를 항상 두 개의 \n으로 구분하기 때문에 가능한 것이다.

그러나 보통의 경우엔 복잡한 데이터를 JSON 형태로 전송하고 이를 다시 인코딩 하는 형태를 취한다. 이때 멀티 라인은 \n으로 표현할 수 있기 때문에, Multiline Data 형식은 잘 사용하지 않는다.

data: {"user":"John","message":"First line\n Second line"}

서버로부터 수신되는 데이터는 웹소켓과 마찬가지로 message 이벤트를 통해 받아볼 수 있다.

let source = new EventSource("/events/subscribe");

source.onmessage = function (event) {
  console.log("new msg", event.data);
};

// 또는 source.addEventListener('message', ...);

EventSource를 이용한 SSE 통신은 새로운 프로토콜을 사용하는 것이 아닌 기존 HTTP 프로토콜 위에서 돌아가기 때문에 CORS 정책이 동일하게 적용된다. 때문에 크로스 오리진 요청을 위해서는 앞에서 다룬 fetch 메서드 때와 마찬가지로 동일한 절차가 필요하다.

let source = new EventSource("https://another-site.com/events");

이때 서버에서는 Origin 헤더에 접근할 것이고, Access-Control-Allow-Origin 헤더를 응답 보내게 되며 크로스 오리진 요청이 가능한 지 확인하게 될 것이다.

또한 자격 증명 관련 정보를 같이 보내고 싶은 경우엔 마찬가지로 withCredentials 옵션을 true로 설정해주어야 한다.

let source = new EventSource("https://another-site.com/events", {
  withCredentials: true
});

2) 재연결

new EventSource 호출해 EventSource를 생성하는 즉시 서버와 연결이 이루어지는데, 만약 이 연결이 도중에 종료되는 경우엔 자동으로 다시 재연결을 요청한다. 이는 개발자가 재요청에 신경 쓸 필요가 없기 때문에 매우 편리한 기능이다.

재연결을 위해 약간의 딜레이가 소요될 수 있다. 보통 이는 브라우저별로 조금씩 다른데 약 3초 정도가 기본적으로 소요된다. 서버는 재연결에 필요한 지연시간을 retry: 키워드와 함께 재설정 할 수 있다.

retry: 15000
data: Hello, I set the recoonection delay to 15 seconds

retry:는 재연결을 시도하기 전에 대기 할 시간을 밀리초 단위로 포함하여 어떤 데이터와 응답으로 전달된다.

브라우저는 재연결 전에 명시된 시간 만큼 대기하다가 재연결을 시도하는데, 만약 브라우저가 현재 네트워크 연결이 없음을 알고 있는 경우 다시 연결이 가능할 때까지 기다리기 때문에 이보다 더 오래걸리는 경우가 발생할 수도 있다.

  • 만약 서버가 브라우저가 재연결을 시도하는 것을 중단시키고자 한다면 204 상태코드로 응답할 수 있다.

  • 브라우저 측에서 연결을 해제하고 싶은 경우엔 eventSource.close() 메서드를 호출한다.

또한 응답이 부정확한 Content-Type 헤더를 가지고 있거나, HTTP 상태가 301, 307, 200, 204와 다른 경우엔 재연결을 시도하지 않는다. 이러한 경우는 보통 error 이벤트가 발생하기 때문에 관련 이벤트 리스너로 이 순간을 캐치할 수 있고 에러처리 되므로 자연스레 재연결을 다시 시도하지 않는다.

또한 정상적으로 연결이 브라우저측에서 호출을 통해 종료된 경우 이를 다시 재개하는 방법은 없다. 기존 연결을 종료되면 없어지므로, 다시 연결을 위해서는 새로운 EventSource을 만들어야 한다.

3) 메시지 id

네트워크 문제로 인해 연결이 중단된 경우엔 어떤 메시지가 수신완료 되었고, 어떤 메시지가 수신되지 않았는지 클라이언트와 서버 모두 정확하게 확신할 수 없다. 이를 정확하게 판단하고 통신을 재개하기 위해서 필요한 것이 각 메시지마다 고유 id 필드를 부여하는 것이다.

data: Message 1
id: 1

data: Message 2
id: 2

data: Message 3
data: of two lines
id: 3

이 처럼 메시지가 id: 키워드와 함께 수신되면 브라우저는 다음의 작업을 수행한다.

  • eventSource.lastEventId 프로퍼티를 해당하는 id값으로 설정
  • 재연결 시 Last-Event_ID 헤더에 lastEventId 값을 담아 전송하고 서버는 이를 받아 어떤 메시지부터 다시 보낼지 판단

이때 id: 키워드는 보통 data: 키워드 다음에 위치하는 것이 좋다. 그래야 lastEventId를 특정하기 더 쉽기 때문이다.

4) 연결 상태: readyState

EventSource 객체 역시 readyState 프로퍼티를 가지고 있다. 각 상태값은 다음과 같이 3가지가 있다.

  • CONNECTING : 0 - 연결중 또는 재연결중
  • OPEN : 1 - 연결
  • CLOSED : 2 - 연결 종료

5) 이벤트 타입

EventSource 객체는 기본적으로 3가지 이벤트를 가지고 있다.

  • event : 메시지를 수신한 경우 발생 (event.data)
  • open : 연결이 확립된 경우 발생
  • error : 연결이 확립되지 못한 경우 발생 (500 HTTP status)

EventSource의 특별한 기능 중 하나는 커스텀 이벤트를 event: 키워드로 손쉽게 설정할 수 있다는 것이다. 따라서 위 3가지 기본 이벤트 외에도 개발자 임의로 이벤트를 생성할 수 있다. 서버에서 관련 키워드와 함께 어떤 이벤트 이름을 같이 보내면, 브라우저에서는 해당 이벤트 이름에 해당하는 리스너를 등록할 수 있다.

event: join
data: Bob

data: Hello

event: leave
data: Bob

이때 커스텀 이벤트 리스너를 등록할 때는 항상 addEventListener를 통해 등록해야 한다는 점을 주의하자.

// 커스텀 이벤트 join
eventSource.addEventListener('join', event => {
  console.log(`Joined: ${event.data}`);
});

// 기본 이벤트 message
eventSource.addEventListener('message', event => {
  console.log(`Said: ${event.data}`);
});

// 커스텀 이벤트 leave
eventSource.addEventListener('leave', event => {
  console.log(`Left: ${event.data}`);
});

6) 예제

1, 2, 3 메시지를 순차적으로 출력하고 그 후엔 bye를 출력하고 연결을 중단시키는 코드를 살펴보자. 이때 연결이 중단되면 브라우저는 자동으로 재연결을 시도한다. 클라이언트와 서버 사이드 모두 코드를 살펴보자.

  1. 클라이언트 사이드
let eventSource;

function start () {
  if (!window.EventSource) {
    // IE 또는 구식 브라우저에선 지원 X
    alert('해당 브라우저가 EventSource를 지원하지 않습니다.');
    return;
  }
  
  eventSource = new EventSource('digits');
  
  eventSource.onopen = function (e) {
    log("Event: open");
  };
  
  eventSource.onerror = function (e) {
    log("Event: error");
    
    if (this.readyState === EventSource.CONNECTING) {
      log(`Reconnecting (readyState=${this.readyState})...`);
    } else {
      log(`Error has occured`);
    }
  };
  
  eventSource.addEventListener('bye', function (e) {
    log("Event: bye, data: " + e.data);
  });
  
  eventSource.onmessage = function (e) {
    log("Event: message, data: " + e.data);
  };
}

function stop() {
  eventSource.close();
  log('eventSource.close()');
}

function log(msg) {
  logElem.innerHTML += msg + "<br>";
  document.documentElement.scrollTop = 999999999;
}
  1. 서버 사이드
const http = require('http');
const url = require('url');
const querystring = require('querystring');

function onDigits(req, res) {
  res.writeHead(200, {
    'Content-Type' : 'text/event-stream; charset=utf-8',
    'Cache-Control' : 'no-cache'
  });
  
  let i = 0;
  
  let timer = setInterval(write, 1000);
  write();
  
  function write() {
    i++;
    
    if(i === 4) {
      res.write('event: bye\ndata: bye-bye\n\n');
      clearInterval(timer);
      res.end();
      return;
    }
    
    res.write('data: ' + i + '\n\n');
  }
}

function accept(req, res) {
  if (req.url === '/digits') {
    onDigits(req, res);
    return;
  }
  
  fileServer.serve(req, res);
  
}

if (!module.parent) {
  http.createServer(accept).listen(8080);
} else {
  exports.accept = accept;
}

References

  1. https://ko.javascript.info/network
  2. https://www.iana.org/assignments/websocket/websocket.xml
  3. https://datatracker.ietf.org/doc/html/rfc6455#section-7.4.1
  4. https://hamait.tistory.com/792
profile
개발잘하고싶다

0개의 댓글