본문 바로가기
공부/Spring

[Spring][WebSocket] 스프링 STOMP와 웹 소켓 개념 및 사용법 (Web Socket with STOMP) (1)

by 웅대 2023. 4. 20.
728x90
반응형

클라이언트와 서버가 통신할 때 HTTP 통신을 주로 사용한다.

 

HTTP 통신은 다음과 같은 특징이 있다.

 

  1. 비연결성 (connectionless) : 연결을 맺고 요청을 하고 응답을 받으면 연결을 끊어버린다.
  2. 무상태성 (stateless) : 서버가 클라이언트의 상태를 가지고 있지 않는다.
  3. 단방향 통신이다.

이러한 HTTP 통신의 경우 채팅과 같은 실시간 통신에 적합하지 않다.

 

물론 HTTP 통신으로 실시간 통신을 흉내낼 수는 있으나 완벽하지는 않다.

 

실시간 통신이 필요할 때 사용하는 통신을 소켓 통신이라고 한다.

 

HTTP 통신과 다르게 연결을 맺고 바로 끊어버리는 게 아니라 계속 유지를 하기 때문에 실시간 통신에 적합하다.

 

이제 스프링에서 간단하게 웹 소켓으로 통신을 해보려고 한다.

 

구현에 앞서 STOMP 프로토콜에 대해서 이해를 해야한다.

 

STOMP

STOMP는 Simple Text Oriented Messaging Protocol의 약자이다.

 

간단한 메시지를 전송하기 위한 프로토콜로 메시지 브로커를와 publisher - subscriber 방식을 사용한다.

 

메시지의 발행자와 구독자가 존재하고 메시지를 보내는 사람과 받는 사람이 구분되어 있다.

 

메시지 브로커는 발행자가 보낸 메시지를 구독자에게 전달해주는 역할을 한다.

 

STOMP는 HTTP와 비슷하게 frame 기반 프로토콜 command, header, body로 이루어져 있다.

 

<STOMP frame 구조>

COMMAND
header1:value1
header2:value2
Body^@

 

 

 

웹 소켓과 STOMP를 함께 사용하면 frame의 구조가 정해져있기 때문에 통신에 용이하다.

 

다음은 STOMP를 사용할 때  통신 과정이다.

https://technicalsand.com/spring-server-sent-events/

수신자는 /topic 경로를 구독하고 있고 발행자는 /app 혹은 /topic으로 메시지를 보내는 모습을 확인할 수 있다.

 

만약 발행자가 /topic 경로로 메시지를 보내면 바로 수신자에게 도착하고 /app 경로로 메시지를 보내면 가공을 한 다음 보내게 된다.

 

스프링 웹 소켓 구현

아래 경로에 있는 코드를 사용했다.

https://spring.io/guides/gs/messaging-stomp-websocket/

 

Spring | Home

Cloud Your code, any cloud—we’ve got you covered. Connect and scale your services, whatever your platform.

spring.io

 

start.spring.io에서 websocket와 lombok 종속성을 추가한 뒤 생성한다.

 

build.gradle의 dependencies 영역에 다음과 같은 코드를 추가한다. 자바스크립트에서 사용하기 위한 파일들이다.

 

<build.gradle>

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-websocket'
	implementation 'org.webjars:webjars-locator-core'
	implementation 'org.webjars:sockjs-client:1.0.2'
	implementation 'org.webjars:stomp-websocket:2.3.3'
	implementation 'org.webjars:bootstrap:3.3.7'
	implementation 'org.webjars:jquery:3.1.1-1'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

src/main/resources/static 경로의 index.html 파일에 아래 코드를 입력한다.

 

<index.html>

<!DOCTYPE html>
<html>
<head>
    <title>Hello WebSocket</title>
    <link href="/webjars/bootstrap/css/bootstrap.min.css" rel="stylesheet">
    <link href="/main.css" rel="stylesheet">
    <script src="/webjars/jquery/jquery.min.js"></script>
    <script src="/webjars/sockjs-client/sockjs.min.js"></script>
    <script src="/webjars/stomp-websocket/stomp.min.js"></script>
    <script src="/app.js"></script>
</head>
<body>
<noscript><h2 style="color: #ff0000">Seems your browser doesn't support Javascript! Websocket relies on Javascript being
    enabled. Please enable
    Javascript and reload this page!</h2></noscript>
<div id="main-content" class="container">
    <div class="row">
        <div class="col-md-6">
            <form class="form-inline">
                <div class="form-group">
                    <label for="connect">WebSocket connection:</label>
                    <button id="connect" class="btn btn-default" type="submit">Connect</button>
                    <button id="disconnect" class="btn btn-default" type="submit" disabled="disabled">Disconnect
                    </button>
                </div>
            </form>
        </div>
        <div class="col-md-6">
            <form class="form-inline">
                <div class="form-group">
                    <label for="name">What is your name?</label>
                    <input type="text" id="name" class="form-control" placeholder="Your name here...">
                </div>
                <button id="send" class="btn btn-default" type="submit">Send</button>
            </form>
        </div>
    </div>
    <div class="row">
        <div class="col-md-12">
            <table id="conversation" class="table table-striped">
                <thead>
                <tr>
                    <th>Greetings</th>
                </tr>
                </thead>
                <tbody id="greetings">
                </tbody>
            </table>
        </div>
    </div>
</div>
</body>
</html>

동일 경로에 app.js 파일을 추가한다.

 

<app.js>

var stompClient = null;

function setConnected(connected) {
    $("#connect").prop("disabled", connected);
    $("#disconnect").prop("disabled", !connected);
    if (connected) {
        $("#conversation").show();
    }
    else {
        $("#conversation").hide();
    }
    $("#greetings").html("");
}

function connect() {
    var socket = new SockJS('/gs-guide-websocket');
    stompClient = Stomp.over(socket);
    stompClient.connect({}, function (frame) {
        setConnected(true);
        console.log('Connected: ' + frame);
        stompClient.subscribe('/topic/greetings', function (greeting) {
            showGreeting(JSON.parse(greeting.body).content);
        });
    });
}

function disconnect() {
    if (stompClient !== null) {
        stompClient.disconnect();
    }
    setConnected(false);
    console.log("Disconnected");
}

function sendName() {
    stompClient.send("/app/hello", {}, JSON.stringify({'name': $("#name").val()}));
}

function showGreeting(message) {
    $("#greetings").append("<tr><td>" + message + "</td></tr>");
}

$(function () {
    $("form").on('submit', function (e) {
        e.preventDefault();
    });
    $( "#connect" ).click(function() { connect(); });
    $( "#disconnect" ).click(function() { disconnect(); });
    $( "#send" ).click(function() { sendName(); });
});

이 포스팅의 목적은 스프링 서버 측에서 웹 소켓을 구현하는 방식에 대해 알아보는 것이기 때문에 html, js 파일에 대한 설명은 생략하려고 한다.

 

이제 자바 코드 차례이다.

 

이름에 관한 데이터를 전달하기 위한 객체이다.

 

<HelloMessage.java>

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class HelloMessage {
    private String name;
}

 

다음은 메시지 내용에 관한 데이터를 전달하기 위한 객체이다.

 

<Greeting.java>

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Greeting {
    private String content;
}

이제 컨트롤러를 만들 차례이다.

 

스프링의 @RequestMapping 어노테이션과 상당히 유사하다.

 

@Controller
public class GreetingController {
    @MessageMapping("/hello")
    @SendTo("/topic/greetings")
    public Greeting greeting(HelloMessage message) throws Exception {
        Thread.sleep(1000); // simulated delay
        return new Greeting("Hello, " + HtmlUtils.htmlEscape(message.getName()) + "!");
    }
}

"/hello" 경로로 메시지가 날아오면 greeting 메소드가 실행되어 Greeting 객체가 반환된다.

 

이 Greeting 객체는 @SendTo 어노테이션에 매핑되어있는 "/topic/greeings"를 구독하고 있는 모든 구독자들에게 메시지를 전달한다.

 

이제 설정 파일을 만들 차례이다.

반응형

<WebSocketConfig.java>

@Configuration
@EnableWebSocketMessageBroker //웹 소켓 메시지를 다룰 수 있게 허용
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic"); //발행자가 "/topic"의 경로로 메시지를 주면 구독자들에게 전달
        config.setApplicationDestinationPrefixes("/app"); // 발행자가 "/app"의 경로로 메시지를 주면 가공을 해서 구독자들에게 전달
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/gs-guide-websocket").withSockJS(); // 커넥션을 맺는 경로 설정. 만약 WebSocket을 사용할 수 없는 브라우저라면 다른 방식을 사용하도록 설정
    }

}

여기서 enableSimpleBroker와 setApplicationDestinationPrefixes의 정확한 역할이 잘 이해가 되지 않았다.

 

이를 이해하기 위해서는 발행자, 구독자, 메시지 브로커 사이 통신 과정을 알아야한다.

 

아래 그림을 보면 어렵지 않게 이해할 수 있다.

 

메시지 브로커

 

https://www.cloudamqp.com/blog/part4-rabbitmq-for-beginners-exchanges-routing-keys-bindings.html

구독자는 특정 Topic을 구독하고 발행자는 해당 Topic으로 메시지를 날린다.

 

메시지 브로커는 이 메시지를 관리하여 이를 구독 중인 구독자들에게 메시지를 보내준다.

 

메시지를 바로 전달하는 것이 아니라 중간에 존재하는 메시지 브로커에게 전달하고 이 브로커가 구독자들에게 전달해주는 형태이다.

 

다시 configureMessageBroker 메소드를 보자.

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic"); //발행자가 "/topic"의 경로로 메시지를 주면 구독자들에게 전달
        config.setApplicationDestinationPrefixes("/app"); // 발행자가 "/app"의 경로로 메시지를 주면 가공을 해서 구독자들에게 전달
    }

 

enableSimpleBroker

스프링 다큐먼트에서 enableSimpleBroker의 설명을 보면

Enable a simple message broker and configure one or more prefixes to filter destinations targeting the broker (e.g.
  1. simple message broker를 활성화한다.
  2. 브로커를 타겟으로하는 하나 이상의 접두사를 구성한다.

우리는 "/topic" 접두사가 붙은 경로는 브로커를 타겟으로 한다는 설정을 한 것이다.

 

즉 "/topic/..." 경로로 메시지를 보내면 이 메시지는 브로커로 향하게 되고
브로커는 이 경로를 구독 중인 구독자들에게 메시지를 발송한다.

 

setApplicationDestinationPrefixes

스프링 다큐먼트에서 setApplicationDestinationPrefixes의 설명을 보면

Configure one or more prefixes to filter destinations targeting application annotated methods.
 
applicatioin annotated method를 타겟으로하는 하나 이상의 접두사를 구성한다.

 

우리는 "/app" 접두사가 붙은 경로는 @MessageMapping 어노테이션이 붙은 곳을 타겟으로 한다는 설정을 한 것이다.

 

즉 "/app/..." 경로로 메시지를 보내면
이 메시지는 @MessageMapping 어노테이션이 붙은 곳으로 향하게 된다.

@MessageMapping으로 이동하게 되면 그 곳에서 이 메시지를 가공할 수 있게 된다.

 

또한 @SendTo 어노테이션을 사용해서 이 메시지가 향할 곳을 정할 수 있다.

 

이 @SendTo 어노테이션으로 메시지가 향하는 곳을 "/topic/..."으로 지정하면 어떻게 될까?

 

enableSimpleBroker 설정에서 "/topic"으로 시작하는 경로는 메시지 브로커를 향하도록 설정했다.

 

즉 메시지 브로커로 향하게 되고 메시지 브로커는 이 경로를 구독하는 구독자들에게 메시지를 전송하는 것이다.

 

두 설정 모두 prefix, 즉 접두사를 등록하는 것이라고 보면 된다.

 

마지막으로 이번 포스팅의 모드에서 구독 및 발행자가 메시지를 전송하는 과정을 정리해보자.

 

  1. "/topic"으로 시작하는 경로는 메시지 브로커를 향하도록 설정한다.
  2. "/app"으로 시작하는 경로는 @MessageMapping을 향하도록 설정한다.
  3. 구독자는 "/topic/greetings"으로 시작하는 경로를 구독한다. (1번 설정 때문에)
  4. 발행자는 "/app/hello"로 시작하는 경로로 메시지를 보낸다.
  5. 2번 설정 때문에 @MessageMapping("/hello")가 붙어있는 곳으로 메시지가 간다.
  6. 메시지 가공이 끝난 후 ("/topic/greetings")로 보낸다.
  7. 1번 설정 때문에 이 메시지는 메시지 브로커로 가게 된다.
  8. 메시지 브로커에서 "/topic/greetings"를 구독 중인 구독자들에게 메시지를 전송한다.

결과

 

 

실시간으로 메시지가 올라오는 것을 확인할 수 있다.

 

탭을 하나 더 열어서 접속해보면 다른 탭에서 보낸 이름이 보이는 것도 확인할 수 있다.

 

<참고>

https://www.youtube.com/watch?v=rvss-_t6gzg 

https://spring.io/guides/gs/messaging-stomp-websocket/

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/messaging/simp/config/MessageBrokerRegistry.html

https://tsh.io/blog/message-broker/

728x90
반응형

댓글