FrontEnd, STOMP와 WebSocket 연결 그리고 비동기 처리

Jihu Kim·2024년 12월 23일
0

FrontEnd

목록 보기
13/13
post-thumbnail

개발 환경

  • 백엔드: SpringBoot
  • 프론트엔드: Vite + TypeScript(React)

WebSocket

WebSocket: 클라이언트와 서버 간 양방향 실시간 통신을 가능하게 하는 프로토콜

기본적으로 HTTP를 업그레이드(Upgrade Header 사용)하여 WebSocket 연결을 설정한 뒤, 지속적인 통신 채널을 유지

  1. 클라이언트가 서버에 WebSocket 연결 요청 전송
  2. 서버가 연결을 승인하면 HTTP 프로토콜이 WebSocket으로 업그레이드
  3. 이 후 양방향 통신 진행

STOMP

STOMP(Simple or Streaming Text Oriented Messaging Protocol): 메시지 브로커와 클라이언트 간의 통신을 위한 텍스트 기반 메시징 프로토콜 (WebSocket이나 TCP 위에서 작동하며, 메시지 전송과 구독을 위한 규격화된 방식과 구조를 제공)

  • 클라이언트와 메시지 브로커 간의 통신을 간단하고 효율적으로 수행할 수 있도록 설계되었음.
  • Websocket을 사용하여 클라이언트와 서버 간의 메시지 교환을 구조화하고 표준화하는 데 사용됨.
    • 복잡한 WebSocket 프로토콜을 직접 구현하지 않고도 손쉽게 구축할 수 있도록 도움.

STOMP를 활용한 WebSocket 연결

STOMP는 WebSocket 위에서 동작하는 메시징 프로토콜로 메시지 발행, 구독, 라우팅 기능을 제공함.

클라이언트 구현

클라이언트는 SockJSstomp.js 라이브러리를 사용해 WebSocket과 STOMP 프로토콜을 처리한다.

기본적으로 제공되는 메소드

(WebSocket)SockJS

  • WebSocket 연결 생성: new SockJS();

STOMP(stompjs)

  • STOMP 클라이언트 생성: new Client()
  • 연결하고 대기: onConnect
  • 구독: subscribe()
  • 메시지 전송: publish()

이 외에도 다양한 기능들이 있다. (버전에 따라서 달라짐)

STOMP 통신 순서

  1. WebSocket 연결
  2. STOMP 연결 초기화(onConnect를 통해서 초기 작업 처리)
  3. 특정 Topic 구독(subscribe())
  4. 클라이언트가 서버로 메시지 전송(publish())
  5. 서버가 메시지를 클라이언트로 브로드캐스트

기본적인 코드

import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';

const connectStomp = async () => {
    // SockJS 인스턴스를 생성합니다.
    const socket = new SockJS('/ws'); // WebSocket 연결 생성

    // STOMP 클라이언트를 생성합니다.
    const stompClient = new Client({
        webSocketFactory: () => socket, // SockJS 연결 적용
        debug: (str) => console.log(str), // 디버깅 메시지 출력
        reconnectDelay: 5000, // 재연결 지연 시간 (5초)
        heartbeatIncoming: 4000, // 서버에서 클라이언트로 하트비트 주기 (4초)
        heartbeatOutgoing: 4000, // 클라이언트에서 서버로 하트비트 주기 (4초)
    });

    // 연결을 열고 대기합니다.
    stompClient.onConnect = (frame) => {
        console.log('Connected:', frame);

        // 메시지 구독
        stompClient.subscribe('/topic/messages', (message) => {
            console.log('Received message:', message.body);
        });

        // 메시지 발행
        stompClient.publish({
            destination: '/app/chat',
            body: 'Hello, STOMP!',
        });
    };

    // 연결 오류 시 처리
    stompClient.onStompError = (frame) => {
        console.error('Broker reported error:', frame.headers['message']);
        console.error('Additional details:', frame.body);
    };

    // STOMP 클라이언트를 활성화합니다.
    stompClient.activate();
};

// STOMP 연결 함수 실행
connectStomp();

프로젝트

우리가 만들고자 하는 서비스는 방장이 방을 생성하고, 다른 사용자가 해당 방에 접속해 게임을 진행하는 서비스였다. (만들고자 하는 서비스에 따라서 구현은 조금 달라질 것이다)

요청 순서

  • 방장: 방 생성 -> 웹 소켓 연결(connect) -> 메시지 구독(subscribe) -> 방 입장(sendMessage)
  • 클라이언트: 웹 소켓 연결(connect) -> 메시지 구독(subscribe) -> 방 입장(sendMessage)

효율적인 처리를 위한 코드(캡슐화)

소켓 통신에 대한 기능을 캡슐화해 역할을 확실하게 나누어서 재사용성을 높이고 유지보수성을 향상시키고자 했다.

import { Client, IFrame, IMessage } from '@stomp/stompjs';
import { NavigateFunction } from 'react-router-dom';
import SockJS from 'sockjs-client';
import { RoomDataProps } from "../types/RoomData.type.ts";
import { GameStateDataProps } from "../types/GameStateData.type.ts";

type GameStateUpdater = (state: RoomDataProps) => void;
type GameReadyUpdater = (state: GameStateDataProps) => void;
type ShowMessageFunction = (message: string) => void;
type ShowRoomChiefLeaveMessageFunction = (isChiefLeft: boolean) => void;
type ShowResultMessageFunction = (isGameEnded: boolean) => void;

type WebSocketManagerParams = {
  roomId: string;
  nickname: string;
  updateGameState: GameStateUpdater;
  updateGameReadyState: GameReadyUpdater;
  navigate: NavigateFunction;
  showMessage: ShowMessageFunction;
  showRoomChiefLeaveMessage: ShowRoomChiefLeaveMessageFunction;
};

class WebSocketManager {
  private static instance: WebSocketManager | null = null;
  private client: Client | null = null;
  private roomId!: string;
  private nickname!: string;
  private updateGameState!: GameStateUpdater;
  private updateGameReadyState!: GameReadyUpdater
  private navigate!: NavigateFunction
  private showMessage!: ShowMessageFunction
  private showRoomChiefLeaveMessage!: ShowRoomChiefLeaveMessageFunction
  private showResultMessage!: ShowResultMessageFunction

  init(params: WebSocketManagerParams) {
    this.roomId = params.roomId;
    this.nickname = params.nickname;
    this.updateGameState = params.updateGameState;
    this.updateGameReadyState = params.updateGameReadyState;
    this.navigate = params.navigate;
    this.showMessage = params.showMessage;
    this.showRoomChiefLeaveMessage = params.showRoomChiefLeaveMessage;
  }

  public static getInstance(
  ): WebSocketManager{
    if (!WebSocketManager.instance) {
      WebSocketManager.instance = new WebSocketManager();
    }
    return WebSocketManager.instance;
  }

  // 웹 소켓 연결
  connect(): Promise<void> {
    if (this.client && this.client.connected) {
      console.log('WebSocket is already connected.');
      return Promise.resolve(); // 이미 연결되어 있으면 바로 성공 처리
    }

    return new Promise((resolve, reject) => {
      this.client = new Client({
        webSocketFactory: () => new SockJS(import.meta.env.VITE_API_URL + '/api/ws/connect'),
        debug: (str: string) => console.log(`[STOMP Debug] ${str}`),
        reconnectDelay: 5000,
        onConnect: (frame: IFrame) => {
          console.log('WebSocket connected:', frame);

          // 방 정보 구독
          this.subscribe(`/topic/room/${this.roomId}`, (message) => {
            this.processData(message);
          });

          // 방 입장 요청
          this.roomEnterRequest();
          resolve(); // 연결 성공 시 프라미스 해결
        },
        onStompError: (error: any) => {
          console.error('WebSocket STOMP Error:', error);
          reject(new Error('WebSocket STOMP Error')); // 에러 발생 시 프라미스 거부
        },
        onDisconnect: () => {
          console.log('WebSocket disconnected.');
        },
      });

      this.client.activate();
      console.log('WebSocket Client activated.');
    });
  }

  // stomp 메세지 데이터 처리 함수
  processData(message: any): void {
    switch (message.type) {
      case 'ROOM':
          console.log(`${message.type} 처리`);
          this.updateGameState(message.data);

        break;

      case 'GAME_READY':
          console.log(`${message.type} 처리`);
          this.updateGameReadyState(message.data);

          // 플레이어 준비 요청
          this.playerReadyRequest();
        break;

      case 'GAME_START':
          console.log(`${message.type} 처리`);
          this.updateGameReadyState(message.data);

          // game 사이트로 이동
            this.navigate(`/game/${this.roomId}`);
        break;

      case 'GAME_PROGRESS':
          console.log(`${message.type} 처리`);
          this.updateGameState(message.data);
        break;

      case 'ROOM_LEAVE':
          console.log(`${message.type} 처리`);
          this.updateGameState(message.data.data);
          this.showMessage(`${message.data.target}님이 나갔습니다.`);

        break;

      case 'ROOM_ENTER':
          this.showMessage(`${message.data.target}님이 입장하였습니다.`);
        break;

      case 'GAME_END':
          console.log(`${message.type} 처리`);
          this.updateGameState(message.data);
          this.showResultMessage(true);
        break;

      case 'ROOM_CHIEF_LEAVE':
          this.showRoomChiefLeaveMessage(true);
        break;

      default:
        console.log(`다른 type${message.type} ${message.data.message}`);
    }
  }

  setShowResultMessage(showResultMessage: (state: any) => void): void {
    this.showResultMessage = showResultMessage;
  }

  // 메시지 전송 메소드
  sendMessage(destination: string, body: Object = ''): void {
    if (!this.client || !this.client.connected) {
      console.error('Cannot send message: WebSocket is not connected.');
      return;
    }

    try {
      this.client.publish({
        destination,
        body: typeof body === 'string' ? body : JSON.stringify(body),
      });
      console.log(`Message sent to ${destination}:`, body);
    } catch (error) {
      console.error('Failed to send message:', error);
    }
  }

  // stomp 프로토콜 구독
  subscribe(destination: string, callback: (message: any) => void): void {
    if (!this.client || !this.client.connected) {
      console.error(`Cannot subscribe to ${destination}: WebSocket is not connected`);
      return;
    }

    this.client.subscribe(destination, (message: IMessage) => {
      const parsedBody = JSON.parse(message.body);
      callback(parsedBody);
    });

    console.log(`Subscribed to ${destination}`); // 후에 삭제
  }

  roomEnterRequest() {
    if (this.roomId && this.nickname) {
      const requestBody = {
        roomId: this.roomId,
        nickname: this.nickname,
      };

      this.sendMessage('/app/room/enter', requestBody);
    } else {
      console.error('Room ID or Nickname is not set');
    }
  }

  // 방장 게임 시작 요청
  startGameRequest() {
    if (this.roomId) {
      this.sendMessage(`/app/start/${this.roomId}`);
    } else
      console.error('Room ID is not set');
  }

  // 플레이어 준비 요청
  playerReadyRequest() {
    if (this.roomId) {
      this.sendMessage(`/app/start/${this.roomId}/${this.nickname}`);
    } else {
      console.error('Room ID is not set');
    }
  }

  // 클릭 이벤트 전송
  sendClickEvent() {
    if (this.roomId) {
      this.sendMessage(`/app/click/${this.roomId}/${this.nickname}`);
    } else {
      console.error('Room ID is not set');
    }
  }

  // 팀 이동
  moveTeamRequest(targetTeamName: string, currentTeamName: string) {
    const requestBody = {
      nickname: this.nickname,
      targetTeamName: targetTeamName,
      currentTeamName: currentTeamName,
    };

    if (this.roomId) {
      this.sendMessage(`/app/room/${this.roomId}/move`, requestBody);
    } else {
      console.error('Room ID is not set');
    }
  }

  // 웹 소켓 연결 종료
  disconnect(): void {
    if (this.client) {
      if (this.client.connected) {
        console.log('Disconnecting WebSocket...');
      }
      this.client.deactivate();
      this.client = null;
      console.log('WebSocket disconnected.');
    } else {
      console.warn('WebSocket is already disconnected.');
    }
  }
}

export default WebSocketManager;

기능 설명

  • WebSocketManager라는 클래스를 생성해서 소켓 통신과 관련된 기능을 캡슐화했다.

  • 싱글톤 패턴을 적용해 하나의 인스턴스만 생성되도록 하고 재사용하도록 했다.

  • init()이라는 메소드를 통해서 WebSocket 연결에 필요한 정보를 설정하도록 했다.

  • connect()라는 메소드를 통해서 WebSocket 연결을 하도록 했다.

  • subscribe()를 통해서 STOMP의 subscribe()를 처리하도록 했다.

    • STOMP의 subscribe()를 처리하고 받은 callback을 processData()를 통해서 처리하도록 했다.
    • processData()에서 callback의 type에 따라서 받은 메시지를 처리하도록 했다.
  • sendMessage()를 통해서 STOMP의 publish()를 처리하도록 했다.

    • sendMessage()를 활용해 그 외에 필요한 기타 기능을 처리하는 메소드를 만들었다.

코드 설명

핵심 코드
소켓 연결 메소드
// 웹 소켓 연결
  connect(): Promise<void> {
    if (this.client && this.client.connected) {
      console.log('WebSocket is already connected.');
      return Promise.resolve(); // 이미 연결되어 있으면 바로 성공 처리
    }

    return new Promise((resolve, reject) => {
      this.client = new Client({
        webSocketFactory: () => new SockJS(import.meta.env.VITE_API_URL + '/api/ws/connect'),
        debug: (str: string) => console.log(`[STOMP Debug] ${str}`),
        reconnectDelay: 5000,
        onConnect: (frame: IFrame) => {
          console.log('WebSocket connected:', frame);

          // 방 정보 구독
          this.subscribe(`/topic/room/${this.roomId}`, (message) => {
            this.processData(message);
          });

          // 방 입장 요청
          this.roomEnterRequest();
          resolve(); // 연결 성공 시 프라미스 해결
        },
        onStompError: (error: any) => {
          console.error('WebSocket STOMP Error:', error);
          reject(new Error('WebSocket STOMP Error')); // 에러 발생 시 프라미스 거부
        },
        onDisconnect: () => {
          console.log('WebSocket disconnected.');
        },
      });

      this.client.activate();
      console.log('WebSocket Client activated.');
    });
  }
  • Promise를 생성해 WebSocket 연결 작업을 비동기로 처리
  • 웹소켓 연결 상태 확인

    • 연결된 경우 Promise.resolve() 반환
  • 웹소켓 연결된 상태가 아니라면 Promise 생성(비동기 작업을 처리하기 위해)

    • 성공 시 resolve, 실패 시 reject 반환
  • 초기화 된 웹소켓 정보를 바탕으로 웹소켓 연결 처리

    • subscribe()메소드에서 STOMP의 subscribe()를 처리하고 받은 메시지를 callback
    • processData()메소드에서 받은 callback을 type에 따라서 상태를 업데이트 또는 동작 수행
subscribe 메소드
  // stomp 프로토콜 구독
  subscribe(destination: string, callback: (message: any) => void): void {
    if (!this.client || !this.client.connected) {
      console.error(`Cannot subscribe to ${destination}: WebSocket is not connected`);
      return;
    }

    this.client.subscribe(destination, (message: IMessage) => {
      const parsedBody = JSON.parse(message.body);
      callback(parsedBody);
    });

    console.log(`Subscribed to ${destination}`); // 후에 삭제
  }
processData 메소드
// stomp 메세지 데이터 처리 함수
  processData(message: any): void {
    switch (message.type) {
      case 'ROOM':
        console.log(`${message.type} 처리`);
        console.log(message);
        this.updateGameState(message.data);
        break;

      case 'GAME_READY':
        console.log(`${message.type} 처리`);
        this.updateGameReadyState(message.data);
        break;

      case 'GAME_START':
        console.log(`${message.type} 처리`);
        this.updateGameReadyState(message.data);

        // game 사이트로 이동
        this.navigate(`/game/${this.roomId}`);
        break;

      case 'GAME_PROGRESS':
        console.log(`${message.type} 처리`);
        this.updateGameState(message.data);
        break;

      case 'ROOM_LEAVE':
        console.log(`${message.type} 처리`);
        this.updateGameState(message.data.data);
        this.showMessage(`${message.data.target}님이 나갔습니다.`);
        break;

      case 'ROOM_ENTER':
        this.showMessage(`${message.data.target}님이 입장하였습니다.`);
        break;

      case 'GAME_END':
        console.log(`${message.type} 처리`);
        this.updateGameState(message.data);
        this.showResultMessage(true);
        break;

      case 'ROOM_CHIEF_LEAVE':
        this.showRoomChiefLeaveMessage(true);
        break;

      default:
        console.log(`다른 type${message.type} ${message.data.message}`);
    }
  }
소켓연결 메소드 사용법
import WebSocketManager from './path/to/WebSocketManager';
import { useNavigate } from 'react-router-dom';
import { useRecoilState } from 'recoil';


const webSocketManager = WebSocketManager.getInstance();

const [user, setUser] = useRecoilState<RoomClientProps>(userState);
const [game, setGame] = useRecoilState<RoomDataProps | null>(gameState);
const [gameReady, setGameReady] = useRecoilState<GameStateDataProps>(gameReadyState);
const [roomChiefModal, setRoomChiefModal] = useState<boolean>(false);

const navigate = useNavigate();


const playerRoomEnter = async () => {
      try {
        webSocketManager.init(
          {
            roomId: user.roomId!,
            nickname : user.nickname!,
            updateGameState : setGame,
            updateGameReadyState : setGameReady,
            navigate : navigate,
            showMessage : showMessage,
            showRoomChiefLeaveMessage : setRoomChiefModal
          }
        )
        await webSocketManager.connect();

        setIsConnected(true);
      } catch (err) {
        console.error('Failed to enter room:', err);
        setIsConnected(false);
      }
    };
  • async 키워드를 사용해 함수를 비동기로 선언
  • 함수 내에서 await 키워드를 사용해 Promise가 해결될 때까지 대기
  • WebSocketManager의 init()메소드를 통해서 websocket과 관련된 정보(방ID, 닉네임, 상태 업데이트 함수)를 전달해 초기화
    • WebSocketManager는 이를 받고 상태 업데이트 진행
  • connect()가 반환하는 Promise가 완료되면 다음 코드가 실행 됨.
    • connect()가 성공적으로 되면 setIsConnected() 실행.
profile
Jihukimme

0개의 댓글