ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Spring Websocket 이론과 간단한 구현
    프로젝트/WebSocket 2023. 6. 11. 00:01
    728x90

    개요

    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

     

    QR 로그인을 구현하기 전, 브라우저와 서버 간 메시지를 주고받는 웹소켓의 Hello, World 애플리케이션을 만드는 과정을 수행해 봅니다.

    웹소켓은 TCP 위에 있는 가벼운 계층이며 WebSocket 위에서 동작하는 STOMP라는 프로토콜을 활용합니다.

     

    WebSocket이란?

    HTTP프로토콜과 호환되며, 실시간 양뱡향 통신을 제공하기 위한 프로토콜입니다.

    웹 초창기의 전형적인 브라우저 렌더링 방식은 HTTP 요청에 대한 응답을 받아 브라우저의 화면을 깨끗하게 지우고 받은 내용을 새로 표기했습니다.

     

    하지만 브라우저의 깜빡임 없이 원하는 부분만 그리는 사용자와 상호작용하는 방식이 나타나고 이를 위해 Long Polling, Stream 등 다양한 방법을 사용했습니다.

    위의 방식은 단방향 메시지 교환 규칙을 변경하지 않았으며 이로 인해 복잡하고 어려운 코드를 구현해야 했습니다.

     

    보다 쉽게 상호작용하는 웹 페이지를 만들기 위해 브라우저와 웹 서버 사이의 자유로운 양방향 메시지 송수신이 필요했고, HTML5 표준안의 일부로 WebSocket API가 등장하게 되었습니다.

     

    RFC 6455 규약을 따르며 HTTP와는 다른 TCP 통신이지만 포트 80 및 443을 사용해 HTTP를 통해 작동하고, 기존 방화벽 규칙을 재사용할 수 있도록 설계되었습니다.

     

    보통 채팅, 주식 등 실시간 통신이 중요한 곳에서 많이 활용됩니다.

     

    WebSocket의 동작원리

    https://kellis.tistory.com/65

    붉은 박스: Opening Handshake

    노란 박스: Data transfer

    보라 박스: Closing Handshake

     

    Opening Handshake에서는 웹소켓 클라이언트에서 핸드셰이크 요청 (HTTP Upgrade)을 전송하고 이에 대한 응답으로 101을 받습니다.

    GET /chat HTTP/1.1
    Host: server.example.com
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
    Sec-WebSocket-Protocol: chat, superchat
    Sec-WebSocket-Version: 13
    Origin: http://example.com

     

    HTTP 101 response는 프로토콜 전환을 서버가 승인했을 알리는 코드입니다.

    HTTP/1.1 101 Switching Protocols
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
    Sec-WebSocket-Protocol: chat

     

    Opening Handshake가 끝나면 ws(or wss)로 프로토콜을 변환하여 커넥션을 맺습니다.

    서버와 클라이언트는 서로가 살아 있는지 확인하기 위해 heartbeat 패킷을 보내며, 주기적으로 ping을 보내 체크합니다.

     

    Closing Handshake 단계에서는 클라이언트와 서버가 모두 커넥션을 종료하기 위해 컨트롤 프레임을 전송할 수 있습니다.

    연결 종료 이후에 수신되는 모든 추가적인 데이터는 버려집니다.

     

    HTTP와 호환 가능하도록 설계되었고, HTTP 요청으로 시작하지만 두 프로토콜의 아키텍처와 애플리케이션 프로그래밍 모델은 매우 다릅니다.

    HTTP와 REST에서는 HTTL URL, 메서드, 헤더를 기반으로 요청을 적절한 핸들러로 라우팅 합니다.

    반면 WebScoekt에는 일반적으로 초기 연결을 위한 URL이 하나 존재하고, 모든 애플리케이션 내 메시지는 동일한 TCP 연결을 통해 흐릅니다.

     

    STMOP란?

    STMOP란 Simple Text Oriented Messaging Protocol의 약자입니다.

    WebSocket 프로토콜은 Text 또는 Binary 두 가지 유형의 메시지 타입은 정의하지만 메시지의 내용에 대해서는 정의하지 않습니다.

     

    즉, WebSocket만 활용하면 해당 메시지가 어떤 요청인지, 어떤 포맷으로 오는지, 메시지 통신 과정을 어떻게 처리해야 하는지 일일이 구현해야 합니다.

     

    이때 STMOP란 서브 프로토콜을 사용하면 클라이언트와 서버가 통신할 때 메시지의 형식, 유형, 내용 등의 정의해 줍니다.

    Spring은 spring-websocket 모듈을 통해서 STOMP를 제공합니다.

     

    기본적으로 Publish-Subscribe 구조를 따르며 frame을 사용해 전송하는 프로토콜로 명령(command)과 헤더(header) 바디(body)로 구성됩니다.

    COMMAND 
    header1:value1 
    header2:value2 
    
    Body^@

     

    예를 들어 한 client가 하나의 채팅방에 대해 구독했을 경우입니다.

    >>> SUBSCRIBE
    id:sub-0
    destination:/sub/chat/room/07905aff-a14a-4162-b065-14418519c9d5

     

    예를 들어 한 client가 구독한 채팅방에서 채팅 메시지를 보내는 경우입니다.

    >>> SEND
    destination:/pub/chat/message
    content-length:104
    
    {"chatRoomNo":"07905aff-a14a-4162-b065-14418519c9d5","chatMessage":"예시 메세지","chatWriter":"메시지 작성자"}

     

    Stmop 서버는 모든 구독자에게 메시지를 BroadCasting 하기 위해 Message Command를 활용합니다.

    <<< MESSAGE
    destination:/sub/chat/room/07905aff-a14a-4162-b065-14418519c9d5
    content-type:application/json
    subscription:sub-0
    message-id:w5xbq2x5-0
    content-length:115
    
    {"chatRoomNo":"07905aff-a14a-4162-b065-14418519c9d5","chatWriter":"메시지 작성자","chatMessage":"예시 메시지"}

     

    Message Broker

    https://velog.io/@ohjinseo/WebSocket-Spring-Boot-stomp-Redis-PubSub-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%B1%84%ED%8C%85-%EA%B5%AC%ED%98%84

    STMOP는 Rabbit MQ, Active MQ 등을 사용하여 Pub/Sub 서비스를 이용할 수 있습니다.

    기본적으로 In Memory Broker를 사용할 수 있지만 다음과 같은 단점이 있습니다.

    • 세션을 수용할 수 있는 크기가 제한되어 있다.
    • 장애 발생 시 메시지의 유실 가능성이 높다
    • 따로 모니터링하는 것이 불편하다.
    • 서버가 여러대 떠있다면 Message Broker끼리 동기화가 되지 않는다.

     

    WebSocket 구현시 필요 사항

    • 15분
    • IDE
    • Java 17 이상
    • Garadle 7.5 이상 또는 Maven 3.5 이상

     

    Git Clone

    git clone https://github.com/spring-guides/gs-messaging-stomp-websocket.git

    또는 spring initializr에서 따로 생성하셔도 됩니다.

    gs-messaging-stomp-websocket을 clone 받으면 추후에 /initial 디렉터리에서 시작하여 /complete 디렉터리의 완성본 코드와 비교해 볼 수 있습니다.

     

    저는 clone 받아서 진행해 보겠습니다.

     

    Gradle Dependencies

    dependencies {
        implementation("org.springframework.boot:spring-boot-starter-websocket")
        testImplementation("org.springframework.boot:spring-boot-starter-test")
    }

    기본적으로 간단하게 spring-boot-starter-websocket만 존재합니다.

     

    Add Dependencies

      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")

     

    HelloMessage

    data class HelloMessage(
      val name: String,
    )

     

    클라이언트가 이름을 전송하면 해당 이름을 포함하여 인사말을 나타내기 위한 클래스입니다.

    예를 들어 Fred를 클라이언트에게 받으면 "Hello, Fred!"가 전송됩니다.

     

    Greeting

    data class Greeting(
      val content: String,
    )

     

    Message-handling Controller

    import org.springframework.messaging.handler.annotation.MessageMapping
    import org.springframework.messaging.handler.annotation.SendTo
    import org.springframework.stereotype.Controller
    import org.springframework.web.util.HtmlUtils
    import java.lang.Thread.sleep
    
    @Controller
    class GreetingController {
    
      @MessageMapping("/hello")
      @SendTo("/topic/greetings")
      fun greeting(message: HelloMessage): Greeting {
        //service 호출
        sleep(1000)
        return Greeting("Hello, ${HtmlUtils.htmlEscape(message.name)} !")
      }
    
    }

    @MessageMapping의 경우에는 /hello 대상으로 전송될 경우 greeting() 메서드가 호출되도록 합니다.

    sleep(1000)의 경우에는 내부적으로 서버가 메시지를 처리하는데 시간이 걸릴 수 있음을 보여주기 위함입니다.

    이후 return을 통해 반환되는 값은 @SendTo 어노테이션에 지정된 /topic/greetings의 모든 구독자에게 브로드캐스트 됩니다.

    즉, 클라이언트에서 메시지 브로커의 /topic/greetings를 구독하고 있다면 반환되는 값을 볼 수 있게 됩니다.

     

    Configure Spring for STOMP messaging

    @Configuration
    @EnableWebSocketMessageBroker
    class WebSocketConfig : WebSocketMessageBrokerConfigurer {
    
      override fun registerStompEndpoints(registry: StompEndpointRegistry) {
        registry.addEndpoint("/qr-websocket").withSockJS()
      }
    
      override fun configureMessageBroker(config: MessageBrokerRegistry) {
        config.setApplicationDestinationPrefixes("/app")
        config.enableSimpleBroker("/topic")
      }
    }

    @Configuration으로 Spring 구성 클래스임을 명시합니다.

    @EnableWebSocketMessageBroker로 메시지 브로커와 WebSocket 메시지 처리를 활성화합니다.

     

    configureMessageBroker()의 enableSimpleBroker는 간단한 메모리 기반 메시지 브로커가 /topic 접두사가 붙은 인사말 메시지를 클라이언트로 전달할 수 있도록 합니다.

    setApplicationDestinationPrefixes는 @MessageMapping 애노테이션이 달린 주석이 달린 메서드에 /app을 접두사로 지정합니다.

    예를 들어 /app/hello는 GrettingController.gretting() 메서드가 처리하도록 매핑된 endpoint입니다.

     

    registerStmopEndpoints() 메서드는 /gs-guide-websocket endpoint를 등록하고 Websocket의 fallback option으로 SockJS를 활성화합니다.

    예를들어 SockJs 클라이언트는 /gs-guide-websocket에 연결을 시도합니다.

     

    아래에도 나오겠지만 app.js에서 다음과 같은 flow가 일어납니다.

    1. 클라이언트에서 /gs-guide-websocket에 connect 요청을 보낸다.

    2. 클라이언트가 /topic/greetings을 구독한다.

    3. 클라이언트가 /app/hello에 요청을 보낸다.

    4. GreetingController에서 /app/hello의 요청을 받아, /topic/greetings을 구독하고 있는 클라이언트들에게 메시지를 전송한다.

    5. 클라이언트는 메시지를 받아 표기한다.

     

    Client 화면 구성

    src/main/resources/static/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>

     

    src/main/resources/static/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(); });
    });

    connect 함수는 SocketJs와 stmop.js를 사용하여 /gs-guide-websocket에 대한 연결을 엽니다.

    만약 연결에 성공하면 클라이언트는 서버가 인사말 메시지를 broadcast 하는 /topic/greetings를 구독합니다.

     

    sendName함수는 /app/hello로 유저의 이름을 STMOP client를 통해 send 합니다.

     

    src/main/resources/static/main.css

    body {
        background-color: #f5f5f5;
    }
    
    #main-content {
        max-width: 940px;
        padding: 2em 3em;
        margin: 0 auto 20px;
        background-color: #fff;
        border: 1px solid #e5e5e5;
        -webkit-border-radius: 5px;
        -moz-border-radius: 5px;
        border-radius: 5px;
    }

     

    실행 후 테스트해 보기

    main 메서드 실행 후 localhost:8080/index.html으로 접속합니다.

    만약 Connect에 실패하게 된다면 개발자도구 Console에 다음과 같이 표기됩니다.

    Whoops! Lost connection to http://localhost:8080/gs-guide-websocket

     

    만약 Connect가 성공한다면 다음과 같이 표기됩니다.

    >>> CONNECTED
    accept-version:1.1,1.0
    heart-beat:10000,10000
    
    
    <<< CONNECTED
    version:1.1
    heart-beat:0,0
    
    
    connected to server undefined
    
    Connected: CONNECTED
    heart-beat:0,0
    version:1.1
    
    
    >>> SUBSCRIBE
    id:sub-0
    destination:/topic/greet

     

     

    hi를 send 하면 다음과 같이 표기됩니다.

    /hello/greeting을 구독하는 client는 모두 해당 메시지를 받을 수 있습니다.

     

    추가적으로 개발자도구 console에는 다음과 같이 표기됩니다.

    >>> SEND
    destination:/app/hello
    content-length:13
    
    {"name":"hi"}
    
    
    <<< MESSAGE
    destination:/topic/greetings
    content-type:application/json
    subscription:sub-0
    message-id:ca2pltde-0
    content-length:25
    
    {"content":"Hello, hi !"}

    SEND로 메시지를 보내고, MESSAGE로 브로드캐스트를 받습니다.

     

    마지막으로 Disconnect시에는 연결이 끊어지고 console에는 다음과 같이 표기됩니다.

    >>> DISCONNECT

     

     

     

    참고자료

    https://d2.naver.com/helloworld/1336

    https://developer.mozilla.org/ko/docs/Web/API/WebSockets_API/Writing_WebSocket_servers

    https://datatracker.ietf.org/doc/rfc6455/?include_text=1 

    http://stomp.github.io/

     

     

    '프로젝트 > WebSocket' 카테고리의 다른 글

    RSocket이란?  (0) 2023.07.20
    WebSocket Scale Out - 이론편  (0) 2023.06.24
    TCP Socket vs WebSocket  (0) 2023.06.23
    Spring WebSocket 활용  (0) 2023.06.12
    QR코드 인증방식 원리  (0) 2023.06.02

    댓글

Designed by Tistory.