๐[WEB] STOMP๋?/SpringBoot๋ก ์ฑํ ๊ตฌํ
STOMP
STOMP๋ Simple/Stream Text Oriented Message Protocol์ ์ฝ์๋ก, ๋ฉ์์ง ์ ์ก์ ํจ์จ์ ์ผ๋ก ํ๊ธฐ ์ํด ๋์จ ํ๋กํ ์ฝ์ด๋ค.
๊ธฐ๋ณธ์ ์ผ๋ก pub/sub ๊ตฌ์กฐ๋ก ๋์ด์์ด ๋ฉ์์ง๋ฅผ ๋ฐ์กํ๊ณ , ๋ฉ์์ง๋ฅผ ๋ฐ์ ์ฒ๋ฆฌํ๋ ๋ถ๋ถ์ด ํ์คํ ์ ํด์ ธ์๊ธฐ ๋๋ฌธ์ ๊ฐ๋ฐํ๋ ์
์ฅ์์ ๋ช
ํํ๊ฒ ์ธ์งํ๊ณ  ๊ฐ๋ฐํ  ์ ์๋ ์ด์ ์ด ์๋ค.
STOMP๋ ์น์์ผ(WebSocket) ์์์ ๋์ํ๋ฉฐ, http์ ๋ง์ฐฌ๊ฐ์ง๋ก frame์ ์ฌ์ฉํด ์ ์กํ๋ ํ๋กํ ์ฝ์ด๋ค. 
ํด๋ผ์ด์ธํธ์ ์๋ฒ๊ฐ ์ ์กํ  ๋ฉ์์ง์ ์ ํ, ํ์, ๋ด์ฉ๋ค์ ์ ์ํ๋ ๋งค์ปค๋์ฆ์ด๋ค.
๋ํ stomp๋ฅผ ์ด์ฉํ๋ฉด ํต์ ๋ฉ์์ง์ ํค๋์ ๊ฐ์ ์ธํ ํ ์ ์์ด ํค๋ ๊ฐ์ ๊ธฐ๋ฐ์ผ๋ก ํต์ ์ ์ธ์ฆ์ฒ๋ฆฌ๋ฅผ ๊ตฌํํ๋ ๊ฒ๋ ๊ฐ๋ฅํ๋ค๊ณ ํ๋ค.
STOMP๋ฅผ ๊ฐ๋จํ๊ฒ ์ดํดํด๋ณด์
- Simple Text Oriented Messaging Protocol
- ๋ฉ์์ง ๋ธ๋ก์ปค๋ฅผ ํ์ฉํด ์ฝ๊ฒ ๋ฉ์์ง๋ฅผ ์ฃผ๊ณ  ๋ฐ์ ์ ์๋ ํ๋กํ ์ฝ
    - Pub - Sub(๋ฐํ - ๊ตฌ๋ ): ๋ฐ์ ์๊ฐ ๋ฉ์์ง๋ฅผ ๋ฐํ -> ์์ ์๊ฐ ๊ทธ ๊ฒ์ ์์ ํ๋ ๋ฉ์์ง ํจ๋ฌ๋ค์
- ๋ฉ์์ง ๋ธ๋ก์ปค : ๋ฐ์ ์์ ๋ฉ์์ง๋ฅผ ๋ฐ์์์ ์์ ์๋ค์๊ฒ ๋ฉ์์ง๋ฅผ ์ ๋ฌํ๋ ์ด๋ค ๊ฒ
 
- ์น์์ผ ์์ ์น์ด ํจ๊ป ์ฌ์ฉํ ์ ์๋ ํ์(์๋ธ) ํ๋กํ ์ฝ
Frame?
Frame์ ๋ช
๋ น(Command)๊ณผ ์ถ๊ฐ์ ์ธ ํค๋(Header)์ ์ถ๊ฐ์ ์ธ ๋ฐ๋(Body)๋ก ๊ตฌ์ฑ๋๋ค.
Frame์ ๋ช ๊ฐ์ ํ
์คํธ ๋ผ์ธ์ผ๋ก ์ง์ ๋ ๊ตฌ์กฐ์ธ๋ฐ, ์ฒซ ๋ฒ์งธ ๋ผ์ธ์ ํ
์คํธ(Command ๋ช
๋ น์ด)๊ณ , ์ดํ key:value ํํ๋ก header ์ ๋ณด๋ฅผ ํฌํจํ๋ค.
header ์ดํ์ ๊ณต๋ฐฑ ์ค์ ํ๋ ๋ ์ถ๊ฐํ๋ ๊ฒ์ผ๋ก header์ ๋์ ์ค์ ํ  ์ ์๋ค.
header ์ดํ์๋ Payload(Body)๊ฐ ์กด์ฌํ๊ณ , ์ด๋ ์ ์ก๋๋ ๋ฐ์ดํฐ๋ฅผ ์๋ฏธํ๋ค.
[์์]
COMMAND 
header1:value1 
header2:value2 
Body^@
SpringBoot & STOMP๋ฅผ ์ด์ฉํ ์ฑํ ๊ตฌํ
build.gradle
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
spring:
  devtools:
    livereload:
      enabled: true
    restart:
      enabled: false
  freemarker:
    cache: false
application.properties
spring.devtools.livereload.enabled=true
spring.devtools.restart.enabled=false
spring.freemarker.cache=false
WebSockConfig ์์ 
Stomp๋ฅผ ์ฌ์ฉํ๊ธฐ ์ํด @EnableWebSocketMessageBroker๋ฅผ ์ ์ธํ๊ณ , WebSocketMessageBrokerConfigurer๋ฅผ ์์๋ฐ์ configureMessageBroker๋ฅผ ๊ตฌํํ๋ค.
๋ฉ์์ง๋ฅผ ๊ตฌ๋
ํ๋ ์์ฒญ์ prefix/sub๋ก, stomp websocket connection endpoint๋ /ws-stomp๋ก ์ค์ ํ๋ค.
์ฆ, ๊ฐ๋ฐ ์๋ฒ์ ์ ์ ์ฃผ์๋ ws://localhost:8080/ws-stomp ๊ฐ ๋๋ค.
@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();
    }
}
ChatRoom ์์  (DTO)
pub/sub ๋ฐฉ์์ ์ด์ฉํ๋ฉด ๊ตฌ๋
์ ๊ด๋ฆฌ๊ฐ ์์์ ๋๋ฏ๋ก, ์น์์ผ ์ธ์
 ๊ด๋ฆฌ๊ฐ ํ์ ์์ด์ง๋ค.
๋, ๋ฐ์ก์ ๊ตฌํ๋ ์์์ ํด๊ฒฐ๋๋ฏ๋ก ํด๋ผ์ด์ธํธ์๊ฒ ๋ฉ์์ง๋ฅผ ๋ฐ์กํ๋ ๊ตฌํ์ด ํ์ ์์ด์ง๋ค.
@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;
    }
}
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 ์์ 
@MessageMapping์ ํตํด ์น์์ผ์ผ๋ก ๋ค์ด์ค๋ ๋ฉ์์ง ๋ฐํ์ ์ฒ๋ฆฌํ๋ค.
ํด๋ผ์ด์ธํธ์์๋ prefix๋ฅผ ๋ถ์ฌ /pub/chat/message๋ก ๋ฐํ์ ์์ฒญํ๋ฉด ์ปจํธ๋กค๋ฌ๊ฐ ํด๋น ๋ฉ์์ง๋ฅผ ๋ฐ์ ์ฒ๋ฆฌํ๋ค.
๋ฉ์์ง๊ฐ ๋ฐํ๋๋ฉด /sub/chat/room/{roomId}๋ก ๋ฉ์์ง๋ฅผ sendํ๋ ๊ฒ์ ๋ณผ ์ ์๋๋ฐ, ํด๋ผ์ด์ธํธ์์๋ ํด๋น ์ฃผ์๋ฅผ (/sub/chat/room/{roomId}) ๊ตฌ๋
ํ๊ณ  ์๋ค๊ฐ ๋ฉ์์ง๊ฐ ์ ๋ฌ๋๋ฉด ํ๋ฉด์ ์ถ๋ ฅํ๋ค.
@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 ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ฌ์ฉํด ๊ตฌ๋
์ ์ฃผ์๋ฅผ ๋ฐ๋ผ๋ณด๊ณ  ์๋ ์ฝ๋๋ฅผ ์์ฑํ๋ฉด ๋๋ค.
ChatRoomController ์์ฑ
@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๋ฅผ ์์ฑํ๋ค.
/resources/templates/์ room.ftl, roomdetail.ftl ํ์ผ์ ์์ฑํ๋ค.
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)">
                
            </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.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></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">
                 - </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>
์ฐธ๊ณ ์๋ฃ
https://velog.io/@qkrqudcks7/STOMP%EB%9E%80
https://www.daddyprogrammer.org/post/4691/spring-websocket-chatting-server-stomp-server/
WebSocket ๊ณต์๋ฌธ์
[10๋ถ ํ
์ฝํก] โจ ์๋ก ์ แแ
ฐแธแแ
ฉแแ
ฆแบ&แแ
ณแแ
ณแ
แ
ตแผ
 
      
๋๊ธ๋จ๊ธฐ๊ธฐ