Spring WebSocket Stomp 메시지 보내기 - 2. specific user
web/websocket

Spring WebSocket Stomp 메시지 보내기 - 2. specific user

반응형

이전 글에서 broadcast로 메시지를 보내는 것에 대해 공부해봤다. 그럼 이제 특정 유저에게 메시지를 보낼 수 있는 방법에 대해 알아보자.

https://wedul.site/694

 

Spring WebSocket Stomp 메시지 보내기 - 1. broadcast

@Controller public class GreetingController { @MessageMapping("/hello") // simple broker를 사용하려면 Broker 설정에서 사용한 config에 맞는 값을 사용해야한다. @SendTo("/topic/greetings") public Greet..

wedul.site

 

설정 

우선 messgeBroker를 통해서 특정 유저와 메시지를 주고 받을 수 있는 prefix 설정이 필요하다. MessageBrokerRegistry에 setApplicationDestinationPrefixes를 통해 들어오는 모든 url에 대해 모든 prefix를 기존에 설정해주었듯이 setUserDestinationPrefix를 통해 특정 유저에게 보내는 사용자 path를 지정해줄 수 있다. 아무 설정 안할 시 기본값으로 /user로 지정된다.

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketBrokerConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        //
        config.enableSimpleBroker("/topic", "/direct")
            .setTaskScheduler(taskScheduler())
            // 안쓰면 그냥 10s 씩 (inbound, outbound)
            .setHeartbeatValue(new long[] {3000L, 3000L});
        // client -> server로 보내는 endpoint의 기본 prefix
        config.setApplicationDestinationPrefixes("/app");
        // specific user endpoint prefix (default /user)
        config.setUserDestinationPrefix("/user");
    }
}

 

그리고 현재 sessionId만을 가지고 session의 subscription을 찾아서 메시지를 주고 받는 broadcast messaging만 하였는데 user에게 메시지를 전달 할 때 해당 user에 모든 세션에 메시지를 전달할 수 있어야한다. 그러기 위해서는 현재 접속한 세션이 어떤 유저인지를 binding시켜주어야하는데 WebSocketMessageBrokerConfigurer에서 configureClientInboundChannel 설정을 override해서 들어오는 세션에 대해 user를 매핑해주도록 했다. 이렇게 작업하거나 아니면 stompEndpoint등록 시에 setHandshakeHandler를 등록하거나 별도의 filter를 만들어서 동작하게 해줘도 된다.

 

아래 예제에서는 메시지가 Connection을 맺을 때 header에서 user정보를 뽑고 session에 저장해줬다.

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketBrokerConfig implements WebSocketMessageBrokerConfigurer {

	@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);

                if (StompCommand.CONNECT.equals(accessor.getCommand())) {
                    String user = accessor.getFirstNativeHeader("user");
                    if (user != null) {
                        List<GrantedAuthority> authorities = new ArrayList<>();
                        authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
                        Authentication auth = new UsernamePasswordAuthenticationToken(user, user, authorities);
                        SecurityContextHolder.getContext().setAuthentication(auth);
                        accessor.setUser(auth);
                    }
                }

                return message;
            }
        });
    }
}

 

그럼 client code로 실제 connection을 맺어보자. wedul, chul이라는 두개의 user name으로 각각 연결을 시도했다.

 

그럼 SimpleBrokerMessageHandler에서 session에 매핑된 Pricipal 정보를 내부에 저장하고 있는 sessions map에 sessionId와 함께 저장하고 ack 메시지를 client에게 전송한다.

else if (SimpMessageType.CONNECT.equals(messageType)) {
	logMessage(message);
	if (sessionId != null) {
		long[] heartbeatIn = SimpMessageHeaderAccessor.getHeartbeat(headers);
		long[] heartbeatOut = getHeartbeatValue();
		Principal user = SimpMessageHeaderAccessor.getUser(headers);
		MessageChannel outChannel = getClientOutboundChannelForSession(sessionId);
		this.sessions.put(sessionId, new SessionInfo(sessionId, user, outChannel, heartbeatIn, heartbeatOut));
		SimpMessageHeaderAccessor connectAck = SimpMessageHeaderAccessor.create(SimpMessageType.CONNECT_ACK);
		initHeaders(connectAck);
		connectAck.setSessionId(sessionId);
		if (user != null) {
			connectAck.setUser(user);
		}
		connectAck.setHeader(SimpMessageHeaderAccessor.CONNECT_MESSAGE_HEADER, message);
		connectAck.setHeader(SimpMessageHeaderAccessor.HEART_BEAT_HEADER, heartbeatOut);
		Message<byte[]> messageOut = MessageBuilder.createMessage(EMPTY_PAYLOAD, connectAck.getMessageHeaders());
		getClientOutboundChannel().send(messageOut);
	}
}

연결이 완료되면 SimpleBrokerMessageHandler에서 남긴 로그를 확인해볼수있는데 header로 전달한 user가 잘 전달되어 세션과 잘 매핑되었으며 session에서 subscribe한 subscription정보도 확인 할 수 있다.

2021-08-15 18:01:10.538 DEBUG 25682 [clientInboundChannel-3] --- o.s.m.s.b.SimpleBrokerMessageHandler : Processing CONNECT user=wedul session=35q3pmue
2021-08-15 18:01:10.538 DEBUG 25682 [clientInboundChannel-5] --- o.s.m.s.b.SimpleBrokerMessageHandler : Processing CONNECT user=chul session=crplrw02
2021-08-15 18:01:10.553 DEBUG 25682 [clientInboundChannel-10] --- o.s.m.s.b.SimpleBrokerMessageHandler : Processing SUBSCRIBE destination=/topic/data-usercrplrw02 subscriptionId=sub-0 session=crplrw02 user=chul payload=byte[0]
2021-08-15 18:01:10.553 DEBUG 25682 [clientInboundChannel-12] --- o.s.m.s.b.SimpleBrokerMessageHandler : Processing SUBSCRIBE destination=/topic/data-user35q3pmue subscriptionId=sub-0 session=35q3pmue user=wedul payload=byte[0]
2021-08-15 18:01:10.557 DEBUG 25682 [clientInboundChannel-16] --- o.s.m.s.b.SimpleBrokerMessageHandler : Processing SUBSCRIBE destination=/topic/message-usercrplrw02 subscriptionId=sub-1 session=crplrw02 user=chul payload=byte[0]
2021-08-15 18:01:10.558 DEBUG 25682 [clientInboundChannel-14] --- o.s.m.s.b.SimpleBrokerMessageHandler : Processing SUBSCRIBE destination=/topic/message-user35q3pmue subscriptionId=sub-1 session=35q3pmue user=wedul payload=byte[0]

 

 

전송

그럼 설정이 완료되었으니 실제 메시지를 전송해보자. 메시지를 보내는 방법은 @SendToUser 애노테이션을 사용하는 방법과 messagingTemplate를 사용해서 메시지를 보내는 방법 두가지가 존재한다. 

 

우선 messagingTemplate를 통해서 메시지를 보내는 방법을 알아보자.

 

1. messagingTemplate를 사용해서 메시지 전송

@Controller
@RequiredArgsConstructor
public class UserController {

    private final SimpMessagingTemplate messageTemplate;

		@MessageMapping("/message/sendToUser")
    // simple broker를 사용하려면 Broker 설정에서 사용한 config에 맞는 값을 사용해야한다.
    public void greeting(UserMessage message) {
        messageTemplate.convertAndSendToUser(message.getTargetUserName(), "/topic/data", new Greeting(HtmlUtils.htmlEscape(message.getMessage())));
    }

}




package com.wedul.websocket.dto;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
public class UserMessage {

    private String targetUserName;
    private String message;

    @Builder
    public UserMessage(String targetUserName, String message) {
        this.targetUserName = targetUserName;
        this.message = message;
    }
}

client로 부터 /app/message/sendToUser로 요청을 보내고 함께 보내는 payload에 targetUSerName과 message를 담아서 전송할 수 있다.

 

이때 호출되는 convertAndSendUser 파라미터로는 targetUserName, 브로커 채널 destination주소, payload 순서로 전송된다. 우리로 따지면 wedul 유저에게 전송할 시 "wedul", "/topic/data", payload 순서이다.

 

그리고 중요한 부분이 있는데 전송되는 endpoint는 broker 설정 시 지정했던 prefix가 붙은 endpoint만 가능하다. 우리는 /topic, /direct를 지정했기 때문에 /topic/data로 메시지를 보낼때 클라이언트에서 subscribe를 했을 때 메시지를 받을 수 있다. 만약 /send/data와 같이 브로커에 등록되지 않은 엔드포인트로 사용 시 클라이언트가 구독을 하고 있어도 받을수가 없다.

 

그리고 이렇게 메시지가 messagingTemplate를 통해서 서버에서 전송 시 UserDestinationMessageHandler를 통해서 /user/{username}/endpoint의 형태로 변경하게 되는데 우리 예시로 따지면 wedul user에게 메시지를 전송한다고 가정했을 때 /user/wedul/topic/data로 변형되어 전송된다. 이를 통해서 별도의 그들의 이름이나 목적지를 알 필요없이 메시지가 사용자에게 잘 전달될 수 있게 해준다.

 

클라이언트에서는 서버의 destination 경로 path와 상관없이 /user/topic/data를 subscribe 하고 있으면 되는데 /user로 시작하는 경우에 unique identifier를 위해서 일반적으로 /topic/data-userid{sessionId}의 형태로 목적지를 설정한다. 서버의 경우도 UserDestinationManageHandler에서 target을 동일한 형태로 생성 후 전송하기 때문에 다른 유저에게 보내지 않고 특정 유저에게만 잘 전달할 수 있다.

// client code

// 버튼 클릭 시 
$("#sendMessageTemplateToUser").click(function () {
    let sendUserName = $("#sendUserName option:selected").val();
    let receiveUserName = $("#receiveUserName option:selected").val();

    let client = sendUserName === 'wedul' ? wedulStompClient : chulStompClient;
    sendMessageTemplateToUser(client, receiveUserName);
});

function sendMessageTemplateToUser(client, user) {
    client.send("/app/message/sendToUser", {}, JSON.stringify({
        'message': $("#message").val(),
        'targetUserName': user
    }));
}

이렇게 설정 후 메시지를 보내보면 전송된 메시지가 /app/message/sendToUser를 통해 컨트롤러에 잘 전송되고 wedul(sessionId 3sunl30)으로 잘 전송된 것을 확인할 수 있다.

2021-08-15 21:06:24.660 DEBUG 25682 [clientInboundChannel-33] --- .WebSocketAnnotationMethodMessageHandler : Searching methods to handle SEND /app/message/sendToUser session=e0hyyt0e, lookupDestination='/message/sendToUser'
2021-08-15 21:06:24.669 DEBUG 25682 [clientInboundChannel-33] --- .WebSocketAnnotationMethodMessageHandler : Invoking UserController#greeting[1 args]
2021-08-15 21:30:09.658 DEBUG 25682 [clientInboundChannel-33] --- o.s.m.s.b.SimpleBrokerMessageHandler : Processing MESSAGE destination=/topic/data-userid3unl30 session=null payload={"content":"안녕"}
2021-08-15 21:30:09.666 DEBUG 25682 [clientInboundChannel-33] --- o.s.m.s.b.SimpleBrokerMessageHandler : Broadcasting to 1 sessions.

 

chul과 wedul 두 명의 사용자가 같은 endpoint를 subscribe하고 있었으나 실제로 wedul 사용자에게만 메시지가 잘 전달된것을 확인할 수 있다. 

 

selectBox로 sender와 receiver를 바꿔가면서 메시지를 주고 받아도 잘 되는 것을 확이할 수 있다.

 

두 번째 전송 방법으로는 위에서 명시한 @SendToUser 애노테이션을 붙여서 보내는 방법이다. convertAndSendToUser로 직접 보내는 방법 이외에도 @MessageMapping영역에 @SendToUser 애노테이션을 통해 메시지를 전송할 수도 있는데 이때는 세션에 binding되어 있는 Principal 정보를 보고 전달하기 때문에 명시적인 targetUserName등으로 전송하기에는 어려움이 따른다.

 

@MessageMapping("/message")
// simple broker를 사용하려면 Broker 설정에서 사용한 config에 맞는 값을 사용해야한다.
@SendToUser("/topic/message")
public Greeting greetingMessage(UserMessage message) {
    return new Greeting(HtmlUtils.htmlEscape("To "+ message.getTargetUserName() + ", " + message.getMessage()));
}

 

 

다음번에는 현재는 SimpleBroker만 사용하고 있으나 서버가 한대가 아니라 여러대이기 때문에 세션과 User를 공유하기 위해서는 redis연동이 필요하기 때문에 redis연동을 진행해보자.

 

코드

https://github.com/weduls/websocket_study

반응형