오봉이와 함께하는 개발 블로그

Spring Framework - STOMP (convertAndSend vs convertAndSendToUser) 본문

BE/Spring

Spring Framework - STOMP (convertAndSend vs convertAndSendToUser)

오봉봉이 2024. 2. 22. 00:30
728x90

개요

회사 업무를 위해 WebSocket 기술 중 STOMP를 사용할 일이 생겼는데, 어떤 방식을 통해 클라이언트에서 서버를 구독할지 고르던 중 팀장님의 요청으로 어떤 것이 좋을지 자료 조사를 부탁하셔서 업무차 자료조사 했지만 의미있는 시간이 된 거 같아 해당 내용은 글로 남긴다.
프레임워크 내부까지 코드를 보는 것이 좋은 건지 모르겠지만 적어도 내가 특정한 무언가를 사용할 때 사이드 이펙트를 만들지 않기 위해 그런 코드를 보고 어떻게 동작하는지, 왜 이런 동작이 발생하는지 알아두는 것이 좋을 거 같다는 생각에 의미있다 생각한다.

먼저 WebSocket을 모른다면 아래 글부터 보는 것을 추천한다.
https://5bong2-develop.tistory.com/541

convertAndSend vs convertAndSendToUser

먼저 위 글올 봤다면 WebSocket은 기본적으로 브로드캐스팅 방식으로 동작한다는 것을 알 수 있다. 그럼 커넥션을 맺고, 해당 구독 경로를 사용하는 모든 유저에게 메시지를 보내는데... 민감한 정보를 브로드캐스팅 방식으로 보낸다? 생각만 해도 끔찍한 일이다.
근데 이상하다 우리가 사용하는 채팅방이나 그런 것들은 단일로 전송이 되는데 어떻게 브로드캐스팅이라는 규약을 무시하고 가능한거지?
정답은 구독 경로에 있다. 구독 경로를 각 유저마다 독립적으로 사용하면 해당 유저에게만 메시지를 전송할 수 있다.

그래서 convertAndSendconertAndSendToUser가 있다.

  • convertAndSend
    • convertAndSend("/구독 경로", 전송할 데이터);
  • conertAndSendToUser
    • conertAndSendToUser(user의 uniqueID, "/구독 경로", 전송할 데이터);

위와 같은 형식으로 사용한다.

convertAndSend는 해당 구독 경로가 모든 유저에게 동일하게 적용된다.
conertAndSendToUser는 구독 경로가 유저 객체마다 다르게 생성되어 적용된다.
예를 들어 convertAndSend의 구독 경로가 /topic/subscribe 일 때 WebSocket에 연결된 모든 유저는 해당 경로를 통해 생성된 메시지를 전송 받는다.

conertAndSendToUser의 경우 구독 경로가 /topic/subscribe 일 때 user의 uniqueID가 추가되어 /user/user의 uniqueID/topic/subscribe 이라는 경로로 메시지를 구독한다.

사실은...

하지만 사실 conertAndSendToUser의 경우 실제 구현한 코드를 보면 convertAndSend를 사용한다.
즉, 내부적으로는 같은 프로토콜을 사용하여 메시지를 전송한다는 것이다.

    @Override
    public void convertAndSendToUser(String user, String destination, Object payload,
            @Nullable Map<String, Object> headers, @Nullable MessagePostProcessor postProcessor)
            throws MessagingException {

        Assert.notNull(user, "User must not be null");
        String username = user;
        Assert.isTrue(!user.contains("%2F"), () -> "Invalid sequence \"%2F\" in user name: " + username);
        user = StringUtils.replace(user, "/", "%2F");
        destination = destination.startsWith("/") ? destination : "/" + destination;
        super.convertAndSend(this.destinationPrefix + user + destination, payload, headers, postProcessor);
    }

내부에서 String user를 통해 convertAndSend에 사용할 구독 경로를 생성해준다.
이 뜻은 예를 들어 user의 uniqueID가 user_1234일 경우 아래와 같이 사용하면 똑같은 효과 내지 비슷한 효과를 낼 수 있다는 것이다.
convertAndSend("/topic/subscribe/user_1234", MessageData);
물론 WebSocket 커넥션 시 user_1234라는 user의 uniqueID를 파싱할 수 있는 인터셉터를 WebSocketConfig에 작성해두어야 할 것이다.
이런저런 귀찮은 작업들이 있으니... Principal이라는 java.security 객체를 사용해 user uniqueID생성과 파싱은 Spring Framework에 맡기고 convertSendToUser를 사용하여 자동으로 매핑시켜 사용할 수 있다.

아래는 실제 코드이다 (DefaultUserDestinationResolver.class)

@Override
@Nullable
public UserDestinationResult resolveDestination(Message<?> message) {
    ParseResult parseResult = parse(message);
    if (parseResult == null) {
        return null;
    }
    String user = parseResult.getUser();
    String sourceDest = parseResult.getSourceDestination();
    Set<String> sessionIds = parseResult.getSessionIds();
    Set<String> targetSet = new HashSet<>();
    for (String sessionId : sessionIds) {
        String actualDest = parseResult.getActualDestination();
        String targetDest = getTargetDestination(sourceDest, actualDest, sessionId, user);
        if (targetDest != null) {
            targetSet.add(targetDest);
        }
    }
    String subscribeDest = parseResult.getSubscribeDestination();
    return new UserDestinationResult(sourceDest, targetSet, subscribeDest, user, sessionIds);
}

@Nullable
private ParseResult parse(Message<?> message) {
    MessageHeaders headers = message.getHeaders();
    String sourceDestination = SimpMessageHeaderAccessor.getDestination(headers);
    if (sourceDestination == null || !checkDestination(sourceDestination, this.prefix)) {
        return null;
    }
    SimpMessageType messageType = SimpMessageHeaderAccessor.getMessageType(headers);
    if (messageType != null) {
        return switch (messageType) {
            case SUBSCRIBE, UNSUBSCRIBE -> parseSubscriptionMessage(message, sourceDestination);
            case MESSAGE -> parseMessage(headers, sourceDestination);
            default -> null;
        };
    }
    return null;
}

@Nullable
private ParseResult parseSubscriptionMessage(Message<?> message, String sourceDestination) {
    MessageHeaders headers = message.getHeaders();
    String sessionId = SimpMessageHeaderAccessor.getSessionId(headers);
    if (sessionId == null) {
        logger.error("No session id. Ignoring " + message);
        return null;
    }
    int prefixEnd = this.prefix.length() - 1;
    String actualDestination = sourceDestination.substring(prefixEnd);
    if (isRemoveLeadingSlash()) {
        actualDestination = actualDestination.substring(1);
    }
    Principal principal = SimpMessageHeaderAccessor.getUser(headers);
    String user = (principal != null ? principal.getName() : null);
    Assert.isTrue(user == null || !user.contains("%2F"), () -> "Invalid sequence \"%2F\" in user name: " + user);
    Set<String> sessionIds = Collections.singleton(sessionId);
    return new ParseResult(sourceDestination, actualDestination, sourceDestination, sessionIds, user);
}

결론

convertAndSend와 convertAndSendToUser 방식의 차이는 user의 uniqueID(유저 정보 혹은 접근 정보)를 직접 관리하는지, 프레임워크가 관리하는지의 차이이다.
즉, 명시적으로 /user/topic/subscribe/user_id1234 라는 구독 경로를 만들어 구독하게 하는지
/user/topic/completion 이라는 경로를 프레임워크가 /user/user_id1234/topic/completion 이라는 경로를 만들어 구독하게 하는지의 차이인 것이다.

내가 하나부터 열까지 직접 관리하고 싶다면 WebSocketConfig에 interceptor를 걸어서 초기 HTTP 연결 시 id를 주는 방법을 사용하고, Framework에 맡기고 싶다면 WebSocketConfig에 Principal 객체를 사용하는 interceptor를 걸어서 Principal을 사용하는 방법을 선택하자.

728x90
Comments