본문 바로가기

WEB

[WebSocket] Spring, React, Stomp로 실시간 채팅, 저장 구현하기

 

팀원들과 프로젝트를 진행하였고 의미 있는 내용들을 포스팅 해보려고 합니다.
프로젝트는 다음과 같이 진행되었습니다.

  • Java 17
  • Spring Boot 3.2.0
  • JPA
  • Gradle
  • React

저에게는 자그마한 꿈이 있었습니다. 바로 저장되는 실시간 채팅입니다. 한번 좌절을 맛보았던 터라 이번 프로젝트는 반드시 해내자고 다짐하고 결과적으로 성공했습니다. 그 과정을 설명해보려고 합니다. 이번 프로젝트에서 프론트엔드를 맡아서 백엔드 코드는 설명이 부족할 수 있습니다.

1. 목표

제가 생각하는 구현하고자 했던 채팅은 당근마켓과 유사한 1:1 채팅이였습니다. 참고자료들에서는 사용자 입장메세지, 일반메세지로 나누었지만 1대1 채팅을 원하는 저에게는 의미없었습니다. 또한 채팅내용이 저장이 되어야 했습니다. 굳이 다른 백엔드 서버를 사용하여야 되나 싶어서 JPA를 이용해 스프링 백엔드 서버를 이용하길 원했습니다.

 

2. 개념

1) STOMP

  • Simple Text Oriented Messaging Protocol
  • WebSocket과 같은 양방향 네트워크 프로토콜 기반으로 동작
  • @MessageMapping을 이용하여 handler를 직접 구현하지 않고 controller를 따로 분리해서 관리 가능
  • subscribe/publish 기반으로 동작

STOMP는 sub와 pub으로 이루어집니다. subscribe은 구독입니다. 구독하고 있는 채팅방을 지속적으로 바라보고 있다고 생각하시면 됩니다. publish는 출판하다는 의미를 가집니다. 즉 사용자 A와 B가 같은 채팅방 1번방을 구독(sub)하고 있으면 서로가 출판하는 메세지(pub)를 볼 수 있다. 라고 이해하시면 됩니다.

 

2) Stomp  Endpoint

  • Endpoint : 웹 애플리케이션에서 클라이언트가 서버에 요청을 보내는 특정 URL 경로

EndPoint를 지정해줍니다. 예를들어 Stomp EndPoint를 ws로 지정한다면 이러한 요청이 왔을때 STOMP 통신인 것을 알 수 있게 하는것입니다.

 

3. 준비

1) DTO

메세지를 저장할 DTO를 만들어 줍니다. 저희는 채팅방, 메세지 등을 준비하였습니다. 필요에 맞게 준비하시면 될 것 같습니다.

2) 의존성 추가

3) 모듈 설치


구현

프로젝트 코드를 거의 그대로 들고와서 수정해야될 부분이 많이 있습니다. 그대로 사용하기보단 참고하기를 권장드립니다.

1. 백엔드

1) WebSocketConfig 작성

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry){
        registry.addEndpoint("/ws")     
                .setAllowedOrigins("*");
    }
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry){
        registry.enableSimpleBroker("/sub");
        registry.setApplicationDestinationPrefixes("/pub");
    }
}

 

엔드포인트 등록을 위해 registerStompEndpoints를 override합니다. 제가 설정한 endpoint는 /ws입니다.
이제 /ws로 도착하는 것은 stomp통신으로 인식할 것 입니다.

 

또한 configureMessageBroker에서 sub, pub을 설정해 줍니다. 이제 /sub가 prefix로 붙으면 구독할것이고,
/pub는 메세지를 송신할 때 사용할 것 입니다.

 

2) Controller 작성

import com.ssafy.lam.chat.domain.ChatMessage;
import com.ssafy.lam.chat.dto.*;
import io.swagger.v3.oas.annotations.Operation;
import com.ssafy.lam.chat.service.ChatService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.*;
import org.springframework.messaging.simp.SimpMessagingTemplate;

import java.util.List;


@RestController
public class WebSocketController {

    @Autowired
    private ChatService chatService;

    @Autowired
    private SimpMessagingTemplate messagingTemplate;
    

    // 채팅 메시지 수신 및 저장
    @MessageMapping("/message")
    @Operation(summary = "메시지 전송", description = "메시지를 전송합니다.")
    public ResponseEntity<String> receiveMessage(@RequestBody ChatMessageDto messageDto) {
        // 메시지 저장
        ChatMessage chatMessage = chatService.saveMessage(messageDto);

        // 메시지를 해당 채팅방 구독자들에게 전송
        messagingTemplate.convertAndSend("/sub/chatroom/" + messageDto.getChatroomSeq(), messageDto);
        return ResponseEntity.ok("메시지 전송 완료");
    }

 

ConvertAndSend를 통해 /sub/chatroom/{chatRoomSeq}를 구독하고 있는 사용자들에게 메세지를 보내줍니다.

저는 메세지를 여기서 저장했습니다. /pub로 발행된 메세지가 서버로 도착해 저장되고 convertAndSend를 통해 구독중인 사용자들에게 메세지를 보냅니다.

 

2. 프론트엔드

1) 연결

// 웹소켓 연결 설정
  const connect = () => {
    const socket = new WebSocket("ws://localhost:80/ws");
    stompClient.current = Stomp.over(socket);
    stompClient.current.connect({}, () => {
      stompClient.current.subscribe(`/sub/chatroom/${roomId}`, (message) => {
        const newMessage = JSON.parse(message.body);
        setMessages((prevMessages) => [...prevMessages, newMessage]);

        if (newMessage.senderSeq !== currentUser.userSeq) {
          setCustomerSeq(newMessage.senderSeq);
        }
      });
    });
    console.log("방 번호", roomId);
  };

 

http가 아닌 웹 소켓 통신을 해야하므로 "ws://로 시작합니다. 

/sub/chatroom/${roomId}를 구독합니다.

 

2) 메세지 보내기

const sendMessage = () => {
    if (stompClient.current && message) {
      const messageObj = {
        chatroomSeq: roomId,
        senderSeq: currentUser.userSeq,
        sender: currentUser.userId,
        message: message,
      };
      stompClient.current.send(`/pub/message`, {}, JSON.stringify(messageObj));
      setMessage(""); // 입력 필드 초기화
    }
  };

 

messageObj를 JSON으로 바꿔서 발행하는 코드입니다.

 

3. 프론트엔드 전체 코드 및 참고사항

프로젝트를 거의 수정없이 그대로 긁어와서 필요없는 부분이 많이 있습니다. 주석을 나름 열심히 달아놨으니 필요한 부분만 참고하시면 될듯합니다. 
저는 axios로 채팅방에 저장된 메세지들을 불러오고 그 이후에 실시간 채팅을 하는 방법을 사용하였습니다. 참고하시면 코드 이해에 도움이 될 듯 합니다.
return 부분은 필요없을 듯 하여 포함하지 않겠습니다.

 

import React, { useEffect, useState, useRef } from "react";
import { useParams } from "react-router-dom";
import axiosApi from "../../api/axiosApi";
import { useSelector } from "react-redux";
import { Stomp } from "@stomp/stompjs";
import styles from "./ChatApp.module.css";
function ChatApp() {
  // URL에서 채팅방 ID를 가져옴
  const { roomId } = useParams();
  // 채팅 메시지 상태
  const [messages, setMessages] = useState([]);
  // 메시지 입력 상태
  const [message, setMessage] = useState("");
  // STOMP 클라이언트를 위한 ref. 웹소켓 연결을 유지하기 위해 사용
  const stompClient = useRef(null);
  // Redux store에서 현재 사용자 정보 가져오기
  const currentUser = useSelector((state) => state.user);
  // 채팅 메시지 목록의 끝을 참조하는 ref. 이를 이용해 새 메시지가 추가될 때 스크롤을 이동
  const messagesEndRef = useRef(null);
  // 컴포넌트 마운트 시 실행. 웹소켓 연결 및 초기 메시지 로딩
  const [profileImg, setProfileImg] = useState(null);
  const [customerSeq, setCustomerSeq] = useState("");

  useEffect(() => {
    connect();
    fetchMessages();
    // 컴포넌트 언마운트 시 웹소켓 연결 해제
    return () => disconnect();
  }, [roomId]);
  // 메시지 목록이 업데이트될 때마다 스크롤을 최하단으로 이동시키는 함수
  useEffect(() => {
    scrollToBottom();
  }, [messages]);
  const scrollToBottom = () => {
    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  };
  // 웹소켓 연결 설정
  const connect = () => {
    const socket = new WebSocket("ws://localhost:80/ws");
    stompClient.current = Stomp.over(socket);
    stompClient.current.connect({}, () => {
      stompClient.current.subscribe(`/sub/chatroom/${roomId}`, (message) => {
        const newMessage = JSON.parse(message.body);
        setMessages((prevMessages) => [...prevMessages, newMessage]);

        if (newMessage.senderSeq !== currentUser.userSeq) {
          setCustomerSeq(newMessage.senderSeq);
        }
      });
    });
    console.log("방 번호", roomId);
  };
  // 웹소켓 연결 해제
  const disconnect = () => {
    if (stompClient.current) {
      stompClient.current.disconnect();
    }
  };
  // 기존 채팅 메시지를 서버로부터 가져오는 함수
  const fetchMessages = () => {
    axiosApi
      .get(`/api/chatroom/${roomId}/messages`)
      .then((response) => {
        console.log("메시지 목록", response.data);
        setMessages(response.data);
      })
      .catch((error) => console.error("Failed to fetch chat messages.", error));
  };
  // 새 메시지를 보내는 함수
  const sendMessage = () => {
    if (stompClient.current && message) {
      const messageObj = {
        chatroomSeq: roomId,
        senderSeq: currentUser.userSeq,
        sender: currentUser.userId,
        message: message,
      };
      stompClient.current.send(`/pub/message`, {}, JSON.stringify(messageObj));
      setMessage(""); // 입력 필드 초기화
    }
  };

 


저의 부족한 코드가 조금이라도 도움이 될 수 있다면 좋겠습니다.

초보 개발자의 글이라 부족한 부분이 많이 있습니다. 지적해 주시면 감사히 받겠습니다.