5 ๋ถ„ ์†Œ์š”

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๋ถ„ ํ…Œ์ฝ”ํ†ก] โœจ ์•„๋ก ์˜ แ„‹แ…ฐแ†ธแ„‰แ…ฉแ„แ…ฆแ†บ&แ„‰แ…ณแ„‘แ…ณแ„…แ…ตแ†ผ

ํƒœ๊ทธ: ,

์นดํ…Œ๊ณ ๋ฆฌ: ,

์—…๋ฐ์ดํŠธ:

๋Œ“๊ธ€๋‚จ๊ธฐ๊ธฐ