리액트로 구현하고 이해해보는 웹 소켓 + STOMP

미키오·2024년 8월 12일
2

DizzyCode

목록 보기
5/5
post-thumbnail

0. 들어가며..

오늘날 웹 애플리케이션은 사용자와의 상호작용을 중시하며, 이를 위해 실시간 데이터 통신의 중요성이 갈수록 높아지고 있다.

그 중에서도 소켓 통신을 활용한 대표적인 서비스인 Discord를 클론코딩하는 dizzyCode 프로젝트를 하면서 DM 관련 기능들에 관여를 할 수 있게 되어 이론 공부의 필요성을 느끼게 되었다. 이번 글에서는 웹 소켓과 STOMP 프로토콜이 무엇인지, 리액트에서 어떻게 구현하였는지에 관해 정리해보겠다.

1. 웹 소켓 유사기술

전통적인 HTTP 요청/응답 모델은 클라이언트가 서버에 요청을 보내고, 서버가 응답하는 단방향 통신 방식이다. 이를 문자 메시지로 비유하자면, 클라이언트 측에서 선톡을 날리면 서버에서는 데이터를 보내주는 방식으로 답장을 한다. 이 방식은 웹 페이지가 사용자의 요청에만 반응하여 데이터를 업데이트할 수 있으며, 서버에서 자동으로 새 데이터를 클라이언트에게 푸시하는 것은 불가능하다. 이러한 한계를 극복하기 위해 웹소켓과 같은 기술이 등장했다.

필자가 주로 사용하는 슬랙, 디스코드, google docs, figma와 같은 실시간 협업도구에도 사용되고 예전에 한창 하던 Slither.io 일명 지렁이 게임에서도 활용된다고 한다.

사실 웹소켓 이전에도 실시간 통신이라는 개념은 있었다. 폴링(Polling), 롱 폴링(Long Polling), 스트리밍(Streaming)과 같은 기술들이 그 예이다.

폴링 (polling)

이 방식에서 클라이언트는 정해진 시간 간격(예: 매 5초마다)으로 서버에 HTTP 요청을 보내어 최신 정보를 요청한다. 서버는 요청을 받은 즉시 현재의 데이터 상태를 클라이언트에게 응답한다.

ex) 실시간 대시보드 모니터링 시스템

단점:

효율성: 서버에 데이터가 업데이트되지 않았어도 클라이언트는 계속 요청을 보내므로 네트워크 자원과 서버 처리 능력을 낭비하게 된다.
지연 시간: 클라이언트가 정보를 요청한 후에야 데이터를 받을 수 있으므로, 실제 데이터 변경 후 클라이언트가 변경 사항을 인지하는 데까지 지연이 발생한다. 이를 과연 실시간 서비스라고 명명해도 될지에 대한 의문도 든다.

롱 폴링 (Long Polling)

롱 폴링은 폴링의 한 변형으로, 클라이언트의 요청을 서버가 즉시 응답하지 않고, 새로운 데이터가 생길 때까지 요청을 유지(보류)한다. 새로운 데이터가 발생하면 서버는 그 데이터를 응답으로 보내고, 클라이언트는 즉시 다른 요청을 보낸다.

ex) 스포츠 경기 실시간 서비스

단점:

연결 유지: 서버는 각 클라이언트의 연결을 열어 두어야 하므로, 많은 수의 클라이언트를 동시에 처리할 때 서버 자원을 많이 소모하게 된다.
응답 지연: 데이터가 자주 변경되지 않는 경우, 클라이언트는 여전히 응답을 기다리는 동안 대기해야 하며, 이는 서버와 클라이언트 간에 비효율적인 연결 유지를 의미한다.

스트리밍 (Streaming)

스트리밍은 서버가 연결을 열어 두고 데이터가 생길 때마다 계속해서 데이터를 클라이언트로 푸시하는 방식이다. 이는 서버와 클라이언트 사이에 지속적인 데이터 흐름을 제공한다.

ex) 유튜브 스트리밍

단점:

오버헤드: 스트리밍은 TCP 연결을 계속 유지해야 하므로, 오버헤드가 발생할 수 있다.
클라이언트 처리: 클라이언트는 서버로부터 오는 데이터 스트림을 지속적으로 처리해야 하므로, 클라이언트 측의 자원도 상당히 사용된다.
양방향 통신 제한: 대부분 스트리밍은 서버에서 클라이언트로의 단방향 통신에 초점을 맞추고 있어, 양방향 데이터 통신에는 제한적이다.

이처럼 이러한 기술들은 서버에 불필요한 부하를 주거나 실시간성을 완벽하게 보장하지 못하는 등의 한계를 가지고 있다. 또한 결과적으로 이 모든 방법이 HTTP를 통해 통신하기 때문에 Request, Response 둘 다 Header가 불필요하게 크다.

2. 웹소켓이란?

웹소켓은 HTML5 표준 기술로, 클라이언트와 서버 간에 풀 듀플렉스(full-duplex), 양방향 통신 채널을 제공한다. 이를 통해 사용자와 서버 사이의 상호작용이 실시간으로 이루어질 수 있으며, 이는 앞서 언급된 방식들의 단점을 상쇄할 수 있다.

핸드셰이킹 (HandShaking)

웹소켓 연결은 초기에 "핸드셰이킹" 과정을 통해 시작된다.
이는 클라이언트가 서버에 HTTP 요청을 보내고, 서버가 웹소켓 프로토콜로 업그레이드하라는 응답을 보냄으로써 이루어진다. 이 과정은 HTTP 프로토콜을 사용하지만, 연결이 성립된 후에는 순수한 웹소켓 프로토콜을 사용한다. 핸드셰이킹은 기본적으로 두 기기 간의 초기 통신을 설정하는 과정을 의미하며, 이 과정에서 서로의 존재를 확인하고 통신 준비가 되었음을 알린다.

GET /chat HTTP/1.1
Host: localhost:8080 -> host 주소 
Origin: http://example.com -> 클라이언트로 웹 브라우저를 사용하는 경우에 필수항목으로, 클라이언트의 주소 
Connection: Upgrade -> Upgrade 헤더 필드가 명시되었을 경우, 송신자는 반드시 Upgrade 옵션을 지정한 Connection 헤드 필드로 전송
Upgrade: websocket -> 현재 클라이언트, 서버, 전송 프로토콜 연결에서 다른 프로토콜로 업그레이드 또는 변경하기 위한 규칙
Sec-WebSocket-Key: y5JJHMbDL1EzLkh9GBhXDw== -> 길이가 16바이트인 임의로 선택된 숫자를 base64로 인코딩 한 값
Sec-WebSocket-Version: 13 -> Socket의 버전
Sec-WebSocket-Protocol: chat, superchat

이 과정은 다음과 같은 단계를 포함한다:

  1. 업그레이드 요청: 클라이언트는 표준 HTTP 요청 헤더에 Upgrade: websocketConnection: Upgrade를 포함하여 서버에 전송한다. 이는 HTTP 프로토콜을 웹소켓 프로토콜로 전환하고자 하는 의도를 나타낸다.
  2. 핸드셰이크 응답: 서버가 클라이언트의 요청을 수락하면, 101 Switching Protocols 상태 코드와 함께 응답을 보낸다. 이 응답은 서버가 프로토콜을 웹소켓으로 전환하는 것을 승인했음을 클라이언트에 알리게 된다.
  3. 연결 확립: 이 단계에서 클라이언트와 서버 간에 웹소켓 연결이 확립되며, 이후로는 양방향 통신이 가능해진다.

즉, 두 기기가 서로 "안녕"을 하고 정식으로 통신을 시작하기 전에 필요한 절차들을 수행하는 것과 유사하다.

프레임(Frame)

웹소켓 데이터는 "프레임"이라는 기본 단위로 교환된다. 웹소켓 연결이 확립되면, 클라이언트와 서버 간에 데이터는 프레임 단위로 전송된다. 각 프레임은 독립적인 정보 덩어리를 포함하며, 웹소켓 프로토콜은 이러한 프레임들을 사용하여 전체 메시지를 구성하고 교환한다.

프레임의 구조

웹소켓 프레임은 다음과 같은 구성 요소를 포함한다:

  1. 프레임 헤더: 각 프레임은 헤더를 가지며, 이 헤더는 프레임의 타입, 크기, 마스킹 등에 대한 정보를 포함한다. 헤더의 구성은 다음을 포함할 수 있다:
    • FIN 비트: 이 비트가 설정되어 있으면, 현재 프레임이 메시지의 마지막 부분임을 나타낸다. 메시지가 여러 프레임에 걸쳐 전송되는 경우, 마지막 프레임에서만 FIN 비트가 설정된다.
    • Opcode: 데이터 프레임의 종류를 결정한다. 예를 들어, 텍스트 데이터, 바이너리 데이터, 연결 종료, 핑/퐁 등이다.
    • Masking key: 클라이언트에서 서버로 보내는 모든 데이터 프레임은 마스킹 처리되어야 하며, 이 키를 사용하여 데이터를 마스크하고, 서버에서는 이를 해제한다.
  2. Payload Length: 프레임의 데이터 부분(페이로드)의 길이를 나타낸다. 길이는 7비트, 7+16비트, 또는 7+64비트로 표현될 수 있다.
  3. Payload Data: 실제 전송하려는 데이터를 포함한다. 데이터의 형태는 opcode에 의해 결정된다 (예: 텍스트, 바이너리).

프레임의 종류

웹소켓 프로토콜은 몇 가지 다른 타입의 프레임을 정의한다:

  • 연속 프레임(Continuation Frames): 하나의 메시지를 여러 프레임으로 나누어 전송할 때 사용한다.

  • 텍스트 프레임(Text Frames): UTF-8 텍스트 데이터를 전송하는 데 사용된다.

  • 바이너리 프레임(Binary Frames): 바이너리 데이터를 전송하는 데 사용된다.

  • 핑·퐁 프레임(ping/pong frame): 커넥션이 유지되고 있는지 확인할 때 사용하는 프레임으로 서버나 브라우저에서 자동 생성해서 보내는 프레임이다.

  • 컨트롤 프레임(Control Frames): 연결을 종료하거나, 핑을 보내거나, 퐁 응답을 하는 등의 제어 목적으로 사용된다.

웹소켓의 프레임 기반 구조는 네트워크 효율성을 높이고, 실시간 통신에서 높은 성능을 제공한다. 프레임을 사용함으로써, 큰 데이터도 작은 단위로 나누어 점진적으로 전송할 수 있으며, 이는 네트워크 지연을 최소화하고, 사용자 경험을 향상시키는 데 도움을 준다는 장점이 있다.

이러한 프레임 구조 덕분에 웹소켓은 다양한 실시간 웹 애플리케이션에서 데이터 스트리밍과 상호작용이 필요한 기능을 효과적으로 지원할 수 있다.

3. STOMP 프로토콜

STOMP (Simple Text Oriented Messaging Protocol)는 메시징 프로토콜로서, 간단한 텍스트 기반의 프로토콜이다. 이는 웹소켓과 같은 전송 계층 위에서 동작하며, 클라이언트와 서버 간의 메시지 교환을 위해 설계되었다. STOMP는 메시지를 명령어, 헤더, 바디의 세 부분으로 구성하여 전송한다. 각각의 부분은 특정한 역할을 수행하며, 이를 통해 높은 수준의 메시지 교환 패턴을 제공한다.

STOMP 프레임의 구조

STOMP 메시지는 프레임 단위로 구성되며, 각 프레임은 다음과 같은 세 부분으로 이루어진다:

명령어 (Command): 프레임의 유형을 결정한다. 일반적인 명령어에는 CONNECT, SUBSCRIBE, SEND, UNSUBSCRIBE, ACK, NACK, BEGIN, COMMIT, ABORT, DISCONNECT 등이 있다. 각 명령어는 프레임이 수행할 작업을 서버에 지시한다.

헤더 (Headers): 키와 값의 쌍으로 구성되며, 메시지에 대한 메타데이터나 명령어의 추가적인 매개변수를 제공한다. 예를 들어, destination 헤더는 메시지가 전송될 목적지를 지정하고, content-type 헤더는 메시지의 바디 데이터 유형을 설명한다.

바디 (Body): 실제 전송할 데이터를 포함하며, 텍스트 또는 바이너리 데이터일 수 있다. 바디는 선택적이며, 모든 명령어가 데이터를 포함하는 것은 아니다.

통신 패턴

STOMP는 특히 발행-구독 (publish-subscribe) 모델을 지원하여, 한 서버에 여러 클라이언트가 연결되어 있을 때 유용하다. 클라이언트는 특정 "목적지"를 구독하고, 서버나 다른 클라이언트가 해당 목적지에 메시지를 발행할 때 메시지를 받는다. 이 모델은 뉴스 피드, 주식 시세 정보, 그룹 채팅 등 다양한 용도로 활용될 수 있다.

메시지 브로커의 역할

STOMP 프로토콜에서는 일반적으로 "메시지 브로커"가 중간에서 메시지를 관리한다. 메시지 브로커는 클라이언트로부터 메시지를 수신하여 적절한 구독자에게 메시지를 전달한다. 브로커는 메시지의 라우팅, 메시지의 버퍼링, 메시지 전달 실패 시 재전송 등의 기능을 수행할 수 있다.

REST API와의 차이점?

이전에 정리해두었던 또 다른 통신 방법인 REST API와는 어떤 차이가 있을까?

REST API는 상태를 유지하지 않는 HTTP 기반의 인터페이스로, 주로 웹 리소스에 대한 CRUD 작업을 처리하며, 각 요청은 독립적으로 처리되고 실시간 통신을 기본적으로 지원하지 않아 실시간 기능을 위해서는 추가적인 기술이 필요하다.

반면 STOMP는 웹소켓 위에서 동작하는 실시간, 양방향 통신을 지원하는 메시징 프로토콜로, 메시지를 명령어, 헤더, 바디의 프레임 구조로 구성하여 발행-구독 모델을 통해 메시지 브로커가 관리한다.

따라서 실시간 대화나 데이터 피드가 중요한 경우 STOMP가 더 적합하며, 일반적인 데이터 조작과 리소스 관리에는 REST API가 더 적합하다.

4. 웹소켓 실시간 통신의 리액트 구현 코드

다음 코드는 우리 프로젝트의 천재 프런트 H양께서 제공해주셨다🙏

1. 소켓 상태 관리

이번 프로젝트에서는 웹소켓 클라이언트의 상태를 관리하기 위해 zustand 상태 관리 라이브러리를 사용하였다. 이는 웹소켓 연결 상태(isConnected) 및 클라이언트 인스턴스(client)를 전역적으로 관리할 수 있게 해준다.

const useSocketStore = create<ISocketState>((set) => ({
  client: null,
  isConnected: false,
  setClient: (client) => set({ client }),
  setIsConnected: (isConnected) => set({ isConnected }),
}));

2. STOMP 클라이언트 설정

이 부분에서는 실제 웹소켓 연결을 처리하는 useStompClient 훅을 정의한다.
STOMP 클라이언트는 실제 웹소켓 연결을 초기화하고, 메시지 송수신을 담당한다. SockJS를 사용하여 호환성을 높이고, 서버와의 연결을 STOMP 프로토콜로 활성화한다. 사용자 인증 토큰을 포함하여 보안 연결을 구성할 수 있다.

const useStompClient = () => {
  const { client, isConnected } = useSocketStore();

  const sendMessage = (destination, body) => {
    if (client && isConnected) {
      const messageBody = JSON.stringify(body);
      client.publish({
        destination,
        headers: { Authorization: `Bearer ${localStorage.getItem('accessToken')}` },
        body: messageBody,
      });
    }
  };
};

endMessage 함수는 메시지를 JSON 형식으로 직렬화하고, STOMP 클라이언트의 publish 메소드를 사용하여 서버로 메시지를 보낸다. 여기서 사용된 Authorization 헤더는 보안을 위해 액세스 토큰을 서버로 전송한다.

3. 연결 및 인증 처리

useSocketConnection 훅에서는 웹소켓 연결을 초기화하는 로직을 처리한다.

const useSocketConnection = () => {
  useEffect(() => {
    const token = await getSecondaryToken();
    const socket = new SockJS(`${BROKER_URL}?token=${token}`);
    const stompClient = new Client({
      webSocketFactory: () => socket,
      connectHeaders: { Authorization: `Bearer ${localStorage.getItem('accessToken')}` },
      onConnect: () => setIsConnected(true),
      onDisconnect: () => setIsConnected(false),
    });

    stompClient.activate();
    return () => deactivateSocket();
  }, []);
};

먼저 getSecondaryToken 함수를 통해 보안 인증에 필요한 보조 토큰을 받아오고, 이 토큰을 사용하여 SockJS 클라이언트 인스턴스를 생성한다. 이 인스턴스는 Client 생성자의 webSocketFactory 옵션으로 사용된다. 연결이 성공적으로 이루어지면 onConnect 콜백에서 isConnected 상태를 true로 설정하고, 연결이 끊길 경우 onDisconnect에서 false로 설정한다. 또한, 컴포넌트가 언마운트될 때 deactivateSocket 함수를 호출하여 연결을 안전하게 해제한다.

📚 Bibliography

profile
교육 전공 개발자 💻

0개의 댓글