토이프로젝트/ProjectSassy

리액트 웹소켓(Stomp)

여행 가고싶다 2023. 4. 24. 21:57

 

이번 토이프로젝트로 MBTI 유형을 선택해서 채팅을 할 수 있는 서비스를 구현했다.

서버는 Java, Spring을 사용했고

클라이언트는 React, Typescript를 사용했다.

 

처음에 버전이 안 맞아서 조금 헤매었었다.

	"dependencies": {
		"@stomp/stompjs": "^7.0.0",
		"react": "^18.2.0",
		"sockjs-client": "^1.6.1",
		"typescript": "^4.9.5",
	},

보통 라이브러리를 사용하지 않고, 할 수 있다면 구현하는 방식을 해왔는데

 

웹소켓 라이브러리를 사용하지 않고 단체방, 채팅을 기다리는 방.. 등등 다양한 기능을 구현하는데 어려움이 너무 많을 것 같아서

Stomp 라이브러리를 사용했다.

 

 

전체적인 플로우는 다음과 같다.

 

MBTI선택 -> 대기방으로 연결 -> 매칭 -> 매칭방으로 연결 -> 채팅 -> 연결종료

 

구현하는 데 다양한 방식이 있겠지만, 내가 이 라이브러리를 이해한 바로 내 프로젝트 정책에 맞게 만들면

 

1. 웹소켓 연결

2. MBTI를 선택하면 대기방을 구독시킨다.

`/sub/chat/wait/${userId}`

3. 대기방의 메시지를 받기 위해 퍼블리쉬시킨다.

'/pub/chat/wait'

4. 매칭이 되었다는 메시지가 도착하면 새로운 채팅방을 구독한다.

`/sub/chat/match/${id}`

5. 마찬가지로 채팅방의 메시지를 받기 위해 퍼블리쉬시킨다.

`/sub/chat/match/${id}`

6. 채팅한다.

 

이 플로우를 이해했다면 Stomp로 채팅구현하는데 아무런 문제가 없다.

 

좀 더 자세히 코드를 살펴보자

 

연결

const connect = () => {
  client.current = new StompJs.Client({
    brokerURL: 'ws://api.projectsassy.net:8080/ws',
    onConnect: () => {
      console.log('success');
      subscribe();
      publishOnWait();
    },
  });
  client.current.activate();
};

 

연결 끊기

  const disconnect = () => {
    client.current.deactivate();
    console.log('채팅이 종료되었습니다.');
    setIsMatch(false);
  };

 

처음 MBTI 선택 시 구독 ( 매칭되기 전 )

    const subscribe = () => {
      client.current.subscribe(`/sub/chat/wait/${userId}`, (body) => {
        const watingRoomBody = JSON.parse(body.body) as WatingRoomBody;
        const { type, roomId: newRoomId } = watingRoomBody;

        if (type === 'open') {
          console.log('채팅 웨이팅 시작');
        }

        if (type === 'match') {
          console.log('매칭이 되었습니다!');
          subscribeAfterGetRoomId(newRoomId);
          setRoomId(newRoomId);
          setIsMatch(true);
        }
      });
    };

 

대기 방 메시지를 받기 위한 퍼블리쉬

    const publishOnWait = () => {
      if (!client.current.connected) return;

      client.current.publish({
        destination: '/pub/chat/wait',
        body: JSON.stringify({
          type: 'open',
          userId,
          selectMbti: `${selectedMbti}`,
        }),
      });
    };

 

이후 매칭 후 새로운 채팅 방 구독

  const subscribeAfterGetRoomId = (id: number) => {
    client.current.subscribe(`/sub/chat/match/${id}`, onMessageReceived);
  };

 

채팅 방의 메세지를 받기 위한 퍼블리쉬

  const publishAfterGetRoomId = (event: React.FormEvent<HTMLFormElement>, content: string) => {
    event.preventDefault();
    if (!client.current.connected) return;

    client.current.publish({
      destination: `/pub/chat/match/${roomId}`,
      body: JSON.stringify({
        type: 'match',
        roomId,
        sendUserId: userId,
        content,
      }),
    });

    setChat('');
  };

 

받은 메세지 관리하는 함수

  const onMessageReceived = (message: StompJs.Message) => {
    const messageBody = JSON.parse(message.body) as MessageBody;
    const { type, sendUserId, content, time, nickname } = messageBody;
    const isMine = sendUserId === userId;
    const newChat = {
      id: generateId(),
      content,
      isMine,
      time,
      nickname,
    };
    console.log(newChat);
    setChatList((prevChatList) => [...prevChatList, newChat]);
    console.log(chatList);

    if (type === 'close') {
      console.log('closed');
    }
  };

 

 

 

다음은 전체 코드이다

 

import { useRef, useState, useEffect, useContext } from 'react';
import { useParams } from 'react-router-dom';
import * as StompJs from '@stomp/stompjs';
import styles from './style.module.scss';
import { MbtiContext } from '../../Context/MbtiContext';
import styled from 'styled-components';
import ChatMessageForm from '../../components/styled-components/ChatMessageForm';
import UserInfo from '../../components/styled-components/UserInfo';

interface Chat {
  id: number;
  content: string;
  isMine: boolean;
  time: string;
  nickname: string;
}

interface WatingRoomBody {
  type: string;
  roomId: number;
  sendUserId?: string;
  content?: string;
}

interface MessageBody {
  type: string;
  sendUserId: string;
  content: string;
  time: string;
  nickname: string;
}

interface StyledMessageProps {
  isMine: boolean;
}

const ChatPage: React.FC = () => {
  const { userId } = useParams<{ userId: string }>();
  const { selectedMbti } = useContext(MbtiContext);
  const client = useRef<any>({});
  const [chatList, setChatList] = useState<Chat[]>([]);
  const [chat, setChat] = useState<string>('');
  const [roomId, setRoomId] = useState<number>();
  const userNickname = localStorage.getItem('nickname');
  const [isMatch, setIsMatch] = useState<boolean>(false);
  // 고유한 ID를 발급하는 함수
  const generateId = (() => {
    let id = 0;
    return () => {
      id += 1;
      return id;
    };
  })();

  const disconnect = () => {
    client.current.deactivate();
    console.log('채팅이 종료되었습니다.');
    setIsMatch(false);
  };

  const onMessageReceived = (message: StompJs.Message) => {
    const messageBody = JSON.parse(message.body) as MessageBody;
    const { type, sendUserId, content, time, nickname } = messageBody;
    const isMine = sendUserId === userId;
    const newChat = {
      id: generateId(),
      content,
      isMine,
      time,
      nickname,
    };
    console.log(newChat);
    setChatList((prevChatList) => [...prevChatList, newChat]);
    console.log(chatList);

    if (type === 'close') {
      console.log('closed');
    }
  };

  const subscribeAfterGetRoomId = (id: number) => {
    client.current.subscribe(`/sub/chat/match/${id}`, onMessageReceived);
  };

  const publishAfterGetRoomId = (event: React.FormEvent<HTMLFormElement>, content: string) => {
    event.preventDefault();
    if (!client.current.connected) return;

    client.current.publish({
      destination: `/pub/chat/match/${roomId}`,
      body: JSON.stringify({
        type: 'match',
        roomId,
        sendUserId: userId,
        content,
      }),
    });

    setChat('');
  };

  const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
    setChat(event.target.value);
  };

  // 최초 렌더링시 실행
  useEffect(() => {
    const subscribe = () => {
      client.current.subscribe(`/sub/chat/wait/${userId}`, (body) => {
        const watingRoomBody = JSON.parse(body.body) as WatingRoomBody;
        const { type, roomId: newRoomId } = watingRoomBody;

        if (type === 'open') {
          console.log('채팅 웨이팅 시작');
        }

        if (type === 'match') {
          console.log('매칭이 되었습니다!');
          subscribeAfterGetRoomId(newRoomId);
          setRoomId(newRoomId);
          setIsMatch(true);
        }
      });
    };

    const publishOnWait = () => {
      if (!client.current.connected) return;

      client.current.publish({
        destination: '/pub/chat/wait',
        body: JSON.stringify({
          type: 'open',
          userId,
          selectMbti: `${selectedMbti}`,
        }),
      });
    };

    const connect = () => {
      client.current = new StompJs.Client({
        brokerURL: 'ws://api.projectsassy.net:8080/ws',
        onConnect: () => {
          console.log('success');
          subscribe();
          publishOnWait();
        },
      });
      client.current.activate();
    };

    connect();

    return () => disconnect();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [userId]);

  return (
    <div className={styles.ChatPage}>
      <div className={styles.ChatPage__form}>
        <div className={styles.ChatPage__form__participants}>
          <UserInfo user={userNickname} />
          {isMatch ? (
            <UserInfo user={userNickname} />
          ) : (
            <span>{selectedMbti}와의 채팅을 기다리는 중...</span>
          )}
        </div>

        <div className={styles.ChatPage__form__chatform}>
          {/* 채팅 시작시 알림 */}
          <div className={styles.ChatPage__form__chatlist}>
            {chatList.map((chatt: Chat) => (
              <StyledMessage key={chatt.id} isMine={chatt.isMine}>
                <span>{chatt.content}</span>
                <span>{chatt.time}</span>
              </StyledMessage>
            ))}
          </div>
          {/* 채팅 종료시 알림 */}
          <ChatMessageForm
            publishAfterGetRoomId={publishAfterGetRoomId}
            handleChange={handleChange}
            chat={chat}
          />
        </div>
      </div>
    </div>
  );
};

const StyledMessage = styled.div<StyledMessageProps>`
  display: flex;
  flex-direction: ${(props) => (props.isMine ? 'row-reverse' : 'row')};
  margin: 20px;
  background-color: #f2f2f2;
  align-items: end;

  > span {
    :nth-child(1) {
      border: none;
      border-radius: 5px;
      padding: 5px 8px;
      background-color: ${(props) =>
        props.isMine ? 'var(--chat-background-you)' : 'var(--chat-background-me)'};
    }
    :nth-child(2) {
      font-size: 10px;
      color: #949494;
      border: none;
      border-radius: 5px;
      padding: 5px 8px;
      background-color: #f2f2f2;
    }
  }
`;

export default ChatPage;

 

확실히 라이브러리를 잘 이해하고 코드를 짜면 생각보다 어렵지 않게 짤 수 있는 거 같다.

 

Stomp를 처음 써보신다면 꼭 전체적인 플로우를 이해하고 코드 작성하는 게 아주 도움이 될 거다.,

 

 

리팩토링 하기 전이라 추상화도 레벨이 맞지 않고 하나의 함수가 한 가지 일을 하지도 않은 엉망인 코드이지만, 내가 까먹기 전에 얼른 블로깅을 해야겠다 싶어서 올린다..!

 

 

 

다음은 결과 화면

채팅