1. @RestControllerAdvice는 동작하지 않는다

GlobalExceptionHandler(@RestControllerAdvice)는 WebSocket (@MessageMapping)에서 발생하는 예외를 전혀 잡아주지 못한다.

따라서 @MessageMapping 메서드가 호출한 서비스 레이어(예: MessageService)에서 예외가 발생하면, GlobalExceptionHandler는 이를 무시하고, 클라이언트는 서버가 다운된 것처럼 연결이 끊기거나 아무런 에러 메시지도 받지 못하게 된다.


2. WebSocket 예외 처리의 핵심 StompSubProtocolErrorHandler

Spring은 WebSocket/STOMP 프로토콜에서 발생하는 예외를 전문적으로 처리할 수 있는 StompSubProtocolErrorHandler라는 추상 클래스를 제공한다.

이 클래스를 상속받아 WebSocketErrorHandler를 만들고, 핵심 메서드를 Override해야 한다.


3. ErrorCode 전략을 활용한 핸들러 구현

ErrorCode, BusinessException를 활용하여 WebSocket 예외 핸들러를 구현


  1. WebSocketErrorHandler 구현하기

    이 핸들러의 목표는 2가지입니다.

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()
        );
    }
}

[💡 핵심 포인트]