SockJS, StompJS를 활용한 채팅 설정하기

SOCKET 연결 및 채팅

채팅과 관련된 기능들을 구현하면서 백엔드와의 협의 후 Sock.JS와 Stomp.JS를 사용하게 됐습니다. 이 두 가지를 사용하기에 앞서 두 가지가 어떠한 역할을 하는지, 왜 이것들을 써야하는지 간단하게 살펴보고 어떻게 사용하는 지 적어보고자 합니다.

Web Socket

TCP 연결에 완전 이중 통신 채널을 제공하는 컴퓨터 프로토콜입니다. 일반적인 서버 - 클라이언트의 관계가 stateless인 것에 반해, Web Socket은 stateful한 방식을 사용해, 지속적인 연결이 이루어지게 됩니다.

서버 - 클라이언트간 웹소켓 연결은 HTTP 프로토콜을 통해 이루어지며, 연결의 시작은 클라이언트가 서버로 random key를 보냅니다. 이후 서버에서 random key를 통해 토큰을 만든 뒤, 클라이언트에 response를 보내고, 이를 통해 handshake 과정이 이루어져서 양방향 통신이 진행됩니다.

서버 - 클라이언트간 웹소켓 연결(TCP/IP)이 이루어지고 일정 시간이 지나면 HTTP 연결은 자동으로 끊어지게 됩니다.

SockJS

(큰 도움을 주신 사이트)

브라우저에서 web socket을 지원하지 않는 경우에도 web socket을 사용 가능하게 해주는 WebSocket Emulation으로서 사용되는 라이브러리입니다.
브라우저와 서버 사이의 짧은 지연시간, 크로스 브라우징의 지원이 장점입니다.
(현재 서버에서는 Spring을 사용하고 있어, Sock JS를 사용했으며, 서버에서 Node.js를 사용할 경우, Socket.io를 사용합니다.)
구성과 전송타입은 아래와 같고 추후에 아래 내용들을 자세히 정리할 예정입니다!

구성

전송 타입

  1. WebSocket
  2. HTTP Streaming
  3. HTTP Long Polling

StompJS

(추후 참고할 만한 링크)

웹소켓의 서브 프로토콜로서, 프로토콜 연결, 메세지 전송, 상대방 구독 기능을 제공하는 텍스트 기반 메세지 프로토콜입니다. stomp를 이용하면 여러 개의 채팅방 개설이 가능하다는 장점이 있습니다.

서버가 Spring을 사용할 경우, 보통 SockJS, StompJS 이 두 가지를 통해 소켓 연결을 진행하게 됩니다.

사용

설치

아래 코드를 통해서 기본적인 sockjs, stompjs를 설치할 수 있습니다.

npm install --save-dev sockjs-client stompjs

SockJS, StompJS 연결

이후 주소를 설정하고 Sock을 새로 만든 뒤, 이를 Stomp Client 위에서 작동할 수 있도록 설정합니다.

import SockJs from "sockjs-client";
import StompJs from "stompjs";

// 들어갈 주소 설정
const sockServer = `${process.env.REACT_APP_BASE_URL}/${WEBSOCKET}`;
const sock = new SockJs(sockServer);
const stompClient = StompJs.over(sock);

Stomp Client로 서버에 연결 요청

Stomp Client에 연결을 요청하고, 해당 요청이 성공적일 시 구독으로 진행할 작업을 적어줍니다.

const connectChatroom = (subscribeParams: subscribeParamsType) => {
  const headers = getAuthHeaders();
  // 현재 서버에서 확인을 위해 필요로 하는 헤더

  try {
    // stompClient.debug = () => null;
    // console 창에 나오게 되는 내용들이 보이지 않도록 함
    stompClient.connect(headers, () => subscribeChat(subscribeParams));
    // 연결을 시도함
  } catch (error) {
    console.log(error);
  }
};

원하는 주소로 구독 및 메세지 올 때 처리하기

subscribeChat이라는 함수를 따로 만들어, Stomp Client가 연결에 성공할 시 진행할 작업을 따로 빼내어 정리해주었습니다. stompClient.connect를 통해서 연결이 이루어지게 되는데, 이는 서버에 connect 프레임을 전송하는 것으로, 이 프레임에 header, body를 넣을 수 있고 현재는 header만 보낸 상황입니다.

const subscribeChat = ({
  onReceive,
  chatroomId,
  chatroomIds,
}: subscribeParamsType) => {
  const headers = getAuthHeaders();
  const subscribeToStomp = (id: string | number) => {
    const subscribeURL = `/${TOPIC}/${CHATROOM_MEMBERS}/${id}`;
    // 서버와 협의된 구독 URL
    stompClient.subscribe(subscribeURL, onReceive, headers);
    // Stomp Client에서 구독 후, 이후 메세지가 올 시 처리할 값을 onReceive 함수로 처리함
  };

  // id값이 한 개일 때, 여러 개일 때를 구분해서 구독을 진행하도록 설정
  if (chatroomId) subscribeToStomp(chatroomId);
  if (chatroomIds) chatroomIds.forEach((id) => subscribeToStomp(id));
};

현재 앱의 특성상 id값을 하나만 받는 경우(채팅 상세에 들어갈 시), id값을 여러 개 받는 경우(모든 채팅의 알림을 받을 시)에 대해 같은 형식의 처리가 이루어지기 때문에 아래와 같이 같은 함수 내에서 구분해 처리하도록 했습니다.

subscribeURL을 통해서 구독할 주소를 설정하게 되고, 이후 그 주소를 통해 등록한 곳으로부터 메세지가 오면 onReceive 함수를 통해 처리를 할 수 있게 됩니다.

구독 끊기

const unsubscribe = () => {
  stompClient.unsubscribe("sub-0");
};

unsubscribe 함수를 통해 구독을 끊을 수 있으며, 서버와의 연결 해제가 아닌 해당 주소에 대한 구독만 끊어내는 개념으로 볼 수 있습니다.

연결 끊기

const disconnectChatroom = () => {
  try {
    // stompClient.debug = () => null;
    stompClient.disconnect(() => unsubscribe());
  } catch (error) {
    console.log(error);
  }
};

diconnect를 통해서 서버와의 연결을 해제할 수 있으며, 이 때에도 콜백 함수를 통해 특정 작업을 진행할 수 있습니다.


추가적인 문제

(위에 반영되었습니다.)

아래의 코드로 호출 시 하나의 구독 주소에 여러번 접근하는 형식이 적용되어, 문제가 발생합니다.

const useChatAlarm = () => {
  const { state, contents } = useRecoilValueLoadable(chatroomIdsState);

  return () => {
    useEffect(() => {
      if (state !== "hasValue" || !contents) return;
      contents?.forEach(({ id }) => {
        const { connectChatroom } = chatSocket();
        connectChatroom({ chatroomId: id });
      });
    }, [state]);
  };
};

const subscribeChat = ({ setter, chatroomId }: subscribeParamsType) => {
  const subscribeURL = `/${TOPIC}/${CHATROOM_MEMBERS}/${chatroomId}`;
  // 하나의 id만 등록이 가능함
  const headers = getAuthHeaders();

  // 이 부분을 forEach로 반복하기 때문에, 같은 주소(subscribeURL)에 반복적인 요청이 들어가게 됨
  stompClient.subscribe(
    subscribeURL,
    // 콜백 함수가 직접적으로 들어가 있어, 다른 작업이 필요한 경우, 이 곳을 직접적으로 고쳐야 함
    (chatData) => {
      const newChat = JSON.parse(chatData.body);
      setter((chats) => {
        const newChats = [...chats, newChat];
        return newChats;
      });
    },
    headers
  );
};

사진과 같이 같은 주소에 대한 여러 번의 호출로 인해 연결이 끊겨버린다는 문제가 발생합니다.

해결 방법

1. onSet 함수 및 주소 분리

type subscribeParamsType = {
  onSet: (chatData: StompJs.Message) => void;
  chatroomId?: string | number;
  chatroomIds?: string[] | number[];
};

const subscribeChat = ({ onSet, chatroomId, chatroomIds }: subscribeParamsType) => {
  const subscribeToStomp = (id: string | number) => {
    const headers = getAuthHeaders();
    const subscribeURL = `/${TOPIC}/${CHATROOM_MEMBERS}/${id}`;
		// id 값에 따라 주소가 나뉠 수 있도록 공통 함수를 설정
    stompClient.subscribe(subscribeURL, onSet, headers);
		// subscribe 내에서 콜백(onSet)을 만드는 것이 아니라 이를 분리해 외부에서 적용하도록
  };
	...
};

chatroomId의 개수가 늘어나는 경우에 대해서 이전 코드에서는 제대로 된 해결책이 없어 우선 이 사항부터 해결하기 위해 onSet이라는 set이 발생했을 때의 함수를 먼저 따로 빼냈습니다. 이로 인해 set이 발생했을 때의 사항에 대해서는 직접적으로 사용하는 곳에서 처리를 하도록 빼낼 수 있었고, 아래 코드와 같이 사용하는 곳에서 set 상황을 컨트롤 할 수 있게 됐습니다.

const getNewChatMessage = (chatData: StompJs.Message) => {
  const newChat = JSON.parse(chatData.body);
  setCurChats((chats) => {
    const newChats = [...chats, newChat];

    return newChats;
  });
};
// onSet 함수로, 들어온 값에 대해 새로운 채팅에 추가하는 함수

useEffect(() => {
  const { disconnectChatroom, connectChatroom } = chatroomSocket();
  connectChatroom({ onSet: getNewChatMessage, chatroomId });
  // 연결할 때 함수를 대입
  return () => disconnectChatroom();
}, []);

2. id 개수에 따른 상황 분리

id가 하나인 경우와 여러개인 경우에 대해서 구독하는 방식이 약간의 차이가 존재하는데, 이를 해결하기 위해 상황별로 구독하는 방법을 바꾸도록 설정해 문제를 해결했습니다.

if (chatroomId) subscribeToStomp(chatroomId);
if (chatroomIds) chatroomIds.forEach((id) => subscribeToStomp(id));