React - 채팅하기

sarang_daddy·2023년 7월 17일
3

React

목록 보기
10/26
post-thumbnail

React로 SockJS, STOMP를 사용하여 채팅기능을 구현해보자.

  • 구현된 채팅은 중고 거래 웹에 맞게 1:1 채팅 기능으로 구현했다.
  • 이번 채팅 구현에는 JavaScript 라이브러리인 SockJS와 STOMP 프로토콜로 구현했다.
  • 서버측 구현은 BE팀에서 담당했다. (여기선 React에서 사용된 코드만 정리했다.)
  • 채팅은 서버와 클라이언트 양쪽에서 실시간 통신을 위해 동일한 라이브러리를 사용해야하기에 협업이 잘 되어야 한다 🤝

SockJS

SockJS란 웹 애플리케이션과 웹 서버 간에 실시간 양방향 통신을 가능하게 해주는 JavaScript라이브러리다.

SockJS는 웹 소켓(WebSocket)을 사용할 수 있는 경우에는 웹 소켓을 사용하여 통신하지만, 웹 소켓을 지원하지 않는 경우에는 다른 대안 수단으로 통신하도록 해주는 유용한 라이브러이다.

웹 소켓을 지원하지 않는 환경에서의 SockJS

STOMP

STOMP(Streaming Text Oriented Messaging Protocol)은 메시징 시스템과 클라이언트 간에 상호 작용하기 위해 사용되는 간단하고 가볍운 텍스트 중심의 프로토콜이다.

  • 가볍고 간단한 프로토콜
    : STOMP는 텍스트 기반으로 이해하기 쉬운 프로토콜로 HTTP와 유사한 구문을 사용하며, 프로토콜 자체가 가볍고 단순하다.

  • 다양한 클라이언트 및 서버 지원
    : STOMP는 다양한 프로그래밍 언어와 프레임워크에서 지원된다. Java, Python, JavaScript, Ruby, 등 다양한 언어에서 STOMP 클라이언트 및 서버를 구현할 수 있다.

  • 발행-구독 및 요청-응답 모델
    : STOMP는 발행-구독 모델을 통해 메시지의 발행과 구독을 지원하며, 요청-응답 모델을 통해 메시지의 요청과 응답을 처리할 수 있다.

  • 헤더와 프레임
    : STOMP는 메시지에 대한 헤더와 본문 프레임으로 구성되는데 헤더는 메시지의 속성을 나타내고, 프레임은 메시지의 실제 내용을 포함합니다.

STOMP는 대표적으로 웹 소켓(WebSocket)과 함께 사용되며, 웹 애플리케이션에서 실시간 통신을 구현하는 데 널리 사용된다. STOMP 클라이언트를 사용하여 메시지를 발행하고 구독하며, STOMP 서버는 이러한 메시지를 처리하고 전달한다.

CompatClient

STOMP 프로토콜을 사용하기 위해 클라이언트 라이브러리 @stomp/stompjs를 사용하는데,
이 패키지에서 CompatClient는 호환성을 가진 STOMP 클라이언트 객체를 나타낸다.

CompatClient는 SockJS와 STOMP.js의 호환성을 지원하여 SockJS 클라이언트와 STOMP 프로토콜을 함께 사용할 수 있도록 도와준다.

CompatClient 객체는 STOMP 라이브러리를 사용하여 소켓 연결을 수행하고 STOMP 프로토콜을 통해 메시지를 주고받는 역할을 한다.

CompatClient를 client 변수로 useRef를 사용하여 초기화하면 CompatClient 객체가 생성되고 소켓 연결을 수행할 때 해당 객체를 참조하도록 할 수 있다. 이렇게 CompatClient 객체로 초기화한 client 변수는 다른 함수나 이벤트 핸들러에서 CompatClient 객체에 접근할 수 있게 된다.

예를 들어, client.current를 통해 CompatClient 객체에 접근할 수 있고, 해당 객체를 사용하여 소켓 연결 설정이나 메시지 발행 등을 처리할 수 있다. useRef를 사용했기에 리액트 컴포넌트의 렌더링이 다시 발생해도 client 변수가 유지되므로, CompatClient 객체에 접근할 수 있는 참조를 유지할 수 있다.

1. 채팅방 만들기

  • 채팅은 먼저 판매자와 사용자 둘만의 채팅방이 필요하다.

1-1. 사용자는 판매자의 상품에서 [채팅하기]를 누르면 새로운 채팅방을 만든다.


- 여기서 사용자는 제품ID로 서버에 방 생성 요청을 보내고

- 서버는 요청을 받으면 제품 ID, 사용자 ID, 판매자 ID로 Room을 만들어 준다.
export const postNewChatRoom = async (
  token: string | null,
  productId: string,
) => {
  try {
    const res = await axiosInstanceWithBearer.post(
      `/chat/room/create`,
      {
        productId: productId,
      },
      {
        headers: {
          Authorization: `Bearer ${token}`,
        },
      },
    );
    return res.data.data.roomId;
  } catch (error) {
    console.error('방 생성 에러:', error);
  }
};
// Example Response 

{
  "success": true,
  "status": "OK",
  "code": 20000,
  "message": "요청이 완료되었습니다.",
  "data": {
    "roomId": 2,
    "productId": 2,
    "sellerId": 2,
    "buyerId": 1
  }
}

1-2. 채팅방이 만들어지면 입장한다. 이미 채팅방이 존재한다면 바로 입장한다.

// 채팅방 생성 요청 페이지 (상품 상세) 전체 코드

interface DetailTapBarProps {
  price: number | null;
  curProductsId: string | undefined;
}

interface Room {
  roomId: string;
  productId: string;
  sellerId: number;
  buyerId: number;
}

const DetailTapBar = ({ price, curProductsId }: DetailTapBarProps) => {
  const navigate = useNavigate();
  const accessToken = localStorage.getItem(ACCESS_TOKEN);

  const handleChatClick = async () => {
    if (curProductsId) {
      try {
        // 방 생성 전에 있는 존재하는 방 리스트 확인하기
        const roomsList = await getRoomsList(accessToken);
        // 현재 제품 ID와 일치하는 방이 있는지 확인
        const matchedRoom = roomsList.find(
          (room: Room) => String(room.productId) === curProductsId,
        );

        if (matchedRoom) {
          // 일치하는 방으로 이동
          enterChatRoom(matchedRoom.roomId);
        } else {
          // 일치하는 방이 없으면 새로운 방 생성
          await createChatRoom(curProductsId);
        }
      } catch (error) {
        console.error('방 생성 에러:', error);
      }
    }
  };

  // 방이 없다면 curProductsId와 accessToken으로 방 생성
  const createChatRoom = async (curProductsId: string) => {
    try {
      await postNewChatRoom(accessToken, curProductsId);
      // 생성된 방으로 이동
      enterChatRoom(curProductsId);
    } catch (error) {
      console.error('방 생성 에러:', error);
    }
  };

  // 방으로 이동
  const enterChatRoom = (roomId: string) => {
    sessionStorage.setItem('curRoomId', roomId);
    if (curProductsId) sessionStorage.setItem('curProductsId', curProductsId);
    navigate(`${CHATROOM}/${roomId}`);
  };

  return (
    <>
      <S.DetailTapBarContainer>
        <S.Menu>
          <div>
            <S.Left>
              <Icon name="heart" width="27" height="28" />
              {price !== null && <S.Price>{formatNumber(price)}</S.Price>}
            </S.Left>
          </div>
          <S.Right>
            <Button active={!!curProductsId} onClick={handleChatClick}>
              채팅하기
            </Button>
          </S.Right>
        </S.Menu>
      </S.DetailTapBarContainer>
    </>
  );
};

2. 채팅하기

  • 채팅은 실시간으로 이루어지기에 메시지를 보내면 바로 화면에 보여줘야 한다.
  • 판매자와 사용자는 서로가 보내는 사람 입장이 되어야 한다.

2-1. 처음 방에 입장하면 우선 이전 채팅 내역을 확인하고 있다면 보여줘야 한다.

export const getChatDetails = async (
  token: string | null,
  curRoomId: string | undefined,
) => {
  try {
    const res = await axiosInstanceWithBearer.get(
      `/chat/room/history/${curRoomId}`,
      {
        headers: {
          Authorization: `Bearer ${token}`,
        },
      },
    );
    return res.data.data.messageHistory;
  } catch (error) {
    console.error('채팅 내역 조회 에러', error);
  }
};

2-2. CompatClient 객체를 참조하는 변수 client를 초기화 해준다.

// Stomp의 CompatClient 객체를 참조하는 객체 (리렌더링에도 유지를 위해 useRef 사용)
// Stomp라이브러리와 소켓 연결을 수행하는 cliet객체에 접근할 수 있게 해준다.
  const client = useRef<CompatClient | null>(null);

2-3. 채팅 내역을 불러오고 SockJS를 사용하여 웹 소켓(Stomp) 연결을 한다.

// 소켓 연결
  const connectHandler = () => {
    // SockJS 클라이언트 객체를 생성하고, 웹 소켓을 연결한다.
    // ws-stomp는 서버의 Endpoint 경로로, 웹 소켓 통신을 위한 특정 경로를 의미한다.
    const socket = new SockJS(`${BASE_URL}/ws-stomp`);

    // SockJS 클라이언트 객체 socket를 STOMP 프로토콜로 오버랩하여 client.current에 할당
    client.current = Stomp.over(socket);
    // 클라이언트 객체를 서버와 연결
    client.current.connect(
      {
        Authorization: 'Bearer ' + accessToken,
        'Content-Type': 'application/json',
      },
      () => {
        // 연결 성공 시 해당 방을 구독하면 서버로부터 새로운 매시지를 수신 한다.
        client.current?.subscribe(
          `/sub/chat/room/${curRoomId}`,
          (message) => {
            // 기존 대화 내역에 새로운 메시지 추가
            setChatHistory((prevHistory) => {
              return prevHistory
                ? [...prevHistory, JSON.parse(message.body)]
                : null;
            });
          },
          {
            Authorization: 'Bearer ' + accessToken,
            'Content-Type': 'application/json',
          },
        );
      },
    );
  };

  useEffect(() => {
    connectHandler();
  }, [accessToken, curRoomId]);
  • client 객체가 소켓과 연결되고 해당 방을 구독했다.
  • 서버에서 새로운 메시지를 해당 방에 전송하면 client 객체는 해당 메시지를 수신하고 처리한다.

2-4. 이제는 서버로 메시지를 전송하는 함수를 구현한다.

  const sendHandler = (inputValue: string) => {
    // client.current가 존재하고 연결되었다면 메시지 전송
    if (client.current && client.current.connected) {
      client.current.send(
        '/pub/chat/message',
        {
          Authorization: 'Bearer ' + accessToken,
          'Content-Type': 'application/json',
        },
        // JSON 형식으로 전송한다
        JSON.stringify({
          type: 'TALK',
          roomId: curRoomId,
          message: inputValue,
        }),
      );
    }
  • client 객체에서 메시지를 전송하면 client 객체가 구독외어 있는 특정 방으로 메시지가 전달되고 서버에서는 해당 메시지를 받아 처리를 수행한다. 그것을 client 객체가 수신하여 처리한다.

즉, client 객체가 메시지를 전송하면 서버는 그것을 받아서 처리(방 DB에 저장) 후 client 객체에게 다시 보내주고 client 객체는 전달 받은 메시지를 대화 내역에 추가하면서 실시간으로 메시지를 주고 받을 수 있다.

2-5. 채팅 전체 코드

import { useNavigate } from 'react-router-dom';
import { useEffect, useRef, useState } from 'react';

import SockJS from 'sockjs-client';
import { Stomp, CompatClient } from '@stomp/stompjs';

import ChatRoomContents from '../../components/ChatRoomContents';
import ChatRoomItem from '../../components/ChatRoomItem';
import ChatInputBar from '../../components/ChatInputBar';
import NavBarTitle from '../../components/NavBarTitle';
import { BASE_URL } from '../../constants/api';
import { ACCESS_TOKEN } from '../../constants/login';
import useAsync from '../../hooks/useAsync';
import { getSeller } from '../../api/member';
import { getChatDetails } from '../../api/chat';

interface ChatHistoryProps {
  type: string;
  sender: string;
  message: string;
}

const ChatRoom = () => {
  const navigate = useNavigate();
  const accessToken = localStorage.getItem(ACCESS_TOKEN);

  // Stomp의 CompatClient 객체를 참조하는 객체 (리렌더링에도 유지를 위해 useRef 사용)
  // Stomp라이브러리와 소켓 연결을 수행하는 cliet객체에 접근할 수 있게 해준다.
  const client = useRef<CompatClient | null>(null);

  const curRoomId = sessionStorage.getItem('curRoomId') || undefined;
  const curProductsId = sessionStorage.getItem('curProductsId') || undefined;

  // TODO : 판매자 번호 (추후 닉네임으로 받기)
  const sellerData = useAsync(() => getSeller(accessToken, curRoomId));
  const sellerId = sellerData?.data?.data.sellerId;

  const [chatHistory, setChatHistory] = useState<ChatHistoryProps[] | null>(
    null,
  );
  const [inputValue, setInputValue] = useState('');

  // 채팅 내역 조회하고 불러오기
  const checkChatDetails = async () => {
    try {
      const chatDetails = await getChatDetails(accessToken, curRoomId);
      setChatHistory(chatDetails);
    } catch (error) {
      console.error('채팅 내역 불러오기 에러:', error);
    }
  };

  useEffect(() => {
    checkChatDetails();
  }, [accessToken, curRoomId]);

  // 소켓 연결
  const connectHandler = () => {
    // SockJS 클라이언트 객체를 생성하고, 웹 소켓을 연결한다.
    // ws-stomp는 서버의 Endpoint 경로로, 웹 소켓 통신을 위한 특정 경로를 의미한다.
    const socket = new SockJS(`${BASE_URL}/ws-stomp`);

    // SockJS 클라이언트 객체 socket를 STOMP 프로토콜로 오버랩하여 client.current에 할당
    client.current = Stomp.over(socket);
    // 클라이언트 객체를 서버와 연결
    client.current.connect(
      {
        Authorization: 'Bearer ' + accessToken,
        'Content-Type': 'application/json',
      },
      () => {
        // 연결 성공 시 해당 방을 구독하면 서버로부터 새로운 매시지를 수신 한다.
        client.current?.subscribe(
          `/sub/chat/room/${curRoomId}`,
          (message) => {
            // 기존 대화 내역에 새로운 메시지 추가
            setChatHistory((prevHistory) => {
              return prevHistory
                ? [...prevHistory, JSON.parse(message.body)]
                : null;
            });
          },
          {
            Authorization: 'Bearer ' + accessToken,
            'Content-Type': 'application/json',
          },
        );
      },
    );
  };

  useEffect(() => {
    connectHandler();
  }, [accessToken, curRoomId]);

  // 소켓을 통해 메시지를 전송
  const sendHandler = (inputValue: string) => {
    // client.current가 존재하고 연결되었다면 메시지 전송
    if (client.current && client.current.connected) {
      client.current.send(
        '/pub/chat/message',
        {
          Authorization: 'Bearer ' + accessToken,
          'Content-Type': 'application/json',
        },
        // JSON 형식으로 전송한다
        JSON.stringify({
          type: 'TALK',
          roomId: curRoomId,
          message: inputValue,
        }),
      );
    }
  };

  const handleBackIconClick = () => {
    sessionStorage.clear();
    navigate(-1);
  };

  useEffect(() => {
    sendHandler(inputValue);
  }, [inputValue]);

  return (
    <>
      <NavBarTitle
        type="high"
        backIcon
        prevTitle="뒤로"
        centerTitle={sellerId}
        moreIcon
        preTitleClick={handleBackIconClick}
      />
      <ChatRoomItem curProductsId={curProductsId} />
      <ChatRoomContents chatHistory={chatHistory} />
      <ChatInputBar onChange={setInputValue} />
    </>
  );
};

export default ChatRoom;

참고자료

STOMP로 실시간 채팅 구현하기
[리액트] 실시간 채팅 구현하기 (socket.io, Nodejs)

profile
한 발자국, 한 걸음 느리더라도 하루하루 발전하는 삶을 살자.

1개의 댓글

comment-user-thumbnail
2023년 7월 18일

유익한 글 잘 봤습니다, 감사합니다.

답글 달기