이 연재글은 웹소켓(websocket)으로 채팅서버 만들기의 2번째 글입니다.

이전 장에서 websocket을 통하여 간단한 서버/클라이언트 통신을 구현해 보았습니다. 메시징 방식을 잘 정의한다면 websocket만으로도 충분히 좋은 서버/클라이언트 소켓 서버를 완성할 수 있습니다. 하지만 단순한 통신 구조로 인해 Websocket만을 이용해 채팅을 구현하면 해당 메시지가 어떤 요청인지, 어떻게 처리해야 되는지에 따라 채팅룸과 세션을 일일이 구현하고 메시지 발송 부분을 관리하는 추가 코드를 구현해 줘야 합니다. 

이번 장에서는 Websocket의 프로세스를 좀 더 고도화하고 메시징에 좀 더 최적화된 방식을 구현하기 위해 Stomp를 적용해 보겠습니다.

Stomp

Stomp는 메시징 전송을 효율적으로 하기 위해 나온 프로토콜이며 기본적으로 pub/sub 구조로 되어있어 메시지를 발송하고, 메시지를 받아 처리하는 부분이 확실히 정해져 있기 때문에 개발하는 입장에서 명확하게 인지하고 개발할 수 있는 이점이 있습니다. 또한 Stomp를 이용하면 통신 메시지의 헤더에 값을 세팅할 수 있어 헤더 값을 기반으로 통신 시 인증처리를 구현하는 것도 가능합니다.

pub/sub란 메시지를 공급하는 주체와 소비하는 주체를 분리하여 제공하는 메시징 방법입니다. 기본적인 콘셉트를 예로 들면 우체통(topic)이 있으면 집배원(publisher)이 신문을 우체통에 배달하는 액션이 있고, 우체통에 신문이 배달되는 것을 기다렸다가 빼서 보는 구독자(subscriber)의 액션이 있습니다. 여기서 구독자는 여러명이 될 수 있습니다. pub/sub 콘셉트를 채팅룸에 대입하면 다음과 같습니다. 

  • 채팅방을 생성한다 – pub/sub 구현을 위한 Topic이 하나 생성된다.
  • 채팅방에 입장한다 – Topic을 구독한다.
  • 채팅방에서 메시지를 보내고 받는다 – 해당 Topic으로 메시지를 발송하거나(pub) 메시지를 받는다(sub)

Stomp를 이용한 채팅 구현

build.gradle

다음과 같이 dependencies에 stomp관련 라이브러리를 추가합니다. webjar는 채팅 웹 화면을 구현하기 위해 필요한 js를 로드하기 위해 선언합니다. 여기서는 프론트웹 개발을 freemarker, vue.js를 사용하여 개발할 것입니다. 그리고 stomp 방식으로 통신하기위한 js도 추가합니다. sockjs는 websocket를 지원하지 않는 낮은 버전의 브라우저에서도 websocket를 사용할수 있도록 해주는 라이브러리 입니다.

plugins {
    id 'org.springframework.boot' version '2.1.5.RELEASE'
    id 'java'
}

apply plugin: 'io.spring.dependency-management'

group = 'com.spring'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

repositories {
    mavenCentral()
}

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'
}

application.yml

intellij에서 static파일을 개발할때 서버를 재시작 하지 않고도 수정한 내용이 반영되도록 다음과 같이 추가합니다.

spring:
  devtools:
    livereload:
      enabled: true
    restart:
      enabled: false
  freemarker:
    cache: false

WebSockConfig 수정

Stomp를 사용하기 위해 @EnableWebSocketMessageBroker을 선언하고 WebSocketMessageBrokerConfigurer를 상속받아 configureMessageBroker를 구현합니다.
pub/sub 메시징을 구현하기 위해 메시지를 발행하는 요청의 prefix는 /pub로 시작하도록 설정하고 메시지를 구독하는 요청의 prefix는 /sub로 시작하도록 설정합니다. 그리고 stomp websocket의 연결 endpoint는 /ws-stomp로 설정합니다. 따라서 개발서버의 접속 주소는 다음과 같이 됩니다. ws://localhost:8080/ws-stomp

package com.websocket.chat.config;

import ... 생략

@Configuration
@EnableWebSocketMessageBroker
public class WebSockConfig 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();
    }
}

채팅 방 DTO 수정

pub/sub방식을 이용하면 구독자 관리가 알아서 되므로 웹소켓 세션 관리가 필요 없어집니다. 또한 발송의 구현도 알아서 해결되므로 일일이 클라이언트에게 메시지를 발송하는 구현이 필요 없어집니다. 따라서 채팅방 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;
    }
}

채팅방 Repository 생성

채팅방을 생성하고 정보를 조회하는 Repository를 생성합니다. 실습에서는 간단하게 만들 것이므로 채팅방 정보를 Map으로 관리하지만, 서비스에서는 DB나 다른 저장 매체에 채팅방 정보를 저장하도록 구현해야 합니다. 그리고 ChatService는 ChatRoomRepository가 대체하므로 삭제합니다. 

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;
    }
}

ChatController수정 ( publisher 구현 )

@MessageMapping을 통해 Websocket으로 들어오는 메시지 발행을 처리합니다. 클라이언트에서는 prefix를 붙여서 /pub/chat/message로 발행 요청을 하면 Controller가 해당 메시지를 받아 처리합니다. 메시지가 발행되면 /sub/chat/room/{roomId}로 메시지를 send 하는 것을 볼 수 있는데 클라이언트에서는 해당 주소를(/sub/chat/room/{roomId}) 구독(subscribe)하고 있다가 메시지가 전달되면 화면에 출력하면 됩니다. 여기서 /sub/chat/room/{roomId}는 채팅룸을 구분하는 값이므로 pub/sub에서 Topic의 역할이라고 보면 됩니다.
기존의 WebSockChatHandler가 했던 역할을 대체하므로 WebSockChatHandler는 삭제합니다. 

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);
    }
}

구독자(subscriber) 구현

서버단에는 따로 추가할 구현이 없습니다. 웹뷰에서 stomp 라이브러리를 이용해서 subscriber 주소를 바라보고 있는 코드만 작성하면 됩니다.

ChatRoomController 생성

Websocket 통신 외에 채팅 화면 View 구성을 위해 필요한 Controller를 생성합니다.

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);
    }
}

채팅 화면(view) 생성

채팅방리스트(생성) , 채팅방 상세 화면을 위한 view를 구현합니다. /resources/templates 하위에 room.ftl, roomdetail.ftl 두개 파일을 생성합니다. 해당 파일은 freemarker 형식으로 생성(.ftl)되었지만 껍데기만 그렇게 생성 하고 내부 로직은 vue.js를 통해 구현하였습니다. 또한 기본 UI는 bootstrap로 구성되어 있습니다.

/resources/template/chat/room.ftl

채팅방을 개설하거나 현재 채팅방의 리스트를 보여줍니다. 리스트를 클릭하면 채팅방으로 입장합니다.

<!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>

/resources/template/chat/roomdetail.ftl

<!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>

채팅방에 입장했을 때의 view입니다. 접속한 클라이언트 간에 간단하게 채팅 메시지를 주고받을 수 있습니다. 입장 시엔 ws-stomp로 서버 연결을 한 후에 채팅룸을 구독하는 액션을 수행하는 것을 볼 수 있습니다. 
ws.subscribe(“/sub/chat/room/”+vm.$data.roomId, function(message) {……} 
구독은 “/sub/chat/room/채팅방 번호”로 구독하는 것을 볼 수 있는데 이 주소를 Topic으로 삼아 서버에서 메시지를 발행합니다. 채팅방에서 클라이언트가 메시지를 입력하면 서버에서 topic(“/sub/chat/room/채팅방 번호”)으로 메시지를 발행(publish)을하는데 이것을 구독자는 ws.subscribe에서 대기하고 있다가 발송된 메시지를 받아서 처리할 수 있는 것입니다. 

테스트

서버를 실행하고 localhost:8080/chat/room을 실행합니다. 최초에는 방이 없으므로 방제목을 입력하고 채팅방 개설을 클릭하여 방을 개설합니다. 개설 후에는 리스트를 클릭하고 대화명을 입력후에 채팅방 입장이 가능합니다.

채팅방 생성
유저 1 채팅방 입장

대화를 위해 브라우저를 하나 더 띄워 대화방에 접속합니다. 대화를 입력하여 서로의 브라우저간에 메시지가 전달되는지 확인해봅니다. 별다른 문제 없이 메시지가 전송되고 상대방 클라이언트의 브라우저에서 메시지를 확인 할 수 있음을 볼수 있습니다.

유저2 채팅방 입장
유저1, 유저2 채팅
유저1, 유저2 채팅

Stomp를 통해 기존의 채팅 서버를 개선해 보았습니다. pub/sub를 이용함으로서 메시지의 이동경로가 명확해졌고, 채팅방마다 개별의 클라이언트 session을 저장하고 있다가 메시지를 발송해야 하는 일련의 절차가 단순해졌습니다. 다음장에서는 Redis의 pub/sub 기능을 이용하여 다중서버에서도 채팅이 가능하도록 고도화해 보겠습니다.

이번장에서 실습한 내용은 Github에서 확인하실 수 있습니다.

https://github.com/codej99/websocket-chat-server/tree/feature/stomp

연재글 이동[이전글] Spring websocket chatting server(1) – basic websocket server
[다음글] Spring websocket chatting server(3) – 여러대의 채팅서버간에 메시지 공유하기 by Redis pub/sub