- 이번 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
'프로젝트 > You-Together' 카테고리의 다른 글
next.js에서 Open Graph 적용하기 (1) | 2024.09.23 |
---|---|
Hydration API를 사용한 데이터 프리패칭을 통한 초기 로딩 속도 개선 (0) | 2024.08.29 |
You-Together 회고 (0) | 2024.07.26 |
react-query 에러 중앙화 처리 (0) | 2024.07.26 |
SEO 처리하기 (0) | 2024.07.25 |