실시간 통신을 위해 WebSocket API 적용하기

부루베릐·2024년 1월 14일
0

TIL

목록 보기
14/20

개요

이번에 음성 데이터를 각 언어로 번역하여 채팅 형식으로 보여주는 프로젝트에 참여하였다. 사실 빨리 만들고 얼른 MVP를 시연하는 것이 중요한 프로젝트였기 때문에, 일단은 채팅 데이터를 REST API polling을 통해 1초에 한번씩 받아오고 있었다. 백엔드 개발자와 협력하여 실시간 통신 기능을 처음으로 구축하는 과정이었기에, 우선 기능이 동작하는 것을 확인한 후 WebSocket으로의 개선을 계획했었다.

회사 내부 행사에서 이 프로젝트를 시연할 기회가 있었는데, 50명의 사용자가 동시에 채팅방에 접속하자 채팅 기능이 일시적으로 멈추는 문제가 발생했다. 여러 원인이 있었겠지만 추측하기로는 첫째, 서버의 번역 API 처리 속도가 느려져 발생한 병목 현상과 둘째, 폴링 방식으로 인한 서버 부하 증가가 원인으로 보인다. 비록 초당 50회의 요청이 큰 부담으로 보이지 않을 수도 있지만, 클라이언트 측에서 할 수 있는 최선의 조치를 취하는 것이 바람직하므로 서버와 함께 클라이언트도 WebSocket을 도입하여 서버의 부담을 줄여주고자 한다.

이번 포스팅에서는 간단하게 HTTP Polling 방식과 WebSocket 방식을 비교하고, 특히 WebSocket에서의 연결이 비정상적으로 끊어졌을 때 이를 다루는 방법에 대해 알아보도록 한다.


HTTP Polling

클라이언트가 새로운 데이터를 받기 위해 주기적으로 서버에 요청을 보내는 것은 일반적으로 효율적이지 못하다. 특히 실시간 통신이 필요한 서비스에서 그 한계가 드러나며, 따라서 종종 polling madness라고 불리기도 한다.

우리 서비스의 경우 채팅이 업데이트되었는지 여부와 상관없이 매초 API 요청을 보내고 있었다. HTTP의 connectionless 특성으로 인해 매 요청마다 새로운 연결을 수립하고(3 way handshake), 데이터를 전송한 후 연결을 끊는 과정을 반복해야 한다. 문제는, 실제로 데이터 변경이 없는 경우에도 이러한 과정이 불필요하게 진행되어 서버와 클라이언트 양쪽에서 리소스 낭비를 초래한다는 점이다.

이와 같은 상황은 특히 많은 사용자가 동시에 서비스를 이용할 때 문제가 될 수 있다. 서버는 불필요한 요청을 지속적으로 처리하게 되어 부하가 증가되고 전반적인 시스템 성능에 부정적인 영향을 미칠 수 있다. 좀 더 효율적인 통신 방식이 필요한 때이다. WebSocket을 사용하면 서버와 클라이언트 간에 지속적인 연결을 유지하면서 데이터가 변경될 때만 정보를 교환할 수 있어 훨씬 더 효율적인 통신이 가능하다.


HTTP의 stateless와 connectionless 특성

흔히들 HTTP의 특성으로 뽑는 것이 stateless와 connectionless이다.

Stateless 특성으로 인해 각 HTTP 요청은 서로 독립적이다. 서버가 이전 요청/응답에 대한 정보를 저장하지 않는다. 따라서 클라이언트는 이전 연결에서 이미 자신을 서버가 인증했음에도 불구하고 매 HTTP 연결을 맺을 때마다 다시 서버에 자신을 인증해야 하는 귀찮음이 있을 수 있다. 하지만 서버가 이런 상태 정보를 저장하지 않음으로써 서버의 구조를 더 단순하게 만들 수 있고, 서버를 교체하거나 scale-out할 때 저장된 상태 정보를 신경쓰지 않아도 된다는 점에서 유리하다. 사실 나는 프론트엔드라 잘 와닿지는 않지만 매 요청이 독립적이라는 것은 다른 요청과의 관계를 생각할 필요가 없다는 뜻이고, 요청의 수가 많거나 종류가 다양해질수록 고려해야 하는 것이 해당 요청에만 한정되어 있으므로 핸들링하기 편할 것이라 생각이 든다.

Connectionless 특성으로 인해 HTTP 연결은 일회성이다. 연결을 맺고 요청과 응답을 한 차례씩 주고 받으면 연결을 끊는다. 만약 한 번 맺은 연결을 계속 유지한다면 서버는 동시에 여러 클라이언트와의 연결을 관리해야 한다. 그러다 보니 서버에 상당한 부담을 주며, 특히 대규모 트래픽 상황에서는 심각한 성능 저하로 이어질 수 있다. 그러나 HTTP의 connectionless 특성은 각 요청과 응답이 끝나면 연결을 종료함으로써 서버가 동시에 많은 클라이언트를 효율적으로 처리할 수 있도록 해 준다.


WebSocket이란?

WebSocket 프로토콜이란 클라이언트와 서버 간의 실시간 양방향 통신을 위한 기술이다. HTTP가 각 요청과 응답 후 연결을 종료하는 connectionless 특성을 갖는 것과 다르게, WebSocket은 한 번의 핸드셰이크를 통해 클라이언트와 서버가 연결을 맺으면 그 연결이 지속되며 데이터가 변경될 때만 실시간으로 데이터를 전송한다.

HTTP가 클라이언트의 요청에 따른 서버의 응답으로 이루어진 단방향 통신 방식이라면, WebSocket은 이벤트 중심(event-driven)의 양방향 통신 방식이다. 따라서 서버와 클라이언트 모두 상대방의 요청이 없더라도 데이터를 주고받을 수 있다. 이는 HTTP와는 달리 WebSocket은 애초에 양방향 통신을 위해 만들어진 프로토콜이기 때문이다.


TMI
WebSocket 프로토콜은 기존 HTTP 프로토콜의 한계를 극복하기 위해 만들어졌으므로, HTTP를 지원하는 환경과 호환되도록 설계되었다. 한 예로 WebSocket 역시 HTTP와 같은 80 포트를 사용하며, WSS(WebSocket Secure)의 경우 HTTPS와 같은 443 포트를 사용한다. 또한 연결을 위한 오프닝 핸드셰이크 역시 HTTP 요청의 형태를 취한다. 아래는 클라이언트가 서버로 보내는 WebSocket 오프닝 핸드셰이크 HTTP 요청 헤더의 예시이다(RFC 6455 참조).

# WS Opening Handshake HTTP Request Header
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
...
Sec-WebSocket-Protocol: json, chat
Sec-WebSocket-Version: 25

서버가 이 요청을 받아 Upgrade 헤더에 websocket을 읽으면 정상적인 경우 101 Switching Protocols 응답을 내려준다. 말 그대로 프로토콜을 WebSocket으로 업그레이드한다는 뜻인데, 101 상태 코드를 전송한 직후 서버는 웹소켓 프로토콜을 사용하여 클라이언트와 소통하게 된다.

말만 들으면 어려울 것 같지만 WebSocket API를 사용하여 웹소켓 프로토콜을 설정하는 과정은 다음 한 줄이면 끝난다. 이 안에서 HTTP(1.1 버전) 연결을 생성하고, 핸드셰이크를 수행하고 Upgrade 헤더를 서버에 보내는 과정을 개발자를 위해 해 준다.

webSocket = new WebSocket("ws://example.url", "optional-sub-protocol");

구현

앞서 말했듯 WebSocket API를 사용하는 방법은 간단하고 표준화되어 있다. 이것이 우리가 socket.io나 다른 실시간 통신 기술이 아닌 WebSocket API를 사용하고자 했던 이유였다. 웹 표준이기도 하고. 일단 웹소켓 객체를 생성하여 서버와 연결한 뒤 이벤트를 핸들링하면 된다.

먼저 new WebSocket() 생성자를 사용하여 웹소켓 인스턴스를 생성해 보자. 이 때 첫 번째 인자로 웹소켓 엔드포인트 URL이, 두 번째 인자로는 웹소켓의 하위 프로토콜이 들어간다.

/* 웹소켓을 생성 */
const webSocketUrl = 'wss://example.url'
const socket = new WebSocket(webSocketUrl, [protocolOne, protocolTwo])

하위 프로토콜은 서버와 클라이언트가 주고받는 데이터 양식이나 규약을 정할 때 쓴다. 예를 들어 json 프로토콜을 사용하면 모든 데이터는 JSON 형식으로 주고받기로 하거나, chat 프로토콜을 사용하면 채팅 형식에 맞게 데이터를 가공해서 보내주기로 한다거나. 이런 프로토콜은 서버와 클라이언트가 미리 정한 후에 사용해야 한다.

이제 서버와 연결이 성사되었다면 이벤트를 핸들링해보자. 앞서 WebSocket은 이벤트 중심 통신 방식이라 했는데, WebSocket의 이벤트는 크게 4 가지로 나뉜다. Open, Close, Message, 그리고 Error이다. 프론트엔드로서 우리는 각 이벤트가 발생했을 때의 핸들러를 작성해야 한다. 소켓 인스턴스에 addEventListener를 사용하여 핸들러를 등록하거나, onopen, onclose, onmessage, 그리고 onerror와 같이 인스턴스의 이벤트 핸들러 프로퍼티에 함수를 등록하면 된다.

socket.addEventListener("open", (event) => {
  console.log("[Chat] WS Connected")
})

socket.onopen = (event) => {
  console.log("[Chat] WS Connected")
}

밑은 Vue 타입스크립트 코드에서 WebSocket WebSocket 인스턴스를 생성하고, 이벤트를 핸들링하는 로직을 보여준다.

<script setup lang="ts">
const webSocketUrl = 'wss://example.url'
const socket = new WebSocket(webSocketUrl)

const handleWebSocketOpen = () => {
  console.log('[Chat] WS Connected')
}

const handleWebSocketMessage = (e: MessageEvent) => {
  const res = JSON.parse(e.data)
  // 채팅 데이터 핸들링하기
}

const handleWebSocketClose = (e: CloseEvent) => {
  console.log('[Chat] WS Closed')
  socket = null // 소켓 초기화하기
}

socket.onopen = handleWebSocketOpen
socket.onmessage = handleWebSocketMessage
socket.onclose = handleWebSocketClose
</script>

여기에 더해서, 만약 사용자가 채팅방을 나가게 되면 웹소켓 연결을 끊어주어야 한다. 그렇지 않다면 다른 페이지에서도 계속 웹소켓이 유지되어 불필요하게 데이터를 수신하게 되기 때문이다. 따라서 Vue의 unmounted 라이프사이클 훅에서 소켓을 닫아주도록 하자.

onUnmounted(() => {
  socket.close()
})

0개의 댓글