[Spring Websocket] 메세징 파이프라인 예외처리 - StompSubProtocolErrorHandler
개요
컨트롤러 내부 예외 처리는 @MessageExceptionHandler에서,
메세징 파이프라인 예외 처리는 StompSubProtocolErrorHandler에서 다룬다.
위의 말이 아직 어렵다면, StompSubProtocolErrorHandler 학습 전, 프레임의 End To End 플로우를 이해하고 가자.
프로젝트에 웹소캣을 첫 적용을 하기 위해, 블로그 검색을 하다보면 다양한 예외 처리 지점이 보게된다.
(HandshakeInterceptor, ChannelInterceptor, @MessageExceptionHandler, StompSubProtocolErrorHandler 등)
내가 모르는 예외 핸들러들이 있어서 추가적으로 학습하다보면, 무엇이 어느 지점에 작동하는지 혼동될 위험이 있다.
(사전지식) 전체 플로우(End To End)
1. handshake
웹소캣은 핸드쉐이크 이후 웹소켓 세션이 연결된다.

핸드쉐이크 과정 중에, 서버에서 InboundChannel에서 Hadnshake인터셉터를 처리하다가 예외가 터질 수 있다.
이때 기존 HTTP 예외 처리 방식처럼 @ControllAdvice, Spring Security에서 에러를 처리하면 된다.
Handshake에서 예외가 터지는 상황은 업그레이드 불가/Origin/CORS/쿠키 관련 문제들이 있을때다.
한편 문제 없이 핸드쉐이크가 진행됐다면, 클라이언트 <--> 서버간의 웹소캣 세션이 열린다. 이때부터 프레임을 보낼 수 있다.
2. websocket session(End to End)

- Client → Server
- WebSocket 프레임 페이로드에 STOMP 프레임(CONNECT/SEND/SUBSCRIBE/etc)을 텍스트로 직렬화해 전송한다.
- SubProtocolWebSocketHandler (디코딩)
- WS 프레임 --> STOMP 프레임 파싱 --> Spring Message<?> 로 래핑 --> clientInboundChannel 로 publish한다.
- ClientInboundChannel (인바운드 인터셉터 & 라우팅)
- ChannelInterceptor들 동작(ex: JWT 인증 인터셉터)
- 목적지에 따라 애플리케이션 목적지(/app/**)는 SimpAnnotationMethodMessageHandler, 브로커 목적지(/topic, /queue)는 브로커로 라우팅
- SimpAnnotationMethodMessageHandler (컨트롤러 진입 전 변환/검증)
- MessageConverter로 @Payload 바인딩, Bean Validation 등 수행
- @MessageMapping 메서드 호출
- @MessageMapping (애플리케이션 로직)
- 리턴값 또는 SimpMessagingTemplate 호출 --> 프레임워크가 Message<?> 로 만들어 brokerChannel 로 전달
- Broker (SimpleBroker / Broker Relay)
- 구독 해석/대상 세션 결정 --> 클라이언트별 Message<?> 생성 --> clientOutboundChannel로 publish한다.
- (Relay 사용 시 브로커 자체 ERROR가 직행할 수 있음)
- ClientOutboundChannel (아웃바운드 인터셉터)
- 로깅/헤더 보정 등
- SubProtocolWebSocketHandler (인코딩 & 전송)
- Message<?> --> STOMP MESSAGE 프레임 --> WebSocket 프레임으로 인코딩 --> 클라이언트 전송
(사전지식) Flow에서 발생하는 예외 처리 핸들러 구분
예외핸들링에 있어서, 앞으로 배울 StompSubProtocolErrorHandler가 담당하는 섹션들은 노란색이다.
한편, @Controller에서 발생하는 예외는 @MessageExceptionHandler가 담당하는데 붉은색으로 표현했다.

이제 StompSubProtocolErrorHandler을 학습할 준비가 됐다.
StompSubProtocolErrorHandler 학습하기
무엇을 하나?
StompSubProtocolErrorHandler (org.springframework.messaging.simp.stomp.StompSubProtocolErrorHandler)는 클라이언트 STOMP 프레임을 처리하던 중 예외가 발생하면, 받은 프레임을 서버 --> 클라이언트 방향의 STOMP ERROR 프레임으로 표준화하여 내려보내는 역할을 한다.
(저는 JwtChannelInterceptor 인증 처리 도중 실패 시 예외를 던져줬을때, 그 다음 StompSubProtocolErrorHandlerr를 커스텀하여 저의 앱 응답형식에 맞게 Payload를 구성하여 ERROR 프레임으로 변환하도록 구현해 사용했습니다.)
반대로 클라이언트 --> 서버로의 'ERROR' 프레임은 프로토콜 위반으로 간주해 세션을 종료한다.
STOMP 규격상 ERROR는 서버→클라이언트 제어 프레임입니다. 클라이언트가 보낸 ERROR는 드롭하거나 세션 종료하는 것이 일반적이다. 이 로직은 인바운드 인터셉터에서 차단하는 것이 깔끔하다.
if (StompCommand.ERROR.equals(accessor.getCommand())) {
// 허용하지 않음
throw new IllegalArgumentException("Client must not send ERROR frame");
// 또는 조용히 return null;
}
이렇게 예외를 던지면, 아래와 같이 ERROR 프레임이 만들어 진다.
예외 --> handleClientMessageProcessingError(...)가 호출 --> 클라이언트 ERROR 프레임으로 표준화
어디에서(Scope)?
이 핸들러는 컨트롤러(@MessageMapping)까지 도달하기 전 단계에서 발생한 예외를 표준화한다.
- (예: 프레임 파싱/변환, 채널 인터셉터, 라우팅 등)
블로그 글들에서 흔히 보이는 @MessageExceptionHandler는 STOMP 프레임이 생성된 뒤, 컨트롤러 내부에서 발생한 예외를 처리한다. 따라서 이 글에서 다루는 StompSubProtocolErrorHandler의 범위에는 포함되지 않는다.
무엇을 안 하나?
이 섹션을 넣은 이유는, 이제 막 알아가는 제가 해깔려서 넣었습니다.
1) Handshake(HTTP Upgrade) 실패를 다루지 않는다.
아직 STOMP 세션(핸드쉐이크의 결과)이 없으므로 ERROR를 보낼 통로도 없고, 프레임도 보낼 수 없다.
즉, HTTP 업그레이드 실패나 HandshakeInterceptor.beforeHandshake에서의 false/예외는 이 핸들러의 관할이 아니다.
2) 외부 브로커가 생성한 ERROR를 다루지 않는다.
브로커 릴레이 사용 시, 브로커 --> 클라이언트로 직접'ERROR'가 흘러갈 수 있다고 한다. 이때 서버가 '들어온 ERROR를 다시 가공'하는 흐름은 아니다. 서버 내부에서 발생한 예외를 표준화하는 것이 메인 역할이기 때문이다.
문서 확인하기
Interface SubProtocolErrorHandler<P>


위는 SubProtocolErrorHandler의 스펙입니다. 스펙 자체보다, 기본 구현인 StompSubProtocolErrorHandler가 어떻게 동작하는지를 통해 커스텀 핸들러 설계의 힌트를 얻을 수 있다.
Class StompSubProtocolErrorHandler implements SubProtocolErrorHandler<byte[]>
[공식 문서] StompSubProtocolErrorHandler 코드 보러가기
1. handleClientMessageProcessingError()
handleClientMessageProcessingError(@Nullable Message<byte[]> clientMessage, Throwable ex)
어디에서 사용되나?
클라이언트 프레임을 처리하는 도중 예외가 발생했을 때 예외처리 하고싶은 경우 이 핸들러를 사용하면 됩니다. (컨트롤러 이전 단계의 예외를 표준화할 때 사용)
파라미터 해석
@Nullable Message<byte[]> clientMessage
// 처리 중이던 클라이언트의 STOMP 메세지
// 왜 null 가능?
// - Handshake/Transport 단계에서 실패(아직 STOMP 프레임 없음)
// - STOMP로 파싱되기 전 I/O 오류 등으로 메시지가 구성되지 못함
기본 동작
1. ERROR 헤더를 생성합니다.
StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.ERROR);
accessor.setMessage(ex.getMessage());
accessor.setLeaveMutable(true);// 이후 하위 메서드(handleInternal)가 헤더 수정 가능하도록
2. receipt-id를 있을 때만 복사합니다.
StompHeaderAccessor clientHeaderAccessor = null;
if (clientMessage != null) {
clientHeaderAccessor = MessageHeaderAccessor.getAccessor(clientMessage, StompHeaderAccessor.class);
if (clientHeaderAccessor != null) {
String receiptId = clientHeaderAccessor.getReceipt();
if (receiptId != null) {
accessor.setReceiptId(receiptId);
}
}
}
// 클라이언트가 보낸 프레임에 receipt 헤더가 있다면, ERROR 프레임의 receipt-id로 복사
// clientMessage == null이면 receipt 복사 불가
3. 최종 메시지를 만듭니다.
return handleInternal(accessor, EMPTY_PAYLOAD, ex, clientHeaderAccessor);
// 내부 메서드
protected Message<byte[]> handleInternal(StompHeaderAccessor errorHeaderAccessor, byte[] errorPayload,
@Nullable Throwable cause, @Nullable StompHeaderAccessor clientHeaderAccessor) {
return MessageBuilder.createMessage(errorPayload, errorHeaderAccessor.getMessageHeaders());
}
요약: 기본 구현은 헤더만 있는 빈 ERROR 프레임을 만듭니다.
@Override로 커스텀해서 사용 한다면
언제 사용하나?
예) Inbound-Channel 인터셉터에서 발생한 예외로부터 생긴 ERROR 메세지를 저희 애플리케이션 서비스에 맞게 응답하고 싶을 때 사용합니다. (주로 공통으로 쓰는 응답 모델 형식)
어떻게 구현하나?
이 메서드를 오버라이드 해서 아래처럼 커스텀합니다.
- 예외를 잡아 도메인 에러코드 매핑합니다.
- content-type 설정합니다.(application/json;charset=UTF-8)
- Payload(json)를 만듭니다.(ApiResponse.error(...) 등)
- 메세지를 생성해서 반환합니다.
- (필요시 세션 종료도 넣을 수 있습니다.)
2. handleErrorMessageToClient()
handleErrorMessageToClient(Message<P> erreorMessage)
어디에서 사용되나?
이미 ERROR로 보내기로 한 아웃바운드 메시지를 송신 직전에 마지막으로 손보기 위한 메서드다. 다른 경로에서 만들어진 ERROR 메시지(예: 브로커/애플리케이션 내부)가 이쪽으로 들어올 수 있다.
기본 동작
1. 헤더엑세서를 가변화시킵니다.
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(errorMessage, StompHeaderAccessor.class);
Assert.notNull(accessor, "No StompHeaderAccessor");
if (!accessor.isMutable()) {
accessor = StompHeaderAccessor.wrap(errorMessage);
}
// ERROR 메시지의 헤더를 수정할 수 있도록 가변화함
2. 최종 메시지를 생성합니다.
return handleInternal(accessor, errorMessage.getPayload(), null, null);
요약: 이미 ERROR로 정해진 메시지를 마지막에 다듬는 기능을 제공합니다.
StompSubProtocolErrorHandler를 적용해보자.
1. StompSubProtocolErrorHandler를 상속한 커스텀 클래스를 만듭니다.
...
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageDeliveryException;
import org.springframework.messaging.converter.MessageConversionException;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompConversionException;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.security.core.AuthenticationException;
import org.springframework.web.socket.messaging.StompSubProtocolErrorHandler;
...
@Component
@RequiredArgsConstructor
public class StompErrorResponseHandler extends StompSubProtocolErrorHandler {
private final ObjectMapper objectMapper;
@Override
public Message<byte[]> handleClientMessageProcessingError(@Nullable Message<byte[]> clientMessage, Throwable ex) {
Throwable cause = rootCause(ex);
RunningErrorCode code = mapToErrorCode(cause);
String reason = resolveReason(cause, code);
StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.ERROR);
accessor.setLeaveMutable(true);
accessor.setContentType(APPLICATION_JSON);
accessor.setMessage(code.name());
copyReceiptId(clientMessage, accessor);
byte[] payload = toJson(ApiResponse.error(code, reason));
return MessageBuilder.createMessage(payload, accessor.getMessageHeaders());
}
private void copyReceiptId(@Nullable Message<byte[]> clientMessage, StompHeaderAccessor errorHeaders) {
if (clientMessage == null)
return;
StompHeaderAccessor clientHeaders = StompHeaderAccessor.wrap(clientMessage);
String receiptId = clientHeaders.getReceipt();
if (receiptId != null) {
errorHeaders.setReceiptId(receiptId);
}
}
private RunningErrorCode mapToErrorCode(Throwable t) {
// 1) 인증/세션
if (t instanceof AuthenticationException
|| t instanceof IllegalStateException
/*|| t instanceof ExpiredTokenException
|| t instanceof InvalidTokenException*/) {
return RunningErrorCode.UNAUTHORIZED_SESSION;
}
// 2) STOMP/JSON 변환 실패
if (t instanceof StompConversionException
|| t instanceof MessageConversionException
|| t instanceof JsonProcessingException) {
return RunningErrorCode.PAYLOAD_INVALID;
}
// 3) 라우팅/전달 실패
if (t instanceof MessageDeliveryException) {
return RunningErrorCode.EVENT_PUBLISH_FAILED;
}
// 4) 프로토콜 위반/필수 헤더 누락(일부 IllegalArgumentException)
if (t instanceof IllegalArgumentException) {
return RunningErrorCode.PAYLOAD_INVALID;
}
// 5) 그 외
return RunningErrorCode.INTERNAL_ERROR;
}
private String resolveReason(Throwable t, RunningErrorCode code) {
String msg = t.getMessage();
if (msg == null || msg.isBlank()) {
return code.getMessage();
}
// 메시지가 과하게 길면 잘라서 보냄
if (msg.length() > 512) {
return msg.substring(0, 512) + "…";
}
return msg;
}
private byte[] toJson(ApiResponse<?> body) {
try {
return objectMapper.writeValueAsString(body).getBytes(StandardCharsets.UTF_8);
} catch (JsonProcessingException e) {
// 직렬화 실패 시 텍스트로 폴백
String fallback = "{\"success\":false,\"code\":\"INTERNAL_ERROR\",\"message\":\"serialization failed\"}";
return fallback.getBytes(StandardCharsets.UTF_8);
}
}
private Throwable rootCause(Throwable t) {
Throwable cur = t;
while (cur.getCause() != null && cur.getCause() != cur) {
cur = cur.getCause();
}
return cur;
}
}
2. WebSocketConfig에 커스텀 StompSubProtocolErrorHandler 등록하기
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
// 1. DI 주입
private final StompErrorFrameHandler stompErrorHandler;
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("https://*.runky.store", "http://*.runky.store",
"https://localhost:*", "http://localhost:*", "null");
//2. STOMP ERROR 프레임 핸들러 등록
registry.setErrorHandler(stompErrorHandler);
}
// ... 나머지 broker, channel 설정
}
3. 단위 테스트
class StompErrorFrameHandlerTest {
private final ObjectMapper om = new ObjectMapper();
private final StompErrorFrameHandler handler = new StompErrorFrameHandler(om);
private static Message<byte[]> dummyClientMessage() {
StompHeaderAccessor acc = StompHeaderAccessor.create(StompCommand.SEND);
acc.setSessionId("sess-1");
return MessageBuilder.createMessage("x".getBytes(StandardCharsets.UTF_8), acc.getMessageHeaders());
}
private static Stream<Arguments> cases() {
return Stream.of(
Arguments.of(new AuthenticationCredentialsNotFoundException("no token"),
RunningErrorCode.UNAUTHORIZED_SESSION),
Arguments.of(new StompConversionException("bad stomp"),
RunningErrorCode.PAYLOAD_INVALID),
Arguments.of(new MessageConversionException("bad json"),
RunningErrorCode.PAYLOAD_INVALID),
Arguments.of(new JsonProcessingException("bad json") {
},
RunningErrorCode.PAYLOAD_INVALID),
Arguments.of(new MessageDeliveryException("no subscribers"),
RunningErrorCode.EVENT_PUBLISH_FAILED),
Arguments.of(new IllegalArgumentException("bad header"),
RunningErrorCode.PAYLOAD_INVALID),
Arguments.of(new RuntimeException("boom"),
RunningErrorCode.INTERNAL_ERROR)
);
}
@ParameterizedTest(name = "{index} ⇒ {0} → {1}")
@MethodSource("cases")
@DisplayName("컨트롤러 이전 파이프라인에서 발생한 예외는 STOMP ERROR 헤더 + ApiResponse.code로 표준화된다")
void 예외가_ERROR프레임으로_매핑되고_code만_검증한다(Throwable ex, RunningErrorCode expected) throws Exception {
// when
Message<byte[]> errorMsg = handler.handleClientMessageProcessingError(dummyClientMessage(), ex);
// then 1) 헤더: STOMP ERROR 프레임인지
StompHeaderAccessor h = StompHeaderAccessor.wrap(errorMsg);
assertThat(h.getCommand()).isEqualTo(StompCommand.ERROR);
// then 2) 바디: JsonNode로 code만 검증
JsonNode node = om.readTree(errorMsg.getPayload());
assertThat(node.get("code").asText()).isEqualTo(expected.getCode());
}
}
지금까지 배운 StompSubProtocolErrorHandler을 직접 구현해서 적용까지 할 수 있게 됐다.
여기서 포스팅을 마친다.
학습 내용 포스팅 후기
웹소켓을 처음 접하고 바로 프로젝트에 적용했을 때, 예외 핸들링을 하지 않아 매번 서버 로그에서 문제를 확인하느라 불편했었다. 이후 리팩토링하면서 예외처리를 도입하고자 했지만, 여러 예외 처리 핸들러들이 등장해서 오히려 도입하기 어려웠었다. 이거 말고도 더 다른 예외 핸들러 처리가 필요하면 어떻게하지? 이런 질문이 나온 이유는 제대로된 웹소켓 Flow에 대한 이해도가 부족해서 어느 부분에서 어떤 에러들이 터지고, 예외 핸들러가 담당하는 스코프를 블로그 글이나, 공식문서에서도 알 수 없었다.
너무 답답해서 스프링 messaging 모듈에 대한 문서를 읽으며 그 구조를 공부했다. 그 결과, 메시지 Flow의 End To End를 이해할 수 있게 됐고, 어느 부분에서 어떤 에러들이 터지는지, 그리고 그 에러를 핸들링하는 스코프가 자연스럽게 알게 됐다.
레퍼런스
- https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/socket/messaging/StompSubProtocolErrorHandler.html
- https://shout-to-my-mae.tistory.com/434?category=1420550#%EC%9D%B8%ED%84%B0%EC%85%89%ED%84%B0%EC%97%90%EC%84%9C%20%EB%B0%9C%EC%83%9D%ED%95%98%EB%8A%94%20%EC%98%88%EC%99%B8%20%EC%B2%98%EB%A6%AC%20-%20%EC%97%90%EB%9F%AC%20%ED%95%B8%EB%93%A4%EB%9F%AC%20%EA%B5%AC%ED%98%84-1
.