웹소켓과 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;
'SpringBoot > Project' 카테고리의 다른 글
[Springboot/Firebase] Firebase CRUD 정리 (0) | 2024.05.13 |
---|---|
Firebase Storage 시작하기 (0) | 2024.05.13 |
[Spring boot/Firebase] Spring boot와 Firebase 연결 (0) | 2024.05.13 |
[Spring boot] Spring boot와 React 이용한 굿즈 중고거래어플 프로젝트 (0) | 2024.05.13 |