WebSocket 채팅 (3) - Stomp 적용

보트·2023년 9월 21일
0

채팅

목록 보기
3/7

로컬 설정

프로젝트 환경

  • stomp 추가
  • sockjs : websocket을 지원하지 않는 낮은 버전의 브라우저에서도 websocket 사용할 수 있도록
  • webjar : 채팅 웹 화면 구현 관련 js 로드
  • freemarker, vue.js : 프론트 웹 개발
    • build.gradle
      	implementation 'org.springframework.boot:spring-boot-starter-freemarker'
      	implementation 'org.springframework.boot:spring-boot-devtools'
      	// 채팅 웹 화면 구현 js
      	implementation 'org.webjars.bower:bootstrap:4.3.1'
      	implementation 'org.webjars.bower:vue:2.5.16'
      	implementation 'org.webjars.bower:axios:0.17.1'
      	// sockjs
      	implementation 'org.webjars:sockjs-client:1.5.1'
      	// stomp
      	implementation 'org.webjars:stomp-websocket:2.3.4'
      	// gson
      	implementation 'com.google.code.gson:gson:2.9.0'
  • static 파일 개발할 때 서버를 재시작 하지 않고 수정한 내용이 반영되도록
    • application.properties
      spring.devtools.livereload.enabled=true
      spring.devtools.restart.enabled=false
      spring.freemarker.cache=false

기능

Stomp

  • WebSocketConfig.java
    • Stomp를 사용

      • **@EnableWebSocketMessageBroker** 선언
      • **WebSocketMessageBrokerConfigurer** 상속
    • pub/ sub 구현

      • 메세지 발행 요청 prefix → /pub
      • 메세지 구독 요청 prefix → /sub
    • Stomp websocket 의 endpoint → /ws-stomp
      - 접속 주소 : ws://localhost:8080/ws-stomp

      @Slf4j(topic = "WebSocketChatHandler")
      @Component
      public class WebSocketChatHandler extends TextWebSocketHandler {
      
          @Override
          protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
              String payload = message.getPayload();
              log.info("payload {}", payload);
              TextMessage textMessage = new TextMessage("입장하셨습니다.");
              session.sendMessage(textMessage);
          }
      }

Chat

  • 간단하게 ChatRoom을 Map으로 관리하도록 구현 → 서비스에서는 DB 혹은 다른 저장 매체에 저장하도록 구현
    • ChatRoomRepository.java
      • ChatService → ChatRoomRepository

        @Repository
        public class ChatRoomRepository {
        
            private Map<String, ChatRoom> chatRoomMap;
        
            @PostConstruct
            private void init() {
                chatRoomMap = new LinkedHashMap<>();
            }
        
            public List<ChatRoom> findAllRoom() {
                // 채팅방 생성 순서 최신 순으로 반환
                List<ChatRoom> chatRooms = new ArrayList<>(chatRoomMap.values());
                Collections.reverse(chatRooms);
                return chatRooms;
            }
        
            public ChatRoom findRoomById(String id) {
                return chatRoomMap.get(id);
            }
        
            public ChatRoom creaetChatRoom(String name) {
                ChatRoom chatRoom = ChatRoom.create(name);
                chatRoomMap.put(chatRoom.getRoomId(), chatRoom);
                return chatRoom;
            }
        }
  • **@MessageMapping** 을 통해 Websocket으로 들어오는 메세지 발행 처리
    • ChatController.java
      • WebSocketChatHandler → ChatController

        /*
            publisher 구현
         */
        @RequiredArgsConstructor
        @RestController
        @RequestMapping("/chat")
        @Slf4j(topic = "채팅방 생성/ 조회")
        public class ChatController {
        
            private final SimpMessageSendingOperations messagingTemplate;
        
            /*
                @MessageMapping -> websocket 으로 들어오는 메세지 발행 처리
             */
            @MessageMapping("/chat/message")
            public void message(ChatMessage message) {
                // 1. 클라이언트 - prefix 붙여 "/pub/chat/message"로 발행 요청
        
                // 입장 메세지일 경우
                if(ChatMessage.MessageType.ENTER.equals(message.getType())){
                    message.setMessage(message.getSender() + "님이 입장하셨습니다.");
                }
        
                // 2. "/sub/chat/room/{roomId}"로 메세지 발송
                // 클라이언트 : "/sub/chat/room/{roomId}" 를 구독하고 있다가 메세지가 전달되면 화면에 출력
                messagingTemplate.convertAndSend("/sub/chat/room/" + message.getRoomId(), message);
            }
        }
  • 채팅방 관련 controller 생성
    • ChatRoomController.java
      @RequiredArgsConstructor
      @Controller
      @RequestMapping("/chat")
      public class ChatRoomController {
      
          private final ChatRoomRepository chatRoomRepository;
      
          // 채팅 리스트 화면
          @GetMapping("/room")
          public String rooms(Model model) {
              return "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 "roomdetail";
          }
      
          // 특정 채팅방 조회
          @GetMapping("/room/{roomId}")
          @ResponseBody
          public ChatRoom roomInfo(@PathVariable String roomId) {
              return chatRoomRepository.findRoomById(roomId);
          }
      }

프론트

  • 프론트 코드는 샘플 코드 그대로 가져옴
  • room.html
    <!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>
  • roomdetail.html
    <!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">
    
        <!-- 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.5.1/sockjs.min.js"></script>
        <script src="/webjars/stomp-websocket/2.3.4/stomp.min.js"></script>
    
        <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>
    
    <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>

테스트

뷰 페이지

  • 두 개의 브라우저로 테스트 성공

(참고 : https://www.daddyprogrammer.org/post/4691/spring-websocket-chatting-server-stomp-server/)

profile
일주일에 한 번

0개의 댓글