@RestControllerAdvice는 동작하지 않는다GlobalExceptionHandler(@RestControllerAdvice)는 WebSocket (@MessageMapping)에서 발생하는 예외를 전혀 잡아주지 못한다.
@RestControllerAdvice: HTTP 요청/응답 프로토콜 위에서 동작합니다.@MessageMapping: STOMP, WebSocket이라는 별도의 메시징 프로토콜 위에서 동작합니다.따라서 @MessageMapping 메서드가 호출한 서비스 레이어(예: MessageService)에서 예외가 발생하면, GlobalExceptionHandler는 이를 무시하고, 클라이언트는 서버가 다운된 것처럼 연결이 끊기거나 아무런 에러 메시지도 받지 못하게 된다.
StompSubProtocolErrorHandlerSpring은 WebSocket/STOMP 프로토콜에서 발생하는 예외를 전문적으로 처리할 수 있는 StompSubProtocolErrorHandler라는 추상 클래스를 제공한다.
이 클래스를 상속받아 WebSocketErrorHandler를 만들고, 핵심 메서드를 Override해야 한다.
handleClientMessageProcessingError(...): 클라이언트가 보낸 메시지(SEND, SUBSCRIBE 등)를 처리하는 과정에서 예외가 발생했을 때 호출되는 가장 중요한 메서드handleErrorMessageToClient(...): 서버가 클라이언트에게 메시지를 보내는 과정(예: /topic/room.1로 브로드캐스팅)에서 예외가 발생했을 때 호출handleInternalException(...): 브로커 내부 등 그 외의 예외 상황에서 호출됩니다.ErrorCode 전략을 활용한 핸들러 구현ErrorCode, BusinessException를 활용하여 WebSocket 예외 핸들러를 구현
WebSocketErrorHandler 구현하기
이 핸들러의 목표는 2가지입니다.
@MessageMapping에서 발생한 BusinessException을 붙잡습니다.{"code":"...", "message":"..."} 형식의 에러 메시지를 STOMP ERROR 프레임으로 전송합니다.global/exception/WebSocketErrorHandler.java
package com.example.chatserver.global.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.lang.Nullable;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageDeliveryException;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.StompSubProtocolErrorHandler;
import java.nio.charset.StandardCharsets;
@Component
@Slf4j
public class WebSocketErrorHandler extends StompSubProtocolErrorHandler {
public WebSocketErrorHandler() {
super();
}
/**
* 클라이언트 메시지 처리 중 발생한 예외를 처리합니다.
* (예: @MessageMapping 메서드 실행 중 발생한 예외)
*/
@Override
public Message<byte[]> handleClientMessageProcessingError(
@Nullable Message<byte[]> clientMessage, Throwable ex) {
// 1. 💡 실제 원인 예외(BusinessException)를 찾습니다.
// Spring이 예외를 MessageDeliveryException으로 감싸는 경우가 많습니다.
Throwable cause = (ex.getCause() != null) ? ex.getCause() : ex;
// 2. ✅ BusinessException인지 확인합니다.
if (cause instanceof BusinessException) {
BusinessException be = (BusinessException) cause;
ErrorCode errorCode = be.getErrorCode();
log.warn("WebSocket BusinessException: code={}, message={}, clientMessage={}",
errorCode.name(), errorCode.getMessage(), clientMessage.getHeaders());
// 3. 🚀 클라이언트에게 보낼 STOMP ERROR 메시지를 생성합니다.
return prepareErrorMessage(errorCode);
}
// 4. (선택) @Valid 유효성 검증 예외 처리
// (이 부분은 @MessageMapping에서 @Valid를 사용할 경우 필요)
/*
if (cause instanceof MethodArgumentNotValidException) {
String validationMessage = ... // 에러 메시지 파싱
log.warn("WebSocket ValidationException: {}", validationMessage);
return prepareErrorMessage(HttpStatus.BAD_REQUEST, "VALIDATION_ERROR", validationMessage);
}
*/
// 5. 🚫 처리하지 못한 기타 예외 (e.g. NullPointerException)
log.error("WebSocket unexpected error occurred", ex);
// 기본 에러 메시지 반환 (클라이언트는 "Internal server error"만 받게 됨)
return super.handleClientMessageProcessingError(clientMessage, ex);
}
/**
* ErrorCode를 기반으로 클라이언트에게 보낼 STOMP ERROR 프레임을 생성합니다.
*/
private Message<byte[]> prepareErrorMessage(ErrorCode errorCode) {
StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.ERROR);
accessor.setMessage(errorCode.getStatus().getReasonPhrase()); // (e.g. "Not Found")
accessor.setLeaveMutable(true);
// 💡 클라이언트가 파싱할 수 있도록 JSON 형태의 Payload를 생성합니다.
String errorPayload = String.format(
"{\\"code\\":\\"%s\\", \\"message\\":\\"%s\\"}",
errorCode.name(), // (e.g. "USER_NOT_FOUND")
errorCode.getMessage()
);
return MessageBuilder.createMessage(
errorPayload.getBytes(StandardCharsets.UTF_8),
accessor.getMessageHeaders()
);
}
}
[💡 핵심 포인트]