[Spring Boot + React] 실시간 채팅 Infinite scroll 구현 (Java17, TypeScript, 클린아키텍처)

이홍준·2024년 1월 12일
0

Spring Boot

목록 보기
10/11

문제정의

기존에서 사용했던 방법은 모든 채팅방, 채팅 메시지목록을 조회하는 로직으로 구현했었다.

기존 방식으로 구현한 페이지

하지만 데이터가 많을수록 리소스 낭비가 심할 것으로 예상된다. 그래서 페이지네이션을 적용하고자한다. 그리고 채팅메시지 관련 페이지처리는 무한 스크롤을 사용하는 것이 일반적으로 사용되기도하기때문에 해당 방법으로 구현해 보았다.

사전에 미리 알아야 할 개념

  1. API
    • Java 17의 record에 대한 지식(DTO에 사용)
    • Jpa 기본적인 지식, 양방향 매핑, 영속성 컨텍스트 개념
    • React & TypeScript
    • WebSocket 기본적인 개념(양방향)
    • Message Broker 및 pub/sub 방식의 간단한 개념
    • 클린아키텍처(Port & Adapter 또는 Hexagonal)
  2. Client
    • React 18
    • TypeScript 사용법
    • React router, axios, hook 사용법

Github Repository 주소

클린아키텍처 구조에 따라 만들어서 계층마다 전송 클래스형식이 다릅니다. 그래서 핵심적인 로직만 해당 게시글에 적고, 나머지 보일러플레이트적인 요소들은 해당 레포지토리를 참고하길 바랍니다.

기술 스택

  1. API
    • Language: Java 17,
    • Framework: Spring Boot 3.x, STOMP
    • ORM: JPA
    • DB: MYSQL
  2. Client
    • Lanaguage: TypeScript
    • Library: React, Axios, stompjs, react-infinite-scroll-component

API 구성

  1. 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");
        }
    }
  2. 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("*");
                }
            };
        }
    }
  3. 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));
        }
    }
  4. 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());
        }
    }
  5. 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());
        }
    }
  6. 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 vs Slice

page는 페이지네이션을 위해 존재하는 타입인데 JPA에서 조회쿼리를 할 시 COUNT 함수를 실행하는 쿼리를 발생시킨다. slice는 count없이 그냥 LIMIT 로 가져오는 형식이다. 그래서 페이지 번호목록들을 나타내는 일반적인 게시판 페이지는 Page, 무한 스크롤과 같이 전체 개수를 몰라도 되는 부분은 Slice가 적절하다고 생각한다.

Client 구성

마찬가지로 stompjs, react-router-dom, axios등이 필요하다.

  1. 모듈 추가

    $ yarn add @stomp/stompjs
    $ yarn add react-router-dom
    $ yarn add axios
  2. 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;
  3. 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;

적용 화면

  • 채팅메시지 목록(아래→위 스크롤) 채팅목록

References

profile
I'm a web developer.

0개의 댓글