[Spring Boot + React] STOMP 로 실시간 채팅 (Java17, TypeScript)

이홍준·2024년 1월 4일
5

Spring Boot

목록 보기
9/11

웹 서비스 중에서 전형적인 기능 중에 채팅 모듈을 만들어 보고자 공부해 정리해 보았습니다. 서버는 Spring Boot, Client는 React 로 구현하였다. 기본적으로 API는 클린 아키텍처로 구성했기 때문에 유의 해야 합니다.

사전에 미리 알아야 할 개념

  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
    • ORM: JPA
    • DB: MYSQL
  2. Client
    • Lanaguage: TypeScript
    • Library: React, Axios

STOMP(Simple Text Oriented Messaging Protocol)

클라이언트가 Message Broker와 통신할 수 있도록 상호 운용 가능한 와이어 형식을 제공하여 여러 언어, 플랫폼 및 브로커 간에 쉽고 광범위한 메시징 상호 운용성을 제공합니다. 즉 STOMP는 본질적으로 pub/sub 패턴을 지원하기 때문에 좀 더 간단하게 구현할 수 있습니다.

API 구성

채팅은 기본적으로 websocket로 구현하는 것이 간단합니다. 그래서 웹 소켓 관련 설정을 해주어야 합니다. 웹 소켓 위에 STOMP을 올려서 pub/sub 형식이 동작하도록 하는 것이 구조적 목표입니다.

  1. dependency 추가(build.gradle)

    implementation 'org.springframework.boot:spring-boot-starter-websocket'
  2. 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
  3. configuration 정의

    Client는 React를 사용하기 때문에 CORS를 허용해야만 한다. 개발테스트 단계이므로 일단 다 열어 주자

    • websocket
    @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 정의 
        }
    }
    • webConfig
      @Configuration
      public class WebConfig {
          @Bean
          public WebMvcConfigurer corsConfigurer() {
              return new WebMvcConfigurer() {
                  @Override
                  public void addCorsMappings(CorsRegistry registry) {
                      registry.addMapping("/**").allowedOriginPatterns("*");
                  }
              };
          }
      }
  4. DTO 정의 (record로 작성)

    • ChatMessageRequest: ChatMessage 관련 Payload Dto
      public record ChatMessageRequest(String from, String text) {
      }
    • ChatMessageCreateCommand: service에 전달할 Command 객체
      @Builder
      public record ChatMessageCreateCommand(Long roomId, String content, String from) {
      }
  5. 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;
        }
    }
    • @MessageMapping: 클라이언트에서 서버로 보낸 메시지를 메시지를 라우팅
    • @SendTo: 구독한 클라이언트에게 response를 제공할 url 정의
    • @DestinationVariable: 구독 및 메시징의 동적 url 변수를 설정. RestAPI의 @PathValue와 같다.
    • @Payload: 메시지의 body를 정의한 객체에 매핑합니다.
  6. 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);
        }
    }
  7. PersistenceAdater 정의(간단하게 구현하고 싶으면 Service와 합쳐도 된다.)

    • ChatRoomPersistenceAdapter : 채팅 방 등록 및 수정 PersistenceAdater
      @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;
          }
      }
    • ChatRoomLoadPersistenceAdapter: 채팅 방 조회 PersistenceAdapter
      @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());
          }
      }
    • ChatMessagePersistenceAdapter: 채팅 메시지 등록 PersistenceAdapter
      @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();
          }
      }
  8. Repository

    • SpringDataChatRoomRepository: 채팅 방의 Reposotory
      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);
      }
  9. JPA Entity

    • ChatRoomJpaEntity : 채팅방 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);
          }
      
      }
    • ChatMessageJpaEntity: 채팅 메시지 JPA Entity
      @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;
      }

Client 구성

  1. 모듈 추가

    $ yarn add @stomp/stompjs
    $ yarn add react-router-dom
  2. 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;
  3. 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;
  4. 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. 채팅목록 화면(1번 채팅방)

  3. 채팅메시지 작성

  4. 다른 클라이언트에서도 실시간 반영

  5. 다른 채팅방(2번 채팅방)

다음 과제

웹 소켓+pub/sub 방식으로 기본적인 채팅방 및 채팅메시지를 작성 및 조회하는 기능을 구현하였다. 다음 해야할 일은 Pagination 및 Infinity Scroll 기능을 넣고자 한다.


References

profile
I'm a web developer.

5개의 댓글

comment-user-thumbnail
2024년 1월 5일

유익한 글 잘 봤습니다. 감사합니다😄

답글 달기
comment-user-thumbnail
2024년 1월 11일

안녕하세요! 클린 아키텍처라는 구조를 처음보는 코린이 입니다. 우선, 포스팅 잘봤습니다! 하나 궁금한게 있습니다!
Controller에서 useCase를 의존 주입하신다음 내부 메서드를 호출하여 구현하셨던데, 해당 인터페이스의 구현체가 보이지 않아서요! 혹시 해당 구현체가 필요가 없는 건가요?? 아니면 port 구현체와 관련이 있는건가요? 관련이 있다면 어떻게 동작하는지 간략하게 설명해주실 수 있나요..ㅜㅜ

1개의 답글