들어가기
이 글은 https://khdscor.tistory.com/121에서 이어진다. 여기선 의존성, Config 파일 등 세부적인 파일을 다루지 않고 바로 비즈니스 로직을 작성한 과정을 담았다. 이에 대해 궁금한 것은 이전 글을 참고하길 바란다.
이 글에선 Mysql과 MongoDB과 함께 Spring boot와 React 연동 및 STOMP를 통한 간단한 채팅 시스템을 구현하는 과정을 작성하였다. 참고로 Spring boot 버전은 '3.2.5'이다.
구현한 내용은 크게 3가지로 아래와 같다.
1. 채팅방 리스트 조회, 새 채팅방 생성
2. 채팅방 별 이전에 작성한 채팅 리스트 조회
3. 메시지 전송
Mysql에는 채팅방 테이블만, MongoDB에는 채팅 내용 컬렉션만을 생성할 것이다.
서번 단(Spring boot)에서 작성한 API 로직은 아래의 3가지이다.(클릭 시 이동)
1. 모든 채팅방 리스트 조회, 새 채팅방 생성(With Mysql)
2. 채팅방 ID를 통해 해당 채팅방에 기록된 채팅 메시지 리스트 조회(With MongoDB)
3. 채팅 메시지 전송에 따른 메시지 수신, 메시지 DB에 저장(With STOMP, MongoDB)
클라이언트 단(React)에서 작성한 페이지는 아래의 2가지이다.(클릭 시 이동)
1. 메인화면 : 화면에 채팅방 리스트 노출, 채팅방 클릭 시 해당 채팅방으로 이동, 새 채팅방 생성
아래는 간단한 시연 영상이다.
Sprint boot
1. 모든 채팅방 리스트 조회, 새 채팅방 생성(With Mysql)
이 부분은 JPA를 활용하여 간단한 리스트 조회 기능이다. Mysql에 관한 의존성, 연동 과정은 https://khdscor.tistory.com/35를 참고하길 바란다.
채팅방은 Mysql에 테이블을 생성하여 관리하도록 하였다. JPA를 사용하였고, 아래는 채팅방 엔티티이다.
@Entity
@Getter
@Table(name = "chat_room")
@NoArgsConstructor
public class ChatRoom {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "title")
private String title;
@Column(name = "create_date")
private Date newDate;
public ChatRoom(String title, Date newDate) {
this.title = title;
this.newDate = newDate;
}
}
채팅방의 PK와 제목(title)과 생성일(create_date)를 담았다.
요청을 직접 받는 Controller 부분을 봐보자.
@PostMapping("/create")
public ResponseEntity<ResponseChatRoomDto> createChatRoom(
@RequestBody RequestChatRoomDto requestChatRoomDto) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(ChatRoomService.createChatRoom(requestChatRoomDto));
}
@GetMapping("/chatList")
public ResponseEntity<List<ResponseChatRoomDto>> getChatRoomList() {
List<ResponseChatRoomDto> responses = ChatRoomService.findChatRoomList();
return ResponseEntity.ok().body(responses);
}
채팅방을 생성하는 서비스를 실행하여 생성한 채팅방을 반환하는 로직과, 채팅방 리스트를 조회하는 서비스를 실행하여 모든 채팅방 리스트를 받아 응답하는 로직이다.
아래는 Service 부분이다.
@Transactional
public ResponseChatRoomDto createChatRoom(RequestChatRoomDto requestChatRoomDto) {
ChatRoom chatRoom = new ChatRoom(requestChatRoomDto.getTitle(), new Date());
return ResponseChatRoomDto.of(chatRoomRepository.save(chatRoom));
}
@Transactional
public List<ResponseChatRoomDto> findChatRoomList() {
List<ChatRoom> chatRooms = chatRoomRepository.findAll();
return chatRooms.stream().map(ResponseChatRoomDto::of).collect(Collectors.toList());
}
save는 반환 타입을 dto로 설정하였고, find는 dto 리스트를 변환하여 응답하도록 하였다.
아래는 dto 내부 코드이다.
@Getter
@AllArgsConstructor
public class ResponseChatRoomDto {
private Long id;
private String title;
private Date createDate;
public static ResponseChatRoomDto of(ChatRoom chatRoom) {
return new ResponseChatRoomDto(chatRoom.getId(), chatRoom.getTitle(),
chatRoom.getNewDate());
}
}
2. 채팅방 ID를 통해 해당 채팅방에 기록된 채팅 메시지 리스트 조회(With MongoDB)
빠른 처리를 위해 사용자의 요청과 MongoDB를 비동기적으로 연동하여 작성하였다. 이를 위한 WebFlux 설정 및 MongoDB 의존성, Config, 연동 과정은 https://khdscor.tistory.com/118를 참고하길 바란다.
채팅방과 다르게 채팅 내용은 MongoDB에 컬렉션으로 저장을 하였다.
@Document(collection = "chatting_content") // 실제 몽고 DB 컬렉션 이름
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessage {
@Id
private ObjectId id;
private Long roomId;
private String content;
private Long writerId;
private Date createdDate;
public ChatMessage(Long roomId, String content, Long writerId, Date date) {
this.roomId = roomId;
this.content = content;
this.writerId = writerId;
this.createdDate = date;
}
}
방번호(roomId)와 채팅 내용(content), 유저 ID(writerId), 작성일(createDate)의 필드를 가지도록 생성하였다.
이제 이를 활용한 Controller 메서드를 봐보자.
// 이전 채팅 내용 조회
@GetMapping("/find/chat/list/{id}")
public Mono<ResponseEntity<List<ResponseMessageDto>>> find(@PathVariable("id") Long id) {
Flux<ResponseMessageDto> response = chatService.findChatMessages(id);
return response.collectList().map(ResponseEntity::ok);
}
Mono 객체를 데이터로 응답하여 비동기적으로 응답하도록 하였다. 방 번호(id)를 요청과 함께 받아 채팅 방에 있는 모든 채팅 메시지들을 응답하는 구조이다.
Flux 객체로 응답하면 아래와 같이 원하는 응답되는 데이터 형식이 이뤄지지 않아서 Mono 객체로 List를 감싸 응답하도록 하였다.
Mono 객체로 응답할 시 아래와 같이 데이터가 응답된다. (글을 작성하면서 느꼈는데 id값에 date가 자동으로 들어가니 굳이 createDate 필드를 넣을 필요가 없었네요ㅜ)
이제 이를 실행하는 비즈니르 로직인 서비스 메서드를 봐보자.
@Transactional
public Flux<ResponseMessageDto> findChatMessages(Long id) {
Flux<ChatMessage> chatMessages = chatMessageRepository.findAllByRoomId(id);
return chatMessages.map(ResponseMessageDto::of);
}
단순히 Repository 메서드를 통해 메시지들을 Flux로 받고 이를 dto 형식으로 반환하는 구조이다.
아래는 Repository 내부이다.
public interface ChatMessageRepository extends ReactiveMongoRepository<ChatMessage, String> {
Flux<ChatMessage> findAllByRoomId(Long roomId);
}
3. 채팅 메시지 전송에 따른 메시지 수신, 메시지 DB에 저장(With STOMP, MongoDB)
이번엔 사용자가 채팅 메시지를 전송 시 채팅방에 다른 사용자들에게 메시지를 전송하고, 메시지는 MongoDB 데이터베이스에 저장하는 로직을 알아보자.
컬렉션은 2번 사항에서 설정한 'ChatMessage'와 같다. Controller 메서드부터 봐보자.
private final SimpMessageSendingOperations template;
//메세지 송신 및 수신
@MessageMapping("/message")
public Mono<ResponseEntity<Void>> receiveMessage(@RequestBody RequestMessageDto chat) {
return chatService.saveChatMessage(chat).flatMap(message -> {
// 메시지를 해당 채팅방 구독자들에게 전송
template.convertAndSend("/sub/chatroom/" + chat.getRoomId(),
ResponseMessageDto.of(message));
return Mono.just(ResponseEntity.ok().build());
});
}
서비스 메서드로부터 save를 실시한 후 Mono 객체를 반환받은 후, flatMap(Map을 사용해도 무방하다.)을 통해 메시지를 전송하고 Void로 응답하였다. 혹은 아래와 같은 방식으로도 구현 가능하다.
@MessageMapping("/message")
public Mono<ResponseEntity<Void>> receiveMessage(@RequestBody RequestMessageDto chat) {
return chatService.saveChatMessage(chat).doOnNext(message -> {
// 메시지를 해당 채팅방 구독자들에게 전송
template.convertAndSend("/sub/chatroom/" + chat.getRoomId(),
ResponseMessageDto.of(message));
}).thenReturn(ResponseEntity.ok().build());
}
(저도 하나의 방식만 알고 있었지만, 비동기 방식은 하면 할수록 무궁무진하네요..! 구현하는 데 있어 정해진 방식은 없으므로 원하는 데로 하면 될 것 같습니다.)
아래는 서비스 메서드이다. 단순히 데이터를 저장 후 save 메서드의 기본 반환 객체인 Mono 객체로 반환하는 로직이다.
@Transactional
public Mono<ChatMessage> saveChatMessage(RequestMessageDto chat) {
return chatMessageRepository.save(
new ChatMessage(chat.getRoomId(), chat.getContent(), chat.getWriterId(), new Date()));
}
React
1. 메인화면
화면 사진(뭐라도 꾸며봤습니다ㅎ)
크게 화면에 채팅방 리스트를 노출하는 기능, 채팅방 클릭 시 해당 채팅방으로 이동하는 기능, 새로운 채팅방 생성하는 기능을 구현하였다.
우선 화면 구성 부분부터 살펴보겠다.
return (
<div>
<ul>
<div>
{/* 입력 필드 */}
<input
type="text"
value={inputValue}
onChange={handleInputChange}
/>
{/* 채팅방 추가 */}
<button onClick={createRoom}>입력</button>
</div>
{/* 채팅방 리스트 출력 */}
{chatRoom.map((item, index) => (
<Link
key={index}
to={`/room/${item.id}`}
style={{ textDecoration: 'none'}}
>
<div className="list-item">{item.title}</div>
</Link>
))}
</ul>
</div>
);
크게 입력 필드, 입력된 값으로 채팅방을 추가하는 버튼, 채팅방 리스트를 보여주는 부분으로 나뉜다.
우선 useEffect()를 통해 페이지 도착 시 이전에 작성해 둔 채팅방 리스트를 서버로부터 가져온다.
const [chatRoom, setChatRoom] = new useState([]);
const fetchRooms = () => {
axios.get("http://localhost:8080/chatList" )
.then(response => {setChatRoom(response.data)});
};
useEffect(() => {
fetchRooms();
}, []);
입력 값을 넣고 채팅방 추가 버튼을 누르면 채팅방이 추가되는 로직이 서버로부터 실행되고, 추가된 채팅방은 채팅방 리스트에 더해진다.
const [inputValue, setInputValue] = useState('');
// 입력 필드에 변화가 있을 때마다 inputValue를 업데이트
const handleInputChange = (event) => {
setInputValue(event.target.value);
};
const createRoom = () => {
if (inputValue) {
const body = {
title : inputValue
};
axios.post("http://localhost:8080/create", body)
.then(response => {
if(response.status === 201){
setInputValue('');
setChatRoom((prev) => [...prev, response.data]);
} else {
alert("경고경고!");
}
}
)
}
};
아래는 JS 파일 및 CSS 파일 전체 코드이다.
JS 파일
import React, { useEffect, useState } from "react";
import axios from "axios";
import { Link } from 'react-router-dom';
import './ChatRoomList.css';
const ChatRoomList = () => {
const [chatRoom, setChatRoom] = new useState([]);
const [inputValue, setInputValue] = useState('');
// 입력 필드에 변화가 있을 때마다 inputValue를 업데이트
const handleInputChange = (event) => {
setInputValue(event.target.value);
};
const fetchRooms = () => {
axios.get("http://localhost:8080/chatList" )
.then(response => {setChatRoom(response.data)});
};
const createRoom = () => {
if (inputValue) {
const body = {
title : inputValue
};
axios.post("http://localhost:8080/create", body)
.then(response => {
if(response.status === 201){
setInputValue('');
setChatRoom((prev) => [...prev, response.data]);
} else {
alert("경고경고!");
}
}
)
}
};
useEffect(() => {
fetchRooms();
}, []);
return (
<div>
<ul>
<div>
{/* 입력 필드 */}
<input
type="text"
value={inputValue}
onChange={handleInputChange}
/>
{/* 채팅방 추가 */}
<button onClick={createRoom}>입력</button>
</div>
{/* 채팅방 리스트 출력 */}
{chatRoom.map((item, index) => (
<Link
key={index}
to={`/room/${item.id}`}
style={{ textDecoration: 'none'}}
>
<div className="list-item">{item.title}</div>
</Link>
))}
</ul>
</div>
);
};
export default ChatRoomList;
CSS 파일
.list-item {
text-decoration: none;
padding: 10px;
margin: 5px 0;
width: 120px;
border-radius: 5px; /* 박스 모서리를 둥글게 만듭니다 */
text-align: center; /* 텍스트를 가운데 정렬합니다 */
color: #fff; /* 텍스트 색상을 흰색으로 변경합니다 */
background-image: linear-gradient(to right, #FFC10E, #8914ff); /* 배경에 그라데이션 추가 */
}
input, button {
margin: 5px;
}
2. 채팅방 내부 화면
화면 사진(뭐라도 꾸며봤습니다ㅎ)
이전 채팅 리스트 조회 기능(userId마다 다른 색으로 구분), 채팅 메시지 전송 기능, 회원 ID(1~5) 선택하는 기능(로그인을 미구현에 따라 임시로 userId를 대체)들을 구현하였다.
우선 화면구성 부분부터 살펴보겠다.
return (
<div>
<ul>
{/* userId 선택 칸 */}
<div style={{ display: 'flex' }}>
{numbers.map((number, index) => (
<div
key={index}
className={`num-${number}`}
onClick={() => handleNumberClick(number)}
style={{
marginRight: '5px',
padding: '5px',
width: '40px',
height: '25px',
border: '1px solid black',
borderRadius: '5px',
textAlign: 'center',
}}
>
{number}
</div>
))}
<p style={{ marginTop: '7px'}}>회원 번호: {selectedNumber}</p>
</div>
<div>
{/* 입력 필드 */}
<input
type="text"
value={inputValue}
onChange={handleInputChange}
/>
{/* 메시지 전송, 메시지 리스트에 추가 */}
<button onClick={sendMessage}>입력</button>
</div>
{/* 메시지 리스트 출력 */}
{messages.map((item, index) => (
<div key={index} className={`list-items num-${item.writerId}`}>{item.content}</div>
))}
</ul>
</div>
);
크게 userId 선택 부분, 입력 필드, 입력 값으로 메시지 전송하는 버튼, 메시지 리스트 노출 기능으로 나뉜다.
우선 useEffect를 통해 url에 들어갈 시 아래 로직을 시행한다.
useEffect(() => {
connect();
fetchMessages();
// 컴포넌트 언마운트 시 웹소켓 연결 해제
return () => disconnect();
}, []);
connect()는 STOMP를 통해 웹통신을 할 수 있도록 한다.(자세한 내용은 이전글을 참고해 주세요.)
const { roomId } = useParams();
const stompClient = useRef(null);
// 채팅 내용들을 저장할 변수
const [messages, setMessages] = new useState([]);
// 웹소켓 연결 설정
const connect = () => {
const socket = new WebSocket("ws://localhost:8080/ws-stomp");
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]);
});
});
};
fetchMessage()를 통해 이전에 작성되었던 채팅 메시지들을 가져온다.
const { roomId } = useParams();
// 채팅 내용들을 저장할 변수
const [messages, setMessages] = new useState([]);
const fetchMessages = () => {
axios.get("http://localhost:8080/find/chat/list/" + roomId)
.then(response => {setMessages(response.data)});
};
disconnect()는 페이지를 나갈 시 webSocket 연결을 종료한다.
// 웹소켓 연결 해제
const disconnect = () => {
if (stompClient.current) {
stompClient.current.disconnect();
}
};
입력 값을 넣고 메시지 전송 버튼을 누르면 메시지가 전송되는 로직이 서버로부터 실행되고, 추가된 메시지는 메시지 리스트에 더해진다.
const { roomId } = useParams();
// 채팅 내용들을 저장할 변수
const [messages, setMessages] = new useState([]);
// 사용자 입력을 저장할 변수
const [inputValue, setInputValue] = useState('');
// 입력 필드에 변화가 있을 때마다 inputValue를 업데이트
const handleInputChange = (event) => {
setInputValue(event.target.value);
};
//메세지 전송
const sendMessage = () => {
//selectdNumber는 userId로 선택된 값
if (stompClient.current && inputValue && selectedNumber) {
const body = {
roomId : roomId,
content : inputValue,
writerId : selectedNumber
};
stompClient.current.send(`/pub/message`, {}, JSON.stringify(body));
setInputValue('');
}
};
로그인 기능 대신 userID를 표시하기 위해 1~5개의 번호를 선택할 수 있도록 하였다.
const [selectedNumber, setSelectedNumber] = useState([]);
const numbers = [1, 2, 3, 4, 5]; // 회원 번호를 위한 숫자 배열
const handleNumberClick = (number) => {
setSelectedNumber(number);
};
아래는 JS파일, CSS파일 전체코드이다.
JS파일
import axios from "axios";
import React, { useEffect, useState, useRef } from "react";
import { Stomp } from "@stomp/stompjs";
import { useParams } from "react-router-dom";
import './ChatRoom.css';
const ChatRoom = () => {
const [selectedNumber, setSelectedNumber] = useState([]);
const numbers = [1, 2, 3, 4, 5]; // 회원 번호를 위한 숫자 배열
const handleNumberClick = (number) => {
setSelectedNumber(number);
};
const { roomId } = useParams();
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-stomp");
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]);
});
});
};
// 웹소켓 연결 해제
const disconnect = () => {
if (stompClient.current) {
stompClient.current.disconnect();
}
};
// 기존 채팅 메시지를 서버로부터 가져오는 함수
const fetchMessages = () => {
axios.get("http://localhost:8080/find/chat/list/" + roomId)
.then(response => {setMessages(response.data)});
};
useEffect(() => {
connect();
fetchMessages();
// 컴포넌트 언마운트 시 웹소켓 연결 해제
return () => disconnect();
}, []);
//메세지 전송
const sendMessage = () => {
if (stompClient.current && inputValue && selectedNumber) {
const body = {
roomId : roomId,
content : inputValue,
writerId : selectedNumber
};
stompClient.current.send(`/pub/message`, {}, JSON.stringify(body));
setInputValue('');
}
};
return (
<div>
<ul>
<div style={{ display: 'flex' }}>
{numbers.map((number, index) => (
<div
key={index}
className={`num-${number}`}
onClick={() => handleNumberClick(number)}
style={{
marginRight: '5px',
padding: '5px',
width: '40px',
height: '25px',
border: '1px solid black',
borderRadius: '5px',
textAlign: 'center',
}}
>
{number}
</div>
))}
<p style={{ marginTop: '7px'}}>회원 번호: {selectedNumber}</p>
</div>
<div>
{/* 입력 필드 */}
<input
type="text"
value={inputValue}
onChange={handleInputChange}
/>
{/* 메시지 전송, 메시지 리스트에 추가 */}
<button onClick={sendMessage}>입력</button>
</div>
{/* 메시지 리스트 출력 */}
{messages.map((item, index) => (
<div key={index} className={`list-items num-${item.writerId}`}>{item.content}</div>
))}
</ul>
</div>
);
}
export default ChatRoom;
CSS파일
.list-items {
text-decoration: none;
padding: 10px;
margin: 5px 0;
width: 100px;
border-radius: 5px; /* 박스 모서리를 둥글게 만듭니다 */
text-align: center; /* 텍스트를 가운데 정렬합니다 */
color: #fff; /* 텍스트 색상을 흰색으로 변경합니다 */
}
.num-1 {
background-color: #FF4136; /* 숫자 1의 색상: 빨간색 */
}
.num-2 {
background-color: #2ECC40; /* 숫자 2의 색상: 초록색 */
}
.num-3 {
background-color: #0074D9; /* 숫자 3의 색상: 파란색 */
}
.num-4 {
background-color: #FFDC00; /* 숫자 4의 색상: 노란색 */
}
.num-5 {
background-color: #B10DC9; /* 숫자 5의 색상: 보라색 */
}
결론
이렇게 동기적, 비동기적으로 Mysql, MongoDB를 연동하고 STOMP를 통해 채팅 시스템을 구현해 보았다.
간단한 로직이지만 핵심적인 부분은 담겼다고 생각한다.
이후엔 여기서 발전시켜 추가적인 기능들을 공부하고 적용시켜 보고자 한다.
잘못된 내용이나 궁금한 점은 댓글 남겨주시면 감사하겠습니다..!
참고
https://terianp.tistory.com/149
'프로젝트 관련' 카테고리의 다른 글
Spring boot - email 발송 기능 구현(with Gmail) (2) | 2024.08.27 |
---|---|
일일 단위로 환율 DB 저장을 위한 스케줄링 구현(with Spring boot) (0) | 2024.08.11 |
Spring boot with React: STOMP를 통해 채팅 시스템을 구현해보자(With Mysql, MongoDB)(1) (8) | 2024.05.13 |
프로젝트 일지 - 쿼리 통합: 여러 번의 db접근을 감소시키자(with springboot, mysql, mybatis) (0) | 2023.12.25 |
Springboot With React: 커서 기반 페이징 기법을 통한 댓글 무한 스크롤 구현 (1) | 2023.10.19 |