BackEnd/Spring Boot

채팅 만들기_2(chatting_room)

Raconer 2023. 4. 16. 23:34
728x90

개요

이전에 소켓을 사용한 채팅을 만들어 보았습니다.
이번 게시글에서는 STOMP를 사용한 채팅방 구성을 하였습니다.
사용자 로그인 및 인증은 사용하지 않았고 최초 방에 들어오면 date로 사용자 닉네임을 설정하여 테스트 하였습니다.

참고

채팅 방 있는 채팅
Terian의 IT 도전기

설명

현재 프로젝트

Terian의 IT 도전기 블로그를 참고 하여 정리 하였습니다.
개념 적으로 Terian의 IT 도전기를 참고 하는것을 추천 드리고
현재 게시글은 Terian의 IT 도전기의 기반으로 내가 필요한 코드만 뽑아서 사용 했기 때문에
Terian의 IT 도전기와 코드가 많이 다를 수 있습니다.
또한 View 개발을 위해 CSS를 작성하기 귀찮아 Bootstrap을 간단히 적용 하였습니다.

STOMP란?

간단하게 설명하면 WebSocket 기반 메시지 프로토콜 이다.
PUB/SUB 구성이며 송신과 수신을 구별 할수있다.
Terian의 IT 도전기을 기반으로 작성 되었고 설명이 잘 되어 있어서
내용상 이해가 안되면 Terian의 IT 도전기를 참고 하는것이 좋다.

스펙

  • Spring Boot2.7.7

  • Java 17

  • jsp 사용

  • dependencies

    • // Spring Boot Web Server Dependency
      implementation 'org.springframework.boot:spring-boot-starter-web'
      // Spring Web Socket
      implementation 'org.springframework.boot:spring-boot-starter-websocket'
      // Find Views Path
      implementation 'org.apache.tomcat.embed:tomcat-embed-jasper'
      implementation 'javax.servlet:jstl'
      // Develop Tool
      implementation 'org.springframework.boot:spring-boot-devtools'
      
      // Mysql
      implementation 'mysql:mysql-connector-java'
      // JPA
      implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
      
      // Properties
      compileOnly 'org.projectlombok:lombok'
      annotationProcessor 'org.projectlombok:lombok'
      testImplementation 'org.springframework.boot:spring-boot-starter-test'

      DDL

테스트 용으로 만들다 보니 최대한 간략 하게 구성 하였습니다.
localhost 용으로 만들었으니 application.yml파일을 참고 하시면 될거 같습니다.

  /* chatting db definition */
  CREATE DATABASE `chatting` 
  /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci */ /*!80016 DEFAULT ENCRYPTION='N' */;

  -- room definition
  CREATE TABLE `room` (
    `id` int NOT NULL AUTO_INCREMENT,
    `name` varchar(100) NOT NULL,
    `user_cnt` varchar(100) NOT NULL DEFAULT '0',
    `reg_date` datetime NOT NULL,
    PRIMARY KEY (`id`)
  ) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

프로젝트 구조

  src
     └─ main
        ├─ java
        │  └─ com
        │     └─ server
        │        └─ chat
        │           ├─ ChatApplication.java // 프로젝트 시작
        │           ├─ config
        │           │  └─ WebSocketConfig.java // Socket 환경설정
        │           ├─ controller
        │           │  ├─ ChatController.java // 소켓 연결 및 채팅 관련 이벤트가 있는 Controller
        │           │  ├─ MainController.java  // 화면 이동을 위한 Controller
        │           │  └─ rest
        │           │     └─ ChatRoomRestController.java // ajax를 사용하여 채팅방 생성하는 API가 있는 Controller
        │           ├─ model // 보통 DTO
        │           │  ├─ chat
        │           │  │  ├─ Chat.java // 채팅 관련 DTO 
        │           │  │  └─ Room.java // 채팅 방 DTO
        │           │  └─ rest // API 에 사용되며 개인적으로 사용하는 방법이다. 중요한 내용이 있지는 않다.
        │           │     └─ common
        │           │        ├─ DefDataRes.java
        │           │        └─ DefRes.java
        │           ├─ repository // JPA 사용하기 위한 Repository
        │           │  └─ chat
        │           │     └─ RoomRepository.java
        │           └─ service
        │              └─ ChatRoomService.java // 채팅방 생성 및 조회 하는 Service
        ├─ resources // 환경 설정 및 Static 파일이 존재 합니다.
        │  ├─ application.yml
        │  ├─ logback.xml
        │  ├─ static
        │  │  ├─ css
        │  │  │  └─ main.css
        │  │  └─ js
        │  │     ├─ chat
        │  │     │  ├─ index.js
        │  │     │  └─ room.js
        │  │     ├─ common
        │  │     │  └─ index.js
        │  │     └─ jquery
        │  │        └─ jquery-3.6.1.min.js
        │  └─ templates
        └─ webapp
           └─ WEB-INF
              └─ views
                 ├─ chatRoom.jsp // 채팅방 화면(상세) 
                 └─ index.jsp // 채팅 방 리스트 화면(리스트)

적용!!!!!

  1. WebSocketConfig.java 생성

    @Configuration 
    @EnableWebSocketMessageBroker 
    public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { 
      /** * @desc STOMP 접속 주소 url => /ws-stomp */ 
      @Override 
      public void registerStompEndpoints(StompEndpointRegistry registry) { 
          registry.addEndpoint("/ws-stomp").withSockJS(); 
      } 
      /** * @desc STOMP 접속 주소 url => /ws-stomp */ 
      @Override 
      public void configureMessageBroker(MessageBrokerRegistry registry) { 
        registry.enableSimpleBroker("/sub"); // 메시지 구독 요청 url -> 메시지 수신 
        registry.setApplicationDestinationPrefixes("/pub"); // 메시지 발행 -> 메시지 송신 
      } 
    }
  2. ChatController.java 생성

    @Slf4j
    @Controller
    @AllArgsConstructor
    public class ChatController {
       /**
        * SimpMessageSendingOperations -> convertAndSend()
        * 이 메소드는 매개변수로 각각 메시지의 도착 지점과 객체를 넣어준다.
        * 이를 통해서 도착 지점으로 인자를 들어온 객체를 Message객체로 변환해서 해당 도착지점의 Sub하고 있는 모든 사용자에게 메시지를
        * 보내게 된다.
        */
       private SimpMessageSendingOperations template;
       public static final String SUB_PATH = "/sub/chat/room/";
    
       /**
        * @param Chat
        * @param headerAccessor
        * @desc User 방에 입장시 실행
        * @MessageMapping : 이 어노테이션은 Stomp에서 들어오는 Message를 서버에서 발송("pub")한 메시지가 도착하는
        *                 엔드포인트이다.
        *                 여기서 "/chat/enterUser" 로 되어 있지만 실제로는 "/pub/chat/enterUser"로
        *                 발송해야 @MessageMapping가 실행된다.
        */
       @MessageMapping("/chat/enterUser") // /pub/chat/enterUser 와 같다.("/pub" 생략)
       public void enterUser(@Payload Chat chat, SimpMessageHeaderAccessor headerAccessor) {
    
           if (headerAccessor != null) {
               // 반환 결과를 Socket Session에 UserUUID로 저장
               headerAccessor.getSessionAttributes().put("userId", chat.getUserName());
               headerAccessor.getSessionAttributes().put("roomId", chat.getRoomId());
               headerAccessor.getSessionAttributes().put("userName", chat.getUserName());
    
               chat.setMessage(chat.getUserName() + "님 입장!!!");
               template.convertAndSend(SUB_PATH.concat(chat.getRoomId()), chat);
           }
       }
    
       /**
        * @param Chat
        * @desc 사용자 메시지 전송시 실행.
        *       * 실제로 '/pub/chat/message' 로 Message Send 요청
        *       * 처리가 완료시 '/sub/chat/room/roomId' 로 메시지가 전송 된다. //
        *       frontend(js\chat\index.js)에서
        *       subscribe("/sub/chat/room/" + roomId, onMessageReceived)로 설정 하였다.
        */
       @MessageMapping("/chat/sendMessage")
       public void sendMessage(@Payload Chat chat) {
           log.info("CHAT {}", chat);
           chat.setMessage(chat.getMessage());
           template.convertAndSend(SUB_PATH.concat(chat.getRoomId()), chat);
       }
    
       /**
        * @param event
        * @desc 사용자 퇴장시
        */
       @EventListener
       public void webSocketDisConnectListener(SessionDisconnectEvent event) {
           StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
    
           if (headerAccessor != null) {
               String roomId = (String) headerAccessor.getSessionAttributes().get("roomId");
               String userName = (String) headerAccessor.getSessionAttributes().get("userName");
               if (userName != null) {
                   Chat chatDto = Chat.builder()
                           .type(Chat.MessageType.LEAVE)
                           .userName(userName)
                           .message(userName + "님 퇴장!!!")
                           .build();
    
                   template.convertAndSend(SUB_PATH.concat(roomId), chatDto);
               }
           }
       }
    }
  3. resources > static > chat > index.js 생성 및 작성

메시지 좌우 구분은 내가 보낸건지 상대 방이 보낸건지 구분하기 위해 작성 하였고
테스트 용으로 작성 하였기 때문에 실전에는 다른 방법을 사용해야한다.

  // 메시지 좌우 구분으로 인한 변수 설정
  let isSend = false;
  // 임시 사용자 이름
  let date = new Date();
  const userName = "user_" + date.getHours() + "-" + date.getMinutes() + "-" + date.getSeconds();

  // JS파일 존재시 실행
  $(document).ready(function () {
      connect();
  });

  // 메시지 전송 이벤트 
  $(document).on("click", "#message-submit", () => {
      sendMessage();
  });
  // STOMP 변수
  let stompClient = null;

  // Room Id 파라미터 가져오기
  const url = new URL(location.href).searchParams;
  const roomId = url.get("id");

  // STOMP로 연결한다.
  async function connect(event) {
      let socket = new SockJS("/ws-stomp");
      stompClient = await Stomp.over(socket);
      // 크롬 및 개발자 모드의 콘솔 추력 방지
      // stompClient.debug = null;


      // stomp Client 접속
      // onConnected : 채팅방 접근 및 접속 처리
      // onError : STOMP 에러 발생시
      await stompClient.connect({}, onConnected, onError);

  }

  // STOMP로 roomId별 채팅방에 접근을 한다.
  async function onConnected() {
      let json = {
              roomId: roomId,
              userName : userName,
              type : "ENTER"
          }

      // Response하는 결과는 onMessageReceived에서 처리한다.
      await stompClient.subscribe("/sub/chat/room/" + roomId, onMessageReceived);

      // 사용자 입장
      await stompClient.send("/pub/chat/enterUser",
          {},
          JSON.stringify(json))

  }

  function onError(error) {
      alert("Error")
  }

  // 메시지 전송때는 JSON형식을 메시지를 전달
  function sendMessage() {
      this.isSend = true;
      let messageElement = document.getElementById("message-text");
      console.log("MSG : " + messageElement.value)

      if (messageElement && stompClient) { 
          let chatMessage = {
              "roomId": roomId,
              "userName": userName,
              message: messageElement.value,
              type: "TALK"
          };

          stompClient.send("/pub/chat/sendMessage", {}, JSON.stringify(chatMessage));
          messageElement.value = '';
      }

  }

  // 메시지 받을 때도 마찬가지로 JSON 타입으로 받으며,
  // 넘어온 JSON 형식의 메시지를 parse해서 사용한다.
  function onMessageReceived(payload) {
      console.log('Receive Message')
      let chat = JSON.parse(payload.body);
      let messageElement = document.createElement('li');
      let type = chat.type;
      switch (type) {
          case 'ENTER':
          case 'LEAVE':
              messageElement.classList.add('notice');
              chat.content = chat.user + chat.message;
              break;
          default:
              let sendClass = "other";
              if (this.isSend) { 
                  sendClass = "me";
              }
              messageElement.classList.add(sendClass);
              this.isSend = false;
              chat.content = chat.user + chat.message;

              break;
      }

      let textElement = document.createElement("p");
      let messageText = document.createTextNode(chat.message);

      textElement.appendChild(messageText);

      messageElement.appendChild(textElement);

      let content = document.getElementById("chat-content");
      content.append(messageElement);

  }
  1. chatRoom.jsp 작성
  • 필요한 js
    • jquery-3.6.1.min.js // 이 아이는 단순히 js 를 바로 실행하기 위해 사용되었기 때문에 작성 방법에 따라 꼭 필요는 없습니다.
    • sockjs.min.js
    • stomp.min.js
    • js/common/index.js
    • js/chat/index.js
  <%@ page language="java" contentType="text/html; charset=UTF-8" %>
  <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
  <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
  <!doctype html>
  <html lang="en">
      <head>
          <meta charset="utf-8">
          <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon">
          <meta name="viewport" content="width=device-width, initial-scale=1">
          <link rel="icon" href="data:,">
          <title>채팅 상세 페이지</title>
          <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
          <link href="css/main.css" rel="stylesheet" />

      </head>
      <body>
          <nav class="navbar bg-light">
              <div class="container-fluid">
                  <a class="navbar-brand" href="#">Chatting</a>
              </div>
          </nav>

          <div class="container-md mt-5 chat" >
              <div class="card h-75" >
                  <div class="card-body" style="min-height: 75%;" >
                      <ul id="chat-content" style="overflow-y: scroll;">
                      </ul>
                      <div class="input-group container-md mb-5">
                          <textarea id="message-text" class="form-control" rows="3"></textarea>
                          <div class="input-group-append">
                              <button id="message-submit" class="btn btn-outline-primary" type="button">전송</button>
                          </div>
                      </div>
                  </div>
              </div>
          </div>
      </body>

      <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script>
     <script src="https://code.jquery.com/jquery-3.6.1.min.js" integrity="sha256-o88AwQnZB+VDvE9tvIXrMQaPlFFSUTR+nldQm1LuPXQ=" crossorigin="anonymous"></script>
      <script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.1.4/sockjs.min.js"></script>
      <script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
      <script src="js/common/index.js" type="text/javascript" ></script>
      <script src="js/chat/index.js" type="text/javascript" ></script>
  </html>

실행 순서

  1. webapp > WEB-INF > views > index.jsp 에서 입장(방을 생성, 원하는 방 입장)
  2. webapp > WEB-INF > views > chatRoom.jsp 이동후 "js > chat > index.js" 에서 "connect()"가 실행됩니다.
    • "/ws-stomp"로 path를 지정하여 소켓을 연결 합니다.
    • 접속이 완료 되면 " stompClient = await Stomp.over(socket);" 로 Stomp를 셋팅 합니다.
    • await stompClient.connect({}, onConnected, onError);를 사용하여 채팅방 접근 및 에러를 셋팅 합니다.(서버 ChatController.java 참고)
      • "await stompClient.subscribe("/sub/chat/room/" + roomId, onMessageReceived);" 코드가 실행 되면서 채팅방 연결을 합니다. 모든 채팅 Response는 "onMessageReceived"로 온다.
      • await stompClient.send("/pub/chat/enterUser",{}, JSON.stringify(json)) // 현재 사용자가 채팅 방 입장을 합니다.
      • 입장 셋팅이 다되면 "function onMessageReceived(payload)" 가 실행 되면서 화면에 띄어준다.
  3. 메시지 입력후 전송 버튼을 클릭하면 ("js > chat > index.js")
    • "function sendMessage()"이 실행 하게 되고
    • "stompClient.send("/pub/chat/sendMessage", {}, JSON.stringify(chatMessage));"로 소켓 전송을 한다. // @MessageMapping("/chat/sendMessage")로 간다.pub은 생략
    • 이후 서버에서 처리가 되면 js의 "function onMessageReceived(payload)"에서 또 화면에 어떻게 띄어줄지 적용한다.

서버에서 동작 하는 내용은 진짜 기본만 작성 하여서 DB에서 동작은 하지 않습니다.
각각 필요에 맞게 ChatController.java를 작성 하면 될거 같습니다.

728x90

'BackEnd > Spring Boot' 카테고리의 다른 글

Redis 설정  (0) 2023.04.16
Show JPA Query Log  (0) 2023.04.16
채팅 만들기_1(basic)  (0) 2023.04.16
Request PathVariable Enum Converter  (0) 2023.04.16
Enum 등록된 값 이외에 값 등록시  (0) 2023.04.16