websocket은 HTML5 표준 기술로, 서버와 클라이언트 간 connection을 유지하며 "패킷" 형태로 양방향 통신, 데이터 전송이 가능하도록 하는 기술입니다.
웹 소켓은 다음과 같은 특징을 갖고 있습니다.
통산적인 HTTP 통신이 요청에 대한 응답의 단방향 통신인 반면, websocket은 클라이언트와 서버가 서로에게 원할 때 데이터를 주고 받을 수 있습니다. 데이터 송수신을 동시에 처리할 수 있다는 특징이 있습니다.
WebSocket API를 사용하면 응답을 위해 서버를 폴링하지 않아도 서버로 메시지를 보내고 이벤트 기반(event-driven) 한 응답을 서버로부터 전달받을 수 있습니다.
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))라는 보안 계층을 통과해 전달되므로 송신자 측에서 데이터가 암호화되고, 복호화는 수신자 측에서 이뤄지게 됩니다. 따라서 데이터가 담긴 패킷이 함호화된 상태로 프록시 서버를 통과하므로 프록시 서버는 패킷 내부를 볼 수 없게 됩니다.
웹 소켓은 연속적인 데이터 전송의 신뢰성을 보장하기 위해 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)프로토콜을 이용해 통신합니다.
웹 소켓 통신에 사용되는 데이터는 UTF-8 인코딩을 통해서만 지원됩니다.
0x00{보낼 데이터}0xff 와 같은 형태로 데이터를 주고 받습니다.
핸드 쉐이크로 ws프로토콜로 업그레이드 하고, 양방향 데이터를 전송하며, 전송이 완료되면 Close Frame을 주고 받으며 종료됩니다.
1. HTTP를 이용한 HandShake
2. ws프로토콜로 양방향 통신 (0x00{UTF8 payload}0xff 형식)
3. Close Frame을 이용한 연결 종료
메시지에 포함될 수 있는 교환 가능한 메시지는 오직 텍스트와 바이너리 입니다.
웹 소켓은 문자열을 주고 받을 뿐 그 이상의 일은 하지 않습니다. 즉 주고 받는 문자열은 온전히 에플리케이션에서 담당하죠. 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는 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) 대규모 채팅 어플리케이션 설계도 가능할 것으로 보입니다.
출처