본문 바로가기

Spring

Spring Webflux Exception 전역 처리 (Functional Endpoints)

728x90

webflux로 api 서버를 하나 개발하면서 에러 상황들을 전역으로 처리하고 싶었다.

스프링 mvc나 boot에서는 @ControllerAdvice나 @RestControllerAdvice를 사용하는데,

webflux도 어노테이션 방식으로 @Controller를 사용한다면 동일하게 @ControllerAdvice를 사용할 수 있을것 같았다.

 

하지만 나는 request를 Functional Endpoints 방식으로 RouterFunction을 활용해 요청을 라우팅하기 때문에

다른 예제를 찾아보게 되었고, 그 과정에서 메서드를 까보진 않았지만 까보기전 추측한 생각을 정리하려 한다.

 

 

참고 : https://www.baeldung.com/spring-webflux-errors

 

Handling Errors in Spring WebFlux | Baeldung

Have a look at different methods to gracefully handle errors in Spring Webflux.

www.baeldung.com

나는 전역으로 에러를 처리하는게 목표기 때문에 4번 항목부터 참고했다.

 

 

에러 전역 처리 핵심은 2가지다.

  • ErrorAttributes : Error 발생시 응답하는 Map 형식 데이터
  • ErrorWebExceptionHandler : MVC의 @ControllerAdvice역할 핸들러. 적합한 에러 처리 메서드로 라우팅

ErrorAttributes는 간단하게 포스트맨으로 에러를 테스트할때 아래와 같은 응답메세지를 말한다.

이름처럼 에러에 대한 속성들이라고 할 수 있다.

 

참고한 문서를 보면 각각 GlobalErrorAttributesGlobalErrorWebExceptionHandler라는 이름으로 클래스를 만들어서 다루고 있다. 그래서 나도 동일한 이름으로 구현을 해봤다.

 

GlobalErrorWebExceptionHandler

import java.util.Map;

import org.springframework.boot.autoconfigure.web.WebProperties.Resources;
import org.springframework.boot.autoconfigure.web.reactive.error.AbstractErrorWebExceptionHandler;
import org.springframework.boot.web.error.ErrorAttributeOptions;
import org.springframework.boot.web.reactive.error.ErrorAttributes;
import org.springframework.context.ApplicationContext;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.server.RequestPredicates;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.http.codec.ServerCodecConfigurer;
import reactor.core.publisher.Mono;

@Component
@Order(-2)
public class GlobalErrorExceptionHandler extends AbstractErrorWebExceptionHandler{

	public GlobalErrorExceptionHandler(GlobalErrorAttributes globalErrorAttributes, 
			ApplicationContext applicationContext,
			ServerCodecConfigurer serverCodecConfigurer) {
		super(globalErrorAttributes, new Resources(), applicationContext);
		super.setMessageReaders(serverCodecConfigurer.getReaders());
		super.setMessageWriters(serverCodecConfigurer.getWriters());
	}

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

	private Mono<ServerResponse> renderErrorResponse(ServerRequest request){
		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를 상속받아 구현했고 @Order로 -2 값을 지정했다. 

이건 내 개인 추측이고 까보진 않았지만, webflux에도 mvc처럼 DispatcherServlet에 해당하는, DispatcherHandler가 있는것으로 알고있다.

 

MVC의 경우 초기에 프로젝트가 실행될 때, DispatcherServlet은 여러가지를 init하는데, 그중 빈으로 등록된 컨트롤러들을 HandlerMappings 콜렉션에 넣어 (이때 정해진 값(@Order로 지정)으로 sort를 한다.) 요청마다 매핑작업을 한다.

@ControllerAdvice로 지정된 에러 컨트롤러도 마찬가지로 관리가 될거라고 추측한다.

 

Webflux에서는 이와 비슷하게 DispatcherHandler가 빈으로 등록된 핸들러들을 init하고, 마찬가지로 HandlerMappings 콜렉션에 정해진 순서대로 Sort되어 요청에 따라 라우팅 작업이 이루어질것이다.

 

그리고, 에러가 발생시 AbstractErrorWebExceptionHandler를 상속받은 핸들러를 찾아 getRoutingFunction을 통해 해당 메서드로 라우팅되어 에러를 응답할것으로 생각한다.

때문에 커스텀으로 전역 에러 처리 핸들러를 구현하기 위해 AbstractErrorWebExceptionHandler를 상속받는 핸들러 클래스 GlobalErrorWebExceptionHandler를 생성한 것이고, @Order에 -2를 넣은 이유는, 문서를 참조해보면 DefaultErrorWebExceptionHandler의 @Order 값이 -1이기 때문에 앞에서 언급한 핸들러를 init한 후 sort 할 때

GlobalErrorWebExceptionHandler가 더 우선으로 사용되기 위해 -2를 넣었다고 한다.

 

하지만 내가 -1이나 -1보다 더 큰 값, 아예 지우고 실행했을때도 동일하게 동작했는데 이유는 좀 더 알아봐야 할 것 같다.

 

 

 

 

두번째 부분은 생성자에 GlobalErrorAttributes를 받고 super로 넘겨줘서 기존에 ErrorAttributes를 대체하고있는 점

그리고 세번째는 단순히 모든 에러를 renderErrorResponse 메서드로 라우팅하고 있고, getErrorAttributes 메서드로 데이터를 받아 그냥 넘기고 있다.

 

여기서 사용된 getErrorAttributes는 부모클래스 AbstractErrorWebExceptionHandler의 getErrorAttributes인데, 생성자에서 GlobalErrorAttributes를 넘겨줄때 GlobalErrorAttributes의 getErrorAttributes를 사용한 것으로 생각하면 된다. 

아래를 보면 쉽게 알 수 있다. 아래는 그걸 확인하는 캡처 이미지

 

AbstractErrorWebExceptionHandler 생성자

 

AbstractErrorWebExceptionHandler의 getErrorAttributes 메서드

생성자에서 초기화 시킨 ErrorAttributes의 getErrorAttributes 메서드를 호출한다.

 

 

GlobalErrorAttributes

import java.util.Map;

import org.springframework.boot.web.error.ErrorAttributeOptions;
import org.springframework.boot.web.reactive.error.DefaultErrorAttributes;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;

@Component
public class GlobalErrorAttributes extends DefaultErrorAttributes{
	@Override
	public Map<String, Object> getErrorAttributes(ServerRequest request, ErrorAttributeOptions options) {
		Map<String, Object> map = super.getErrorAttributes(request, options);
		
		Throwable throwable = getError(request);
        if (throwable instanceof CustomException) {
        	CustomException ex = (CustomException) getError(request);
            map.put("exception", ex.getClass().getSimpleName());
            map.put("message", ex.getErrorCode().getMessage());
            map.put("status", ex.getErrorCode().getStatus());
            map.put("errorCode", ex.getErrorCode().getCode());
        }

		return map;
	}

	
}

DefaultErrorAttributes를 상속받고있다.

super.getErrorAttributes를 하면 위에서 캡처한 포스트맨 에러 응답 메세지

이와 같은 Map 데이터를 받는데 DefaultErrorAttributes보다 추가된 점

Throwable throwable = getError(request);
if (throwable instanceof CustomException) {
    CustomException ex = (CustomException) getError(request);
    map.put("exception", ex.getClass().getSimpleName());
    map.put("message", ex.getErrorCode().getMessage());
    map.put("status", ex.getErrorCode().getStatus());
    map.put("errorCode", ex.getErrorCode().getCode());
}

이 부분이다.

 

CustomException은 따로 구현한 Exception들 이라고 생각하고, 그 커스텀으로 생성한 Exception 클래스가 발생한 경우 원하는 데이터를 추가하거나 덮어쓰는 로직이다. 이는 결과 부분을 보면 더 이해가 수월하다.

 

CustomException

import lombok.Getter;

@Getter
public class CustomException extends RuntimeException {
	private final ErrorCode errorCode;
	
	public CustomException(ErrorCode errorCode) {
		this.errorCode = errorCode;
	}
}

 

ErrorCode

import com.fasterxml.jackson.annotation.JsonFormat;

@JsonFormat(shape = JsonFormat.Shape.OBJECT)
public enum ErrorCode {
	INVALID_INPUT_VALUE(400, "ER001", "입력값이 올바르지 않습니다."),
	METHOD_NOT_ALLOWED(405, "ER002", "지원하지 않는 요청 메서드입니다."),
	HANDLE_ACCESS_DENIED(403, "ER003", "접근 권한이 없습니다."),
	INTERNAL_SERVER_ERROR(500, "ER004", "INTERNAL SERVER ERROR"),
	
	HEADER_NOT_FOUND(400, "ER005", "필수 헤더값이 존재하지 않습니다."),
	PREAUTH_NOT_FOUND(400, "ER006", "사전 인증값이 존재하지 않습니다."),
	BUCKET_NOT_FOUND(400, "ER007", "버킷명이 존재하지 않습니다.")
	;
	
	private final String code;
    private final String message;
    private int status;

    ErrorCode(final int status, final String code, final String message) {
        this.status = status;
        this.message = message;
        this.code = code;
    }

    public String getMessage() {
        return this.message;
    }

    public String getCode() {
        return code;
    }

    public int getStatus() {
        return status;
    }
}

 

결과 

에러 발생은 알아서 시켜야한다.

 

CustomException 발생

if(preAuth.length() == 0) {
    throw new CustomException(ErrorCode.PREAUTH_NOT_FOUND);
}

 

 

기존 Exception 발생

if(objectName.length() < 1) {
    throw new BadRequestException("Empty Object's Name");
}

 

728x90