스프링 비동기(4) - 웹소켓

2024. 3. 5. 22:36spring/비동기 처리

과제

서버/ 클라이언트가 웹에서 양방향 통신을 할 수 있게 하라

 

해결책

HTTP와 달리 전이중 통신이 가능한 웹소켓을 이용하면 서버/클라이언트가 서로 양방향 통신을 할 수 있다.

 

풀이

웹소켓 기술과 HTTP는 가까운 기술이다. 웹소켓에서 HTTP는 처음 한번 핸드셰이크를 할 때만 쓰이고 이후에는 접속 프로토콜이 일반 HTTP => TCP 소켓으로 업그레이드 된다.

 

 

웹소켓 지원 기능 설정

 

구성 클래스에 @EnableWebSocket만 붙이면 웹소켓 기능을 활용할 수 있다.

@Configuration
@EnableWebSocket
public class WebSocketConfiguration {

}

 

버퍼 크기, 타입아웃 등 웹소켓 엔진을 추가 설정할 경우 ServletServerContainerFactoryBean 객체를 추가한다.

@Bean
public ServletServerContainerFactoryBean configureWebSocketContainer() {
    ServletServerContainerFactoryBean factory = new ServletServerContainerFactoryBean();
    factory.setMaxBinaryMessageBufferSize(16384);
    factory.setMaxTextMessageBufferSize(16384);
    factory.setMaxSessionIdleTimeout(TimeUnit.MINUTES.convert(30, TimeUnit.MILLISECONDS));
    factory.setAsyncSendTimeout(TimeUnit.SECONDS.convert(5, TimeUnit.MILLISECONDS));
    return factory;
}

 

텍스트 버퍼 크기 및 바이너리 버퍼 크기는 16KB,

비동기 전송 타임아웃 시간은 5초,

비동기 세션 타임아웃 시간은 30분으로 설정했다.

 

 

 

WebSocketHandler 작성하기

 

다음으로 웹소켓 메시지를 처리하고 생애주기 이벤트(핸드 셰이크, 접속 체결 등)을 관장하는 WebSocketHandler를 구현해 엔드포인트 URL에 등록한다.

 

WebSocketHandler 인터페이스에는 다섯 메서드가 선언되어 있고 필요 시 직접 구현해 쓸 수 있다. 커스텀 핸들러를 작성할 경우 TextWebSocketHandler나 BinaryWebSocketHandler 중 하나를 상속한다. 각각 텍스트 메시지, 바이너리 메시지를 처리한다.

 

메서드 설명
afterConnectionEstablished() 웹소켓이 열리고 사용할 준비가 되면 호출된다.
handleMessage() 웹소켓 메시지가 도착하면 호출된다.
handleTransportError 에러가 나면 호출된다.
afterConnectionClosed() 웹소켓 접속이 닫힌 후 호출된다.
supportsPartialMessage() 핸들러의 부분 메시지 지원 여부. true이면 웹 소켓 메시지를 여러 번 호출해서 받아올 수 있다.

 

 

다음 EchoHandler는 TextWebSocketHandler를 상속한 클래스로서 각각 afterConnectionEstablished()와 handleMessage() 메서드를 오버라이드 한다.

public class EchoHandler extends TextWebSocketHandler {

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        session.sendMessage(new TextMessage("CONNECTION ESTABLISHED"));
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String msg = message.getPayload();
        session.sendMessage(new TextMessage("RECEIVED: " + msg));
    }
}

 

 

접속이 체결되면 TextMessage를 클라이언트에 돌려보내 알린다. TextMessage가 수신되면 페이로드를 꺼내 그 앞에 RECEIVED: 를 붙여 클라이언트에 회송한다.

 

이 핸들러를 WebSocketConfigurer 인터페이스를 구현한 @Configuration 클래스에서 registerWebSocketHandlers() 메서드를 오버라이드해 등록한 후 특정 URI를 할당한다.

@Configuration
@EnableWebSocket
public class WebSocketConfiguration implements WebSocketConfigurer {

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(new EchoHandler(), "/echo");
    }
}

 

이제 웹소켓 엔드포인트에 접속할 클라이언트를 만들어보자

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Title</title>
    <link type="text/css" rel="stylesheet" href="https://cdnjs.cloudfare.com/ajax/libs/semantic-ui/2.2.10/semantic.min.css">
</head>
<body>
    <div>
        <div id="connect-container" class = "ui centered grid">
            <div class="row">
                <button id="connect" onclick="connect();" class="ui green button">Connect</button>
                <button id="disconnect" disabled="disabled" onclick="disconnect();" class="ui red button">Disconnect</button>
            </div>
            <div class="row">
                <textarea id="message" style="width: 350px" class="ui input" placeholder="Message To Echo">

                </textarea>
            </div>
            <div class="row">
                <button id="echo" onclick="echo()" disabled="disabled" class="ui button">Echo message</button>
            </div>
        </div>
        <div id="console-container">
            <h3>Logging</h3>
            <div id="logging"></div>
        </div>
    </div>
</body>
<script>
    let ws = null;
    let url = "ws://localhost:8080/echo"

    function setConnected(connected) {
        document.getElementById('connect').disabled = connected;
        document.getElementById('disconnect').disabled = !connected;
        document.getElementById('echo').disabled = !connected;
    }

    function connect() {
        ws = new WebSocket(url);

        ws.onopen = function() {
            setConnected(true);
        };

        ws.onmessage = function(event) {
            log(event.data);
        };

        ws.onclose = function(event) {
            setConnected(false);
            log('info: Closing Connection.');
        };
    }

    function disconnect() {
        if (ws != null) {
            ws.close();
            ws = null;
        }
        setConnected(false);
    }

    function echo() {
        if (ws != null) {
            let message = document.getElementById('message').value;
            log('Sent: ' + message);
            ws.send(message);
        } else {
            alert('connection not established, please connect.');
        }
    }

    function log(message) {
        let console = document.getElementById('logging');
        let p = document.createElement('p');
        p.appendChild(document.createTextNode(message));
        console.appendChild(p);
        while (console.childNodes.length > 12) {
            console.removeChild(console.firstChild);
        }
        console.scrollTop = console.scrollHeight;
    }
</script>
</html>

 

웹소켓 클라이언트 출력

 

 

 

STOMP와 MessageMapping

 

웹소켓 기술을 응용해서 애플리케이션을 개발하는 건 사실상 메시징을 의미하는 것이어서 웹소켓 프로토콜을 그대로 사용할 수도 있지만 하위 프로토콜을 쓰는 것도 가능하다. 스프링 웹소켓이 지원하는 STOMP(Simple Text-Oriented Protocol)도 웹소켓의 하위 프로토콜 중 하나다.

 

STOMP는 루비, 파이썬 같은 스크립트 언어에서 메시지 중개기에 접속하기 위해 고안된 프로토콜로, TCP나 웹소켓처럼 모든 신뢰할 수 있는 양방향 네트워크 프로토콜에서 쓸 수 있다. 텍스트 위주의 프로토콜이지만 메시지 페이로드가 엄격하지 않아 바이너리 데이터도 넣을 수 있다.

 

스프링 웹소켓 지원 기능을 이용해 STOMP를 설정하면 웹소켓 애플리케이션은 모든 접속 클라이언트에 대해 중개기처럼 작동한다. 중개기는 인메모리 중개기 또는 (RabbitMQ나 ActiveMQ처럼) STOMP 프로토콜을 지원하는 온갖 기능이 구비된 기업용 솔루션을 쓸 수 있다. 후자의 경우라면 스프링 웹소켓 애플리케이션이 실제 중개기의 중계기(릴레이) 역할을 할 것이다.

 

메시지를 수신하려면 @Controller 클래스 메서드에 @MessageMapping을 붙여 메시지 수신 지점을 표시한다.

@Controller
public class EchoHandler {

    @MessageMapping("/echo")
    @SendTo("/topic/echo")
    public String echo(String msg) {
        return "RECEIVED: " + msg;
    }
}

 

/echo로 받은 메시지는 @MessageMapping 메서드로 전달된다. @SendTo("/topic/echo")는 echo() 메서드가 반환한 결과 문자열을 /topic/echo 토픽에 넣으라는 뜻이다.

 

이제 메시지 중개기를 구성하고 메시지 수신 엔드포인트를 추가하자. WebSocketConfiguration 클래스가 (웹소켓 메시징을 추가 설정할 수 있게 WebSocketMessageBrokerConfigurer를 구현하고 @EnableWebSocketMessageBroker를 붙인다.

 

@Configuration
@EnableWebSocketMessageBroker
@ComponentScan("com.spring.study.chapter05.application")
public class WebSocketBrokerConfiguration implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic");
        registry.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/echo-endpoint");
    }
}

 

클래스 레벨에 붙인 @EnableWebSocketMessageBroker는 웹소켓을 사용한 STOMP 통신 기능을 활성화한다. 중개기는 configureMessageBroker() 메서드에서 구성한다. 중개기가 처리한 메시지, 앱이 처리한 메시지는 각각 접두어를 달리하여 분간한다.

 

도착지가

/topic으로 시작하는 메시지는 중개기로,

/app으로 시작하는 메시지는 @MessageMapping을 붙인 핸들러 메서드로 각각 보낸다.

 

수신된 STOMP 메시지를 리스닝하는 웹소켓 엔드포인트(/echo-endpoint)는 registerStompEndpoints() 메서드를 오버라이드해서 등록한다.

 

클라이언트 코드도 일반 웹소켓 대신 STOMP를 쓴다.

<script>
  let ws = null;
  let url = "ws://localhost:8080/echo-endpoint"

  function setConnected(connected) {
    document.getElementById('connect').disabled = connected;
    document.getElementById('disconnect').disabled = !connected;
    document.getElementById('echo').disabled = !connected;
  }

  function connect() {
    ws = webstomp.client(url);

    ws.connect({}, function(frame) {
      setConnected(true);
      log(frame);
      ws.subscribe('/topic/echo', function(message) {
        log(message.body);
      })
    })
  }

  function disconnect() {
    if (ws != null) {
      ws.disconnect();
      ws = null;
    }
    setConnected(false);
  }

  function echo() {
    if (ws != null) {
      let message = document.getElementById('message').value;
      log('Sent: ' + message);
      ws.send("/app/echo", message);
    } else {
      alert('connection not established, please connect.');
    }
  }

  function log(message) {
    let console = document.getElementById('logging');
    let p = document.createElement('p');
    p.appendChild(document.createTextNode(message));
    console.appendChild(p);
    while (console.childNodes.length > 12) {
      console.removeChild(console.firstChild);
    }
    console.scrollTop = console.scrollHeight;
  }
</script>

 

중개기에 접속할 STOMP 클라이언트(ws)는 connect() 함수에서 webstomp.client() 로 생성한다.

접속이 맺어지면 클라이언트는 /topic/echo를 구독하고 토픽에 들어온 메시지를 수신한다.

echo() 함수가 메시지를 ws.send() 메서드를 사용해 도착지 /app/echo로 보내면 @MessageMapping 메서드로 전달된다.

 

 

애플리케이션을 켜고 클라이언트를 열어보면, 메시지를 송수신하는 건 마찬가지지만 STOMP라는 하위 프로토콜을 이용한다는 차이가 있다.

 

@MesssageMapping 메서드를 작성할 때 다양한 메서드 인수 및 애너테이션을 이용하면 메시지 정보를 알아낼 수 있다. 기본적으로 하나의 인수가 메시지 페이로드에 대응된다고 가정하며 MessageConverter를 이용해 메시지 페이로드를 원하는 타입으로 변환한다.

타입 설명
Message 헤더와 본문이 포함된 실제 하부 메시지
@Payload 메시지 페이로드
@Header Message에서 주어진 헤더를 가져온다.
@Headers 전체 메시지 헤더를 Map인수에 넣느낟.
MessageHeaders 전체 Message 헤더
Principal 현재 유저