Published on

Spring Webflux GlobalExceptionHandler

Spring Webflux GlobalExceptionHandler

우리에게 익숙한 기존의 Spring MVC에서 @RestControllerAdvice를 선언하고 @ExceptionHandler에 핸들링할 Exception을 매핑하여 사용하였습니다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    private APIResponse<Void> exception(Exception e) {
        log.error("message", e);
        return APIResponse.error(INTERNAL_SERVER_ERROR.getMessage());
    }
}

Spring Webflux에서 Functional 방식으로 사용하면 위 처럼 사용할 수 없습니다.

DefaultErrorWebExceptionHandler 살펴보기

기본적으로 아무런 설정을 하지 않으면 DefaultErrorWebExceptionHandler가 에러를 캐치하여 처리하게 됩니다.

protected Mono<ServerResponse> renderErrorResponse(ServerRequest request) {
    Map<String, Object> error = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL));
    return ServerResponse.status(getHttpStatus(error)).contentType(MediaType.APPLICATION_JSON)
            .body(BodyInserters.fromValue(error));
}

ErrorResponse를 만드는 메소드를 보면 getErrorAttributes를 호출하고 있습니다.

protected Map<String, Object> getErrorAttributes(ServerRequest request, ErrorAttributeOptions options) {
    return this.errorAttributes.getErrorAttributes(request, options);
}

getErrorAttributeserrorAttributes를 통해서 관련 처리를 합니다. 해당 부분은 DefaultErrorWebExceptionHandler에서 오버라이딩하고 있습니다.

즉, 해당 메소드를 오버라이딩 하여 처리하면 된다는 것을 알 수 있습니다.

public DefaultErrorWebExceptionHandler(ErrorAttributes errorAttributes, Resources resources,
    ErrorProperties errorProperties, ApplicationContext applicationContext) {
    super(errorAttributes, resources, applicationContext);
    this.errorProperties = errorProperties;
}

여기서 생성자를 보면 errorAttributes를 주입받는 다는 것을 알 수 있습니다.

ErrorAttributesDefaultErrorAttributes를 기본적으로 사용하고 있습니다.

DefaultErrorWebExceptionHandlerAbstractErrorWebExceptionHandler를 상속받아 구현한 것이기 때문에 저희 또한 AbstractErrorWebExceptionHandler를 상속받아 GlobalExceptionHandler를 만들 수 있다는 것을 알 수 있습니다.

또한 응답 값을 커스텀 하기 위해서는 ErrorAttributes를 상속받아 구현 하면 됩니다.

GlobalErrorAttributes 만들기

ErrorAttributes를 상속받아 구현해도 되지만 위에서 살펴봤듯 중요한건 getErrorAttributes 메소드입니다.

그렇기에 그 외의 부분을 이미 오버라이딩한 DefaultErrorAttributes를 상속받도록 하겠습니다.

@Builder
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public class BaseResponse<T> {

    private final String status;
    private final String message;
    private final T data;

}
@Component
@RequiredArgsConstructor
public class GlobalErrorAttributes extends DefaultErrorAttributes {

    private final ObjectMapper objectMapper;

    @Override
    public Map<String, Object> getErrorAttributes(final ServerRequest request, final ErrorAttributeOptions options) {
        final Throwable error = getError(request);

        if(error instanceof IllegalStateException) {
            return getResponse(error.getMessage(), BAD_REQUEST);
        }
        return getResponse(error.getMessage(), INTERNAL_SERVER_ERROR);
    }

    private Map<String, Object> getResponse(
            final String message,
            final HttpStatus httpStatus
    ) {
        final BaseResponse<Object> response = BaseResponse.builder()
                .status(String.valueOf(httpStatus.value()))
                .message(message)
                .build();
        return objectMapper.convertValue(response, new TypeReference<>() {});
    }
}

getError()를 통해 에러 정보를 가져온 뒤, 해당 에러로 분기 작업을 하면됩니다.

그럼 이렇게 반한한 정보를 받을 GlobalExceptionHandler를 만들어야 합니다.

GlobalExceptionHandler 만들기

@Slf4j
@Component
public class GlobalExceptionHandler extends AbstractErrorWebExceptionHandler {

    public GlobalExceptionHandler(
            final ErrorAttributes errorAttributes,
            final WebProperties.Resources resources,
            final ApplicationContext applicationContext,
            final ServerCodecConfigurer serverCodecConfigurer
    ) {
        super(errorAttributes, resources, applicationContext);
        super.setMessageReaders(serverCodecConfigurer.getReaders());
        super.setMessageWriters(serverCodecConfigurer.getWriters());
    }

    @Override
    protected RouterFunction<ServerResponse> getRoutingFunction(final ErrorAttributes errorAttributes) {
        return RouterFunctions.route(RequestPredicates.all(), this::renderErrorResponse);
    }

    private Mono<ServerResponse> renderErrorResponse(final ServerRequest request) {
        final Map<String, Object> errorProperties = getErrorAttributes(request, ErrorAttributeOptions.defaults());
        return ServerResponse.status(Integer.parseInt(errorProperties.get("status").toString()))
                .contentType(MediaType.APPLICATION_JSON)
                .body(BodyInserters.fromValue(errorProperties));
    }
}

AbstractErrorWebExceptionHandler를 상속받아 구현하면 됩니다.

하지만 여기서 주의해야 할 점은 상속받은 대로 생성자를 생성하면 WebProperties.ResourcessetMessageReaders, setMessageWritersnull이 될수 없다며 예외가 발생합니다.

그렇기에 위의 생성자 처럼 작업을 해준 뒤 WebProperties.Resources부분은 빈을 따로 생성해주면 됩니다.

@Configuration
public class ResourceWebPropertiesConfig {

    @Bean
    public WebProperties.Resources resources() {
        return new WebProperties.Resources();
    }

}

이제 예외가 발생하면 공통으로 처리할 GlobalExceptionHandler를 만들었고, 예외를 터트려보면 잘 작동하는 것을 확인할 수 있습니다.

마치며

Spring MVC에서 어노테이션으로 뚝딱하여 너무 편하게 사용하고 있어서 직접 상속받아 구현을 하는 부분이 조금 낯설기도 하지만 직접 구현을 다 뜯어보는 기분도 들어 재밌는것 같습니다.

현재는 HttpStatusGlobalAttributes에서 반환하는 필드 중 하나로 의존하고 있는데, HttpStatus를 응답 값으로 내려주지 않을 경우 해당 부분만 제외 해야한다면 GLobalExceptionHandler에서 다시 한번 내려줄 응답값만 필터링 과정을 거쳐야 할 것 같습니다.

참고 사이트