
웹소켓(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
엔 문자열이나Blob
,ArrayBuffer
등의 이진 데이터만 들어갈 수 있다.데이터를 받을 때 텍스트 데이터는 항상 문자열 형태로 온다. 이진 데이터를 받을 때엔
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); }); }