티스토리 뷰

이전 장에서 WebSocket만을 이용해서 채팅을 구현해보았다. 이때는 해당 메시지가 어떤 타입의 메시지 인지 (pub, sub 등), 타입별로 어떻게 처리해야 하는지를 일일이 구현해야했다.

 

이번에는 STOMP에 대해서 알아보고 이걸 이용하여 웹 소켓을 구현하면 어떻게 바꿀수 있는지 알아보고 적용해본다.


[STOMP]

STOMP : Simple Text Oriented Messaging Protocol

 

stomp는 메시지 전송을 효율적으로 하기 위한 프로토콜로, 기본적으로 PUB / SUB 구조로 되어있다. 따라서 메시지를 전송하고 / 받아서 처리하는 부분이 확실하게 구조로 정해져있기 때문에 명확하게 인지하고 개발할 수 있다.

  • STOMP 프로토콜은 클라이언트/서버 간 전송할 메시지의 유형, 형식, 내용들을 정의한 규칙이다.
  • TCP 또는 WebSocket과 같은 [양방향 네트워크 프로토콜 기반]으로 동작한다.
  • 헤더에 값을 세팅할 수 있어서 헤더 값을 기반으로 통신 시 인증처리를 구현할 수 있다.

PUB / SUB 구조

pub/sub란 메시지를 공급하는 주체와 소비하는 주체를 분리해 제공하는 메시징 방법이다. 대표적인 예를 들어 설명해 보겠다.

 

우체통(Topic)이 있다면 집배원(Publisher)이 신문을 우체통에 배달하는 행위가 있고, 우체통에 신문이 배달되는 것을 기다렸다가 빼서 보는 구독자(Subscriber)의 행위가 있다. 이때 구독자는 다수가 될 수 있다.

 

즉, 채팅방을 생성하는 것은 우체통 Topic을 만드는 것이고 채팅방에 들어가는 것은 구독자로서 Subsciber가 되는 것이다. 채팅방에 글을 써서 보내는 행위는 우체통에 신문을 넣는 Publisher가 된다.

 

Message Brocker

이때 Message Brocker란 개념이 있는데, 이것은 [Publisher]로 부터 전달받은 메시지를 [Subscriber]에게 메시지를 주고 받게 해주는 중간 역활을 하는 것을 말한다.

 

클라이언트는 SEND, SUBSCRIBE 명령을 통해서 메시지의 내용과 수신 대상을 설명하는 "destination" 헤더와 함께 메시지에 대한 전송이나 구독을 할 수 있다. 이것이 브로커를 통해 연결된 다른 클라이언트로 메시지를 보내거나, 서버로 메시지로 보내 일부 작업을 요청할 수 있는 PUB/SUB 메커니즘을 가능하게 한다.

 

스프링이 지원하는 STOMP에서는 스프링 웹 소켓 애플리케이션이 클라이언트에게 STOMP 브로커의 역활을 한다. 이때 메시지는 @Controller 메시지 처리 방법이나, Subscriber를 추적해서 구독중인 사용자에게 메시지를 전파(Broadcast)하는 Simple In Momory 브로커에게 라우팅 된다.

 

이렇게 Spring 환경에서 추가적인 설정없이 STOMP 프로토콜을 사용하면 메시지 브로커는 자동으로 In Memory Brocker를 사용하게 된다.

 

문제는 이러한 기본 In Memory Brocker를 사용해도 상관이 없을까이다. 결론은 상관이 있다. 몇가지 단점들을 보면

  1. 세션을 수용할 수 있는 크기가 제한되어 있다.
  2. 장애 발생 시 메세지의 유실 가능성이 높다.
  3. 따로 모니터링 하는것이 불편하다.

따라서, In Memory 브로커 대신에 STOMP 전용 외부 브로커를 사용하는것을 지향한다. 외부 브로커로는 RabbitMQ, ActiveMQ 등이 있으며 Message Brocker 기능을 제공한다. (일반적으로 초당 5000 ~ 수만번의 메시지가 발생되는 규모에서 사용한다고 한다)

 

FRAME 구조

WebSocket 위에서 동작하는, STOMP에서 정의한 Frame 구조로 Client - Server 간 상호 메시지 통신을 한다. 여기서 Frame 구조는 몇개의 텍스트 라인으로 지정된 구조이다.

COMMAND
header1:value1
header2:value2

Body^@

첫번째 라인은 명령어 텍스트이고, 다음 줄에 key:value형태로 header정보를 포함한다. 이후 공백 라인 하나가 있고 다음에 payload가 존재한다.


STOMP를 이용한 채팅 구현

WebSocket Config 수정

STOMP를 사용하기 위해 @EnableWebSocketMessageBrocker을 선언하고 WebSocketMessageBrockerConfigurer를 상속받아서 configureMessageBrocker()를 구현한다.

 

pub/sub 메시징을 구현하기 위해

  • 메시지를 발행하는 요청의 prefix는 [/pub] 으로 시작하도록 설정
  • 메시지를 구독하는 요청의 prefix는 [/sub] 으로 시작하도록 설정

그리고 stomp websocket의 연결 endpoint는 /ws-swoomi로 설정한다. 즉, 개발서버의 접속 주소는 [ws://localhost:8070/ws-swoomi]가 된다. WebSocket 또는 SockJs는 /ws-swoomi와 핸드쉐이크 과정을 통해 커넥션이 연결된다.

 

클라이언트와의 연결은 SockJs()로 하기때문에 withSockJS()를 달아준다.

@Slf4j
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

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

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

 

 

MsgRoom DTO 수정

pub/sub 방식을 이용하면 구독자 관리가 알아서 되므로 Set<>으로 관리했던 웹소켓 세션 관리가 필요 없어진다. 또한 발송의 구현도 알아서 해결이 되므로 일일이 클라이언트에게 메시지를 발송하는 구현이 없어진다. 이부분 -> sessions.parallelStream().forEach( ... )

@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Entity
public class MsgRoom {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    // TODO : roomId는 MatchId + 진영 코드
    private String roomId;
}

 

MsgRoom Repository 생성

채팅방을 생성하고 정보를 조회하는 Repository를 생성한다.

@Repository
public class MsgRoomRepository {

    private Map<String, MsgRoom> msgRoomMap;

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

    public List<MsgRoom> findAllRoom() {
        List<MsgRoom> msgRooms = new ArrayList(msgRoomMap.values());
        Collections.reverse(msgRooms);
        return msgRooms;
    }

    public MsgRoom findByRoomId(String roomId) {
        return msgRoomMap.get(roomId);
    }

    public MsgRoom createMsgRoom(String name) {
        MsgRoom room = MsgRoom.builder().roomId(name).build();
        msgRoomMap.put(room.getRoomId(), room);
        return room;
    }
}

❗️현재는 생성한 방을 Map 구조체에다 저장할 뿐이지만 Jpa를 사용해서 DB에 저장하도록 수정해야한다❗️

또한 지금은 MsgRoomService가 할 역활을 MsgRoomRepository가 하고있지만 분리해야한다.

 

MsgController 수정 (Publisher 구현)

채팅을 보내는 행위는 Publisher의 행위로 간주한다. 이때 보내는 채팅의 채팅방은 클라이언트 자신이 Subscribe하고 있는 Topic이며 해당방을 Sub하고 있는 모든 Subscriber에게 보내줘야한다.

 

@MessageMapping을 선언해서 WebSocket으로 들어오는 메시지 발행을 처리한다. 클라이언트는 prefix를 붙여서 [/pub/comm/message]로 발행을 요청하면 MsgController가 해당 메시지를 받아서 처리하게 된다.

메시지가 발행되면 [/sub/comm/message/{roomId}]로 메시지를 send하게 되는데 클라이언트에서는 해당 주소를 Subscribe(구독) 하고 있다가 메시지가 전달되면 화면에 출력하면된다.

@RestController
@RequiredArgsConstructor
public class MsgController {

    private final SimpMessageSendingOperations sendingOperations;

    @MessageMapping("/comm/message")
    public void message(Message message) {
        if (Message.MessageType.ENTER.equals(message.getType())) {
            message.setMessage(message.getSender() + "이 입장했습니다.");
        }
        sendingOperations.convertAndSend("/sub/comm/room/" + message.getRoomId(), message);
    }
}

 

Subscriber 구현 - 프론트 영역

서버에서 할 부분이 아니다. 웹 뷰에서 stomp라이브러리를 사용해서 subscriber 주소를 바라보는 코드를 작성하면 된다.

[sub/comm/room/{roomId}]가 sub 주소이겠다.

 

MsgRoomController 구현

WebSocket 통신 시 필요한 구현과 채팅화면 View 구성을 위한 구현을 한다.

 

❗️현재는 View 구현도 하고 있지만 사실 실제 서비스에서는 실제 채팅이 아니라 클라이언트(유저)가 스펠을 체크하거나, 아이템을 체크하는 이벤트가 발생했을 때 소켓 통신이 발생하면서 동기화를 하는 것이 목적이므로 채팅방을 찾거나, 들어가거나, 보여줄 기능은 필요없다.

 

사용자가 자신의 소환사명을 입력하고 들어오는 순간 자동으로 자신의 endpoint를 기준으로 팀원들은 하나의 방에 자동으로 SUBSCRIBE가 될것이고 자신들이 발생시키는 이벤트들이 소켓통신을 하게 된다.

package teamc.opgg.swoomi.controller;

@Controller
@RequiredArgsConstructor
@RequestMapping("/comm")
public class MsgRoomController {

    private final MsgService msgService;

    @GetMapping("/room")
    public String room(Model model) {
        return "/comm/room";
    }

    @GetMapping("/rooms")
    public List<MsgRoom> rooms() {
        return msgService.findAllRoom();
    }

    @ApiOperation(value = "방 입장", notes = "room ID를 통해서 방에 입장합니다.")
    @GetMapping("/room/enter/{roomId}")
    public String roomEnter(
            Model model,
            @ApiParam(value = "방 ID", required = true)
            @PathVariable String roomId) {

        model.addAttribute("roomId", roomId);
        return "comm/roomEnter";
    }

    @ApiOperation(value = "방 조회", notes = "room ID를 통해서 방을 조회합니다.")
    @GetMapping("/room/{roomId}")
    public MsgRoom roomInfo(
            @ApiParam(value = "방 ID", required = true)
            @PathVariable String roomId) {
        
        return msgService.findById(roomId);
    }

    @PostMapping("/room")
    @ResponseBody
    public MsgRoom createRoom(@RequestParam String name) {
        // TODO : name is Summoner Name
        return msgService.createRoom(name);
    }
}

 


채팅 화면 (VIEW) 구현

여기는 https://daddyprogrammer.org/post/4691/spring-websocket-chatting-server-stomp-server/ 를 참고해서 구현하였다.

 

application.yml 수정

spring:
  freemarker:
    cache: false
    template-loader-path: classpath:/templates
    suffix: .ftlh

 

template.ftlh

[/src/main/resources]경로에 tempaltes디렉토리를 생성하고 template.ftlh 파일을 생성 후 다음과 같이 작성한다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="utf-8">
    <title>${title}</title>
</head>
<body>
<h1>${body}</h1>
</body>
</html>

 

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" @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.roomId}}
        </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 src="/webjars/bootstrap/4.3.1/dist/js/bootstrap.min.js"></script>
<script src="/webjars/sockjs-client/1.1.2/sockjs.min.js"></script>
<script>
    var vm = new Vue({
        el: '#app',
        data: {
            room_name: '',
            chatrooms: []
        },
        created() {
            this.findAllRoom();
        },
        methods: {
            findAllRoom: function () {
                axios.get('/comm/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('/comm/room', params)
                        .then(
                            response => {
                                alert(response.data.roomId + "방 개설에 성공하였습니다.")
                                this.room_name = '';
                                this.findAllRoom();
                            }
                        )
                        .catch(response => {
                            alert("채팅방 개설에 실패하였습니다.");
                        });
                }
            },
            enterRoom: function (roomId) {
                var sender = prompt('대화명을 입력해 주세요.');
                localStorage.setItem('wschat.sender', sender);
                localStorage.setItem('wschat.roomId', roomId);
                location.href = "/comm/room/enter/" + roomId;
            }
        }
    });
</script>
</body>
</html>

 

 

roomdeatil.ftl

채팅방으로 입장했을 때의 view.

입장 시 ws-swoomi로 서버 연결 후 채팅룸을 구독한다.

구독은 ws.subscribe("/sub/comm/room/"+vm.$.data.roomId, function(message){} ); 로 한다.

더보기

방 입장 시 구현 코드, 채팅을 보내면 sub 하는 모든 클라이언트에게 pub한다.

<!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.roomId}}</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-swoomi");
    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('/comm/room/'+this.roomId).then(response => { this.room = response.data; });
            },
            sendMessage: function() {
                ws.send("/pub/comm/message", {}, JSON.stringify({type:'COMM', 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/comm/room/"+vm.$data.roomId, function(message) {
                var recv = JSON.parse(message.body);
                vm.recvMessage(recv);
            });
            ws.send("/pub/comm/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-swoomi");
                    ws = Stomp.over(sock);
                    connect();
                },10*1000);
            }
        });
    }
    connect();
</script>
</body>
</html>

 

실제 구동 모습

방 생성

입장

방에 들어가면 알림이 모든 subscriber에게 뜨는 모습

채팅

왼쪽이 left , 오른쪽이 right

WebSocket + STOMP를 이용해서 양방향 통신이 가능한 채팅 서비스를 구현해 보았다. 이제 위 내용을 바탕으로 나의 서비스에 맞게 바꿔본다.
반응형
Comments
반응형
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday