본문 바로가기

Front-End

[React / Next.js + Zustand] StompJS로 채팅 구현하기 (2) - 실시간 채팅 방 상태 관리하기

지난 포스팅에서 채팅방을 구현하기 위한 stomp 웹소켓 연결 과정을 다뤘었다.

 

이번에는 백엔드에서 개발한 웹 소켓 서버에 맞춰 유저 입장, 채팅, 게임 시작 준비 상태 받기를 구현해 볼 것이다.

 

 

 

1. 소켓에 연결이 되면 `/sub/rooms/${roomId}` 엔드포인트를 subscribe하고, `/pub/rooms/${roomId}/join`로 publish를 한 번 보내서 접속이 되었다는 신호를 보내준다. 서버는 이런 식으로 해당 채팅 방에 새로운 유저가 들어올 때마다 접속한 유저 리스트를 메시지로 보내줄 것이다.

2. 업데이트 된 유저 리스트를 받을 때 마다 화면도 업데이트 시켜준다.

3. 채팅으로 메시지를 받으면 해당 메시지를 보낸 유저프로필 위로 말풍선을 띄운다.

 

완성되어야하는 페이지의 모습....

 

짜놓았던 코드를 기반으로 수정, 추가해보자...

이전 포스팅은 여기 ↓

https://kwoooo.tistory.com/11

 

[React / Next.js] StompJS로 채팅 구현하기 (1) - 웹 소켓 연결

실시간 채팅을 구현하기위해 웹 소켓을 이용해 볼 것이다! 웹소켓이란? 웹 브라우저와 서버 간의 양방향 통신을 가능하게 하는 프로토콜이다. HTTP 프로토콜과 달리 웹소켓은 단일 TCP연결을 통해

kwoooo.tistory.com

 

// 우선 닉네임 대신 userId를 받는 상태
type UserStatus = {
  userId: number;
  isReady: boolean;
  message?: string;
};


const WaitingRoom = ({ params }: { params: { id: number } }) => {
  const { id: userId } = useUserInfoStore();
  const [isConnected, setIsConnected] = useState(false);
  const accessToken = useAuthStore.getState().accessToken;
  
  // 유저가 추가되거나 채팅메시지가 발생하면 userStatuses를 업데이트
  const [userStatuses, setUserStatuses] = useState<UserStatus[]>([]);

  useEffect(() => {
    const subscribeToRoom = (roomId: number) => {
      stompClient.subscribe(`/sub/rooms/${roomId}`, (message) => {
        const chatMessage = JSON.parse(message.body);
        console.log('Received message:', chatMessage);
        
        // join 신호
        if (chatMessage.roomUserStatuses) {
          // 기존 리스트 복사
          const updatedUserStatuses = [...userStatuses];
          chatMessage.roomUserStatuses.forEach((userStatus: UserStatus) => {
            if (userStatus.userId === null) return;
            const userIndex = updatedUserStatuses.findIndex(
              (user) => user.userId === userStatus.userId,
            );
            // 기존 리스트에 없으면 추가한다.
            if (userIndex === -1) {
              updatedUserStatuses.push(userStatus);
            } else if ( // 준비 상태가 변경되었다면 바꿔준다 (아직 publish는 구현 전)
              userStatus.isReady !== updatedUserStatuses[userIndex].isReady
            ) {
              updatedUserStatuses[userIndex].isReady = userStatus.isReady;
            } else return;
          });
          // 업데이트
          setUserStatuses(updatedUserStatuses);
        }
        
        // 채팅 메시지
        else if (chatMessage.chatType && chatMessage.chatType === 'TEXT') {
          // 기존 리스트 복사
          const updatedUserStatuses = [...userStatuses];
          // 리스트에서 메시지의 userId의 해당 인덱스 찾기
          const userIndex = updatedUserStatuses.findIndex(
            (user) => user.userId === chatMessage.userId,
          );
          // 메시지 넣기
          if (userIndex !== -1) {
            updatedUserStatuses[userIndex].message = chatMessage.message;
          }
          // 업데이트
          setUserStatuses(updatedUserStatuses);
        }
      });
    };
	
    
    const joinRoom = (roomId: number) => {
      stompClient.publish({
        destination: `/pub/rooms/${roomId}/join`,
      });
    };

    // 이전 포스팅에서는 userId를 같이 publish로 보냈었는데 이제 토큰으로 유저를 판단해주기로 했다!
    stompClient.beforeConnect = () => {
      console.log('Connecting to WebSocket token: ', accessToken);
      stompClient.configure({
        connectHeaders: { Authorization: `Bearer ${accessToken}` },
      });
    };

    stompClient.onConnect = () => {
      console.log('Connected to WebSocket');
      setIsConnected(true);
      subscribeToRoom(params.id);
      // join으로 입장했다는 신호 보내주기
      joinRoom(params.id);
    };

    stompClient.onDisconnect = () => {
      setIsConnected(false);
      console.log('Disconnected from WebSocket');
    };

    stompClient.activate();

    return () => {
      stompClient.deactivate();
    };
  }, [accessToken]);
  
  return (
    // ...
  );
 };

 

일단 `/sub/rooms/${roomId}` 엔드포인트 하나로 유저상태 리스트도 받고 채팅메시지도 받는 상태라 메세지 내부의 데이터를 판별해서 입장 메시지와 채팅 메시지로 로직을 나눴다. (추후에 구독 엔드포인트를 나누어서 구분할 예정)

 

처음에는 저렇게 useState만 이용해서 리스트를 업데이트하고 그 리스트를 UserList 컴포넌트로 전달해 렌더링하려 했다.

 

 

계정 3개를 만들고 퀴즈 방 한 개를 만들어서 테스트를 해보자~!

유저 입장을 먼저 구현해놓고(유저들이 모두 접속한 상태에서 페이지 새로고침 되어있음) 메시지 업데이트를 추가하면서 하다보니 정상적으로 작동하는 듯 했다.

오~...

 

 

그럼 새로운 방을 생성하고 유저가 한 명씩 입장해 채팅을 보내는 최종 과정을 테스트 해보자

 

왜 이래ㅠ

 

 

최초 메시지를 보내자마자 userStatuses가 빈배열로 초기화 되어버리고 다른 유저들의 방에서도 일부 유저들이 사라진다...

 

아무래도 userStatuses을 업데이트하고 컴포넌트가 재렌더링되는 과정에서 그 상태를 잃어버리는 것 같다. 

 

 

안정적으로 상태를 관리하고 컴포넌트를 렌더링 시키기 위해 userStatuses를 전역상태로 관리해보자...

지금 프로젝트에서는 전역상태관리 라이브러리로 Zustand를 쓰고 있으니 store를 선언해 봅시다.

import { create } from 'zustand';
import { UserStatus } from '@/app/_types/UserStatus';

type WatingStore = {
  userStatuses: UserStatus[];
  updateUsers: (userStatuses: UserStatus[]) => void; // 접속 유저 업데이트 함수
  setMessage: (userId: number, message: string) => void; // 메시지 설정 함수
};

const useWaitingStore = create<WatingStore>((set) => ({
  userStatuses: [],
  updateUsers: (userStatuses) => {
    set((state) => {
      const existingUserIds = state.userStatuses.map((user) => user.userId);
      const newUserStatuses = userStatuses.filter(
        (user) => user.userId && !existingUserIds.includes(user.userId),
      );
      // 새로운 유저만 추가
      const updatedUserStatuses = [...state.userStatuses, ...newUserStatuses];

      // 없어진 유저 삭제
      const filteredUserStatuses = updatedUserStatuses.filter((user) =>
        userStatuses.find((newUser) => newUser.userId === user.userId),
      );

      return { userStatuses: filteredUserStatuses };
    });
  },
  setMessage: (userId, message) => {
    set((state) => {
      const index = state.userStatuses.findIndex(
        (user) => user.userId === userId,
      );
      if (index !== -1) {
        const updatedUserStatuses = [...state.userStatuses];
        updatedUserStatuses[index].message = message;
        return { userStatuses: updatedUserStatuses };
      }
      return state;
    });
  },
}));

export default useWaitingStore;

 

추후에 접속이 끊어진 유저도 삭제해주기 위해 updateUsers함수 부분은 수정했다. 

 

그리고 이전에 있던 코드는 useState로 관리하던 userStatuses를 지우고 store로 만든 userStatuses를 가져와준다.

import useWaitingStore from '@/app/_store/useWaitingStore';

const WaitingRoom = ({ params }: { params: { id: number } }) => {
  const { id: userId } = useUserInfoStore();
  const [isConnected, setIsConnected] = useState(false);
  const accessToken = useAuthStore.getState().accessToken;
  const { userStatuses, updateUsers, setMessage } = useWaitingStore();

  useEffect(() => {
    const subscribeToRoom = (roomId: number) => {
      stompClient.subscribe(`/sub/rooms/${roomId}`, (message) => {
        const chatMessage = JSON.parse(message.body);
        console.log('Received message:', chatMessage);
        // join 신호
        if (chatMessage.roomUserStatuses) {
          updateUsers(chatMessage.roomUserStatuses);
        }
        // 채팅 메시지
        else if (chatMessage.chatType && chatMessage.chatType === 'TEXT') {
          setMessage(chatMessage.userId, chatMessage.message);
        }
      });
    };

    
    // 나머지 코드 동일
  }, [accessToken]);
  
  return (
  	// ...
  );
 };

 

코드가 매우 깔끔해졌다~!

 

 

테스트도 똑같이 해보자

예상했던 해피엔딩

 

 

휴~ 잘 된다. 이제 대기 방의 유저상태를 매우 안정적으로 관리할 수 있게 되었다!