[Spring WebSocket] STOMP - convertAndSendToUser 알고 사용하기

2025. 11. 4. 21:46·트러블슈팅

✅ 개요

Spring Stomp 기술 중 `SimpMessageSendingOperations.convertAndSendToUser()` 메서드를 사용해 특정 사용자에게만 메시지를 전달하는 과정에서의 시행착오와 디버깅을 통해 해결하는 과정을 정리하고자 합니다.

 

✅ 문제 상황

프로젝트 채팅 서비스 설계상 사용자 A가 사용자 B에게 채팅 요청을 보내면 사용자 B에게 채팅 요청을 수락 또는 거절할 수 있는 메시지를 보내는 이벤트를 발행하고 있습니다.

그리고 이벤트를 받아 처리하는 서비스 로직에서 `convertAndSendToUser()` 메서드를 사용하고 있습니다.

더보기
@Component
@RequiredArgsConstructor
public class ChatEventListener {

    private final ChatNotifyService chatNotifyService;

    @EventListener
    public void handleChatRequestCreatedEvent(ChatRequestCreatedEvent event) {
        chatNotifyService.sendChatRequestNotification(event.chatRequest());
    }
}
@Service
@RequiredArgsConstructor
public class ChatNotifyService {

    private final SimpMessageSendingOperations messageTemplate;

    public void sendChatRequestNotification(ChatRequest chatRequest) {
        StompChatRequestNotification notification = StompChatRequestNotification.builder()
                                                                                .requestId(chatRequest.getRequestId())
                                                                                .senderId(chatRequest.getSenderId())
                                                                                .senderName(chatRequest.getSenderName())
                                                                                .senderProfileImage(chatRequest.getSenderProfileImage())
                                                                                .expiresAt(chatRequest.getExpiresAt())
                                                                                .build();
        messageTemplate.convertAndSendToUser(
                String.valueOf(chatRequest.getReceiverId()),
                "/queue/chat-requests",
                notification);
    }
}

`convertAndSendToUser()` 메서드는 첫 번째 인자로 `String user`를 받고 있습니다. 따라서 사용자의 식별자(Id)를 전달하면 되겠거니 하고 Id를 전달해 봤습니다.

 

그리고 WebSocket 설정 클래스는 다음과 같이 구성했습니다.

더보기
@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer {

    private final StompHandler stompHandler;

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/connect")
                .setAllowedOrigins("http://localhost:3000")
                .withSockJS();
    }

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

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(stompHandler);
    }
}

하지만 아무리 해봐도 프론트에서 이를 전달받지 못했습니다. 그래서 내부적으로 어떤 식으로 처리되는지 알아봐야겠다고 생각했습니다.

 

✅ 원인 분석

어떤 과정으로 메시지를 전달하는지 주요 클래스와 메서드들을 디버깅해 보았습니다.

 

1️⃣ SimpMessagingTemplate

💡 `this.destinationPrefix`는 `/user/`로 디폴트 값이 설정되어 있는데, 설정 클래스에서 따로 지정할 수 있습니다.
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
    //...
    registry.setUserDestinationPrefix("/custom"); //커스텀 설정
}

 

2️⃣ UserDestinationMessageHandler

 

3️⃣ DefaultUserDestinationResolver

 

4️⃣ DefaultSimpUserRegistry

`this.users`에 아무런 정보도 저장되어 있지 않습니다. 결론적으로 `DefaultUserDestinationResolver`에서 빈 sessionIds를 반환하고 `UserDestinationMessageHandler`에서는 `targetDestinations`가 비어있어 메시지 전송 로직을 호출하지 못하고 return이 되고 있습니다.

 

✅ 해결 방법

결론만 먼저 정리하자면 위와 같은 문제를 해결하기 위해 `JwtHandshakeInterceptor`라는 클래스와 `StompHandshakeHandler`라는 클래스를 추가했습니다.

더보기
@Component
@RequiredArgsConstructor
public class JwtHandshakeInterceptor implements HandshakeInterceptor {

    private final JwtProvider jwtProvider;

    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
        String token = getTokenFromQuery(request);

        if (token != null) {
            jwtProvider.validateToken(token);
            Long id = jwtProvider.getId(token);
            attributes.put("userId", id);

            return true;
        }

        return false;
    }

    @Override
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { /*do nothing*/ }

    private String getTokenFromQuery(ServerHttpRequest request) {
        String query = request.getURI().getQuery();
        if (query != null) {
            String[] params = query.split("&");
            for (String param : params) {
                if (param.startsWith("token=")) {
                    return param.substring(6);
                }
            }
        }
        return null;
    }
}
@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer {

    private final StompHandler stompHandler;
    private final JwtHandshakeInterceptor jwtHandshakeInterceptor; //추가

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/connect")
                .setAllowedOrigins("http://localhost:3000")
                .addInterceptors(jwtHandshakeInterceptor) //추가
                .setHandshakeHandler(new StompHandshakeHandler()) //추가
                .withSockJS();
    }

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

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(stompHandler);
    }

	//추가
    private static class StompHandshakeHandler extends AbstractHandshakeHandler {

        @Override
        protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) {
            return () -> attributes.get("userId").toString();
        }
    }
}

그리고 프론트 쪽에서는 WebSocket을 연결할 때 JWT를 파라미터로 전달합니다. SockJS가 WebSocket 연결 초기 요청(핸드셰이크) 시 커스텀 HTTP 헤더를 전달하지 않기 때문에 서버에서 헤더로는 읽을 수가 없었습니다.

더보기
const client = new Client({
  webSocketFactory: () =>
    new SockJS(
      `${
        process.env.NEXT_PUBLIC_API_BASE_URL
      }/connect?token=${encodeURIComponent(accessToken)}`
    ),
  connectHeaders: {
    Authorization: `Bearer ${accessToken}`,
  },
  debug: (str) => {
    console.log("STOMP Debug:", str);
  },
  heartbeatIncoming: 4000,
  heartbeatOutgoing: 4000,
  reconnectDelay: 0,
});

그런 다음 위 클래스들이 어떤 과정으로 호출되는지 보겠습니다.

 

먼저 WebSocket은 초기 TCP 기반의 3-way handshake 과정을 반드시 한번 거칩니다. 본격적인 handshake 과정 전 `HandshakeInterceptor`의 구현체(들)가 동작하며, 여기서 위에서 등록한 `JwtHandshakeInterceptor`가 동작하게 됩니다. 그리고 이 과정에서 `attributes`에 원하는 값을 추가로 저장할 수 있습니다. `attributes`는 같은 세션에서 공유되는 Map 저장소입니다. 

이후 본격적인 handshake를 수행하는 메서드에서는 upgrade 요청으로 WebSocket 프로토콜로 전환하는데, 이때 `determineUser`를 호출해 user를 얻고 upgrade 요청에 user를 넘기고 있습니다. 그리고 `determineUser`는 위에서 직접 만든 클래스가 동작합니다.

동일한 세션이기 때문에 `attributes`에는 `HandshakeInterceptor` 과정에서 저장된 값들이 그대로 담겨 있습니다.

 

이후 `SessionConnectedEvent`를 발행하고 `DefaultSimpUserRegistry`에서 이 이벤트를 받아서 users에 저장합니다.

 

이제는 `DefaultSimpUserRegistry`에서 세션에 해당하는 user 정보를 가져올 수 있기 때문에 `UserDestinationMessageHandler`에서 이후 로직을 정상적으로 처리할 수 있습니다.

 

 

📌 정리

이번 디버깅 과정을 통해 WebSocket 기술을 이해하는 데 많은 도움이 되었습니다. 중요한 점은 스프링은 모든 과정 사이사이에 개발자가 직접 커스텀한 기능을 구현할 수 있도록 많은 기능을 지원하고 있기 때문에 디버깅을 하면서 적절히 커스텀이 필요한 부분을 찾는 과정인 것 같습니다.

같은 고민을 하고 계시는 다른 분들에게 조금이나마 도움이 되었으면 좋겠고, 더 좋은 의견 주시면 감사하겠습니다.

 

🔖 참고

https://www.youtube.com/watch?v=MPQHvwPxDUw

https://nijy.tistory.com/267#Principal%EC%9D%84%20%EC%A7%81%EC%A0%91%20%EC%A0%95%EC%9D%98%ED%95%98%EC%97%AC%20%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94%20%EB%B0%A9%EB%B2%95-1

'트러블슈팅' 카테고리의 다른 글

[Redis] 캐시 스탬피드 현상  (0) 2025.12.14
쿠키 Samesite 설정으로 이슈 해결하기  (0) 2025.10.19
Spring OAuth AccessToken 제대로 추출하기  (0) 2025.10.19
JpaRepository에서 default 키워드를 사용할 때 주의점  (0) 2025.10.19
'트러블슈팅' 카테고리의 다른 글
  • [Redis] 캐시 스탬피드 현상
  • 쿠키 Samesite 설정으로 이슈 해결하기
  • Spring OAuth AccessToken 제대로 추출하기
  • JpaRepository에서 default 키워드를 사용할 때 주의점
이런개발
이런개발
geun-00의 흔적 보관소
  • 이런개발
    내일이 기대되는 오늘
    이런개발
  • 전체
    오늘
    어제
    • 분류 전체보기 N
      • 백엔드 면접
      • SQL N
        • SUM, MAX, MIN
        • SELECT
        • GROUP BY
        • JOIN
      • Spring
      • JPA
      • 트러블슈팅
      • Infra
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    백엔드 면접
    raid
    JPA
    데브코스
    토스 페이먼츠
    자바
    오블완
    티스토리챌린지
    스프링
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
이런개발
[Spring WebSocket] STOMP - convertAndSendToUser 알고 사용하기
상단으로

티스토리툴바