들어가기
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를 참고하길 바란다.
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)
잘못된 내용이나 궁금하신 사항 알려주시면 감사하겠습니다..!
참고
https://terianp.tistory.com/149
'프로젝트 관련' 카테고리의 다른 글
일일 단위로 환율 DB 저장을 위한 스케줄링 구현(with Spring boot) (0) | 2024.08.11 |
---|---|
Spring boot with React: STOMP를 통해 채팅 시스템을 구현해보자(With Mysql, MongoDB)(2) (2) | 2024.05.17 |
프로젝트 일지 - 쿼리 통합: 여러 번의 db접근을 감소시키자(with springboot, mysql, mybatis) (0) | 2023.12.25 |
Springboot With React: 커서 기반 페이징 기법을 통한 댓글 무한 스크롤 구현 (1) | 2023.10.19 |
스프링부트 With Mysql - easyRandom을 통한 bulk Insert 및 Index 적용 (0) | 2023.08.03 |