[Spring] 스프링 부트 STOMP 채팅 + 채팅방 구현 Ver.1

2023-10-27


사진: Unsplash 의 Mauro Lima


1. 주요 빌드 정보

 

	implementation 'org.springframework.boot:spring-boot-starter-websocket'
	implementation 'org.webjars:sockjs-client:1.1.2'
	implementation 'org.webjars:stomp-websocket:2.3.3-1'

	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' //순수 HTML 사용 시 불필요
	implementation 'org.springframework.boot:spring-boot-starter-freemarker'
	implementation 'org.webjars.bower:bootstrap:4.3.1' //순수 HTML 사용 시 불필요
	implementation 'org.webjars.bower:axios:0.17.1'
	implementation 'com.google.code.gson:gson:2.8.0'

2. Config

 

WebSocketConfig

 

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
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
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {


    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
                .setAllowedOriginPatterns("*")
                .withSockJS();
    }
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.setApplicationDestinationPrefixes("/app");
        registry.enableSimpleBroker("/topic");
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(new ChannelInterceptor() {
            @Override
            public Message<?> preSend(Message<?> message, MessageChannel channel) {
                StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
                /*
                 * TODO 각 요청에 따라 메시지 정보 및 인증 정보 컨트롤
                 *
                 */
                return ChannelInterceptor.super.preSend(message, channel);
            }
        });
    }
}

아래의 내용은 소켓 등록 시 /ws 경로를 사용할 것이라는 의미이며, CORS 모든 경로를 허용하겠다는 의미이다. 구현 과정에서 front를 따로 두는 경우가 많을 텐데, 그러한 케이스에서 필요한 설정이다.

 

registry.addEndpoint("/ws")
        .setAllowedOriginPatterns("*")
        .withSockJS();

 

메시지를 보내는 경로의 prefix 값은 /app으로 주고 각각의 메시지 세션정보 관리를 하는 브로커의 시작은 /topic으로 하겠다는 의미이다.

 

즉 각 메세지 세션의 /topic/room/1 | topic/room/2 | topic/room/3... 이런 식으로 관리되게 된다.

 

registry.setApplicationDestinationPrefixes("/app");
registry.enableSimpleBroker("/topic");

메시지 전송과정에서의 로직 처리 전 해당 메시지 정보를 캐치할 수 있는 메서드이다. TODO는 각자의 요구사항에 맞게 커스텀이 필요하다.

 

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {...}

 

WebSocketEventListener

 

유저가 예상치 못한 동작으로 세션이 없어질 경우 동작한다.(어떤 경우든 세션이 끊어지면 동작한다.) 해당 부분은 구현하지 않아도 동작에는 아무런 이상이 없다.

 

import com.app.vivada.chat.dto.ChatMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;

@Component
@RequiredArgsConstructor
@Slf4j
public class WebSocketEventListener {

    private final SimpMessageSendingOperations sendingOperations;


    /*
     * 유저가 채팅방을 나갔을 경우 동작하는 이벤트 핸들러
     * @param event
     */
    @EventListener
    public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
        String username = (String) headerAccessor.getSessionAttributes().get("username");
        String roomId = (String) headerAccessor.getSessionAttributes().get("roomId");
        if (username != null && roomId != null) {
            log.info("User disconnected:  {}", username + "  roomId ->" + roomId);

            sendingOperations.convertAndSend("/topic/chat/room/" + roomId,
                    ChatMessage
                            .builder()
                            .sender(username)
                            .roomId(roomId)
                            .message(username + "님이 방에서 나가셨습니다.")
                            .build());
        }else{
            log.info(" ==== [ERROR] username | roomId is null ");
        }
    }
}

3. Controller

 

ChatController

 

실제 메시징 처리가 필요한 부분의 컨트롤러이다. chatRoomLeave는 세션 기반으로 할지 토큰 기반으로 아직 고민 중이라 TODO로 남겨 두었다. 현재는 메시지 창에서 퇴장 문구만 뜨게 구현이 되어있다.(방에서 나가지지는 않음..)

 

import com.***.***.***.dto.ChatMessage;
import lombok.RequiredArgsConstructor;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.web.bind.annotation.RestController;


import java.io.IOException;

@RestController
@RequiredArgsConstructor
public class ChatController {

    private final SimpMessageSendingOperations sendingOperations;

    @MessageMapping("/chat/room/enter")
    public void enter(ChatMessage message, SimpMessageHeaderAccessor headerAccessor) {
        message.setMessage(message.getSender()+"님이 입장하였습니다.");
        headerAccessor.getSessionAttributes().put("username", message.getSender());
        headerAccessor.getSessionAttributes().put("roomId", message.getRoomId());
        sendingOperations.convertAndSend("/topic/chat/room/"+message.getRoomId(),message);
    }

    @MessageMapping("/chat/message")
    public void chat(ChatMessage message, SimpMessageHeaderAccessor headerAccessor) {
        sendingOperations.convertAndSend("/topic/chat/room/"+message.getRoomId(),message);
    }

    @MessageMapping("chat/room/leave")
    public void chatRoomLeave(ChatMessage message, SimpMessageHeaderAccessor headerAccessor) throws IOException {
        message.setMessage(message.getSender()+"님이 퇴장하셨습니다.");
        // WebSocket 세션을 종료할 세션 아이디 가져오기
        String sessionId = headerAccessor.getSessionId();

        /*
         * TODO 세션을 제거하거나 토큰 기반으로 세션제거 구현 코드 필요
         *
         */

        sendingOperations.convertAndSend("/topic/chat/room/"+message.getRoomId(),message);
    }
}

ChatRoomController

 

채팅방 생성과 같은 요청을 받는 컨트롤러이다. 일반적인 컨트롤러와 크게 차이가 없다.

 

import com.***.dto.ChatRoom;
import com.***.service.ChatService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@Controller
@RequiredArgsConstructor
@RequestMapping("/chat")
public class ChatRoomController {

    private final ChatService chatService;

    @GetMapping("/room")
    public String getRoomList(Model model) {
        /*
         * TODO 접속 유저에 따라 방 정보를 구분할 필요 있음
         *  파라미터로 전달 받은 정보 기준 ex) session | token | 인증객체
         */
        return "/chat/room";
    }

    @GetMapping("/rooms")
    @ResponseBody
    public List<ChatRoom> getRoomListResponseBody() {
        /*
         * TODO 접속 유저에 따라 방 정보를 구분할 필요 있음
         *  파라미터로 전달 받은 정보 기준 ex) session | token | 인증객체
         */
        return chatService.findAllRoom();
    }

    @PostMapping("/room")
    @ResponseBody
    public ChatRoom makeRoom(@RequestParam String name) {
        /*
         * TODO 요청 유저 정보를 별개의 파라미터로 받아 처리해야함
         */
        return chatService.makeRoom(name);
    }

    @GetMapping("/room/enter/{roomId}")
    public String enterRoom(Model model, @PathVariable String roomId) {
        /*
         * TODO 요청 유저 정보를 별개의 파라미터로 받아 처리해야함
         *  방에 따른 유저 권한 체크
         */
        model.addAttribute("roomId", roomId);
        return "/chat/roomdetail";
    }

    @GetMapping("/room/{roomId}")
    @ResponseBody
    public ChatRoom roomInfo(@PathVariable String roomId) {
        /*
         * TODO 요청 유저 정보를 별개의 파라미터로 받아 처리해야함
         */
        return chatService.findById(roomId);
    }
}

4. Service

 

ChatService

 

import java.util.List;

public interface ChatService {

    List<ChatRoom> findAllRoom();

    ChatRoom makeRoom(String name);

    ChatRoom findById(String roomId);
}

import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.*;


@Service
@Slf4j
public class ChatServiceImpl implements ChatService {
    
    //DB 사용시 제거 가능
    private Map<String, ChatRoom> chatRooms;

    //DB 사용시 제거 가능
    @PostConstruct
    private void init() {
        chatRooms = new LinkedHashMap<>();
    }

    @Override
    public List<ChatRoom> findAllRoom() {
        return new ArrayList<>(chatRooms.values());
    }

    @Override
    public ChatRoom findById(String roomId) {
        return chatRooms.get(roomId);
    }

    @Override
    public ChatRoom makeRoom(String name) {
        ChatRoom chatRoom = ChatRoom.make(name);
        chatRooms.put(chatRoom.getRoomId(), chatRoom);
        return chatRoom;
    }
}

5. DTO

 

ChatMessage

 

import lombok.*;

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@ToString
public class ChatMessage {

    private String roomId;
    private String message;
    private String sender;
    private String time; // 채팅 발송 시간
    private MessageType type;
}

ChatRoom

 

import lombok.*;

import java.util.UUID;

@Builder
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ChatRoom {
    private String roomId;
    private String roomName;

    public static ChatRoom make(String name) {
        ChatRoom room = new ChatRoom();
        room.roomId = UUID.randomUUID().toString();
        room.roomName = name;
        return room;
    }
}

6. View

 

 

방리스트를 구현하는 화면이다.

 

<!DOCTYPE html>
<html lang="en">
<head>
  <title>Websocket Chat</title>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
  <!-- CSS -->
  <link rel="stylesheet" href="/webjars/bootstrap/4.3.1/dist/css/bootstrap.min.css">
  <style>
    [v-cloak] {
      display: none;
    }
  </style>
</head>
<body>
<div class="container" id="app">
  <div class="row">
    <div class="col-md-12">
      <h3>채팅방 리스트</h3>
    </div>
  </div>
  <div class="input-group">
    <div class="input-group-prepend">
      <label class="input-group-text">방제목</label>
    </div>
    <input type="text" class="form-control" id="room_name">
    <div class="input-group-append">
      <button class="btn btn-primary" type="button" id="createRoom">채팅방 개설</button>
    </div>
  </div>
  <ul class="list-group" id="chatrooms">
  </ul>
</div>
<!-- JavaScript -->
<script src="/webjars/axios/0.17.1/dist/axios.min.js"></script>
<script>
  document.addEventListener("DOMContentLoaded", function() {
    var roomNameInput = document.getElementById("room_name");
    var createRoomButton = document.getElementById("createRoom");
    var chatroomsList = document.getElementById("chatrooms");

    createRoomButton.addEventListener("click", createRoom);

    function findAllRoom() {
      axios.get('/chat/rooms').then(function(response) {
        var chatrooms = response.data;
        chatroomsList.innerHTML = ""; // Clear the existing list
        chatrooms.forEach(function(item) {
          var li = document.createElement("li");
          li.className = "list-group-item list-group-item-action";
          li.textContent = item.roomName;
          li.addEventListener("click", function() {
            enterRoom(item.roomId);
          });
          chatroomsList.appendChild(li);
        });
      });
    }

    function createRoom() {
      var roomName = roomNameInput.value;
      if (roomName === "") {
        alert("방 제목은 필수값입니다.");
        return;
      } else {
        var params = new URLSearchParams();
        params.append("name", roomName);
        axios.post('/chat/room', params)
                .then(function(response) {
                  roomNameInput.value = "";
                  findAllRoom();
                })
                .catch(function(response) {
                  alert("방 생성 실패");
                });
      }
    }

    function enterRoom(roomId) {
      var sender = prompt('사용자 이름을 입력해주세요');
      if (sender !== "") {
        localStorage.setItem('wschat.sender', sender);
        localStorage.setItem('wschat.roomId', roomId);
        location.href = "/chat/room/enter/" + roomId;
      }
    }

    findAllRoom(); // Load chat rooms on page load
  });
</script>
</body>
</html>

실제 방에 입장했을때 동작하는 view 화면이다.

 

<!DOCTYPE html>
<html lang="en">
<head>
    <title>Websocket ChatRoom</title>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">

    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="/webjars/bootstrap/4.3.1/dist/css/bootstrap.min.css">
    <style>
        [v-cloak] {
            display: none;
        }
    </style>
</head>
<body>
<div class="container" id="app">
    <div>
        <h2 id="roomName"></h2>
    </div>
    <div class="input-group">
        <div class="input-group-prepend">
            <label class="input-group-text">내용</label>
        </div>
        <input type="text" class="form-control" id="messageInput">
        <div class="input-group-append">
            <button class="btn btn-primary" type="button" id="sendMessageButton">보내기</button>
            <button class="btn btn-danger" type="button" id="leaveRoomButton">나가기</button>
        </div>
    </div>
    <ul class="list-group" id="messagesList">
    </ul>
    <div></div>
</div>
<!-- JavaScript -->
<script src="/webjars/axios/0.17.1/dist/axios.min.js"></script>
<script src="/webjars/sockjs-client/1.1.2/sockjs.min.js"></script>
<script src="/webjars/stomp-websocket/2.3.3-1/stomp.min.js"></script>
<script>
    var roomId = '';
    var sender = '';

    document.addEventListener("DOMContentLoaded", function() {
        var roomNameElement = document.getElementById("roomName");
        var messageInputElement = document.getElementById("messageInput");
        var sendMessageButton = document.getElementById("sendMessageButton");
        var leaveRoomButton = document.getElementById("leaveRoomButton");
        var messagesList = document.getElementById("messagesList");

        roomId = localStorage.getItem('wschat.roomId');
        sender = localStorage.getItem('wschat.sender');
        findRoom();

        sendMessageButton.addEventListener("click", sendMessage);
        leaveRoomButton.addEventListener("click", liveRoom);

        function findRoom() {
            axios.get('/chat/room/' + roomId).then(function(response) {
                roomNameElement.textContent = response.data.name;
            });
        }

        function sendMessage() {
            var message = messageInputElement.value;
            ws.send("/app/chat/message", {}, JSON.stringify({ type: 'CHAT', roomId: roomId, sender: sender, message: message }));
            messageInputElement.value = '';
        }

        function liveRoom() {
            ws.send("/app/chat/room/leave", {}, JSON.stringify({ type: 'LEAVE', roomId: roomId, sender: sender, message: '' }));
            messageInputElement.value = '';
        }

        function recvMessage(recv) {
            var message = document.createElement("li");
            message.className = "list-group-item";
            message.textContent = recv.sender + " - " + recv.message;
            messagesList.insertBefore(message, messagesList.firstChild);
        }

        function connect() {
            // pub/sub event
            ws.connect({}, function(frame) {
                ws.subscribe("/topic/chat/room/" + roomId, function(message) {
                    var recv = JSON.parse(message.body);
                    recvMessage(recv);
                });
                ws.send("/app/chat/room/enter", {}, JSON.stringify({ type: 'JOIN', roomId: roomId, sender: sender }));
            }, function(error) {
                alert("채팅방 접속 오류")
                console.log(error)
            });
        }

        var sock = new SockJS("/ws");
        var ws = Stomp.over(sock);

        connect();
    });
</script>
</body>
</html>

* 참고 자료

 

https://velog.io/@rainbowweb/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-STOMP%EB%A1%9C-%EC%B1%84%ED%8C%85%EB%B0%A9-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0
https://hyeooona825.tistory.com/89
https://www.youtube.com/watch?v=TywlS9iAZCM&list=LL&index=16&t=4s&ab_channel=BoualiAli


메인 이미지 출처 : 사진: UnsplashMauro Lima