본문 바로가기

항해 99/Spring

WebSocket - STOMP 2

Annotated Controllers

애플리케이션은 클라이언트로부터 받은 메시지를 처리하기 위해 @Controller 클래스를 사용할 수 있음

이러한 컨트롤러는 @MessageMapping, @SubscirbeMapping, @ExceptionHandler 메서드를 선언할 수 있음

 

@MessageMapping

@MessageMapping 메서드는 지정한 경로를 기반으로 메시지를 라우팅할 수 있음, 메서드뿐만 아니라 타입 레벨, 클래스에도 설정할 수 있는데, 이는 컨트롤러 안에서 공통된 경로를 제공하기 위해서 사용된다

 

기본적으로 매핑은 Any-Style Path 패턴으로 구성하고, Template 변수도 지원한다(ex, /something*, /somethig/{id})

 

Template 변수는 @DestinationVariable로 선언한 메서드 인자를 통해서 전달받을 수 있음, 애플리케이션은 dot-separated 기반의 Destination 컨벤션으로 바꿀 수도 있음

 

Method Arguments

@DestinationVariable과 같은 메서드에서 지원하는 인자 목록

  • Message : 완전한 Message 정보에 접근한다
  • MessageHeader : Message 안에 Header 정보에 접근한다
  • MessageHeaderAccessor, SimpMessageHeaderAccessor, StompHeaderAccessor 타입이 지정된 접근자 메서드를 통해서 Header 정보에 접근한다
  • @payload : MessageConverter에 의해서 변환된 메시지의 Payload에 접근한다, 만약 다른 인자와 일치하지 않으면 매칭되기 때문에 반드시 요구되지는 않는다
    • Payload 인자를 자동으로 검증하기 위해 @javax.validation.Valid 또는 스프링의 @Validated을 함께 사용할 수도 있음
  • @Header : 구체적인 Header 값에 접근한다, 필요한 경우에는 org.springframework.core.convert.converter.Converter를 이용한 타입 변환과 함께 사용할 수도 있음
  • @DestinationVariable : 메시지의 Destination 헤더의 경로를 기반으로 Template 변수의 값을 추출하여 접근한다.
    • Value는 선언된 타입에 따라 필수적으로 변환된다
  • java.security.Principal : WebSocket HTTP Handshake 시 로그인한 사용자를 반영한다.

예제

@Controller
public class TestController {

	@MessageMapping("/good/{id}")
	public String handle(Message message, MessageHeaders messageHeaders, 
		MessageHeaderAccessor messageHeaderAccessor, SimpMessageHeaderAccessor simpMessageHeaderAccessor, 
		StompHeaderAccessor stompHeaderAccessor, @Payload String payload, 
		@Header("destination") String destination, @Headers Map<String, String> headers,
		@DestinationVariable String id) {

		System.out.println("---- Message ----");
		System.out.println(message);

		System.out.println("---- MessageHeaders ----");
		System.out.println(messageHeaders);

		System.out.println("---- MessageHeaderAccessor ----");
		System.out.println(messageHeaderAccessor);

		System.out.println("---- SimpMessageHeaderAccessor ----");
		System.out.println(simpMessageHeaderAccessor);

		System.out.println("---- StompHeaderAccessor ----");
		System.out.println(stompHeaderAccessor);

		System.out.println("---- @Payload ----");
		System.out.println(payload);

		System.out.println("---- @Header(\"destination\") ----");
		System.out.println(destination);

		System.out.println("----  @Headers ----");
		System.out.println(headers);

		System.out.println("----  @DestinationVariable ----");
		System.out.println(id);

		return payload;
	}
}

 

 

Return Values

기본적으로 @MessageMapping 메서드가 반환한 값은 일치한 MessageConverter를 통해서 Payload로 직렬화된다

그 후 Message에 담겨 brokerChannel로 보내지고 구독자들(subscribers)에게 브로드캐스팅 된다

 

이 과정에서, Message의 Destination 헤더는 클라이언트로부터 전달받은 Destination 헤더 값에서 접두사만 /topic으로 변경된 값으로 설정된다

 

만약 Destination 헤더를 직접 설정하고 싶다면, @SendTo 또는 @SendToUser을 사용하면 된다. @SendTo과 @SendToUser은 동시에 같은 메서드 또는 클래스에서 사용할 수도 있다.

  • @SendTo는 특정 또는 다수의 목적지(Destination 헤더)를 설정하는 경우에 사용한다.
  • @SendToUser는 오직 Input Message와 관련된 사용자에게만 Output Message를 보내도록 설정한다.

A Quick Example of Spring Websockets' @SendToUser Annotation | Baeldung

또한, 만약 @MessageMapping 메서드에서 메시지를 비동기적으로 처리하고 싶은 경우에는 ListenableFuture, CompletableFuture 또는 CompletionStage 객체를 반환하면 된다.

@SendTo과 @SendToUser은 단순히 SimpMessagingTemplate을 사용해서 메시지를 보내는 편의에 불과하다는 것을 명심하자.

따라서, 요구 상황에 따라 @MessageMapping 메서드에서 SimpMessagingTemplate을 직접 사용해야 할 경우도 있다.

SimpMessagingTemplate을 이용하면 반환 값없이 메서드 내부에서 처리를 끝마칠 수 있다.

 

@SubscribeMapping

@SubscribeMapping은 @MessageMapping과 유사하지만, 오직 Subscription 메시지만 매핑한다는 차이가 있음

 

@SubscribeMapping은 @MessageMapping과 동일한 Method Arguments을 제공한다

 

Return Value는 기본적으로 brokerChannel을 통해서 브로커로 전달된느 것이 아니라, clientOutboundChannel을 통해서 클라이언트에게 직접 보내진다는 차이점이 있음

  • @SendTo 또는 @SendToUser를 통해서 재정의한다면 Return Value를 브로커에게 보낼 수도 있음

@SubscribeMapping은 언제 사용하는 것일까?

브로커는 /topic과 /queue에 매핑되어 있고, 애플리케이션 컨트롤러는 /app에 매핑되어 있다고 가정할 때

이러한 설정에서, 브로커가 /topic, /queue에 대한 모든 구독(subscriptions) 정보를 저장하고 있으므로, 애플리케이션은 개입하지 않아도 된다.

하지만, 클라이언트가 /app 접두사를 가진 목적지로 구독 요청 보내는 상황에서 @SubscribeMapping을 사용한다면, 컨트롤러는 브로커 통과없이 Return Value를 구독에 대한 응답으로 보낸다.

즉, @SubscribeMapping은 브로커에 구독 정보를 저장하지 않을 뿐더러 구독 정보를 재활용하지도 않는 일회성 용도로 사용된다. 일회성 request-reply 교환인 것임.

좀 더 단적인 예로, 시작과 동시에 UI 초기 데이터를 채우기 위한 용도로 많이 사용되고, 위와 같은 이유가 아니라면, 브로커와 컨트롤러는 동일한 Destination 접두사로 매핑하지 않도록 해야한다.

Inbound 메시지는 병렬적으로 처리되기 때문에, 브로커와 컨트롤러 중에 어느 것이 먼저 처리하는 지 보장하지 않는다.

 

@MessageExceptionHandler

애플리케이션은 @MessageMapping 메서드에서 발생한 Exception을 처리하기 위해서 @MessageExceptionHandler 메서드를 지원한다

 

발생한 예외는 Method Argument을 통해 접근할 수 있음

@Controller
public class TestController {
	
	// ...

	@MessageExceptionHandler
	public Exception handleException(CustomeException exception) {
		// ...
		return exception;
	}
}

 

@MessageExceptionHandler 메서드는 전형적으로 선언된 컨트롤러 내부의 예외를 처리한다

전역적으로 예외 처리 메서드를 적용하고 싶다면 @MessageExceptionHandler 메서드를 [@ControllerAdvice](https://docs.spring.io/spring-framework/docs/current/spring-framework-reference/web.html#mvc-ann-controller-advice) 컨트롤러에  선언하면 된다

 

Message 전송

애플리케이션 구성 요소는 BrokerChannel로 메시지를 보낼 수 있는데, 가장 간단한 방법은 아래와 같이 SimpMessagingTemplate을 주입받아서 메시지를 전송하는 것임

@Controller
public class TestController {
	private SimpMessagingTemplate simpMessagingTemplate;

	public TestController(SimpMessagingTemplate simpMessagingTemplate) {
		this.simpMessagingTemplate = simpMessagingTemplate;
	}

	@PostMapping(path = "/greet")
	@ResponseBody
	public void greet(@RequestBody String greet) {
		String now = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
		simpMessagingTemplate.convertAndSend("/topic/greet", "[" + now + "]" + greet);
	}
}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <style>
        .message {
            margin: 5em;
            color: olive;
            border: solid 2px #D2691E;
            -webkit-transition: 0.5s; transition: 0.5s;
            width: 50%;
            padding: 10px 20px;
            box-sizing: border-box;
        }
        .messages {
            margin: 5em;
        }
        .send-btn {
            background-color: #F4FEE2;
            padding: 10px 20px;
        }
        li {
            list-style-type: none;
            margin: 1em;
            text-align: center;
            font-size: 2em;
        }
    </style>
    <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
    <script type="text/javascript" src="node_modules/webstomp-client/dist/webstomp.min.js"></script>
</head>
<body>
    <div>
        <input type="text" class="message"/>
        <button onClick='send()' class="send-btn">보내기</button>
    </div>
    <div class="messages">
        <div id="messages"></div>
    </div>
</body>
    <script>
        let stomp;
        document.addEventListener("DOMContentLoaded", function() {
            // ["websocket", "xhr-streaming", "xhr-polling"]
            const sock = new SockJS('http://localhost:8080/test', null, {transports: ["xhr-polling"]});
            stomp = webstomp.over(sock);
            stomp.connect({}, function(frame) {
                console.log('Connected!!');

                stomp.subscribe("/topic/greet", function(frame) {
                    const messages = document.querySelector("#messages");
                    const message = document.createElement("li");
                    message.innerText = frame.body;
                    messages.appendChild(message)
                });
            });

        });

        function send() {
            const message = document.querySelector(".message");
            fetch("http://localhost:8080/greet", {
                method: "POST",
                body: message.value
            });
            message.value = '';
        }
    </script>
</html>

 

Simple Broker

내장된 Simple Message Broker는 클라이언트에게 받은 구독 요청을 메모리에 저장하고,  Destination 헤더와 일치하는 클라이언트 커넥션에 메시지를 브로드캐스팅 함

 

TaskScheduler와 함께 설정할 경우 Simple Message Broker는 STOMP Heartbeat를 지원함

 

자신만의 TaskScheduler를 구현해서 사용하거나, 기본적으로 등록되는 스케줄러를 사용할 수도 있다

 

자신이 구현한 TaskScheduler를 등록해서 사용하는 예제임

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
	private TaskScheduler taskScheduler;
	
	public WebSocketConfig(TaskScheduler taskScheduler) {
		this.taskScheduler = taskScheduler;
	}

	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		registry.addEndpoint("/test").setAllowedOrigins("*").withSockJS();
	}

	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {
		registry.setApplicationDestinationPrefixes("/simple")
			.enableSimpleBroker("/topic", "/queue")
			.setHeartbeatValue(new long[]{10000, 20000})
			.setTaskScheduler(taskScheduler);
	}
}

 

External Broker

Simple Message Broker는 처음 시작하기에 좋지만, 일부 STOMP COMMAND만 지원한다는 단점이 있음

 

구체적으로, Simple Message Broker는 acks, receipts 등의 다른 기능을 지원하지 않음, 연결된 구독자들에게 메시지를 전송하는 경우 Simple Message Broker는 단순한 반복문(Loop)에 의존하기 때문에 클러스터링에 적합하지 않다는 단점이 있음

 

위와 같은 단점들은 완전한 기능을 갖춘 Message Broker를 사용함으로써, 애플리케이션을 업그레이드 할 수 있음

 

RabbitMQ, ActiveMQ 같은 외부 메시지 브로커를 선택해 설치하고 실행하면, 애플리케이션에서 STOMP broker relay를 아래 같이 설정할 수 있음

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		registry.addEndpoint("/test").setAllowedOrigins("*").withSockJS();
	}

	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {
		registry.setApplicationDestinationPrefixes("/simple")
			.enableStompBrokerRelay("/topic", "/queue");
	}
}

 

STOMP broker relay는 메시지를 외부 메시지 브로커로 포워딩하는 스프링의 MessageHandler(StompBrokerRelayMessageHandler)임

 

StompBrokerRelayMessageHandler 동작 순서

  1. 매 CONNECT 메시지마다 브로커와 TCP 연결을 수립하고(각 클라이언트마다 독립된 TCP 커넥션을 사용하는데, 이는 session-id 메시지 헤더로 식별함)
  2. 모든 메시지를 브로커에 전달
  3. 브로커로부터 수신한 모든 메시지는 각각의 session-id를 메시지 헤더에 더하고, WebSocket 세션을 통해 클라이언트에게 전달 됨

StompBrokerRelayMessageHandler는 메시지를 양방향으로 전달하는 릴레이 역할을 함

 

StompBrokerRelayMessageHandler는 자동으로(기본적으로) 메시지 브로커와 단 하나의 System TCP Connection을 수립함

 

System TCP Connection은 서버 애플리케이션이 메시지 브로커에게 메시지를 전달하기 위한 용도임, 메시지는 어떠한 클라이언트와도 관련이 없기 때문에 session-id도 가지고 있지 않다

 

System TCP Connection은 효율적으로 공유가 가능하지만, 메시지 브로커로부터 메시지를 받는 용도로 사용하지 않음

 

StompBrokerRelayMessageHandler은 System TCP Connection의 몇 가지 설정할 수 있도록 아래와 같은 메서드를 제공함

스프링 STOMP Configuration에서도 아래와 같이 설정할 수 있도록 메서드를 제공함

@Configuration

@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		ExecutorSubscribableChannel
		registry.addEndpoint("/test").setAllowedOrigins("*").withSockJS();
	}

	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {
		registry.setApplicationDestinationPrefixes("/simple")
			.enableStompBrokerRelay("/topic", "/queue")
			.setSystemLogin(String)
			.setSystemPasscode(String)
			.setSystemHeartbeatSendInterval(long)
			.setSystemHeartbeatReceiveInterval(long)
	}
}

 

애플리케이션의 컨트롤러, 서비스 등의 컴포넌트에서도 broker relay에 메시지를 보내어, 구독중인 WebSocket 클라이언트들에게 메시지를 브로드캐스팅할 수 있음

 

broker relay는 강력하고 확장 가능한 메시지 브로드캐스팅을 지원함

TCP 커넥션을 관리하기 위해서는 io.projectreactor.netty:reactor-netty과 io.netty:netty-all 의존성을 추가해야 한다.

 

Connecting to a Broker

StompBrokerRelayMessageHandler는 기본적으로 메시지 브로커와 단 하나의 System TCP Connection을 수립함

 

System TCP Connection을 위해 스프링은 STOMP credentials(STOMP Frame login, passcode headers)을 설정할 수 있도록 메서드를 제공하고, 해당 메서드들은 systemLogin, systemPasscode 속성을 설정하는데, 디폴트 값은 모두 guest이다

 

STOMP broker relay는 연결된 모든 WebSocket 클라이언트에 대해 메시지 브로커와 별도의 TCP 연결을 생성함

 

스프링은 클라이언트 대신 생성된 모든 TCP 커넥션에 STOMP credentials 설정 가능하도록 메서드를 제공하고, 해당 메서드들은 clientLogin, clientPasscode 속성을 설정하는데, 디폴트 값은 모두 guest이다

 

STOMP broker relay는 클라이언트를 대신해서 브로커로 전달하는 모든 CONNECT Frame에 항상 login 및 passcode 헤더를 설정하기 때문에, WebSocket 클라이언트는 해당 헤더를 설정할 필요가 없음(설정해도 무시 됨)

 

WebSocket 클라이언트는 HTTP 인증을 사용해 WebSocket Endpoint를 보호하고 클라이언트 식별자를 설정해야 함

 

STOMP broker relay와 메시지 브로커는 System TCP Connection을 통해서 Heartbeat를 주고 받는다

 

스프링은 StompBrokerRelayMessageHandler에 대한 설정을할 수 있도록 메서드를 제공하는데, 메시지 브로커와 주고 받는 Heartbeat 시간 간격도 설정할 수 있음(default 10초)

 

브로커와 연결이 끊어진다면, STOMP broker relay는 성공할 때까지 5초마다 재연결을 시도한다.

ApplicationListener<BrokerAvailabilityEvent>을 구현한 스프링 Bean은 System TCP Connection 연결이 메시지 브로커와 끊기거나 재연결될 때마다 알림을 받는다.

이를 통해서, System TCP Connection이 비활성화된 경우에는 메시지 전송을 중지할 수 있다.

public class BrokerAvailabilityEventListener implements ApplicationListener<BrokerAvailabilityEvent> {
	@Override
	public void onApplicationEvent(BrokerAvailabilityEvent brokerAvailabilityEvent) {
		// ...
	}
}

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		ExecutorSubscribableChannel
		registry.addEndpoint("/test").setAllowedOrigins("*").withSockJS();
	}

	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {
		registry.setApplicationDestinationPrefixes("/simple")
			.enableStompBrokerRelay("/topic", "/queue");
	}

	@Bean
	public ApplicationListener<BrokerAvailabilityEvent> brokerAvailabilityEventApplicationListener() {
		return new BrokerAvailabilityEventListener();
	}
}

 

기본적으로 STOMP broker relay는 항상 동일한 Host, Port로 재연결함

 

여러 URL을 가지고 매번 다르게 연결 시도를 하고 싶다면, 고정된 Host, Port 대신하여 주소 공급자를 설정할 수 있음

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		registry.addEndpoint("/test").setAllowedOrigins("*").withSockJS();
	}

	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {
		registry.setApplicationDestinationPrefixes("/simple")
			.enableStompBrokerRelay("/topic", "/queue");
			.setTcpClient(createTcpClient());
	}

	private ReactorNettyTcpClient<byte[]> createTcpClient() {
		// 주소 목록
		String[] addresses = new String[] {"something.co.kr:8080", "test.com:9090", "something.co.kr:9080",
			"test.com:8070", "something.co.kr:8070"};
		Queue<String> queue = new LinkedList<>();
		Collections.addAll(queue, addresses);

		return new ReactorNettyTcpClient<>(
			// System TCP Connection 설정
			(TcpClient tcpClient) -> {
				// System TCP Connection의 원격 주소 설정
				return tcpClient.remoteAddress(() -> {
					// 주소 공급자
					// 첫 번째 위치에 있는 주소 dequeue
					String address = queue.poll();

					String[] components = address.split(":");
					String host = components[0];
					Integer port = Integer.parseInt(components[1]);

					SocketAddress socketAddress = new InetSocketAddress(host, port);
					// 마지막 위치에 있는 주소 enqueue
					queue.add(address);
					return socketAddress;
				});
			},
			new StompReactorNettyCodec()
		);
	}
}

 

STOMP broker relay에 VirtualHost 속성을 설정한다면, VirtualHost 속성 값은 모든 CONNECT 프레인의 host 헤더로 세팅되어 유용하게 사용됨

 

"클라우드 기반으로 STOMP 메시지 브로커를 서비스하는 호스트"와 "TCP 커넥션이 수립되는 실제 호스트"가 다른 클라우드 환경에서 유용하게 사용됨

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		registry.addEndpoint("/test").setAllowedOrigins("*").withSockJS();
	}

	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {
		registry.setApplicationDestinationPrefixes("/simple")
			.enableStompBrokerRelay("/topic", "/queue");
			.setVirtualHost("http://virtual-host:8080");
	}
}

 

Dot as Separators

메시지는 AntPathMatcher와 일치하는 @MessageMapping 메서드로 라우팅된다.

AntPathMatcher는 기본적으로 /를 구분자로 사용하는데, 이는 HTTP 기반의 Web 애플리케이션에서 매우 좋은 컨벤션이다.

  • 메시징 시스템에 적합한 컨벤션을 적용하고 싶다면, . 구분자를 사용하면 된다.

. 구분자를 가진 AntPathMatcher를 설정하는 예제

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		registry.addEndpoint("/test").setAllowedOrigins("*").withSockJS();
	}

	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {
		registry.setPathMatcher(new AntPathMatcher("."))
			.setApplicationDestinationPrefixes("/simple")
			.enableStompBrokerRelay("/topic", "/queue");
	}
}
  • 컨트롤러는 @MessageMapping 메서드에 . 구분자을 사용할 수 있음
@Controller
public class TestController {

	@MessageMapping("good.{id}")
	public String handle(@DestinationVariable String id, @Payload String payload) {
		return "[" + id + "]" + payload;
	}

}
  • 클라이언트에서는 /simple/good.ds4j2pkz 경로로 메시지를 보낼 수 있음
  • broker relay에 대한 접두사는 전적으로 외부 메시지 브로커 스펙을 따르기 때문에, 스프링에서는 직접적으로 변경할 수 없음
  • 스프링 소켓 모듈에 내장된 Simple Broker는 설정한 PathMatcher에 의존함, 따라서 구분자를 변경하면 브로커에 적용될 뿐만 아니라, 브로커가 subscripthon 패턴으로 메시지와 구독자를 매칭하는 방법도 변경됨

인증

WebSocket을 통한 모든 STOMP 메시징 세션은 HTTP 요청으로 시작하고, WebSocket HandShake 과정으로 HTTP 요청은 WebSocket으로 Upgrade함(SockJS는 일련의 SockJS HTTP transport 요청을 보냄)

 

많은 웹 애플리케이션은 이미 HTTP 요청을 보호하기 위해 인증 및 권한을 제공하고 있음, Spring Security는 사용자가 login 페이지, HTTP basic 인증 등을 제공하여 사용자를 인증함

 

인증된 사용자에 대한 보안 컨텍스트는 HTTP 세션에 저장되고, 그 다음 요청부터는 쿠기 기반으로 동일하게 연결 됨

 

WebSocket handshake 또는 SockJS HTTP transport 요청의 경우 일반적으로 HttpServletRequest#getUserPrincipal( )으로 접근 가능한 인증된 사용자가 이미 존재함

 

스프링은 자동적으로 WebSocket 또는 SockJS 세션을 갖는 사용자와 해당 세션으로 전달되는 모든 STOMP 메시지를 연관 지음

 

일반적인 웹 애플리케이션은 이미 보안을 위해 이미 많은 작업을 수행하고 있기 때문에 추가적으로 무엇을 할 필요는 없다

 

사용자는 쿠키 기반의 HTTP 세션으로 유지되는 보안 컨텍스트를 사용해서 HTTP 요청 수준에서 인증 됨, 이후 애플리케이션을 통과하는 모든 메시지는 사용자 헤더를 가지고 인증 확인 과정을 거침

 

STOMP 프로토콜도 CONNECT 프레임에 login, passcode 헤더를 가지고 있음, 이러한 헤더들은 STOMP가 TCP 기반으로 동작하는 경우에 필요하지만, WebSocket 기반인 경우에는 스프링에서 STOMP 프로토콜 수준의 인증 헤더를 무시함(사용자가 HTTP 전송 수준에서 이미 인증되었다고 가정하기 때문)

 

WebSocket, SockJS 세션은 서버에 메시지를 보내기 위해 반드시 이미 인증된 사용자를 포함하고 있어야 함

Token 인증

Spring Security OAuth는 JWT 같은 Token 기반의 보안을 제공함, WebSocket 기반 STOMP 프로토콜을 비롯한 웹 애플리케이션에서 Token 기반 보안을 사용할 수 있음

 

Token 기반 보안을 사용하는 이유는 쿠키 기반의 세션이 모든 상황에서 적합할 수 없기 때문

  • 서버 애플리케이션에서 세션을 지원하지 않을 수도 있고, 모바일 애플리케이션에서는 일반적으로 인증 헤더를 선호함

The WebSocket protocol, RFC 6455은 WebSocket Handshake 과정에서 서버가 클라이언트를 인증하는 방법을 규정하고 있지 않고, 브라우저 클라이언트는 오직 표준 인증 헤더 또는 쿠키만 사용할 수 있으며, 커스텀한 사용자 지정 헤더를 사용할 수 없음

 

대신 클라이언트는 Token을 Query Parameter로 전송할 수 있으나 몇 가지 단점이 있음

  • 토큰이 서버 로그에 URL과 함께 실수로 기록 될 수 있음
  • 서버 애플리케이션은 HTTP 수준에서 쿠키 사용없이 인증할 마땅한 대안이 없음

쿠키 사용 대신 STOMP Messaging 프로토콜 수준의 헤더를 이용해서 인증하기 위해서는 두 단계가 필요함

  1.  STOMP 클라이언트는 CONNECT 프레임에 pass 인증 헤더를 추가해야 함
  2. ChannelInterceptor를 사용해서 인증 헤더를 처리함

예제 - 서버측에서 커스텀하게 인증 처리하는 인터셉터 등록 과정

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		registry.addEndpoint("/test").setAllowedOrigins("*").withSockJS();
	}

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

	@Override
	public void configureClientInboundChannel(ChannelRegistration registration) {
		registration.interceptors(new ChannelInterceptor() {
			@Override
			public Message<?> preSend(Message<?> message, MessageChannel channel) {
				StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
				
				// CONNECT 메시지인 경우에만 인증 처리
				if (StompCommand.CONNECT.equals(accessor.getCommand())) {
					Authentication user = ...; // access authentication headers
					accessor.setUser(user); // 사용자 헤더 추가
				}

				return message;
			}
		});
	}
}
@Controller
public class TestController {

	@MessageMapping("/good")
	public String handle(@Payload String payload, Principal user) {
		return "[" + user.getName() + "]" +payload;
	}

}

 

interceptor는 오직 CONNECT 메시지 기반으로 인증하고, 사용자 헤더를 추가하는 기능을 제공함

 

이러한 interceptor를 통해서 스프링은 해당 세션에 대한 인증된 사용자를 저장함, 이후 동일한 세션으로 전달된 STOMP 메시지에 인증된 사용자 정보를 추가해 준다

 

사진에서 simpUser 헤더가 추가된 것을 확인할 수 있는데, 동일한 세션으로 받은 메시지 헤더에는 인증된 사용자에 대한 헤더가 추가되는 것

 

메시지 기반으로 Spring Security 인증을 사용하는 경우 반드시 ChannelInterceptor를 Spring Security보다 앞쪽 순서에 설정해야 함

  • @Order(Ordered.HIGHEST_PRECEDENCE * 99)를 추가하여 순서를 보장할 수 있음
public class AuthenticationChannelInterceptor implements ChannelInterceptor {

	@Override
	public Message<?> preSend(Message<?> message, MessageChannel channel) {
		StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
		if (StompCommand.CONNECT.equals(accessor.getCommand())) {
			Authentication user = ...;
			accessor.setUser(user);
		}

		return message;
	}

}
public class AuthenticationChannelInterceptor implements ChannelInterceptor {

	@Override
	public Message<?> preSend(Message<?> message, MessageChannel channel) {
		StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
		if (StompCommand.CONNECT.equals(accessor.getCommand())) {
			Authentication user = ...;
			accessor.setUser(user);
		}

		return message;
	}

}

 

User Destinations

스프링의 STOMP 모듈은 /user 접두사를 가진 destination 헤더를 인지해서, 애플리케이션이 특정한 사용자에게 메시지를 보낼 수 있도록 지원함

 

사용자가 /user/queue/something 경로의 Destination을 구독하는 경우 Destination UserDestinationMessageHandler에 의해서 처리되는데, 각 사용자 세션마다 고유한 Destination으로 변환 됨(/queue/somethig-user234)

 

이를 통해, 동일한 Destination에 동시로 구독하는 다른 사용자와 충돌이 발생하지 않도록 보장함

 

UserDestinationMessageHandler UserDestinationResolver로 경로를 변형하는 과정 참고

송신측은 /user/{username}/queue/something같은 형식의 Destination에 메시지를 보내고, 이후 메시지는 UserDestinationMessageHandler 의해서 /queue/something-user{session-id} 형태로 변환되어 하나 이상의 Destination으로 전달된다.

이러한 지원을 통해서, 애플리케이션 내의 모든 컴포넌트는 '사용자 이름' 및 'Destination'에 대한 정보없이도 특정한 사용자들에게 메시지를 보낼 수 있는데, 이는 애노테이션과 메시징 템플릿을 이용해서 지원된다.

 

@SendToUser

메시지 핸들링 메서드는 @SendToUser를 이용해서 특정 사용자에게 메시지를 보낼 수 있음

@Controller
public class TestController {

	@MessageMapping("/good")
	@SendToUser("/queue/something")
	public String handle(@Payload String payload) {
		return payload;
	}

}
  • @SendToUser("/queue/something")는 /user/{username}/queue/something Destination의 사용자들에게 메시지를 보내는 것
  • UserDestinationMessageHandler /user/{username}/queue/something /queue/something-user{session-id} 형태로 변환함
  • 참조

참조 링크에서 UserDestinationMessageHandler 객체의 handleMessage() 메서드를 볼 수 있는데, 변경된 경로를 로그로 보면 아래와 같음

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <style>
        .message {
            margin: 5em;
            color: olive;
            border: solid 2px #D2691E;
            -webkit-transition: 0.5s; transition: 0.5s;
            width: 50%;
            padding: 10px 20px;
            box-sizing: border-box;
        }
        .messages {
            margin: 5em;
        }
        .send-btn {
            background-color: #F4FEE2;
            padding: 10px 20px;
        }
        li {
            list-style-type: none;
            margin: 1em;
            text-align: center;
            font-size: 2em;
        }
    </style>
    <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
    <script type="text/javascript" src="node_modules/webstomp-client/dist/webstomp.min.js"></script>
</head>
<body>
    <div>
        <input type="text" class="message"/>
        <button onClick='send()' class="send-btn">보내기</button>
    </div>
    <div class="messages">
        <div id="messages"></div>
    </div>
</body>
    <script>
        let stomp;
        document.addEventListener("DOMContentLoaded", function() {
            // ["websocket", "xhr-streaming", "xhr-polling"]
            const sock = new SockJS('http://localhost:8080/test', null, {transports: ["xhr-polling"]});
            stomp = webstomp.over(sock);
            stomp.connect({}, function(frame) {
                console.log('Connected!!');

                stomp.subscribe("/user/queue/something", function(frame) {
                    const messages = document.querySelector("#messages");
                    const message = document.createElement("li");
                    message.innerText = frame.body;
                    messages.appendChild(message)
                });
            });
        });

        function send() {
            const message = document.querySelector(".message");
            stomp.send("/simple/good", message.value);
            message.value = '';
        }
    </script>
</html>
  • 클라이언트에서는 /user/queue/something 경로에 대해서 구독하고 있는 것을 볼 수 있음
  • 클라이언트에서 다른 사용자에게 메시지를 전송하고 싶다면, /user/{상대방 ID}를 접두사로 하여 메시지를 보내면 됨

 

서버 설정

public class AuthenticationChannelInterceptor implements ChannelInterceptor {
	private int num = 1;
	@Override
	public Message<?> preSend(Message<?> message, MessageChannel channel) {
		StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
		if (StompCommand.CONNECT.equals(accessor.getCommand())) {
			accessor.setUser(new User(accessor.getLogin()));
		}

		return message;
	}
}

 

CONNECT 메시지에서 login 헤더를 추출해 사용자 식별자로 설정

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		registry.addEndpoint("/test").setAllowedOrigins("*").withSockJS();
	}

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

	@Override
	public void configureClientInboundChannel(ChannelRegistration registration) {
		registration.interceptors(authenticationChannelInterceptor());
	}
}
  • SockJS 기반의 STOMP Configuration에서 AuthenticationChannelIntercept를 등록한다
@Controller
public class TestController {

	@MessageMapping("/good")
	@SendToUser("/queue/something")
	public String handle(@Payload String payload) {
		return payload;
	}

}
  • @SendToUser("/queue/something") /user/{username}/queue/something Destination의 사용자들에게 메시지를 보냄
  • UserDestinationMessageHandler /user/{username}/queue/something /queue/something-user{session-id} 형태로 변환함

foo 사용자가 bar 사용자에게 메시지를 전달하기 위해, /user/bar/queue/something Destination으로 요청을 보낸 경우

  • /user/bar/queue/something가 /queue/something-userizyex34p으로 변경된 것을 알 수 있음

 

프론트 설정

foo 사용자

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <style>
        .message {
            margin: 5em;
            color: olive;
            border: solid 2px #D2691E;
            -webkit-transition: 0.5s; transition: 0.5s;
            width: 50%;
            padding: 10px 20px;
            box-sizing: border-box;
        }
        .messages {
            margin: 5em;
        }
        .send-btn {
            background-color: #F4FEE2;
            padding: 10px 20px;
        }
        li {
            list-style-type: none;
            margin: 1em;
            text-align: center;
            font-size: 2em;
        }
    </style>
    <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
    <script type="text/javascript" src="node_modules/webstomp-client/dist/webstomp.min.js"></script>
</head>
<body>
    <div>
        <input type="text" class="message"/>
        <button onClick='send()' class="send-btn">보내기</button>
        <button onClick='sendOther()' class="send-btn">상대방에게 보내기</button>
    </div>
    <div class="messages">
        <div id="messages"></div>
    </div>
</body>
    <script>
        let stomp;
        document.addEventListener("DOMContentLoaded", function() {
            // ["websocket", "xhr-streaming", "xhr-polling"]
            const sock = new SockJS('http://localhost:8080/test', null, {transports: ["xhr-polling"]});
            stomp = webstomp.over(sock);
            stomp.connect({
                login: "foo"
            }, function(frame) {
                console.log('Connected!!');

                stomp.subscribe("/user/queue/something", function(frame) {
                    const messages = document.querySelector("#messages");
                    const message = document.createElement("li");
                    message.innerText = frame.body;
                    messages.appendChild(message)
                });
            });

        });

				// 스스로에게만 메시지 전송
        function send() {
            const message = document.querySelector(".message");
            stomp.send("/simple/good", message.value);
            message.value = '';
        }
				
				// bar 사용자에게 메시지 전송
        function sendOther() {
            const message = document.querySelector(".message");
            stomp.send("/user/bar/queue/something", message.value);
            message.value = '';
        }
    </script>
</html>

 

bar 사용자

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <style>
        .message {
            margin: 5em;
            color: olive;
            border: solid 2px #D2691E;
            -webkit-transition: 0.5s; transition: 0.5s;
            width: 50%;
            padding: 10px 20px;
            box-sizing: border-box;
        }
        .messages {
            margin: 5em;
        }
        .send-btn {
            background-color: #F4FEE2;
            padding: 10px 20px;
        }
        li {
            list-style-type: none;
            margin: 1em;
            text-align: center;
            font-size: 2em;
        }
    </style>
    <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
    <script type="text/javascript" src="node_modules/webstomp-client/dist/webstomp.min.js"></script>
</head>
<body>
    <div>
        <input type="text" class="message"/>
        <button onClick='send()' class="send-btn">보내기</button>
        <button onClick='sendOther()' class="send-btn">상대방에게 보내기</button>
    </div>
    <div class="messages">
        <div id="messages"></div>
    </div>
</body>
    <script>
        let stomp;
        document.addEventListener("DOMContentLoaded", function() {
            // ["websocket", "xhr-streaming", "xhr-polling"]
            const sock = new SockJS('http://localhost:8080/test', null, {transports: ["xhr-polling"]});
            stomp = webstomp.over(sock);
            stomp.connect({
                login: "bar"
            }, function(frame) {
                console.log('Connected!!');

                stomp.subscribe("/user/queue/something", function(frame) {
                    const messages = document.querySelector("#messages");
                    const message = document.createElement("li");
                    message.innerText = frame.body;
                    messages.appendChild(message)
                });
            });

        });
			
				// 스스로에게만 메시지 전송
        function send() {
            const message = document.querySelector(".message");
            stomp.send("/simple/good", message.value);
            message.value = '';
        }

				// foo 사용자에게 메시지 전송
        function sendOther() {
            const message = document.querySelector(".message");
            stomp.send("/user/foo/queue/something", message.value);
            message.value = '';
        }
    </script>
</html>

사용자가 하나 이상의 세션을 가지고 있다면, 기본적으로 Destination에 구독한 모든 세션으로 메시지가 보내진다

  • 메시지를 받은 하나의 세션에만 전송하고 싶은 경우에는 broadcast 속성을 false로 설정하면 됨
@Controller
public class TestController {
	@MessageMapping("/good")
	public String handle(@Payload String payload, Principal user) {
		if(payload.equals("error")) {
			throw new IllegalArgumentException("error 문자열은 취급할 수 없습니다.");
		}

		return payload;
	}

	@MessageExceptionHandler
	@SendToUser(destinations="/queue/errors", broadcast=false)
	public String handleException(IllegalArgumentException exception) {
		return exception.getMessage();
	}
}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <style>
        .message {
            margin: 5em;
            color: olive;
            border: solid 2px #D2691E;
            -webkit-transition: 0.5s; transition: 0.5s;
            width: 50%;
            padding: 10px 20px;
            box-sizing: border-box;
        }
        .messages {
            margin: 5em;
        }
        .send-btn {
            background-color: #F4FEE2;
            padding: 10px 20px;
        }
        li {
            list-style-type: none;
            margin: 1em;
            text-align: center;
            font-size: 2em;
        }
    </style>
    <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
    <script type="text/javascript" src="node_modules/webstomp-client/dist/webstomp.min.js"></script>
</head>
<body>
    <div>
        <input type="text" class="message"/>
        <button onClick='send()' class="send-btn">보내기</button>
        <button onClick='sendOther()' class="send-btn">상대방에게 보내기</button>
    </div>
    <div class="messages">
        <div id="messages"></div>
    </div>
</body>
    <script>
        let stomp1, stomp2;
        document.addEventListener("DOMContentLoaded", function() {
            // ["websocket", "xhr-streaming", "xhr-polling"]
            const sock1 = new SockJS('http://localhost:8080/test', null, {transports: ["xhr-polling"]});
            stomp1 = webstomp.over(sock1);
            stomp1.connect({
                login: "foo"
            }, function(frame) {
                console.log('First Connected!!');

                stomp1.subscribe("/user/queue/errors", function(frame) {
                    const messages = document.querySelector("#messages");
                    const message = document.createElement("li");
                    message.innerText = frame.body + " - first";
                    messages.appendChild(message)
                });
            });

            // ["websocket", "xhr-streaming", "xhr-polling"]
            const sock2 = new SockJS('http://localhost:8080/test', null, {transports: ["xhr-polling"]});
            stomp2 = webstomp.over(sock2);
            stomp2.connect({
                login: "foo"
            }, function(frame) {
                console.log('Second Connected!!');

                stomp2.subscribe("/user/queue/errors", function(frame) {
                    console.log("11");
                    const messages = document.querySelector("#messages");
                    const message = document.createElement("li");
                    message.innerText = frame.body + " - second";
                    messages.appendChild(message)
                });
            });
        });

        function send() {
            const message = document.querySelector(".message");
            stomp1.send("/simple/good", message.value);
            message.value = '';
        }

        function sendOther() {
            const message = document.querySelector(".message");
            stomp1.send("/user/bar/queue/something", message.value);
            message.value = '';
        }
    </script>
</html>
  • 메시지를 보낸 stomp1의 세션을 통해서만 에러 메시지가 전달 됨

일반적으로 /user destination은 인증된 사용자를 대상으로 의미하지만, 사용자 인증을 하지 않은 WebSocket 세션도 /user destination을 구독할 수 있음

  • 사용자 인증을 하지 않은 WebSocket 세션의 경우에는 @SendToUser broadcast 속성이 false로 설정됨

Message Template

애플리케이션 컴포넌트에서는 Messaging Template을 사용해서 /user Destination에 메시지를 보낼 수 있음

Messaging Template SimpMessagingTemplate 객체이다

@Controller
public class TestController {

	private SimpMessagingTemplate simpMessagingTemplate;

	public TestController(SimpMessagingTemplate simpMessagingTemplate) {
		this.simpMessagingTemplate = simpMessagingTemplate;
	}

	@MessageMapping("/good")
	public void handle(@Payload String payload, Principal user) {
		System.out.println(payload);
		System.out.println(user);

		simpMessagingTemplate.convertAndSendToUser(user.getName(), "/queue/something", payload);
	}

}

 

다중 애플리케이션 서버 시나리오에서 사용자가 한 서버에 연결되어 있기 때문에, 다른 서버에서 /user Destination은 확인되지 않은 상태로 남아 있을 수 있음

 

이 경우 다른 서버가 확인되지 않은 메시지를 브로드캐스트 시도하도록 Destination을 설정할 수 있음

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		registry.addEndpoint("/test").setAllowedOrigins("*").withSockJS();
	}

	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {
		registry
			.setApplicationDestinationPrefixes("/simple")
			.enableStompBrokerRelay("/topic", "/queue")
			.setUserDestinationBroadcast("/topic/unresolved-user-destination");
	}
}

 

 

Message 순서

브로커로 부터 받은 메시지는 clientOutboundChannel publish 됨

 

채널은 ThreadPoolExecutor에 보관되며 메시지는 서로 다른 Thread에서 처리되기 때문에, 클라이언가 수신한 메시지 순서는 clientOutboundChannel publication(게시)된 순서와 정확하게 일치한다고 보장할 수 없음

 

해당 이슈가 있다면, 아래와 같이 setPreservePublishOrder 속성을 설정하면 됨

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		registry.addEndpoint("/test").setAllowedOrigins("*").withSockJS();
	}

	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {
		registry
			.setPreservePublishOrder(true)
			.setApplicationDestinationPrefixes("/simple")
			.enableSimpleBroker("/topic", "/queue");
	}
}
  • PreservePublishOrder 속성을 설정했다면, 동일한 클라이언트 세션을 통해서 보낼 메시지들은 한번에 하나씩clientOutboundChannel 채널에 publish 됨
  • clientOutboundChannel 채널에 publication된 순서와 클라이언트가 수신한 메시지 순서를 정확하게 일치시키도록 보장할 수 있음
  • 약간의 성능 오버헤드가 발생할 수 있기 때문에 오직 필요한 경우에만 사용

Events

ApplicationContext가 발생시키는 여러 Events 는 스프링의 ApplicationListener를 통해서 전달받을 수 있음

  • BrokerAvailabilityEvent : BrokerAvailabilityEvent는 브로커가 이용 가능하거나 불가능할 때 발생하는 이벤트
    • Simple Broker는 애플리케이션 시작과 동시에 이용 가능하고 지속적으로 유지되지만, STOMP Broker Relay는 외부 브로커와 연결이 끊길 수 있음
    • STOMP Broker Relay는 System TCP Connection을 다시 연결해야 하며, 이 이벤트는 브로커와의 연결 상태가 변할 때마다 발생한다
    • SimpMessagingTemplate을 사용하는 컴포넌트는 해당 이벤트를 구독하여 브로커를 사용할 수 없는 경우에는 메시지를 보내지 않도록 해야 함
    • SimpMessagingTemplate 사용하는 경우에는 항상 MessageDeliveryException 대한 예외 처리를 준비해야 함
  •  SessionConnectEvent : SessionConnectEvent는 STOMP 프로토콜이 새로운 (클라이언트 세션의 시작을 알리는) CONNECT 메시를 받은 경우에 발생
    • 이 이벤트는 session ID, 사용자 정보, 사용자가 보낸 커스텀 헤더 등을 가지고 있는 CONNECT 메시지를 포함한다(클라이언트 세션 추적에 유용)
    • 해당 이벤트를 구독한 컴포넌트는 메시지를 SimpMessageHeaderAccessor 또는 StompMessageHeaderAccessor으로 Wrapping 할 수 있음
  •  SessionConnectedEvent : SessionConnectedEvent는 브로커가 CONNECT 메시지에 대한 응답으로 STOMP의  CONNECTED 프레임을 보낸 경우, 즉 SessionConnectEvent 이후에 발생
    • 이벤트가 발생한 시점에는 STOMP 세션이 완전하게 수립된 것으로 간주할 수 있음
  •  SessionSubscribeEvent : SessionSubscribeEvent는 새로운 STOMP SUBSCRIBE 메시지를 받은 경우에 발생한다
  • SessionUnsubscribeEvent : SessionUnsubscribeEvent은 새로운 STOMP UNSUBSCRIBE 메시지를 받은 경우에 발생한다
  • SessionDisconnectEvent : SessionDisconnectEvent는 STOMP 세션이 끝난 경우에 발생한다
    • 구체적으로 클라이언트로 부터 DISCONNECT 메시지를 받거나 WebSokcet 세션이 닫히는 경우에 자동으로 이벤트가 발생한다.
    • 경우에 따라 해당 이벤트가 두 번 이상 발생하기 때문에, 컴포넌트는 다양한 disconnect 이벤트와 멱등성을 가져야 한다

 

Interception

Events STOMP 연결의 라이프사이클(과정)에 대한 알림을 제공하지만, 클라이언트가 보낸 모든 메시지에 대해서 이벤트를 제공하지 않음

 

모든 메시지에 대한 처리를 위해 ChannelInterceptor을 등록해 사용할 수 있음

 

예제 - 클라이언트로부터 전달된 inbound message를 가로채서 처리

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		registry.addEndpoint("/test").setAllowedOrigins("*").withSockJS();
	}

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

	@Override
	public void configureClientInboundChannel(ChannelRegistration registration) {
		registration.interceptors(new MyChannelInterceptor());
	}
}
public class MyChannelInterceptor implements ChannelInterceptor {
	@Override
	public Message<?> preSend(Message<?> message, MessageChannel channel) {
		StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
		StompCommand command = accessor.getCommand();

		// ..

		return message;
	}
}

 

플리케이션은 메시지를 별도의 Thread에서 처리하는 ExecutorChannelInterceptor(ChannelInterceptor 하위 인터페이스)를 사용할 수도 있음

 

ChannelInterceptor는 채널로 메시지가 보내질 때마다 한 번씩 호출되지만, ExecutorChannelInterceptor는  각 MessageHandler의 Thread에서 Hook을 제공

 

MessageHandler handleMessage() 메서드가 호출되기 이전과 이후에 ExecutorChannelInterceptor beforeHandle() afterMessageHandled() 메서드가 호출되는데, 실제로 각 메서드는 MessageHandler가 동작중인 Thread에서 실행됨

 

WebSocket Scope

 WebSocket 세션은 Attributes(속성)에 대한 Map을 가지고 있으며, 해당 Map은 클라이언트로 부터 받은 Inbound 메시지 헤더에 추가되기 때문에 여러 컨트롤러에서 접근이 가능하다

@Controller
public class TestController {

	@MessageMapping("/good")
	public void handle(SimpMessageHeaderAccessor messageHeaderAccessor) {
		Map<String, Object> attributes = messageHeaderAccessor.getSessionAttributes();
		// ...
	}

}

 

스프링은 websocket scope를 가진 Bean 선언을 지원하는데, 해당 websocket 스코프를 가진 Bean은 컨트롤러와 clientInboundChannel에 등록된 여러 ChannelInterceptor 구현체에서 주입받아 사용할 수 있음

websocket scope를 가진 Bean은 일반적으로 싱글톤이지만, 개별 WebSocket 세션보다 오래 유지되므로, websocket scope를 가진 Bean은 아래와 같이 Proxy Mode로 사용해야 한다

@Component
@Scope(scopeName = "websocket", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyBean {

	private int data = 1;

	@PostConstruct
	public void init() {
		System.out.println("my bean init");
	}

	@PreDestroy
	public void destroy() {
		System.out.println("my bean destroy");
	}

	public MyBean() {
		System.out.println("my bean construct");
	}

	public void count() {
		System.out.println("my bean count = " + data++);
	}
}

 

각 WebSocket 세션이 지속되는 동안 Bean이 유지되는지 확인하기 위해 data 변수를 사용함

@Controller
public class TestController {

	private final MyBean myBean;

	public TestController(MyBean myBean) {
		this.myBean = myBean;
	}

	@MessageMapping("/good")
	public String handle(SimpMessageHeaderAccessor messageHeaderAccessor, String payload) {
		myBean.count();

		Map<String, Object> attributes = messageHeaderAccessor.getSessionAttributes();
		System.out.println(attributes);

		return payload;
	}

}

  • 커스텀 스코프와 마찬가지로 스프링은 새로운 MyBean을 컨트롤러가 첫 번째로 접근했을 때 생성 및 초기화하고 인스턴스를 WebSocket 세션 Attributes에 저장함
  • 이후 세션 종료 시까지 동일한 인스턴스를 반환함

websocket scope를 가진 Bean은 스프링 라이프 사이클 단계마다 호출해주는 PostConstruct, PreDestory 메서드를 가질 수 있음

 

 

'항해 99 > Spring' 카테고리의 다른 글

WebSocket 활용 웹 게임 구현  (0) 2024.04.03
WebSocket - 실제 코드 분석  (1) 2024.04.03
WebSocket - STOMP 1  (1) 2024.03.29
WebSocket - SockJS  (0) 2024.03.28
WebSocket - 기본 websocket  (0) 2024.03.27