STOMP + Spring Boot

YoungHo-Cha·2022년 4월 26일
3

운동 매칭 시스템

목록 보기
12/17
  • 오늘은 STOMP에 대해서 공부를 해보자.

목차

  • Web Socket이란?
  • STOMP란?
  • STOMP in Spring Boot

Web Socket 이란?


STOMP란?


STOMP in Spring Boot

STOMP를 사용하기 위해서는 여러가지 설정이 필요하다.

먼저 설정하는 순서부터 살펴보자.

  1. Gradle 추가
    1. 여기서 WebSocket과 함께 STOMP 관련 라이브러리도 함께 받아와진다.
  2. Config 추가 및 설정
    1. Config에서 Soket 연결, SUBSCRIBE 연결 설정, PUBLISH 설정을 해주어야 한다.
  3. Message 컨트롤러 생성
    1. Config에서 설정해준 URI로 요청이 메세지 요청이 오면 해당 컨트롤러로 매핑이 된다.

하나씩 살펴보자.

Gradle 설정

plugins {
	id 'org.springframework.boot' version '2.6.3'
	id 'io.spring.dependency-management' version '1.0.11.RELEASE'
	id 'java'
}

group = 'com.sample'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	runtimeOnly 'mysql:mysql-connector-java'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'

	//websocket
	implementation 'org.springframework.boot:spring-boot-starter-websocket'
	implementation 'org.webjars:sockjs-client:1.1.2'
	implementation 'org.webjars:stomp-websocket:2.3.3-1'

	//view
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	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 'com.google.code.gson:gson:2.8.0'
}

tasks.named('test') {
	useJUnitPlatform()
}

핵심 적인 부분만 살펴보자.

  • websocket

해당 부분에서 socket, sockJS, stomp 관련 라이브러리를 다운받는다.

Config 설정

Config를 생성하여, Socket과 STOMP관련 설정을 해주어야 한다.

@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class ChatConfig implements WebSocketMessageBrokerConfigurer {

    private final StompHandler stompHandler;
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws/chat").setAllowedOriginPatterns("*").withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {

        registry.enableSimpleBroker("/queue", "/topic");

        registry.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(stompHandler);
    }
}

하나씩 살펴보자.

registerStompEndPoints

해당 메소드는 소켓 연결과 관련된 설정이다.

  1. addEndpoint() : 소켓 연결 uri이다. 소켓을 연결할 때에는 다음과 같은 통신이 이루어진다.

스크린샷 2022-04-26 오후 6.29.31.png

CONNECT : 연결 요청을 거는 과정이다.

CONNECTED : 연결 성공

ERROR : 연결 실패

  1. setAllowedOriginPatterns(”*”) : 소켓 또한 CORS 설정을 해주어야 한다.
  2. withSockJS() : 소켓을 지원하지 않는 브라우저라면, sockJS를 사용하도록 설정

configureMessageBroker

해당 메소드는 Stomp 사용을 위한 Message Broker 설정을 해주는 메소드이다.

  1. enableSimpleBroker(”/queue”, “/topic”) :
    1. 메세지를 받을 때, 경로를 설정해주는 함수이다.
    2. 스프링에서 제공해주는 내장 브로커를 사용하는 함수이다. 또한 “/queue”, “/topic”을 통해 1:1, 1:N 설정을 해준다.
    3. “/queue”, “/topic”가 api에 prefix로 붙은 경우, messageBroker가 해당 경로를 가로챈다.
  2. setApplicationDestinationPrefixes(”/app”) :
    1. 메세지를 보낼 때, 관련 경로를 설정해주는 함수이다.
    2. 클라이언트가 메세지를 보낼 떄, 경로 앞에 “/app”이 붙어있으면 Broker로 보내진다.

ChatRoomController 생성

먼저 방을 만들고, 입장해야 하니 해당하는 컨트롤러를 생성하자.

@Controller
@RequiredArgsConstructor
@RequestMapping("/chat")
public class ChatRoomController {
    private final ChatService chatService;

    // 채팅 리스트 화면
    @GetMapping("/room")
    public String rooms(Model model) {
        return "/chat/room";
    }
    // 모든 채팅방 목록 반환
    @GetMapping("/rooms")
    @ResponseBody
    public List<ChatRoom> room() {
        return chatService.findAllRoom();
    }
    // 채팅방 생성
    @PostMapping("/room")
    @ResponseBody
    public ChatRoom createRoom(@RequestParam String name) {
        return chatService.createRoom(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 chatService.findById(roomId);
    }
}

Message Controller 생성

@RestController
@RequiredArgsConstructor
public class MessageController {

    private final SimpMessageSendingOperations sendingOperations;

    @MessageMapping("/chat/message")
    public void enter(ChatMessage message) {
        if (ChatMessage.MessageType.ENTER.equals(message.getType())) {
            message.setMessage(message.getSender()+"님이 입장하였습니다.");
        }
        sendingOperations.convertAndSend("/topic/chat/room/"+message.getRoomId(),message);
    }
}

여기서 살펴보면, 매핑이

  • “/chat/message”

라고 적혀있는 것을 볼 수 있다.

아까 Config에서 설정하는 것과는 전혀 다르다. 이유는

setApplicationDestinationPrefixes()를 통해 prefix를 "/app"으로 설정 해주었기 때문에, 경로가 한번 더 수정되어 “/app/chat/message”로 바뀐다.

  • convertAndSend : “/topic”을 Config에서 설정해주었다. 그래서 Message Broker가 해당 send를 캐치하고 해당 토픽을 구독하는 모든 사람에게 메세지를 보내게 된다.

Chat Service 생성

사실 상 앞에서 했던 작업은 api 매핑 정도이고, 채팅 관련 모든 작업은 여기서 수행된다.

@Service
@RequiredArgsConstructor
public class ChatService {

    private Map<String, ChatRoom> chatRooms;

    @PostConstruct
    //의존관게 주입완료되면 실행되는 코드
    private void init() {
        chatRooms = new LinkedHashMap<>();
    }

    //채팅방 불러오기
    public List<ChatRoom> findAllRoom() {
        //채팅방 최근 생성 순으로 반환
        List<ChatRoom> result = new ArrayList<>(chatRooms.values());
        Collections.reverse(result);

        return result;
    }

    //채팅방 하나 불러오기
    public ChatRoom findById(String roomId) {
        return chatRooms.get(roomId);
    }

    //채팅방 생성
    public ChatRoom createRoom(String name) {
        ChatRoom chatRoom = ChatRoom.create(name);
        chatRooms.put(chatRoom.getRoomId(), chatRoom);
        return chatRoom;
    }
}

Stomp Handler 생성

메세지를 보냄에 있어서, 사전 작업이 필요할 가능성이 존재한다.

@RequiredArgsConstructor
@Component
public class StompHandler implements ChannelInterceptor {

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        log.info("Stomp Hanler 실행");
        return message;
    }
}

이렇게 실행하고, 메세지 1개를 보낸다음 로그를 살펴보면

스크린샷 2022-04-26 오후 6.55.44.png

로그를 보면 handler가 먼저 실행된다. handler가 통과하면 controller로 넘어가서 메세지 매핑이 되는 것을 볼 수 있다.

그럼 JWT와 함께 쓴다는 가정을 보면 다음과 같이 수정할 수 있을 것이다.

@RequiredArgsConstructor
@Component
public class StompHandler implements ChannelInterceptor {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
        if(accessor.getCommand() == StompCommand.CONNECT) {
            if(!jwtTokenProvider.validateToken(accessor.getFirstNativeHeader("token")))
                try {
                    throw new AccessDeniedException("");
                } catch (AccessDeniedException e) {
                    e.printStackTrace();
                }
        }
        return message;
    }
}

현재는 JWT 관련 설정과 클래스가 존재하지 않으니 로그만 1개 뜨도록 해두자..

View

view는 간단하게 보자.

room.html 생성

<!doctype html>
<html lang="en" xmlns:v-on="http://www.w3.org/1999/xhtml" xmlns:v-bind="http://www.w3.org/1999/xhtml">
<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.roomName}}
        </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.roomName+"방 개설에 성공하였습니다.")
                                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>

Roomdetails.html 생성

해당 파일은 채팅방에 들어갔을 때, 실행되는 파일이다.

<!doctype html>
<html lang="en" xmlns:v-on="http://www.w3.org/1999/xhtml">
<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>
    <button onclick="location.href=`/chat/room`">돌아가기</button>
</div>
<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}}
        </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/chat");
    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("/app/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("/topic/chat/room/"+vm.$data.roomId, function(message) {
                var recv = JSON.parse(message.body);
                vm.recvMessage(recv);
            });
            ws.send("/app/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/chat");
                    ws = Stomp.over(sock);
                    connect();
                },10*1000);
            }
        });
    }
    connect();
</script>
</body>
</html>
  • var sock = new SockJS("/ws/chat") : 소켓 연결하는 부분이다. 스프링 부트에서도 해당 uri로 설정을 해주었다.
  • ws.subscribe() : STOMP SUBSCRIBE에 해당하는 부분이다. 파라미터로 넘어간 값으로 해당 부분으로 구독한다.
  • ws.send() : STOMP PUBLISH에 해당하는 부분이다. 파라미터로 넘어간 값으로 메세지를 보낸다.

실패에 대한 상황을 만들지 않았다. 나중에 추가하도록 하자!


To Do

  1. JWT 인증 처리
    1. 인증 실패 시, socket 연결을 하지 않기
  2. SUB, PUB 관련 에러 처리

Reference

profile
관심많은 영호입니다. 궁금한 거 있으시면 다음 익명 카톡으로 말씀해주시면 가능한 도와드리겠습니다! https://open.kakao.com/o/sE6T84kf

0개의 댓글