본문 바로가기
프로젝트 관련

Spring boot with React: STOMP를 통해 채팅 시스템을 구현해보자(With Mysql, MongoDB)(1)

by khds 2024. 5. 13.

 

들어가기 

 

Spring boot로 채팅방을 구현하는 프로젝트를 진행하려고 한다. 채팅방은 실시간으로 채팅 내용을 저장할 수 있게 데이터베이스에 저장하도록 생각하였다. 하지만 채팅내용이 보내질 때마다 데이터베이스에 저장을 하는 비용이 발생한다. 이를 위한 해결 방법으로는 메모리에 채팅 내용을 저장해 두고 스케줄링하여 일정 주기마다 데이터베이스에 Bulk Insert 하는 방식, RDB보다 데이터 접근 비용이 적은 NoSQL을 사용하는 방식 등이 있다.

나는 두번째 방법을 선택하여 NoSQL 중 MongoDB를 사용하기로 하였다.

처음 프로젝트 설정부터 연동 및 React 부분까지 두 개의 글로 나눠서 작성할 것이다.

이 글에선 데이터베이스 연동 없이 Spring boot와 React 연동 및 STOMP를 통한 간단한 채팅 시스템 구현을 진행할 것이다. 참고로 Spring boot 버전은 '3.2.5'이다.

 

 

본론

1. 채팅은 어떻게 일어날까?

 

채팅은 우리가 자주 사용했던 HTTP 통신이 아니라 웹소켓(WebSocket) 통신을 통해 일어난다. 

웹소켓은 하나의 TCP 접속에 전이중 통신 채널을 제공하는 컴퓨터 통신 프로토콜로 실시간성을 보장하기 위해서 사용된다. 채팅은 실시간성이 필요하여 웹소켓을 사용한다. 

HTTP 통신은 한번 요청 후 응답하면 연결이 끝나기에, 여러 번 요청하면 여러 번 연결이 맺어진다.

하지만 웹소켓은 한번 요청 후 연결을 끊기 전까지 계속 연결을 유지하기 때문에, 매번 요청마다 연결을 시도할 비용을 절약할 수 있다. 

기본적으로 웹소켓만을 사용한 통신은 메시지를 주고받는 형식이 정해져 있지 않다. 정의된 메시지 형식대로 파싱 하는 로직 또한 따로 구현해야 한다. 그리고 발신자와 수신자를 Spring 단에서 직접 관리를 해야 한다. 사용자를 Map으로 관리하며 클라이언트에서 들어오는 메시지를 다른 사용자에게 전달하는 코드를 직접 구현해야 한다. 

하지만 STOMP는 다르다.

STOMP 는 'Simple Text Oriented Messaging Protocol'의 약자로 메시지 전송을 위한 프로토콜이다. 메시지 브로커를 활용하여 쉽게 메시지를 주고받을 수 있다. pub-sub(발행 - 구독) 방식으로 동작하며, 이 방식은 발신자가 메시지를 발행하면 수신자가 그것을 수신하는 메시징 패러다임이다. 그리고 웹소켓 위에 얹어 함께 사용 가능한 하위 프로토콜이다.

메시지 형식이 정해져 있으며 pub/sub 방식으로 동작하여 추가적인 코드 없이 @MessagingMapping을 사용하여 쉽게 메시지 전송, 수신이 가능하다. 

채팅방을 생성하면 Topic 이 생성되며 이 Topic마다 채팅방이 구분된다. 해당 Topic를 구독(sub)하면 웹 소켓이 연결되어 있는 동안 채팅방을 지속적으로 확인할 수 있다. 그렇기에 새로운 채팅이 송신(pub)되면 구독한 사람들에게 메시지를 전달만 해주면 되기 때문에 더 쉬어진 것이다.

자세한 내용은 https://velog.io/@junghunuk456/WebSocket-Stomp를 참고하길 바란다.

 

WebSocket & Stomp

웹소켓은 전이중 통신을 제공하기 때문에 실시간성을 보장해줄 수 있다.실시간성을 보장하는 서비스 (게임, 채팅, 실시간 주식거래)에서 웹소켓을 사용할 수 있다.웹소켓이 아닌 HTTP를 이용하여

velog.io

 

2. Spring boot 구현

 

우선 의존성을 추가해준다.

implementation 'org.springframework.boot:spring-boot-starter-websocket'

 

이후 웹소켓을 사용하기 위한 Config 파일을 설정해 준다.

@Configuration
@EnableWebSocketMessageBroker
public class ChattingConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // stomp 접속 주소 url = ws://localhost:8080/ws, 프로토콜이 http가 아니다!
        registry.addEndpoint("/ws") // 연결될 엔드포인트
            .setAllowedOrigins("*");
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 메시지를 구독(수신)하는 요청 엔드포인트
        registry.enableSimpleBroker("/sub");

        // 메시지를 발행(송신)하는 엔드포인트
        registry.setApplicationDestinationPrefixes("/pub");
    }
}

 

위에서처럼 엔드포인트를 '/ws'로 설정해 두면 웹소켓 통신이 '/ws'로 도착할 때, 해당 통신이 웹 소켓 통신 중 stomp 통신인 것을 확인하고, 이를 연결한다는 것이다. 

'/sub'로 도착하는 것은 메시지 구독(sub)  시 사용하고, '/pub'로 도착하는 것은 메시지 발행 시 사용하는 엔드포인트가 된다. 클라이언트 단에서 서버 단으로 요청 시 수신이라면 /sub로, 송신이라면 /pub로 url의 끝 부분을 작성하면 된다.

 

그 후 서로 다른 서버를 사용하여 서버 단과 클라이언트 단을 구성하는 것이므로 CORS 문제를 해결하기 위한 config 파일을 생성한다. CORS에 대한 것은 https://khdscor.tistory.com/64를 참고하길 바란다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

    private static final String DEVELOP_FRONT_ADDRESS = "http://localhost:3000";

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
            .allowedOrigins(DEVELOP_FRONT_ADDRESS)
            .allowedMethods("GET", "POST", "PUT", "DELETE")
            .exposedHeaders("location")
            .allowedHeaders("*")
            .allowCredentials(true);
    }
}

 

반환되는 데이터 타입인 ChatMessage를 작성한다. 간단하게 작성하였다. 

@Getter
@NoArgsConstructor
public class ChatMessage {

    private Long id;
    private String name;
    private String message;

    public ChatMessage(Long id, String name, String message) {
        this.id = id;
        this.name = name;
        this.message = message;
    }
}

 

이후 ChatController를 작성한다. 채팅 리스트를 반환하는 api와 메시지 송신 시 처리하는 메서드를 작성하였다.

@RestController
@RequiredArgsConstructor
public class ChatController {

    private final SimpMessageSendingOperations template;

    // 채팅 리스트 반환
    @GetMapping("/chat/{id}")
    public ResponseEntity<List<ChatMessage>> getChatMessages(@PathVariable Long id){
    	//임시로 리스트 형식으로 구현, 실제론 DB 접근 필요
        ChatMessage test = new ChatMessage(1L, "test", "test");
        return ResponseEntity.ok().body(List.of(test));
    }

    //메시지 송신 및 수신, /pub가 생략된 모습. 클라이언트 단에선 /pub/message로 요청
    @MessageMapping("/message")
    public ResponseEntity<Void> receiveMessage(@RequestBody ChatMessage chat) {
        // 메시지를 해당 채팅방 구독자들에게 전송
        template.convertAndSend("/sub/chatroom/1", chat);
        return ResponseEntity.ok().build();
    }
}

 

채팅 리스트 반환은 임시로 고정된 리스트를 반환하도록 하였다. 추후 roomId에 따라 데이터베이스(MongoDB)에서 데이터 리스트를 가져오는 로직이 필요하다. 

@MessageMapping는 Stomp에서 송신 엔드포인트(/pub)로 오는 요청을 쉽게 응답할 수 있다.  뒤의 주소를 통해 요청을 구분하며, 클라이언트 단에선 /pub/message로 요청을 한다.

convertAndSend는 도착지점을 지정하여 내용(위에서는 chat)과 함께 도착지점을 구독하고 있는 사용자들에게 메시지를 전송한다.

 

 

3. React 구현 

 

우선 프로젝트에서 진행한 의존성들이다.(npm install로 설치하였다.)

import './App.css';
import axios from "axios";
import React, { useEffect, useState, useRef } from "react";
import { Stomp } from "@stomp/stompjs";

 

이후 아래와 같이 기본 변수들을 설정하였다. 

//웹소켓 연결 객체
const stompClient = useRef(null);
// 메시지 리스트
const [messages, setMessages] = new useState([]);
// 사용자 입력을 저장할 변수
const [inputValue, setInputValue] = useState('');

 

우선 페이지에 도달 시 useEffect를 통해 초기 시작하는 로직을 작성하였다.

useEffect(() => {
    connect();
    fetchMessages();
    // 컴포넌트 언마운트 시 웹소켓 연결 해제
    return () => disconnect();
}, []);

 

connect() 부터 살펴보자.

const connect = () => {
	//웹소켓 연결
    const socket = new WebSocket("ws://localhost:8080/ws");
    stompClient.current = Stomp.over(socket);
    stompClient.current.connect({}, () => {
    //메시지 수신(1은 roomId를 임시로 표현)
    stompClient.current.subscribe(`/sub/chatroom/1`, (message) => {
    //누군가 발송했던 메시지를 리스트에 추가
    const newMessage = JSON.parse(message.body);
    setMessages((prevMessages) => [...prevMessages, newMessage]);
      });
    });
  };

 

Spring boot에서 설정한 ws 주소로 웹소켓을 연결한다.  subscribe부분은 해당 주소를 구독하고 있고, 구독한 주소를 통해 메시지가 수신 시 로직이 실행된다. 

 

fetchMessage()는 기존에 저장돼있던 채팅리스트를 axios를 통해 http 통신으로 가져온다. 자주 사용되는 Rest API 통신이다.(이 글에선 미리 지정된 데이터들을 가져온다)

const fetchMessages = () => {
	return axios.get("http://localhost:8080/chat/1" )
   	    .then(response => {setMessages(response.data)});

};

 

아래는 웹소켓 연결을 해제하는 메서드이다. 

const disconnect = () => {
    if (stompClient.current) {
      stompClient.current.disconnect();
    }
};

 

화면 출력 부분이다. 간단하게 메시지 작성 input, 메시지 전송 버튼, 메시지 리스트를 화면에 보여주는 부분만 작성하였다.

return (
    <div>
      <ul>
        <div>
          {/* 입력 필드 */}
       <input
        type="text"
        value={inputValue}
        onChange={handleInputChange}
      />
      {/* 메시지 전송, 메시지 리스트에 추가 */}
      <button onClick={sendMessage}>입력</button>
        </div>
        {/* 메시지 리스트 출력 */}
        {messages.map((item, index) => (
          <div key={index} className="list-item">{item.message}</div>
        ))}
      </ul>
    </div>
  );

 

입력한 메시지를 저장하는 변수와 메시지 전송 기능이다.

// 사용자 입력을 저장할 변수
const [inputValue, setInputValue] = useState('');
// 입력 필드에 변화가 있을 때마다 inputValue를 업데이트
const handleInputChange = (event) => {
	setInputValue(event.target.value);
};

//메세지 전송
  const sendMessage = () => {
    if (stompClient.current && inputValue) {
      //현재로서는 임의의 테스트 값을 삽입
      const body = {
        id : 1,
        name : "테스트1",
        message : inputValue
      };
      stompClient.current.send(`/pub/message`, {}, JSON.stringify(body));
      setInputValue('');
    }
  };

 

 Spring boot에서 @MessageMapping로 설정했던 주소로 메시지 내용(Body)을 송신하는 역할을 한다.

 

아래는 전체코드이다.

import './App.css';
import axios from "axios";
import React, { useEffect, useState, useRef } from "react";
import { Stomp } from "@stomp/stompjs";

function App() {
   
  const stompClient = useRef(null);
  // 채팅 내용들을 저장할 변수
  const [messages, setMessages] = new useState([]);
   // 사용자 입력을 저장할 변수
  const [inputValue, setInputValue] = useState('');
   // 입력 필드에 변화가 있을 때마다 inputValue를 업데이트
  const handleInputChange = (event) => {
    setInputValue(event.target.value);
  };

   // 웹소켓 연결 설정
  const connect = () => {
    const socket = new WebSocket("ws://localhost:8080/ws");
    stompClient.current = Stomp.over(socket);
    stompClient.current.connect({}, () => {
    stompClient.current.subscribe(`/sub/chatroom/1`, (message) => {
    const newMessage = JSON.parse(message.body);
    setMessages((prevMessages) => [...prevMessages, newMessage]);
      });
    });
  };
  // 웹소켓 연결 해제
  const disconnect = () => {
    if (stompClient.current) {
      stompClient.current.disconnect();
    }
  };
  // 기존 채팅 메시지를 서버로부터 가져오는 함수
  const fetchMessages = () => {
    return axios.get("http://localhost:8080/chat/1" )
           .then(response => {setMessages(response.data)});
    
  };
   useEffect(() => {
    connect();
    fetchMessages();
    // 컴포넌트 언마운트 시 웹소켓 연결 해제
    return () => disconnect();
  }, []);

  //메세지 전송
  const sendMessage = () => {
    if (stompClient.current && inputValue) {
      const body = {
        id : 1,
        name : "테스트1",
        message : inputValue
      };
      stompClient.current.send(`/pub/message`, {}, JSON.stringify(body));
      setInputValue('');
    }
  };

  return (
    <div>
      <ul>
        <div>
          {/* 입력 필드 */}
       <input
        type="text"
        value={inputValue}
        onChange={handleInputChange}
      />
      {/* 메시지 전송, 메시지 리스트에 추가 */}
      <button onClick={sendMessage}>입력</button>
        </div>
        {/* 메시지 리스트 출력 */}
        {messages.map((item, index) => (
          <div key={index} className="list-item">{item.message}</div>
        ))}
      </ul>
    </div>
  );
}

export default App;

 

 

4. 시연 영상

 



 

결론

 

이렇게 간단하게 STOMP를 통해 Spring boot와 React를 사용한 메시지 송수신 기능을 구현하였다. 이 글에선 간단하게 핵심로직만을 비추며, 그 외 부수적인 내용은 과감히 제거하였다. 처음 채팅 시스템을 구현하려고 했을 땐, 막연한 두려움이 있었지만 실제로 해보니 보다 간단하게 구현할 수 있었다.

이 글에선 데이터베이스 연동 부분에 제외되어 있으며, 이후 데이터베이스와 연동과정은 다음 글을 참고하길 바란다. (https://khdscor.tistory.com/122)

 

Spring boot with React: STOMP를 통해 채팅 시스템을 구현해보자(With Mysql, MongoDB)(2)

들어가기  이 글은 https://khdscor.tistory.com/121에서 이어진다. 여기선 의존성, Config 파일 등 세부적인 파일을 다루지 않고 바로 비즈니스 로직을 작성한 과정을 담았다. 이에 대해 궁금한 것은 이전

khdscor.tistory.com

 

잘못된 내용이나 궁금하신 사항 알려주시면 감사하겠습니다..! 

 

 

참고

 

https://yunae.tistory.com/entry/React-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%B1%84%ED%8C%85-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-STOMP

 

[React] 실시간 채팅 구현하기 - STOMP

소켓 프록시 수동 설정하기 https://create-react-app.dev/docs/proxying-api-requests-in-development/#configuring-the-proxy-manually Proxying API Requests in Development | Create React App Note: this feature is available with react-scripts@0.2.3 and

yunae.tistory.com

 

https://5g-0.tistory.com/17

 

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

팀원들과 프로젝트를 진행하였고 의미 있는 내용들을 포스팅 해보려고 합니다. 프로젝트는 다음과 같이 진행되었습니다. Java 17 Spring Boot 3.2.0 JPA Gradle React 저에게는 자그마한 꿈이 있었습니다.

5g-0.tistory.com

 

https://terianp.tistory.com/149

 

Spring Boot Web Chatting : 스프링 부트로 실시간 채팅 만들기(2) chatDTO, DAO, Socket.js 코드 알아보기

10.29 추가 : 일반(문자) 채팅만 구현하는 코드는 git 의 master 브렌치를 참고해주시기 바랍니다. master-Webrtc-jpa 는 화상 채팅과 jpa 를 이용한 DB 연결을 포함하는 브렌치입니다. 1. 기본 개념 설명 STOMP

terianp.tistory.com

 

https://velog.io/@junghunuk456/WebSocket-Stomp

 

WebSocket & Stomp

웹소켓은 전이중 통신을 제공하기 때문에 실시간성을 보장해줄 수 있다.실시간성을 보장하는 서비스 (게임, 채팅, 실시간 주식거래)에서 웹소켓을 사용할 수 있다.웹소켓이 아닌 HTTP를 이용하여

velog.io