웹 서비스 중에서 전형적인 기능 중에 채팅 모듈을 만들어 보고자 공부해 정리해 보았습니다. 서버는 Spring Boot, Client는 React 로 구현하였다. 기본적으로 API는 클린 아키텍처로 구성했기 때문에 유의 해야 합니다.
클린아키텍처 구조에 따라 만들어서 계층마다 전송 클래스형식이 다릅니다. 그래서 핵심적인 로직만 해당 게시글에 적고, 나머지 보일러플레이트적인 요소들은 해당 레포지토리를 참고하길 바랍니다.
클라이언트가 Message Broker와 통신할 수 있도록 상호 운용 가능한 와이어 형식을 제공하여 여러 언어, 플랫폼 및 브로커 간에 쉽고 광범위한 메시징 상호 운용성을 제공합니다. 즉 STOMP는 본질적으로 pub/sub 패턴을 지원하기 때문에 좀 더 간단하게 구현할 수 있습니다.
채팅은 기본적으로 websocket로 구현하는 것이 간단합니다. 그래서 웹 소켓 관련 설정을 해주어야 합니다. 웹 소켓 위에 STOMP을 올려서 pub/sub 형식이 동작하도록 하는 것이 구조적 목표입니다.
dependency 추가(build.gradle)
implementation 'org.springframework.boot:spring-boot-starter-websocket'
application.yml 정의
server:
port: 8788
spring:
datasource:
url: jdbc:mysql://localhost:3306/mysql?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true
username: root
password: my-secret-pw
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: true
configuration 정의
Client는 React를 사용하기 때문에 CORS를 허용해야만 한다. 개발테스트 단계이므로 일단 다 열어 주자
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/chat") // socket 연결 url
.setAllowedOriginPatterns("*"); // CORS 허용 범위
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic"); // 구독 url
registry.setApplicationDestinationPrefixes("/app"); // prefix 정의
}
}
@Configuration
public class WebConfig {
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**").allowedOriginPatterns("*");
}
};
}
}
DTO 정의 (record로 작성)
public record ChatMessageRequest(String from, String text) {
}
@Builder
public record ChatMessageCreateCommand(Long roomId, String content, String from) {
}
Controller 정의
@Controller
@RequiredArgsConstructor
class ChatController {
private final ChatMessageCreateUseCase chatMessageCreateUseCase;
@MessageMapping("/chat/rooms/{roomId}/send")
@SendTo("/topic/public/rooms/{roomId}")
public ChatMessageResponse sendMessage(@DestinationVariable Long roomId, @Payload ChatMessageRequest chatMessage) {
ChatMessageCreateCommand chatMessageCreateCommand = ChatMessageCreateCommand.builder()
.content(chatMessage.text())
.from(chatMessage.from())
.roomId(roomId)
.build();
Long chatId = chatMessageCreateUseCase.createChatMessage(chatMessageCreateCommand); // DB에 등록 후 Chat Message Id 반환
ChatMessageResponse chatMessageResponse = ChatMessageResponse.builder()
.id(chatId)
.content(chatMessage.text())
.writer(chatMessage.from())
.build();
return chatMessageResponse;
}
}
Service 정의(Usecase)
@UseCase // 사용자 정의 Component
@RequiredArgsConstructor
@Transactional
class CreateChatMessageService implements ChatMessageCreateUseCase {
private final CreateChatMessagePort createChatMessagePort;
@Override
public Long createChatMessage(ChatMessageCreateCommand command) {
ChatMessage chatMessage = ChatMessage.builder()
.chatRoomId(new ChatRoom.RoomId(command.roomId()))
.content(command.content())
.writer(command.from())
.build();
return createChatMessagePort.createChatMessage(chatMessage);
}
}
PersistenceAdater 정의(간단하게 구현하고 싶으면 Service와 합쳐도 된다.)
@PersistenceAdapter // 사용자 정의 Component
@RequiredArgsConstructor
class ChatRoomPersistenceAdapter implements CreateChatRoomPort{
private final SpringDataChatRoomRepository springDataChatRoomRepository;
@Transactional
@Override
public boolean createChatRoom(ChatRoom chatRoom) {
ChatRoomJpaEntity chatRoomJpaEntity = ChatRoomJpaEntity.builder()
.build();
springDataChatRoomRepository.save(chatRoomJpaEntity);
return true;
}
}
@PersistenceAdapter // 사용자 정의 Component
@RequiredArgsConstructor
class ChatRoomLoadPersistenceAdapter implements LoadChatRoomPort{
private final SpringDataChatRoomRepository springDataChatRoomRepository;
@Override
public ChatRoom loadById(Long roomId) {
ChatRoomJpaEntity chatRoomJpaEntity = springDataChatRoomRepository.findById(roomId)
.orElseThrow(RuntimeException::new);
return ChatRoom.builder()
.roomId(new ChatRoom.RoomId(chatRoomJpaEntity.getChatRoomId()))
.messageList(chatRoomJpaEntity.getChatMessageList()
.stream().map(chatMessageJpaEntity ->
ChatMessage.builder()
.chatId(new ChatMessage.ChatId(chatMessageJpaEntity.getChatMessageId()))
.content(chatMessageJpaEntity.getContent())
.writer(chatMessageJpaEntity.getWriter())
.build())
.collect(Collectors.toList()))
.build();
}
@Override
public List<ChatRoom> search() {
List<ChatRoomJpaEntity> chatRoomJpaEntityList = springDataChatRoomRepository.findAll();
return chatRoomJpaEntityList.stream().map(chatRoomJpaEntity -> ChatRoom
.builder()
.roomId(new ChatRoom.RoomId(chatRoomJpaEntity.getChatRoomId()))
.build())
.collect(Collectors.toList());
}
}
@PersistenceAdapter // 사용자 정의 Component
@RequiredArgsConstructor
class ChatMessagePersistenceAdapter implements CreateChatMessagePort {
private final SpringDataChatRoomRepository springDataChatRoomRepository;
@Override
@Transactional
public Long createChatMessage(ChatMessage chatMessage) {
ChatRoomJpaEntity chatRoomJpaEntity = springDataChatRoomRepository.findById(chatMessage.getChatRoomId().value())
.orElseThrow(RuntimeException::new);
ChatMessageJpaEntity chatMessageJpaEntity = ChatMessageJpaEntity.builder()
.chatRoom(chatRoomJpaEntity)
.content(chatMessage.getContent())
.writer(chatMessage.getWriter())
.build();
chatRoomJpaEntity.createMessage(chatMessageJpaEntity);
springDataChatRoomRepository.save(chatRoomJpaEntity);
return chatMessageJpaEntity.getChatMessageId();
}
}
Repository
interface SpringDataChatRoomRepository extends JpaRepository<ChatRoomJpaEntity, Long> {
@Query("select room from ChatRoomJpaEntity room LEFT JOIN FETCH room.chatMessageList message where room.chatRoomId = :id") // N+1 문제 해결을 위한 Fetch join 사용
@Override
Optional<ChatRoomJpaEntity> findById(Long id);
}
JPA Entity
@Entity
@Getter
@Table(name = "chat_rooms")
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChatRoomJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "chat_room_id")
private Long chatRoomId;
@OneToMany(mappedBy = "chatRoom", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<ChatMessageJpaEntity> chatMessageList = new ArrayList<>();
public void createMessage(ChatMessageJpaEntity chatMessageJpaEntity){
chatMessageList.add(chatMessageJpaEntity);
}
}
@Entity
@Getter
@Table(name = "chat_messages")
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessageJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "chat_message_id")
private Long chatMessageId;
private String content;
private String writer;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "chat_room_id")
private ChatRoomJpaEntity chatRoom;
}
모듈 추가
$ yarn add @stomp/stompjs
$ yarn add react-router-dom
ChatRoomPage.tsx: 채팅방목록 페이지
import axios from "axios";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import './ChatRoomPage.css';
interface ChatRoom {
roomId: number;
}
function ChatRoomPage() {
const [chatRoomList, setChatRoomList] = useState<ChatRoom[]>([]);
useEffect(() => {
const loadChatRoomHistory = async () => {
try {
const response = await axios.get("http://localhost:8788/api/v1/rooms");
const chatRoomList: ChatRoom[] = response.data.data.map((item: any) => {
return { roomId: item.roomId } as ChatRoom;
});
setChatRoomList(chatRoomList);
} catch (error) {
console.error("채팅 내역 로드 실패", error);
}
};
loadChatRoomHistory();
}, []);
return (
<>
<div className="ChatRoomPage">
<ul className="chatRoomList">
{chatRoomList.map((chatRoom, idx) => (
<div key={idx}>
<li>
<Link to={`/rooms/${chatRoom.roomId}`}>{chatRoom.roomId} 번 채팅방</Link>
</li>
</div>
))}
</ul>
</div>
</>
);
}
export default ChatRoomPage;
ChatPage.tsx : 채팅방 페이지(채팅목록)
import { useEffect, useState } from "react";
import { Client, IMessage } from "@stomp/stompjs";
import axios from "axios";
import { Link, useParams } from "react-router-dom";
import "./ChatPage.css";
interface ChatMessageReqeust {
from: string;
text: string;
roomId: number;
}
interface ChatMessageResponse{
id: number;
content: string;
writer: string;
}
function ChatPage() {
const { roomId } = useParams();
const [stompClient, setStompClient] = useState<Client | null>(null);
const [messages, setMessages] = useState<ChatMessageResponse[]>([]);
const [writer, setWriter] = useState<string>("");
const [newMessage, setNewMessage] = useState<string>("");
useEffect(() => {
const loadChatHistory = async () => {
try {
const response = await axios.get(
`http://localhost:8788/api/v1/rooms/${roomId}`
);
const messages = response.data.data.messageList as ChatMessageResponse[];
setMessages(messages);
} catch (error) {
console.error("채팅 내역 로드 실패", error);
}
};
loadChatHistory();
const client = new Client({
brokerURL: "ws://localhost:8788/chat", // 서버 WebSocket URL
reconnectDelay: 5000,
onConnect: () => {
client.subscribe(`/topic/public/rooms/${roomId}`, (message: IMessage) => {
const msg: ChatMessageResponse = JSON.parse(message.body);
setMessages((prevMessages) => [...prevMessages, msg]);
});
},
});
client.activate();
setStompClient(client);
return () => {
client.deactivate();
};
}, [roomId]);
const sendMessage = () => {
if (stompClient && newMessage) {
const chatMessage: ChatMessageReqeust = {
from: writer,
text: newMessage,
roomId: parseInt(roomId || ""),
};
stompClient.publish({
destination: `/app/chat/rooms/${roomId}/send`,
body: JSON.stringify(chatMessage),
});
console.log(messages);
setNewMessage("");
}
};
return (
<div className="chat-container">
<div>
<Link to={"/rooms"} className="back-link">
뒤로 가기
</Link>
</div>
<div className="chat-messages">
{messages.map((msg, idx) => (
<div key={idx}>
{msg.writer}: {msg.content}
</div>
))}
</div>
<div className="input-group">
<label>작성자</label>
<input
type="text"
value={writer}
onChange={(e) => setWriter(e.target.value)}
/>
</div>
<div className="input-group">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
/>
<button className="send-button" onClick={sendMessage}>
Send
</button>
</div>
</div>
);
}
export default ChatPage;
App.tsx
import { BrowserRouter, Link, Route, Routes } from "react-router-dom";
import ChatPage from "./ChatPage";
import ChatRoomPage from "./ChatRoomPage";
function App() {
return (
<div className="App">
<BrowserRouter>
<Link to={"/rooms"}>채팅방 보기</Link>
<Routes>
<Route path="/rooms" element={<ChatRoomPage/>}/>
<Route path="/rooms/:roomId" element={<ChatPage/>}/>
</Routes>
</BrowserRouter>
</div>
);
}
export default App;
채팅방목록 화면
채팅목록 화면(1번 채팅방)
채팅메시지 작성
다른 클라이언트에서도 실시간 반영
다른 채팅방(2번 채팅방)
웹 소켓+pub/sub 방식으로 기본적인 채팅방 및 채팅메시지를 작성 및 조회하는 기능을 구현하였다. 다음 해야할 일은 Pagination 및 Infinity Scroll 기능을 넣고자 한다.
유익한 글 잘 봤습니다. 감사합니다😄