[Spring] WebSocket으로 채팅 구현하기 - STOMP를 이용한 채팅 고도화

김강욱·2024년 5월 13일
1

Spring

목록 보기
10/17
post-thumbnail

지난 시간에 이어 WebSocket을 사용한 채팅 기능 구현을 STOMP를 이용하여 고도화 시키도록 하겠습니다.

이전 포스팅 - 일반적인 WebSocket 사용

STOMP를 이용한 채팅 고도화

우선 STOMP에 대해 알아보도록 하겠습니다.

STOMP

STOMP는 메시지 전송을 효율적으로 하기 위해 나온 프로토콜의 종류이며 기본적으로 PUB/SUB 구조로 되어 있어 메시지를 발송하고, 메시지를 받아 처리하는 부분이 확실히 정해져 있어 개발하는 입장에서 명확하게 인지하고 개발할 수 있는 장점이 있습니다.

또한 STOMP를 이용하면 통신 메시지의 헤더 값을 세팅할 수 있어 헤더 값을 기반으로 통신 시 인증 처리에 대한 구현도 가능하게 해줍니다.

STOMPPUB/SUB 구조를 이용하여 채팅방에 대입하면 다음과 같은 흐름을 구성할 수 있습니다.

1. 채팅방 생성 - pub/sub 구현을 위한 Topic이 하나 생성됩니다.
2. 채팅방 입장 - 채팅방 관련 Topic을 구독합니다.
3. 채팅방에서 메시지 발신, 수신 - 해당 Topic으로 메시지를 발송(pub)하거나 메시지를 수신(sub)할 수 있습니다.

그러면 이제 STOMP를 이용하여 고도화 작업을 시작하도록 하겠습니다.

build.gradle 설정

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-websocket'
    implementation 'org.springframework.boot:spring-boot-starter-freemarker'
    implementation 'org.springframework.boot:spring-boot-devtools'
    implementation 'org.webjars.bower:bootstrap:4.3.1'
    implementation 'org.webjars.bower:vue:2.5.16'
    implementation 'org.webjars.bower:axios:0.17.1'
    implementation 'org.webjars:sockjs-client:1.1.2'
    implementation 'org.webjars:stomp-websocket:2.3.3-1'
    implementation 'com.google.code.gson:gson:2.8.0'

    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

서버 사이드 랜더링으로 구현하기 때문에 관련된 의존성을 세팅해주었습니다.

webjar는 채팅 웹 화면을 구현하기 위해 필요한 js를 로드해주기 위해 선언하였습니다.(freemarker, vue.js)

STOMP 방식으로 통신하기 위한 js도 추가해주었습니다. sockjsWebSocket을 지원하지 않는 낮은 버전의 브라우저에서도 WebSocket을 사용할 수 있도록 해주는 라이브러리 입니다.

WebSocketConfig 수정

package com.websocket.chat.config;

import ... 생략

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/sub");
        config.setApplicationDestinationPrefixes("/pub");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws-stomp").setAllowedOrigins("*")
                .withSockJS();
    }
}

STOMP를 사용하기 위해 @EnableWebSocketMessageBroker을 선언하고 WebSocketMessageBrokerConfigurer를 상속받아 configureMessageBroker를 구현하고 있습니다.

PUB/SUB 메시징을 구현하기 위해 메시지를 발행하는 요청의 prefix는 /pub으로 시작하도록 설정하고 메시지를 구독하는 요청의 prefix는 /sub으로 시작하도록 설정합니다.

STOMPWebSocket 연결 endpoint는 /ws-stomp로 설정하였습니다. 개발 서버의 WebSocket 접속 주소는 ws://localhost:8080/ws-stomp가 됩니다.


채팅방 DTO 수정

@Getter
@Setter
public class ChatRoom {
    private String roomId;
    private String name;

    public static ChatRoom create(String name) {
        ChatRoom chatRoom = new ChatRoom();
        chatRoom.roomId = UUID.randomUUID().toString();
        chatRoom.name = name;
        return chatRoom;
    }
}

PUB/SUB 방식을 이용하면 구독자 관리가 알아서 되기 때문에 WebSocket Session 관리를 따로 할 필요가 없어집니다.

발솔 구현 또한 알아서 해결되므로 일일이 클라이언트에게 메시지를 발송하는 구현 또한 필요없어졌습니다.


채팅방 Repository 생성

package com.spring.wschat.repo;

// import 생략....

@Repository
public class ChatRoomRepository {

    private Map<String, ChatRoom> chatRoomMap;

    @PostConstruct
    private void init() {
        chatRoomMap = new LinkedHashMap<>();
    }

    public List<ChatRoom> findAllRoom() {
        // 채팅방 생성순서 최근 순으로 반환
        List chatRooms = new ArrayList<>(chatRoomMap.values());
        Collections.reverse(chatRooms);
        return chatRooms;
    }

    public ChatRoom findRoomById(String id) {
        return chatRoomMap.get(id);
    }

    public ChatRoom createChatRoom(String name) {
        ChatRoom chatRoom = ChatRoom.create(name);
        chatRoomMap.put(chatRoom.getRoomId(), chatRoom);
        return chatRoom;
    }
}

채팅방을 생성하고 정보를 조회하는 Repository를 따로 생성해줍니다. 기존의 ChatService를 대체합니다.


ChatController 수정 (Publisher 구현)

package com.websocket.chat.controller;

// import 생략...

@RequiredArgsConstructor
@Controller
public class ChatController {

    private final SimpMessageSendingOperations messagingTemplate;

    @MessageMapping("/chat/message")
    public void message(ChatMessage message) {
        if (ChatMessage.MessageType.JOIN.equals(message.getType()))
            message.setMessage(message.getSender() + "님이 입장하셨습니다.");
        messagingTemplate.convertAndSend("/sub/chat/room/" + message.getRoomId(), message);
    }
}

@MessageMapping을 통해 WebSocket으로 들어오는 메시지 발행을 처리하게 됩니다.

클라이언트에서는 prefix를 붙여 /pub/chat/message로 메시지 발행 요청을 하면 위의 컨트롤러가 해당 메시지를 받아 처리하는 구조입니다.

메시지가 발행되면 /sub/chat/room/{roomId}로 메시지를 전송하게 되고 클라이언트에서 해당 주소(/sub/chat/room/{roomId})를 구독하고 있다가 메시지가 전달되면 화면에 출력하게 됩니다.

roomId가 채팅방을 구분하는 값으로 PUB/SUB에서 Topic 역할을 하고 있습니다.

기존의 WebSocketChatHandler 역할을 대신하므로 해당 클래스는 삭제합니다.


구독자(Subscriber) 구현

백엔드 서버에서는 구독자를 따로 구현하지 않고 View(프론트)에서 STOMP 라이브러리를 이용해서 subscribe 주소를 바라보는 코드를 구현하시면 됩니다.

  useEffect(() => {
        const sock = new SockJS('http://localhost:8081/ws-stomp');
        const ws = Stomp.over(sock);
        
        ws.connect({"Authorization": getCookie("Authorization")}, function(frame) {
        console.log('Connected: ' + frame);

        // 채팅방 목록에 방생성 및 각종 이벤트를 알리기 위한 구독(재랜더링용)
        ws.subscribe(`/sub/chatRoom/renew/${getCookie("memberId")}`, (message) => {
            const newMessage = JSON.parse(message.body);
            console.log(newMessage);
            setRenewRoom(prevState => !prevState)
        },{"Authorization" : getCookie("Authorization")})
          
  ...

ChatRoomController 생성

package com.websocket.chat.controller;

// import 생략...

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

    private final com.spring.wschat.repo.ChatRoomRepository chatRoomRepository;

    // 채팅 리스트 화면
    @GetMapping("/room")
    public String rooms(Model model) {
        return "/chat/room";
    }
    // 모든 채팅방 목록 반환
    @GetMapping("/rooms")
    @ResponseBody
    public List<ChatRoom> room() {
        return chatRoomRepository.findAllRoom();
    }
    // 채팅방 생성
    @PostMapping("/room")
    @ResponseBody
    public ChatRoom createRoom(@RequestParam String name) {
        return chatRoomRepository.createChatRoom(name);
    }
    // 채팅방 입장 화면
    @GetMapping("/room/enter/{roomId}")
    public String roomDetail(Model model, @PathVariable String roomId) {
        model.addAttribute("roomId", roomId);
        return "/chat/roomdetail";
    }
    // 특정 채팅방 조회
    @GetMapping("/room/{roomId}")
    @ResponseBody
    public ChatRoom roomInfo(@PathVariable String roomId) {
        return chatRoomRepository.findRoomById(roomId);
    }
}

채팅 화면(채팅방 목록, 채팅방 입장)에 필요한 데이터를 전달하기 위해 ChatRoomController를 생성해줍니다.


채팅 화면(view) 생성

1. 채팅방 리스트 및 생성 화면 구성

<!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" v-cloak>
        <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" v-model="room_name" v-on:keyup.enter="createRoom">
            <div class="input-group-append">
                <button class="btn btn-primary" type="button" @click="createRoom">채팅방 개설</button>
            </div>
        </div>
        <ul class="list-group">
            <li class="list-group-item list-group-item-action" v-for="item in chatrooms" v-bind:key="item.roomId" v-on:click="enterRoom(item.roomId)">
                {{item.name}}
            </li>
        </ul>
    </div>
    <!-- JavaScript -->
    <script src="/webjars/vue/2.5.16/dist/vue.min.js"></script>
    <script src="/webjars/axios/0.17.1/dist/axios.min.js"></script>
    <script>
        var vm = new Vue({
            el: '#app',
            data: {
                room_name : '',
                chatrooms: [
                ]
            },
            created() {
                this.findAllRoom();
            },
            methods: {
                findAllRoom: function() {
                    axios.get('/chat/rooms').then(response => { this.chatrooms = response.data; });
                },
                createRoom: function() {
                    if("" === this.room_name) {
                        alert("방 제목을 입력해 주십시요.");
                        return;
                    } else {
                        var params = new URLSearchParams();
                        params.append("name",this.room_name);
                        axios.post('/chat/room', params)
                        .then(
                            response => {
                                alert(response.data.name+"방 개설에 성공하였습니다.")
                                this.room_name = '';
                                this.findAllRoom();
                            }
                        )
                        .catch( response => { alert("채팅방 개설에 실패하였습니다."); } );
                    }
                },
                enterRoom: function(roomId) {
                    var sender = prompt('대화명을 입력해 주세요.');
                    if(sender != "") {
                        localStorage.setItem('wschat.sender',sender);
                        localStorage.setItem('wschat.roomId',roomId);
                        location.href="/chat/room/enter/"+roomId;
                    }
                }
            }
        });
    </script>
  </body>
</html>


2. 채팅방 상세 화면 구성

<!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" v-cloak>
        <div>
            <h2>{{room.name}}</h2>
        </div>
        <div class="input-group">
            <div class="input-group-prepend">
                <label class="input-group-text">내용</label>
            </div>
            <input type="text" class="form-control" v-model="message" v-on:keypress.enter="sendMessage">
            <div class="input-group-append">
                <button class="btn btn-primary" type="button" @click="sendMessage">보내기</button>
            </div>
        </div>
        <ul class="list-group">
            <li class="list-group-item" v-for="message in messages">
                {{message.sender}} - {{message.message}}</a>
            </li>
        </ul>
        <div></div>
    </div>
    <!-- JavaScript -->
    <script src="/webjars/vue/2.5.16/dist/vue.min.js"></script>
    <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>
        //alert(document.title);
        // websocket & stomp initialize
        var sock = new SockJS("/ws-stomp");
        var ws = Stomp.over(sock);
        var reconnect = 0;
        // vue.js
        var vm = new Vue({
            el: '#app',
            data: {
                roomId: '',
                room: {},
                sender: '',
                message: '',
                messages: []
            },
            created() {
                this.roomId = localStorage.getItem('wschat.roomId');
                this.sender = localStorage.getItem('wschat.sender');
                this.findRoom();
            },
            methods: {
                findRoom: function() {
                    axios.get('/chat/room/'+this.roomId).then(response => { this.room = response.data; });
                },
                sendMessage: function() {
                    ws.send("/pub/chat/message", {}, JSON.stringify({type:'TALK', roomId:this.roomId, sender:this.sender, message:this.message}));
                    this.message = '';
                },
                recvMessage: function(recv) {
                    this.messages.unshift({"type":recv.type,"sender":recv.type=='ENTER'?'[알림]':recv.sender,"message":recv.message})
                }
            }
        });

        function connect() {
            // pub/sub event
            ws.connect({}, function(frame) {
                ws.subscribe("/sub/chat/room/"+vm.$data.roomId, function(message) {
                    var recv = JSON.parse(message.body);
                    vm.recvMessage(recv);
                });
                ws.send("/pub/chat/message", {}, JSON.stringify({type:'ENTER', roomId:vm.$data.roomId, sender:vm.$data.sender}));
            }, function(error) {
                if(reconnect++ <= 5) {
                    setTimeout(function() {
                        console.log("connection reconnect");
                        sock = new SockJS("/ws-stomp");
                        ws = Stomp.over(sock);
                        connect();
                    },10*1000);
                }
            });
        }
        connect();
    </script>
  </body>
</html>

채팅방 입장 시엔 `ws-stomp`로 서버에 연결을 한 후에 해당 채팅방을 구독하는 액션을 수행하도록 구성합니다.
  ws.subscribe("/sub/chat/room/"+vm.$data.roomId, function(message) {
                    var recv = JSON.parse(message.body);
                    vm.recvMessage(recv);
                });

구독은 /sub/chat/room/roomId로 구독하고 이 주소를 Topic으로 삼아 서버에서 메시지를 발행하게 됩니다.

위의 코드에서 function은 콜백함수를 정의한 내용입니다. 즉, 해당 Topic으로 메시지가 발송될 시 해당 콜백함수에 전달되어 로직을 수행하게 됩니다.

정리하면 채팅방에서 클라이언트가 메시지를 입력하면 서버에서 Topic으로 메시지를 발행하는데 이것을 구독자가 해당 Topic에서 메시지 수신을 대기하고 있다가 발송된 메시지를 받아서 처리할 수 있습니다.


참고 자료
daddyprogrammer님의 WebSocket 채팅 서버 구현 시리즈

profile
TO BE DEVELOPER

0개의 댓글

관련 채용 정보