IT/Front-End

[Next.js] STOMP 기반 웹소켓 통신

KimCookieYa 2024. 2. 5. 01:48

배경

센디에서 3번째 파일럿 프로젝트에서 웹소켓 통신을 구현해야 했는데, 그 과정에서 Kotlin Spring 서버와 규격을 맞추기 위해 여러 시도를 해보다가 stomp.js와 sock.js를 사용해서 웹소켓 통신을 구현해보았다. socket.io와 순수 WebSocket API, STOMP 등의 다양한 라이브러리를 사용한 구현 사항을 기록하고자 한다. 로직을 분리하기 위해 커스텀 훅으로 구현하였다.

 

socket.io

socket.io 라이브러리는 node.js 기반 라이브러리이고, 표준 WebSocket 위에 추가적인 기능(자동 재연결, Room 등)을 덧붙였기에 서버와 클라이언트 모두 socket.io를 써야만 통신이 가능하다. 그러나 Spring 서버는 socket.io를 지원하지 않기 때문에 통신이 불가능했다..

 

useSocketIO.ts

import { useEffect, useState } from 'react';
import { Socket } from 'socket.io-client';
import io from 'socket.io-client';

export function useSocketIO(url: string, roomId: number) {
  const [socket, setSocket] = useState<Socket | null>(null);

  const handleConnectSocket = () => {
    if (socket) {
      console.log('connected to ' + url + '?roomId=' + roomId);
      socket.connect();
    }
  };
  const handleDisconnectSocket = () => {
    if (socket) {
      console.log('disconnected from ' + url + '?roomId=' + roomId);
      socket.disconnect();
      setSocket(null);
    }
  };

  useEffect(() => {
    if (socket) {
      return;
    }
    console.log('connecting...');
    const socketIo = io({
      path: url,
      query: { roomId: 'room-' + orderId },
      reconnectionAttempts: 3,
      autoConnect: false,
    });
    socketIo.on('connect', () => {
      console.log('connected');
    });
    socketIo.on('connect_error', (error: Error) => {
      console.error(error);
    });
    socketIo.on('message', (message) => {
      console.log('New message:', message);
    });
    socketIo.on('error', (error: Error) => {
      console.error(error);
    });
    socketIo.on('disconnect', () => {
      console.log('disconnected');
    });
    setSocket(socketIo);
    return () => {
      socketIo.disconnect();
    };
  }, []);
  return { socket, handleConnectSocket, handleDisconnectSocket };
}

 

 

WebSocket API

 

서버와 클라이언트의 호환성을 맞추기 위해 순수 WebSocket API도 사용해보았다. JavaScript에 내장된 API 이며, Spring에서도 지원하기 때문에 호환성의 문제는 없다. 따라서 connection 후 메세지를 주고받는 것에도 성공했지만, 문제는 Room의 구현이었다. 백엔드 인턴 동기가 WebSocket API로 Room을 구현하는 것에 어려움을 겪었고, 고심 끝에 다른 라이브러리를 선택했다.

 

useWebSocket.ts

import { useEffect, useRef } from 'react';

export default function useWebSocket(roomId: number) {
  const webSocket = useRef<WebSocket>();

  useEffect(() => {
    if (webSocket.current) {
      return;
    }
    const _socket = new WebSocket('/route?roomId=' + roomId);
    _socket.onopen = () => {
      console.log('connected');
    };
    _socket.onclose = () => {
      console.log('disconnected');
    };
    _socket.onerror = () => {
      console.log('error');
    };
    _socket.onmessage = (event) => {
      console.log(event.data);
    };
    webSocket.current = _socket;
    return () => {
      webSocket.current?.close();
    };
  }, []);

  return webSocket;
}

 

 

stomp.js와 sock.js

stomp.js와 sock.js를 같이 사용해서 구현해냈다. 생각보다 자료가 많지 않아서 조금 헤맸었지만 하루를 꼬박 박아서 성공했다. 사실 sock.js는 필요없었지만 브라우저 호환성을 생각해서 사용했다. socket.io와 WebSocket API보다 사용하기 까다로웠는데, 우선 서버와 통신하기 위해서는 stompClient 객체를 생성할 때, heartbeatIncoming과 hearbeatOutgoing을 서버와 맞춰야했다. 그리고 Room의 개념과는 다른, Topic의 개념을 사용해서 이해가 잘 안됐었다.

 

useStompSocket.ts

import { useEffect, useRef } from 'react';
import SockJS from 'sockjs-client';
import { Client } from '@stomp/stompjs';
import { isValidJson } from '@util/Util';

export default function useStompSocket({
  roomId,
}: {
  roomId: number;
}) {
  const socket = useRef<Client>();

  useEffect(() => {
    if (socket.current) {
      return;
    }
    const _stompClient = new Client({
      connectHeaders: {
        roomId: String(roomId),
      },
      webSocketFactory: () => new SockJS('/ws'),
      debug: (message) => {
        console.log(message);
      },
      reconnectDelay: 4000,
      heartbeatIncoming: 0,
      heartbeatOutgoing: 0,
      onConnect: (frame) => {
        console.log('connected to ' + frame);
        _stompClient.publish({
          destination: '/app/room/' + roomId,
        });
        _stompClient.subscribe('/topic/room/' + roomId, (message) => {
          console.log('subscribed');
          console.log(message.body);
          if (isValidJson(message.body)) {
            const data = JSON.parse(message.body);
            console.log(data);
          } else {
            console.error('invalid json');
          }
        });
      },
      onStompError: (error) => {
        console.error(error);
      },
      onDisconnect: () => {
        console.log('disconnected');
      },
      onWebSocketError: (error) => {
        console.error(error);
      },
      onUnhandledMessage: (message) => {
        console.log(message);
      },
    });
    _stompClient.activate();
    socket.current = _stompClient;

    return () => {
      if (socket.current && socket.current.connected) {
        socket.current.deactivate();
        socket.current = undefined;
        console.log('disconnected');
      }
    };
  }, []);

  return socket.current;
}

 

 

에피소드

stompjs로 서버와 통신할 때 클라이언트에서 서버로는 요청이 잘 갔는데, 서버에서 클라이언트로는 응답이 아무리 해도 안 왔었다. 뭐가 문제인지 백엔드 인턴 동기의 Kotlin Spring 코드를 봤는데.. 라이브러리 구현체의 handler를 직접 override하고 있었다... 예제 코드를 주면서 분명 똑같이 했다고 하던데... override한 걸 지우니까 바로 해결됐다. 인턴하면서 이런 소소한 에피소드도 재밌는 것 같다.