본문 바로가기

프로젝트/You-Together

실시간 동기화를 위한 웹 소켓 사용: 영상 싱크 및 채팅 구현

- 이번 You-Together 프로젝트는 사용자들이 함께 Youtube 동영상의 동일한 부분을 실시간으로 시청하고 동시에 채팅을 할 수 있는 서비스이다.

- 즉, 같은 방에 있는 사용자들은 실시간으로 서버에 저장된 동영상의 동일한 부분을 시청하고 동시에 채팅을 할 수 있다.

- 이전 코드스쿼드 마지막 프로젝트에서 채팅 기능을 구현해보지 못하고 프로젝트가 종료가 되었고 이에 대한 아쉬움이 많이 남아있었기 때문에, 실시간 서비스는 내가 이 프로젝트를 선택한 가장 큰 이유였다.

- 데이터의 실시간 동기화를 위해서 기존의 API 요청 방식인 HTTP를 사용하는 것이 아닌 웹소켓을 사용하여 프로젝트를 구현하였고, 내가 구현한 방법과 왜 웹소켓을 선택했는지에 대해서 설명해보겠다.

웹 소켓이란?

- 웹 소켓(WebSocket)은 웹 클라이언트와 서버 간의 양방향 통신을 제공하는 프로토콜으로써, 일반적인 HTTP 요청과 달리, 웹 소켓은 연결을 유지하면서 클라이언트와 서버가 데이터를 자유롭게 주고받을 수 있게 해준다.

- 즉, HTTP 통신을 이용하면 클라이언트에서 서버에 요청을 보내야만 서버에서 응답을 할 수 있기 때문에 영상의 싱크 정보, 채팅과 같이 실시간으로 서버에서 응답을 받아야 하는 경우에는 HTTP는 적합하지 않을 수 있다.

- 따라서, 클라이언트의 요청이 없이도 양방향으로 통신할 수 있는 웹 소켓 통신을 이용하여 프로젝트를 구현했다.

- 그럼 내가 구현한 코드를 보며 어떻게 웹소켓을 통해 영상의 싱크 정보, 채팅을 서버에 요청을 보내고 응답을 받을 수 있었는지 살펴보자.

 

기능구현

- 나는 SockJS와 StompJS를 사용했다.

- SockJS는 웹 애플리케이션과 웹 서버 간에 실시간 양방향 통신을 가능하게 해주는 JavaScript라이브러리다. WebSocket을 사용할 수 없는 경우에도 사용할 수 있도록 여러 가지 전송 프로토콜을 지원하여 신뢰성 있는 통신을 보장한다.

- StompJS는 STOMP 프로토콜을 사용하여 WebSocket을 통해 메시징을 구현하는 JavaScript 라이브러리입니다.

- STOMP(Streaming Text Oriented Messaging Protocol)이란 쉽게 말해서 메시지 브로커와 클라이언트 간의 통신을 지원하며 WebSocket을 통해 주로 사용되고, 발행-구독 및 요청-응답 모델을 지원하는 텍스트 기반의 메시징 프로토콜이다.

- 다음은 기본 코드를 살펴보자.

// useSocket.ts

import SockJs from 'sockjs-client';
import StompJs, { Client } from '@stomp/stompjs';
import { useRef, useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';

const useSocket = ({ roomCode, passwordExist: isPasswordRoom, password }) => {
  const queryClient = useQueryClient();
  const clientRef = useRef<StompJs.Client | null>(null); // STOMP 클라이언트를 참조할 Ref 생성

  useEffect(() => {
    const joinRoomHandler = async () => {
      try {
        // 방 참여 로직 (생략)
        
        // SockJS 객체 생성
        const socket = new SockJs(`${process.env.NEXT_PUBLIC_BASE_URL}/stomp`);
        // STOMP 클라이언트 객체 생성
        const stompClient = new Client({
          webSocketFactory: () => socket, // SockJS를 통해 WebSocket을 생성
          reconnectDelay: 5000, // 재연결 지연 시간 설정
        });

        stompClient.activate(); // STOMP 클라이언트 활성화
        
        // STOMP 클라이언트 연결 성공 시 실행되는 콜백 함수
        stompClient.onConnect = () => {
          clientRef.current = stompClient; // 연결된 STOMP 클라이언트를 Ref에 저장

          // 메시지 구독 설정
          stompClient.subscribe(
            `/sub/messages/rooms/${roomCode}`,
            (message) => {
              try {
                const response = JSON.parse(message.body) as TWebSocketMessage;
                console.log('웹소켓 응답', response);
                // 메시지 타입에 따른 데이터 처리
                switch (response.messageType) {
                  case 'CHAT':
                    queryClient.setQueryData<TWebSocketMessage[]>(
                      ['chat', roomCode],
                      (old) => {
                        const newChats = [...(old ?? []), response];
                        return newChats.length > MAX_CHAT_LENGTH
                          ? newChats.slice(1)
                          : newChats;
                      }
                    );
                    break;
              case 'VIDEO_SYNC_INFO':
                    queryClient.setQueryData<TVideoSyncInfo>(
                      ['videoSyncInfo', roomCode],
                      () => {
                        const videoSyncInfo = {
                          videoId: response.videoId,
                          playerState: response.playerState,
                          playerCurrentTime: response.playerCurrentTime,
                          playerRate: response.playerRate,
                          videoNumber: response.videoNumber,
                        };
                        return videoSyncInfo;
                      }
                    );
                    break;
                    
                // 중략
              } catch (error) {
                // 에러 핸들링 로직 (생략)
              }
            }
          );
        };
      } catch (error) {
        // 에러 핸들링 로직 (생략)
      }
    };

    joinRoomHandler();

    // 컴포넌트 언마운트 시 연결 해제 및 클린업 작업
    return () => {
      if (clientRef.current) {
        clientRef.current.deactivate();
        clientRef.current = null;
      }
    };
  }, [roomCode, queryClient]);

  // 채팅 메시지 전송 함수
  const sendChat = (content: string) => {
    if (!clientRef.current) return; // STOMP 클라이언트가 없으면 종료
    try {
      clientRef.current.publish({
        destination: `/pub/messages/chat`, // 메시지 전송 목적지 설정
        body: JSON.stringify({
          roomCode,
          content,
        }), // 메시지 본문 JSON 문자열화
      });
    } catch (error) {
      // 에러 핸들링 로직 (생략)
    }
  };

   // 비디오 플레이어 상태 전송 함수
  const sendVideoPlayerState = ({
    roomCode,
    playerState,
    playerCurrentTime,
    playerRate,
  }: {
    roomCode: string;
    playerState: string;
    playerCurrentTime: number;
    playerRate: number;
  }) => {
    if (!clientRef.current) return;
    try {
      clientRef.current.publish({
        destination: `/pub/messages/video`,
        body: JSON.stringify({
          roomCode,
          playerState,
          playerCurrentTime,
          playerRate,
        }),
      });
    } catch (error) {
      // 에러 핸들링 로직 (생략)
    }
  };

  return {
    sendChat,
    sendVideoPlayerState,
  };
};

export default useSocket;

- useSocket 훅의 코드는 위와 같다.

  • 훅 초기화: query에 데이터를 저장하기 위해 queryClient와 STOPM client를 저장할 clientRef 초기화
  • 방 참여 핸들러 실행: joinRoomHandler 함수 실행
  • SockJS 객체 생성: WebSocket 설정
  • STOMP 클라이언트 설정 및 활성화: STOMP 클라이언트 설정 및 활성화
  • STOMP 클라이언트 연결 및 구독 설정: 연결 후 메시지 구독 설정
  • 컴포넌트 언마운트 시 연결 해제: 클린업 함수에서 연결 해제
  • 채팅 메시지 전송 함수: sendChat 함수로 채팅 메시지 전송
  • 비디오 플레이어 상태 전송 함수: sendVideoPlayerState 함수로 비디오 상태 전송

- 다음은 useSoket 훅과 query에 저장된 채팅과 비디오 데이터를 불러오는 훅을 호출하는 페이지의 코드이다.

// RoomPage.tsx

const RoomPage = ({ params: { roomId } }: { params: { roomId: string } }) => {
  const roomCode = roomId;

  const {
    sendChat,
    sendVideoPlayerState,
  } = useSocket({ roomCode });
  
  
    const { data: chats = [] } = useGetChatMessage({ roomCode });
    const { data: videoSyncInfo } = useGetVideoSyncInfo({ roomCode });
    
    
  return (생략)
}

// useGetChatMessage.ts
import { useQuery } from '@tanstack/react-query';

interface useChatMessageProps {
  roomCode: string;
}

const useGetChatMessage = ({ roomCode }: useChatMessageProps) => {
  return useQuery<TChatMessage[]>({
    queryKey: ['chat', roomCode],
    enabled: false,
  });
};
export default useGetChatMessage;


// useGetVideoSyncInfo.ts
import { useQuery } from '@tanstack/react-query';

const useGetVideoSyncInfo = ({ roomCode }: { roomCode: string }) => {
  return useQuery<TVideoSyncInfo>({
    queryKey: ['videoSyncInfo', roomCode],
    enabled: false,
  });
};

export default useGetVideoSyncInfo;

 

느낀점

- 이전 프로젝트에서 채팅을 구현하지 못해 마음 한켠에 아쉬움이 항상 있었는데, 이번에 웹 소켓에 대해서 이해하고 채팅과 영상 정보와 같이 웹 소켓을 사용하여 통신하는 기능을 구현할 수 있어서 뿌듯했다.

- 또한, HTTP가 아닌 새로운 방식의 통신 방식을 사용하면서 새로운 내용을 배우고 익혔다는 점에서 스스로 성장할 수 있었던 계기가 되었던 것 같다.

- 처음에는 막연하고 어렵게 느껴졌지만, 결국 해냈다는 점에서 자신감도 생겼고, 나 스스로 도전하는 자세에 대해 다시금 기분이 좋았다.


 

참고 : https://www.chanstory.dev/blog/post/26

https://velog.io/@sarang_daddy/React-%EC%B1%84%ED%8C%85%ED%95%98%EA%B8%B0

https://dev-gorany.tistory.com/224

https://velog.io/@dldmswjd322/Spring-boot-React-STOMP%EB%A1%9C-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%B1%84%ED%8C%85-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-1-Spring-boot-%EC%84%9C%EB%B2%84-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0