✅ 개요
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 기술을 이해하는 데 많은 도움이 되었습니다. 중요한 점은 스프링은 모든 과정 사이사이에 개발자가 직접 커스텀한 기능을 구현할 수 있도록 많은 기능을 지원하고 있기 때문에 디버깅을 하면서 적절히 커스텀이 필요한 부분을 찾는 과정인 것 같습니다.
같은 고민을 하고 계시는 다른 분들에게 조금이나마 도움이 되었으면 좋겠고, 더 좋은 의견 주시면 감사하겠습니다.
🔖 참고
'트러블슈팅' 카테고리의 다른 글
| [Redis] 캐시 스탬피드 현상 (0) | 2025.12.14 |
|---|---|
| 쿠키 Samesite 설정으로 이슈 해결하기 (0) | 2025.10.19 |
| Spring OAuth AccessToken 제대로 추출하기 (0) | 2025.10.19 |
| JpaRepository에서 default 키워드를 사용할 때 주의점 (0) | 2025.10.19 |