기존에서 사용했던 방법은 모든 채팅방, 채팅 메시지목록을 조회하는 로직으로 구현했었다.
하지만 데이터가 많을수록 리소스 낭비가 심할 것으로 예상된다. 그래서 페이지네이션을 적용하고자한다. 그리고 채팅메시지 관련 페이지처리는 무한 스크롤을 사용하는 것이 일반적으로 사용되기도하기때문에 해당 방법으로 구현해 보았다.
클린아키텍처 구조에 따라 만들어서 계층마다 전송 클래스형식이 다릅니다. 그래서 핵심적인 로직만 해당 게시글에 적고, 나머지 보일러플레이트적인 요소들은 해당 레포지토리를 참고하길 바랍니다.
websocket config: web soceket 설정
package com.example.jpasample.chat.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/chat")
.setAllowedOriginPatterns("*");
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic");
registry.setApplicationDestinationPrefixes("/app");
}
}
webconfig: CORS 설정
package com.example.jpasample.common.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig {
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**").allowedOriginPatterns("*");
}
};
}
}
Controller 정의
RequestParam에 size, page를 추가
package com.example.jpasample.chat.adapter.in.web;
import com.example.jpasample.chat.application.port.in.ChatMessageLoadUseCase;
import com.example.jpasample.chat.application.port.in.query.ChatMessageListQuery;
import com.example.jpasample.common.response.SuccessApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/rooms")
class ChatMessageController {
private final ChatMessageLoadUseCase chatMessageLoadUseCase;
@GetMapping("/{roomId}/messages")
public SuccessApiResponse<?> getMessageList(@PathVariable Long roomId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "5") int size
){
ChatMessageListQuery query = ChatMessageListQuery.builder()
.roomId(roomId)
.page(page)
.size(size)
.build();
return SuccessApiResponse.of(chatMessageLoadUseCase.getChatMessageList(query));
}
}
Service 정의
Pagable의 구현체인 PageRequest객체로 담아줘야 JPA로 페이징처리를 할 수있다. 그리고 최신순으로 보여줘야 하기 때문에 정렬기준을 역순으로 한다.
package com.example.jpasample.chat.application.service;
import com.example.jpasample.chat.adapter.in.web.dto.ChatMessageResponse;
import com.example.jpasample.chat.application.port.in.ChatMessageLoadUseCase;
import com.example.jpasample.chat.application.port.in.command.ChatMessageCreateCommand;
import com.example.jpasample.chat.application.port.in.query.ChatMessageListQuery;
import com.example.jpasample.chat.application.port.out.LoadChatMessagePort;
import com.example.jpasample.common.annotation.UseCase;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@UseCase
@RequiredArgsConstructor
class LoadChatMessageService implements ChatMessageLoadUseCase {
private final LoadChatMessagePort loadChatMessagePort;
@Override
public List<ChatMessageResponse> getChatMessageList(ChatMessageListQuery query) {
PageRequest pageRequest = PageRequest.of(query.page(), query.size(), Sort.by("chatMessageId").descending());
return loadChatMessagePort.loadChatMessegeList(query.roomId(), pageRequest)
.stream().map((chatMessage)->
ChatMessageResponse.builder()
.id(chatMessage.getChatId().value())
.content(chatMessage.getContent())
.writer(chatMessage.getWriter())
.build())
.collect(Collectors.toList());
}
}
PersistenceAdapter
무한 스크롤 구현이므로 Page타입보다 Slice를 사용하도록 한다.
package com.example.jpasample.chat.adapter.out.persistence;
import com.example.jpasample.chat.application.port.out.LoadChatMessagePort;
import com.example.jpasample.chat.domain.ChatMessage;
import com.example.jpasample.chat.domain.ChatRoom;
import com.example.jpasample.common.annotation.PersistenceAdapter;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Slice;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
@PersistenceAdapter
@RequiredArgsConstructor
class ChatMessageLoadPersistenceAdapter implements LoadChatMessagePort {
private final SpringDataChatRoomRepository springDataChatRoomRepository;
private final SpringDataChatMessageRepository springDataChatMessageRepository;
@Transactional
@Override
public List<ChatMessage> loadChatMessegeList(Long roomId, PageRequest pageRequest) {
ChatRoomJpaEntity chatRoomJpaEntity = springDataChatRoomRepository.findById(roomId)
.orElseThrow(RuntimeException::new);
Slice<ChatMessageJpaEntity> chatMessageJpaEntitySlice = springDataChatMessageRepository.findAllByChatRoom(chatRoomJpaEntity, pageRequest);
return chatMessageJpaEntitySlice.getContent().stream().map(
(chatMessageJpaEntity)-> ChatMessage.builder()
.chatId(new ChatMessage.ChatId(chatMessageJpaEntity.getChatMessageId()))
.content(chatMessageJpaEntity.getContent())
.writer(chatMessageJpaEntity.getWriter())
.chatRoomId(new ChatRoom.RoomId(chatRoomJpaEntity.getChatRoomId()))
.build()
).collect(Collectors.toList());
}
}
Repository
Slice<?>를 반환할 함수의 이름을 “find(All)By~”로 시작해야한다.
package com.example.jpasample.chat.adapter.out.persistence;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
interface SpringDataChatMessageRepository extends JpaRepository<ChatMessageJpaEntity, Long> {
Slice<ChatMessageJpaEntity> findAllByChatRoom(ChatRoomJpaEntity chatRoomJpaEntity, PageRequest pageRequest);
}
page는 페이지네이션을 위해 존재하는 타입인데 JPA에서 조회쿼리를 할 시 COUNT 함수를 실행하는 쿼리를 발생시킨다. slice는 count없이 그냥 LIMIT 로 가져오는 형식이다. 그래서 페이지 번호목록들을 나타내는 일반적인 게시판 페이지는 Page, 무한 스크롤과 같이 전체 개수를 몰라도 되는 부분은 Slice가 적절하다고 생각한다.
마찬가지로 stompjs, react-router-dom, axios등이 필요하다.
모듈 추가
$ yarn add @stomp/stompjs
$ yarn add react-router-dom
$ yarn add axios
ChatMessagePage.tsx (채팅 메시지 page)
import { useCallback, useEffect, useRef, useState } from "react";
import { Client, IMessage } from "@stomp/stompjs";
import axios from "axios";
import { Link, useParams } from "react-router-dom";
import "./ChatMessagePage.css";
import ChatMessageList from "../../components/ChatMessageList";
interface ChatMessageReqeust {
from: string;
text: string;
roomId: number;
}
interface ChatMessageResponse {
id: number;
content: string;
writer: string;
}
function ChatMessagePage() {
const { roomId } = useParams();
const [loading, setLoading] = useState(true);
const [stompClient, setStompClient] = useState<Client | null>(null);
const [messages, setMessages] = useState<ChatMessageResponse[]>([]);
const [writer, setWriter] = useState<string>("");
const [newMessage, setNewMessage] = useState<string>("");
const [currentPage, setCurrentPage] = useState<number>(1);
const [hasMore, setHasMore] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = ()=>{
messagesEndRef.current?.scrollTo(0, messagesEndRef.current.scrollHeight);
}
const loadInitChatMessages = useCallback(async () => {
try {
const response = await axios.get(
`http://localhost:8788/api/v1/rooms/${roomId}/messages?size=10`
);
const responseMessages = response.data.data as ChatMessageResponse[];
setMessages(responseMessages);
setHasMore(responseMessages.length > 0);
setLoading(false)
} catch (error) {
console.error("채팅 내역 로드 실패", error);
}
}, [roomId]);
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) => [msg, ...prevMessages]);
}
);
},
});
useEffect(() => {
if(loading){
loadInitChatMessages();
}
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) => [msg, ...prevMessages]);
}
);
},
});
client.activate();
setStompClient(client);
return () => {
client.deactivate();
};
}, [currentPage, loadInitChatMessages, loading, roomId]);
const fetchMessages = async () => {
try {
const response = await axios.get(
`http://localhost:8788/api/v1/rooms/${roomId}/messages?page=${currentPage}&size=10`
);
const responseMessages = response.data.data as ChatMessageResponse[];
setMessages([...messages, ...responseMessages]);
setCurrentPage((prev) => prev+1);
setHasMore(responseMessages.length > 0);
scrollToBottom();
} catch (error) {
console.error("채팅 내역 로드 실패", error);
}
};
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),
});
setNewMessage("");
}
};
return (
<div className="chat-container">
<div>
<Link to={"/rooms"} className="back-link">
뒤로 가기
</Link>
</div>
<ChatMessageList
messagesEndRef={messagesEndRef}
messages={messages}
fetchMessages={fetchMessages}
hasMore={hasMore}
writer={writer}
newMessage={newMessage}
sendMessage={sendMessage}
setWriter={setWriter}
setNewMessage={setNewMessage}
/>
</div>
);
}
export default ChatMessagePage;
ChatMessageList.tsx (채팅 메시지 목록 component)
import { Ref } from "react";
import InfiniteScroll from "react-infinite-scroll-component";
interface ChatMessageResponse {
id: number;
content: string;
writer: string;
}
interface ChatMessageListProps{
messagesEndRef: Ref<HTMLDivElement>;
messages: ChatMessageResponse[];
fetchMessages: ()=> Promise<void>;
hasMore: boolean;
writer: string;
newMessage: string;
sendMessage: () => void;
setWriter: (value: string) => void;
setNewMessage: (value: string) => void;
}
function ChatMessageList({messagesEndRef, messages, fetchMessages, hasMore, writer, newMessage, sendMessage, setWriter, setNewMessage}: ChatMessageListProps) {
return (
<>
<div id="scrollableDiv" className="chat-messages" ref={messagesEndRef}>
<InfiniteScroll
dataLength={messages.length}
next={fetchMessages}
style={{ display: "flex", flexDirection: "column-reverse" }}
hasMore={hasMore}
loader={<h4>Loading...</h4>}
inverse={true} // 스크롤을 위로 올릴 때 데이터 로드
scrollableTarget="scrollableDiv"
>
{messages.map((msg, idx) => (
<div key={msg.id}>
{msg.id}=={msg.writer}: {msg.content}
</div>
))}
</InfiniteScroll>
</div>
<div className="input-group">
<label>작성자</label>
<input
type="text"
value={writer}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setWriter(e.target.value)}
/>
</div>
<div className="input-group">
<input
type="text"
value={newMessage}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewMessage(e.target.value)}
/>
<button className="send-button" onClick={sendMessage}>
Send
</button>
</div>
</>
);
}
export default ChatMessageList;