Logo

실시간 양방향 통신을 위한 웹소켓(WebSocket)

요즘 웹을 보면 예전에는 상상하지도 못했던 방식으로 여러 사용자와 실시간으로 양방향 상호작용을 하는 애플리케이션을 볼 수 있습니다. 대표적인 예로 다수의 참여자가 동시에 메시지를 주고 받는 채팅이나 실시간으로 게이머 간의 동기화해야 하는 온라인 게임을 들 수 있는데요. 게다가 최근에는 ChatGPT가 등장하면서 AI 기반 채팅 기능을 제공하는 웹사이트들이 점점 늘고 있는 것 같습니다.

이 블로그 글에서는 웹에서 이렇게 실시간 양방향 통신을 필요로 하는 애플리케이션을 구현하는데 필수적인 기술인 웹소켓(WebSocket)에 대해서 살펴보는 시간을 갖도록 하겠습니다.

웹소켓 프로토콜이란?

우리가 웹 개발을 할 때 가장 흔하게 사용하는 HTTP 프로토콜은 기본적으로 요청-응답(request-response) 기반의 단방향 통신을 하게 됩니다. 즉, 웹 브라우저와 같은 클라이언트에서 어떤 요청을 보내고, 서버에서는 그 요청을 처리하고 응답을 합니다. 그리고, 클라이언트와 서버 간의 연결은 바로 끊깁니다.

HTTP 프로토콜은 전통적인 웹사이트를 구현하는데 최적화된 통신 모델입니다. 웹페이지를 제공할 때 서버에서는 클라이언트와 연결을 지속할 이유가 없습니다. 따라서 다수의 클라이언트에서 들어오는 요청을 적은 하드웨어 리소스를 가지고 효율적으로 처리할 수 있습니다.

하지만 HTTP 프로토콜로 실시간으로 여러 사용자와 양방향을 상호작용하는 애플리케이션을 만드는데는 크게 2가지 큰 제약 사항이 있습니다.

첫 번째로, HTTP 프로토콜에서는 항상 클라이언트가 연결을 시작하는 주체가 됩니다. 즉, 서버는 클라이언트의 요청을 마냥 기다려야하는 입장이며, 서버에서 먼저 클라이언트로 연결을 맺고 정보를 보낼 수 있는 방법이 없습니다. 만약에 서버에서 데이터 변경과 같은 이벤트가 발생하여 반대로 클라이언트에게 알려줘야 할 때 매우 곤란해집니다. 궁여지책으로 HTTP 폴링(Polling)과 같이 클라이언트가 주기적으로 서버를 계속해서 호출하는 기법도 있지만, 서버에서 이벤트가 잦지 않은 경우 무의미한 요청이 많아지게 되어 매우 비효율적입니다.

두 번째로, HTTP 프로토콜에서는 서버와 클라이언트 간에 연결이 유지되지 않기 때문에 기존 문맥과 상태를 유지하면서 최소한 정보만 효과적으로 주고 받기가 어렵습니다. 기본적으로 HTTP 요청/응답 메시지에는 헤더(header)가 차지하는 공간이 크기 때문에, 채팅 앱처럼 단문의 메시지를 주고 받을 경우 네트워크 대역폭의, 낭비가 심해져 배보다 배꼽이 더 커질 수 있는 상황이 될 수도 있습니다.

이러한 HTTP 프로토콜의 태생적인 한계를 극복하기 위해 등장한 웹소켓 프로토콜은 클라이언트와 서버 간에 보다 효율적인 실시간 양방향 통신을 가능하게 하는 웹 표준 기술입니다.

채팅이나 온라인 게임과 같은 다양한 애플리케이션에서 웹소켓을 사용하면 하나의 서버가 다수의 클라이언트와 효율적인 정보 교환이 가능해지죠. 뿐만 아니라 실시간 알림 시스템이나 주식 거래 사이트와 같이 수시로 바뀌는 정보를 웹페이지에 좀 더 기민하게 노출하기 위해서도 활용될 수 있습니다.

웹소켓 통신 과정

웹소켓은 HTTP와 완전 별개의 프로토콜로 보시기 보다는 HTTP 통신이 진화(upgrade)한 형태라고 보시는 것이 좋습니다.

실제로 웹소켓 연결을 맺으려면 일반 HTTP 통신과 마찬가지로 클라이언트는 서버로 GET 방식으로 요청을 보내야하는데요. 이 때, ConnectionUpgrade와 같이 웹소켓에서만 쓰이는 조금 특별한 헤더를 함께 보내게 됩니다.

GET /chat HTTP/1.1
Host: www.test.com
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

웹소켓 연결을 지원하는 서버라면 이 경우 101 Switching Protocols 상태 코드를 응답해주는데요.

HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

이렇게 본격적인 웹소켓 통신을 시작하기 전에 클라이언트와 서버가 간단히 HTTP로 메시지를 주고 받는 것을 보통 핸드쉐이크(handshake)라고도 합니다.

이 핸드쉐이크 과정이 끝나면 클라이언트와 서버는 이제 웹소켓 프로토콜을 통해서 양방향으로 메시지를 주고 받을 수 있게 됩니다.

HTTP 상태 코드에 대해서는 별도 포스팅에서 자세히 다루고 있으니 참고 바랍니다.

웹소켓 클라이언트

대부분의 경우 웹소켓 클라이언트는 자바스크립트를 실행할 수 있는 사용자의 브라우저 환경이 될 것입니다. 따라서 우리는 WebSocket 웹 표준 API를 사용하여 브라우저에 작동하는 클라이언트 코드를 작성할 수 있습니다.

서버와 웹소켓 통신을 하려면 우선 WebSocket 클래스를 사용해서 웹소켓 객체를 하나 만들어야 합니다. wswss 프로토콜을 사용하는 서버의 URL을 WebSocket 클래스의 생성자에 넘기면 됩니다.

const socket = new WebSocket("ws://www.test.com/chat");

웹소켓 객체가 성공적으로 만들어졌다면 위에서 설명드린 핸드쉐이크 과정이 끝나고 웹소켓을 통해서 서버와 메시지를 주고 받을 수 있는 상태가 됐다는 얘기입니다.

웹소켓 객체를 통해 우리는 서버에서 발생하는 4종류의 이벤트, open, message, error, close를 처리할 수 있습니다. 이 중에서도 가장 빈번하게 발생하는 message 이벤트를 통해서 서버에서 보낸 메시지를 처리할 수 있습니다.

이벤트 처리 핸들러는 기본적으로 addEventListener 메서드를 통해서 설정해줄 수 있습니다.

socket.addEventListener("open", (event) => {
  console.log("서버와 연결을 맺었습니다.");
});

socket.addEventListener("message", (event) => {
  console.log("서버에서 받은 메시지:", event.data);
});

socket.addEventListener("error", (event) => {
  console.error("에러:", event);
});

socket.addEventListener("close", (event) => {
  console.log("서버와 연결을 끊었습니다.");
});

on<이벤트> 속성에 이벤트 핸들러 함수를 할당하는 방법도 됩니다.

socket.onopen = (event) => {
  console.log("서버와 연결을 맺었습니다.");
};

socket.onmessage = (event) => {
  console.log("서버에서 받은 메시지:", event.data);
};

socket.onerror = (event) => {
  console.error("에러:", event);
};

socket.onclose = (event) => {
  console.log("서버와 연결을 끊었습니다.");
};

자바스크립트로 이벤트 처리하는 방법에 대해서는 별도 포스팅에서 자세히 다루고 있으니 참고 바랍니다.

서버로 어떤 메시지를 보내고 싶을 때는 웹소켓 객체의 send() 메서드를 사용하면 됩니다.

socket.send("하이 웹소켓!");

웹소켓 서버

아무래도 서버 측에서는 다양한 프로그래밍 언어가 사용되므로 사용하는 언어와 프레임워크에 따라 사용하는 API가 상이합니다.

예를 들어, 자바스크립트로 Node.js를 써서 웹소켓 서버를 구현할 때는 ws라는 npm 패키지가 많이 사용됩니다.

const WebSocket = require("ws");

const wss = new WebSocket.Server({ port: 8080 });

wss.on("connection", (socket) => {
  console.log("클라이언트가 접속하였습니다.");

  socket.on("message", (message) => {
    console.log("받은 메세지:", message);
    socket.send("메세지 잘 받았습니다!");
  });

  socket.on("close", () => {
    console.log("클라이언트가 접속을 끊었습니다.");
  });
});

차세대 자바스크립트 런타임인 Bun은 추가적인 패키지 설치 없이도 웹소켓을 자체적으로 제공합니다.

Bun.serve({
  fetch(req, server) {
    server.upgrade(req);
  },
  websocket: {
    open(ws) {
      console.log("클라이언트가 접속하였습니다.");
    },
    message(ws, message) {
      console.log("받은 메세지:", message);
      ws.send("메세지 잘 받았습니다!");
    },
    close(ws, code, message) {
      console.log(
        `클라이언트가 접속을 끊었습니다. (코드: ${code}, 메시지: ${message}`
      );
    },
  },
});

마치면서

실시간 양방향 통신을 위해서 웹소켓이 표준이 된지가 꽤 되었고, 현재 대부분의 모던 브라우저에서 웹소켓 API를 지원하고 있는데요. 만약에 웹소켓을 지원하지 않는 구식 브라우저를 지원해야 한다면 Socket.IO와 같은 라이브러리를 사용할 수 있습니다. 이러한 라이브러리는 웹소켓이 지원되는 환경에서는 웹소켓을 사용하고, 지원되지는 않는 환경에서는 대안 기술을 사용함으로써, 개발자들이 웹소켓 호환성을 걱정하지 않고 개발을 할 수 있도록 돕습니다.

본 블로그 글이 웹소켓을 처음 접하시는 분들이 전반적인 기본 개념을 잡으시는데 도움이 되었으면 좋겠습니다.