웹 소켓
전통적인 HTTP 통신만을 지원하던 브라우저에서는 실시간으로 통신할 수 있는 방법이 없었다. 서버의 데이터 갱신을 위해서는 반드시 요청이라는 방법이 필수였고 새로고침, 또는 Ajax통신을 활용하여 long-polling 이라는 비효율적인 방법을 사용해야했다.
최근에는 웹 소켓이 많이 알려져 실시간 통신, 채팅, 멀티플레이 게임, WEBRTC 등등 다양한 방면에서 활용되고 있다. 소켓 통신을 하듯이 서버와 연결을 맺고 바이너리 데이터를 송/수신할 수 있다.
서버 구현
웹 소켓 서버를 구현하려면 통신 프로토콜 표준을 지켜야한다. websocket protocol 표준 문서를 확인하면 상세하게 나와있다.
HTTP 연결 업그레이드
import { STATUS_CODES, createServer } from "http";
const REQUIRED_UPGRADE_BODY = STATUS_CODES[426] as string;
const server = createServer((req, res) => {
res.writeHead(426, {
"Content-Length": REQUIRED_UPGRADE_BODY.length,
"Content-Type": "text/plain",
});
res.end(REQUIRED_UPGRADE_BODY);
});
server.listen(1337, () => {
console.info(`WebSocket 테스트 서버가 "localhost:1337" 호스트로 시작되었습니다.`);
});
웹 소켓 통신은 HTTP 요청을 활용하기 때문에 http 서버로 구현하여야 한다. http 요청이 웹 소켓 요청일 경우 upgrade
라는 프로세스를 거치게 되는데 서버측에서 헤더를 확인하고 통신이 가능한 상태로 만들어 주어야 한다.
통신 헤더 확인
const KEY_REGEX = /^[+/0-9A-Za-z]{22}==$/;
const MAGIC_STRING = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
// codes...
server.on("upgrade", (req, socket, head) => {
const { upgrade, "sec-websocket-key": key, "sec-websocket-version": version } = req.headers;
if (upgrade !== "websocket")
return server.emit("wsClientError", new Error("Invalid Upgrade header"), socket, req);
if (!KEY_REGEX.test(key ?? ""))
return server.emit(
"wsClientError",
new Error("Missing or invalid Sec-WebSocket-Key header"),
socket,
req
);
if (!["8", "13"].includes(version as string))
return server.emit(
"wsClientError",
new Error("Missing or invalid Sec-WebSocket-Version header"),
socket,
req
);
socket.on("error", (err) => {
socket.once("finish", () => socket.destroy());
socket.end(
[
`HTTP/1.1 500 ${STATUS_CODES[500]}`,
`Connection: close`,
`Content-Type: text/plain`,
`Content-Length: ${Buffer.byteLength(err.message)}`,
]
.concat("\r\n")
.join("\r\n") + err.message
);
});
const hash = createHash("sha1")
.update(key + MAGIC_STRING)
.digest("base64");
socket.write(
[
"HTTP/1.1 101 Web Socket Protocol Handshake",
"Upgrade: WebSocket",
"Connection: Upgrade",
`Sec-WebSocket-Accept: ${hash}`,
]
.concat("\r\n")
.join("\r\n")
);
});
// client
const websocket = new WebSocket("ws://localhost:1337");
websocket.addEventListener("open", () => console.info("connection success"));
upgrade 프로세스를 위하여 req.headers
에 upgrade
, sec-websocket-key
, sec-websocket-version
이 올바르게 들어있는지 확인해야한다. 웹 소켓으로 연결을 업그레이드 할 수 있는 확인이 모두 끝났다면 소켓에 오류가 발생했을 때의 오류처리 리스너를 등록하고 연결대상 소켓에 통신 준비가 완료되었음을 알리는 메시지를 송신해준다.
이 때 Sec-WebSocket-Accept
헤더에 웹 소켓의 해시값을 함께 반환해 주어야 하는데 이 hash값은 지정된 UUID와 결합하여 만들어낸다. 위 코드에서 MAGIC_STRING
변수에 설정된 값 258EAFA5-E914-47DA-95CA-C5AB0DC85B11
이 값은 표준에 등록된 약속으로 다른 값을 넣으면 연결이 실패할 수 있다.
서버측 데이터 수신
// codes...
server.on("upgrade", (req, socket, head) => {
// codes...
socket.on("data", (buffer) => {
// 프레임이 나뉘어 전송될 경우 사용
// const isFinalFrame = (firstByte & 0x80) === 0x80;
// 첫 바이트가 0x8이면 연결이 클라이언트로부터 이미 종료됨
if ((buffer[0] & 0x0f) === 0x8) return socket.end();
const data = deframe(buffer).toString();
console.debug("[ReceiveData]", data);
});
const hash = createHash("sha1")
.update(key + MAGIC_STRING)
.digest("base64");
socket.write(
[
"HTTP/1.1 101 Web Socket Protocol Handshake",
"Upgrade: WebSocket",
"Connection: Upgrade",
`Sec-WebSocket-Accept: ${hash}`,
]
.concat("\r\n")
.join("\r\n")
);
});
연결이 성공했다면 데이터를 통신하기 위한 리스너를 등록해야한다. 서버측에서는 data
이벤트를 활용하고 클라이언트 측에서는 message
이벤트를 사용한다.
커넥션이 성공한 직후의 데이터도 놓치지 않도록 성공 메시지를 보내기 전에 data
리스너를 등록하는 것이 바람직하다. 웹 소켓을 통하여 데이터를 통신하면 이진 데이터로 주고 받게 된다. 서버측에서 데이터를 읽기 위하여 이진 데이터를 다른 자료형으로 활용할 수 있도록 해석하는 단계가 필요하다.
여기까지 작성하고 서버 통신을 시도하면 오류가 발생할 것이다. 웹 소켓의 데이터 통신은 프레이밍되어 통신하는데 data
리스너의 deframe
함수가 구현되지 않았기 때문에 데이터를 수신하여도 서버가 그 데이터의 의미를 알 수 없기 때문이다. 이 데이터 프레임 규칙 또한 표준 문서 5.2. Base Framing Protocol 챕터에 잘 정리되어있다.
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|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| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
데이터 프레이밍에 대한 비트 도표이다. 이진 데이터의 나열속에서 정해진 자릿수만큼씩 의미하는 데이터가 정해져 있다. 이 도표를 기준으로 데이터를 해석하는 함수를 구현해보자
const deframe = (buffer: Buffer) => {
const secondByte = buffer[1]; // MASK (1bit), Payload Len (7bit)
/**
* 마스크 여부를 알아내기 위하여 "0x80 (0b10000000)"을 바이트와 AND 연산하여 동일한지 확인한다.
* 마스크 비트는 왼쪽에서 첫번째 비트이므로 나머지 비트는 알 필요가 없다.
* 0x80과 AND 연산하여 나머지 비트를 모두 0으로 바꾸고 마스크 비트가 0인지 1인지만 확인하면 된다.
* 결국 결과값은 00000000 또는 10000000이 나올 것이다.
* 0x80을 AND 연산하고 0x80과 동일하다면 마스크 여부를 알 수 있다.
*/
const isMasked = (secondByte & 0x80) === 0x80;
/**
* 0x7f (0b01111111)와 AND 연산하여 마스크 여부를 제외한 나머지 비트를 확인한다.
* 페이로드의 길이에 대한 헤더이다.
* 이 값은 126 또는 127이며 127일 경우 페이로드 크기를 64비트로 표현할 수 있도록 8바이트를 사용한다.
* 매우 큰 페이로드를 표현할 수 있도록 하기 위한 방법이다.
*
* 126일 경우에는 2바이트를 사용하여 페이로드 크기를 표시한다.
*/
const payloadLength = secondByte & 0x7f;
if (payloadLength === 126) {
// 앞의 2바이트를 건너뛰고 부호비트 없이 정수로 16비트를 빅엔디언 순서로 읽는다.
const payloadLength = buffer.readUInt16BE(2);
// 126일 경우 Extended Payload Length가 2바이트이므로 4바이트 건너뛴다.
// 마스킹 키는 총 4바이트 사용한다.
const maskingKey = buffer.subarray(4, 8);
// 이후로 페이로드 데이터이다.
return buffer.subarray(8, 8 + payloadLength).map((chunk, i) => chunk ^ maskingKey[i % 4]);
} else if (payloadLength === 127) {
// payloadLength가 127이면 Extend Payload Length가 8바이트 (64비트)로 표현된다.
// 상위 32비트와 하위 32비트를 읽어들인다.
const high = BigInt(buffer.readUInt32BE(2));
const low = BigInt(buffer.readUInt32BE(6));
// 64비트 값으로 결합
const payloadLength = (BigInt(high) << BigInt(32)) + BigInt(low);
// 마스킹 키는 총 4바이트 사용한다.
const maskingKey = buffer.subarray(10, 14);
// 이후로 페이로드 데이터이다.
return buffer
.subarray(14, Number(BigInt(14) + payloadLength))
.map((chunk, i) => chunk ^ maskingKey[i % 4]);
}
throw new Error("연결이 올바르지 않습니다.");
};
수신한 데이터를 디프레임하는 함수이다. 자세한 설명은 주석을 보고 의미를 하나씩 이해해보자. 이 함수를 코드에 추가하게 되면 서버측에서 비로소 클라이언트가 무슨 말을 하고 있는지 귀가 트이게 될 것이다.
서버측 데이터 송신
const send = (socket: Duplex, data: unknown) =>
!socket.closed && socket.write(frame(JSON.stringify(data)));
server.on("upgrade", (req, socket, head) => {
// codes...
const repeater = () => {
send(socket, [Date.now(), Number((Math.random() * 100).toFixed(3))]);
if (!socket.closed) setTimeout(repeater, 5000);
};
repeater();
});
이제 들을 줄을 아니 말하는 법도 가르쳐보자. 라우팅을 만들기 귀찮으니 소켓 연결이 성공한 이후에 5초마다 반복적으로 랜덤한 값을 전송하도록 구성하였다. 그러나 오류가 발생할 것이다. 눈치 빠른 사람은 알겠지만 서버에서 클라이언트로 전송할 때에도 마찬가지로 데이터를 프레이밍하여야 한다. 위 코드에서 frame()
함수를 구현해보자.
// codes ...
const encoder = new TextEncoder();
// codes ...
const frame = (data: string) => {
const typedArray = encoder.encode(data);
// WebSocket 프레임 헤더 생성
const frameHeader = Buffer.alloc(2);
frameHeader[0] = 0x81; // FIN + 텍스트 프레임
frameHeader[1] = typedArray.length; // 데이터 길이 (마스킹 없음)
// 프레임과 메시지 결합
return Buffer.concat([frameHeader, Buffer.from(typedArray)]);
};
송신할 데이터를 TextEncoder
를 사용하여 형식화 배열이진 데이터로 변환하고 프레이밍 규칙에 맞게 버퍼를 연결한다. 서버에서 클라이언트로 전송할 때에는 마스킹이 필요없다.
websocket.addEventListener("message", data => console.info(data));
이제 클라이언트 측에서 message
리스너를 등록하여 서버와 클라이언트의 송수신이 올바르게 작동하는지 확인하면 된다. 🎉