본문 바로가기

SpringBoot/Project

[Springboot/React] #2 웹소켓과 STOMP로 실시간 채팅 구현하기

 

웹소켓과 STOMP를 함께 사용한 이유

 WebSocket은 효율적인 저수준 양방향 통신을 제공하여 실시간 응답성을 보장하고, STOMP는 고수준 메시징 프로토콜로, 메시지의 전송, 구독, 관리 등을 간편하게 만들어 준다.

따라서 좀 더 효율적이고 편리하게 관리하기위해 WebSocket에 STOMP를 결합하여 실시간 채팅을 구현하였다. 

 

STOMP 실시간 메시지 송수신 과정

 

사용자는 '/sub' 엔드포인트를 통해 채널을 구독하고, '/pub' 엔드포인트를 통해 메시지를 발행할 수 있다.

발행된 메시지는 동일한 주제를 구독하는 사용자들에게 서버에서 즉시 전송되어 사용자들의 실시간 채팅이 가능하게 한다.

 

서버(Springboot)

1. WebSockConfig 수정

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/sub");
        config.setApplicationDestinationPrefixes("/pub");
    }
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/test/ws-stomp").setAllowedOrigins("*")
                .withSockJS();
    }
}

 

  • config.enableSimpleBroker("/sub"): 메모리 기반의 간단한 메시지 브로커를 활성화한다. 이 브로커는 /sub로 시작하는 주제(topic) 경로에 대해 클라이언트에게 메시지를 전달한다. 이 경로는 클라이언트가 구독(subscribe)하는 주제와 연결된다.
  • config.setApplicationDestinationPrefixes("/pub"): 클라이언트가 서버로 메시지를 보낼 때 사용하는 경로의 접두사를 설정한다. 예를 들어, 클라이언트가 /pub/message로 메시지를 보내면, 서버에서는 이 메시지를 받아서 처리하게 된다. 이 접두사는 메시지를 처리하는 @MessageMapping 메서드와 연결된다.

2. 메시지 매핑

Chat.java

@Getter
@Setter
public class Chat {
    // 메시지 타입 : 입장, 채팅
    public enum MessageType {
        TALK,JOIN,IMG
    }

    private MessageType type; //메시지 타입
    private String roomId;// 방 번호
    private String sender;//채팅을 보낸 사람
    private String message;// 메세지
    private Date time; // 채팅 발송 시간
}

받은 메시지의 타입에 따라 처리하는 로직이 달라지기 때문에 메시지 타입을 정해두고, 그외 메시지를 저장할때 필요한 엔티티들을 설정했다.

 

ChatController.java

@RequiredArgsConstructor
@RestController
public class ChatController {
    private final ChatService chatService;
    private final SimpMessageSendingOperations messagingTemplate;

    @MessageMapping("/Chat/message")
    public void message(Chat message) throws Exception {
        if (Chat.MessageType.JOIN.equals(message.getType()))
        { message.setMessage(message.getSender() + "님이 입장하셨습니다.");}
        chatService.saveChat(message);//메시지를 받을때마다 데이터베이스에 저장
        if(Chat.MessageType.IMG.equals(message.getType())){
            //이미지인 경우 처리할 로직
        }
        messagingTemplate.convertAndSend("/sub/Chat/room/" + message.getRoomId(), message);
    }
}

@MessageMapping("/Chat/message") :

WebSocket 메시지 핸들링을 위해 Spring에서 제공 하는 어노테이션으로  ' /Chat/message' 경로로 들어오는 메시지를 처리하며 이를 통해 실시간 메시지 처리를 간편하게 구현할 수 있다.

 

메시지타입이 JOIN이거나 IMG인 경우 각각에 해당하는 로직을 처리하며 채팅 메시지를 데이터베이스에 저장한다.

JOIN인 경우 채팅방에 입장 메시지가 전송된다.

 

ChatService.java

@Service
@RequiredArgsConstructor
public class ChatService {
    private final Firestore firestore;

    public void saveChat(Chat message) throws Exception{
        DocumentReference docRef = firestore.collection("Chat").document(message.getRoomId());//문서
        Long messageCnt = (Long) docRef.get().get().get("messageCnt");
        CollectionReference subCollectionRef = docRef.collection("Messages");
        subCollectionRef.document(String.valueOf(messageCnt+1)).set(message);//메시지저장
        docRef.update("messageCnt",messageCnt+1);
        subCollectionRef.getId();
    }

}

메시지 저장하는 로직인데 

Chat 컬렉션 밑에 roomId에 해당하는 문서(teest)에서 messageCnt값을 가져와서 +1을 해주며 하위 컬렉션 Messages에 메시지를 저장하는 로직이다.

클라이언트(React)

1. 모듈 설치

npm i net -S
npm install stompjs sockjs-client

 

2. 채팅방 설정

프론트가 아니라서 디자인은 버리고 대충 만들었으니 감안해주세요.

(밑에 화면 부분 전체 코드 첨부했습니다.)

 

- 이름 설정하는 화면

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

const EnterChat = () => {
    const [username, setUsername] = useState(''); // 사용자의 이름
    const [isNameSet, setIsNameSet] = useState(false); // 이름이 설정되었는지 여부
    const navigate = useNavigate();

    const handleNameSubmit = () => {

        localStorage.setItem('username', username);
        setIsNameSet(true);
        navigate('/chatdetail');  // 페이지를 /chatdetail로 이동

    };


    return (
         <div>
              {/* 이름 설정 모달 */}
              {!isNameSet && (
                <div className="modal">
                  <div className="modal-content">
                    <h2>Enter your name</h2>
                    <input
                      type="text"
                      value={username}
                      onChange={(e) => setUsername(e.target.value)}
                    />
                    <button onClick={handleNameSubmit}>Submit</button>
                  </div>
                </div>
              )}
         </div>
    );
};

export default EnterChat;

이름을 입력받아 로컬 스토리지에 저장한다.

 

 

- 웹소켓 연결 및 채팅방 구독(입장)

const socket = new SockJS('http://localhost:8081/test/ws-stomp');
const client = Stomp.over(socket);

client.connect({}, () => {
setStompClient(client);

const roomId = 'teest'; // 원하는 채팅방 ID로 설정
client.subscribe(`/sub/Chat/room/${roomId}`, (message) => {
      const newMessage = JSON.parse(message.body);
      setMessages((prevMessages) => [...prevMessages, newMessage]);
      console.log(newMessage);
    });

    // 입장 메시지 전송
    const joinMessage = {
      type: 'JOIN',
      roomId: roomId,
      sender: storedName,
      time: new Date(),
    };
    client.send(`/pub/Chat/message`, {}, JSON.stringify(joinMessage));
});

client.connect()로 웹소켓 연결 설정.

client.subscribe(`/sub/Chat/room/${roomId}`)을 통해 특정 채팅방(RoomId)에 해당하는 주제를 구독하고 해당 주제로 메시지가 올때마다 해당 메시지를 messages배열에 추가하도록 하였다.

서버로 type을 'JOIN'으로 설정해서 입장 메시지 전송.

메시지를 보낼때는 엔드포인트( ' /pub/Chat/message' )로 메시지를 보내야 서버로 전송이 된다.
(서버에서 config.setApplicationDestinationPrefixes("/pub")와 @MessageMapping("/Chat/message")로 설정해두었기 때문에)

sender는 앞에서 로컬스토리지에 저장한 이름을 가져와서 사용

웹소켓 연결 및 입장 메시지 전송

 

- 메시지 전송

const sendMessage = () => {
    if (stompClient) {
      const roomId = 'teest'; // 원하는 채팅방 ID로 설정
      const message = {
        type: 'TALK',
        roomId,
        sender: username,
        message: messageInput,
        time: new Date(),
      };
      stompClient.send(`/pub/Chat/message`, {}, JSON.stringify(message));
      setMessageInput('');
    }
  };

메시지 전송버튼을 누를때마다 실행되는 함수로 type을 'TALK'으로 해서 'pub/Chat/message'로 메시지를 전송한다.

 

시크릿 모드로 창을 두개 띄워서 테스트해보면

 

메시지가 잘 주고받아 지는걸 확인할 수 있다.

해당 아이디의 문서에 채팅 내용이 잘 저장되고 있는걸 확인할 수 있다.

 

 

전체코드

import React, { useState, useEffect } from 'react';
import "./transition.css";
import Stomp from 'stompjs'; // STOMP 라이브러리
import SockJS from 'sockjs-client'; // SockJS 라이브러리

const ChatRoom = () => {
  const [messages, setMessages] = useState([]); // 채팅 메시지 저장
  const [messageInput, setMessageInput] = useState(''); // 메시지 입력 상태
  const [stompClient, setStompClient] = useState(null); // STOMP 클라이언트
  const [username, setUsername] = useState(''); // 사용자의 이름
  const [isNameSet, setIsNameSet] = useState(false); // 이름이 설정되었는지 여부

  const sender = username; // 사용자의 이름을 sender로 설정

  useEffect(() => {
    // 웹 소켓 연결 설정은 한번만 실행되도록
    if (!stompClient) {
      const socket = new SockJS('http://localhost:8081/test/ws-stomp');
      const client = Stomp.over(socket);

      client.connect({}, () => {
        // 연결 완료 시 작업 수행
        setStompClient(client);

        // 구독할 채팅방의 roomId 설정
        const roomId = 'teest'; // 원하는 채팅방 ID로 설정. 임의로 정한 ID

        client.subscribe(`/sub/Chat/room/${roomId}`, (message) => {
          const newMessage = JSON.parse(message.body);
          setMessages((prevMessages) => [...prevMessages, newMessage]);
          console.log(newMessage);
        });
      });
    }

    return () => {
      if (stompClient) {
        // 연결이 끊길 때 로컬 스토리지의 사용자 이름 삭제
        localStorage.removeItem('username');
        stompClient.disconnect();
      }
    };
  }, [stompClient]); // stompClient가 변경될 때만 실행

  useEffect(() => {
    // 메시지에 show 클래스 추가
    const messageElements = document.querySelectorAll('.message');
    messageElements.forEach((element) => {
      setTimeout(() => {
        element.classList.add('show');
      }, 10);
    });
  }, [messages]);

  const sendMessage = () => {
    // 메시지를 서버로 보내는 함수
    if (stompClient) {
      const roomId = 'teest'; // 원하는 채팅방 ID로 설정
      const message = {
        type: 'TALK', // 메시지 타입
        roomId,
        sender: sender, // sender로 사용자의 이름
        message: messageInput,
        time: new Date(), // 시간 설정
      };
      stompClient.send(`/pub/Chat/message`, {}, JSON.stringify(message));
      setMessageInput('');
    }
  };

  const handleNameSubmit = () => {
    localStorage.setItem('username', username);
    setIsNameSet(true);

    // 입장 메시지 전송
    if (stompClient) {
        const roomId = 'teest'; // 원하는 채팅방 ID로 설정
        const joinMessage = {
            type: 'JOIN',
            roomId: roomId,
            sender: username,
            time: new Date(), // 시간 설정
        };
    stompClient.send(`/pub/Chat/message`, {}, JSON.stringify(joinMessage));
    }
  };

  return (
    <div>
      {/* 이름 설정 모달 */}
      {!isNameSet && (
        <div className="modal">
          <div className="modal-content">
            <h2>Enter your name</h2>
            <input
              type="text"
              value={username}
              onChange={(e) => setUsername(e.target.value)}
            />
            <button onClick={handleNameSubmit}>Submit</button>
          </div>
        </div>
      )}

      {/* 채팅 메시지 출력 */}
      <div>
        {messages.map((msg, index) => (
          <div key={index} className="message">
            {msg.sender || '알 수 없음'}: {msg.message} ({msg.time})
          </div>
        ))}
      </div>

      {/* 메시지 입력 폼 */}
      <div className="input-container">
        <input
          type="text"
          value={messageInput}
          onChange={(e) => setMessageInput(e.target.value)}
        />
        <button onClick={sendMessage}>전송</button>
      </div>
    </div>
  );
};

export default ChatRoom;