파란하늘의 지식창고
반응형

Spring Framework web의 기존 에러 처리

기존 Spring Framework를 사용하여 웹을 구현하면 기본적으로 제공되는 에러 처리를 사용한 에러 응답 결과는 다음과 같은 형태였다.

{ 
	"timestamp":1417379464584, 
	"status":400, 
	"error":"Bad Request", 
	"exception":"org.springframework.web.bind.MethodArgumentNotValidException", 
	"message":"Validation failed for argument at index 0 in method: public org.springframework.http.ResponseEntity<test.User> test.UserController.testUser(test.User), with 2 error(s): [Field error in object 'user' on field 'name': rejected value [null]; codes [NotNull.user.name,NotNull.name,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.name,name]; arguments []; default message [name]]; default message [may not be null]], 
	"path":"/testUser" 
}

그대로 사용하기에는 외부에 공개되지 않았으면 하는 정보가 있거나 개발 확인용 내용이 많다.
Spring Boot를 사용하는 경우 다음과 같은 ErrorProperties 설정을 통해 노출할 내용을 적절히 조절할 수 있다.

server.error.include-exception=false
server.error.include-stacktrace=never
server.error.include-binding-errors=never

해당 설정을 통해 개발 시엔 개발 관련 내용을 확인하면서 서비스 중일 땐 해당 내용을 숨기는 것이 가능하다.
하지만 유저에게 전달하기 위해 좀 더 상세한 에러 메시지를 처리하려고 하는 경우 기본으로 제공해 주는 이 에러 처리가 사용하기 불편하여 에러 메시지 객체를 따로 정의하고 exception handler를 통해 해당 에러를 응답으로 내려보내주는 식으로 에러를 관리하는 경우가 많았다. (아마 대부분은 이렇게 구현해서 사용하지 않았을까 싶다.)
Spring의 에러처리와 관련해서 기존에 다음과 같은 글을 작성하였었다.
2019.04.30 - [Study/Java] - Spring Boot 전역 에러 처리
2020.02.18 - [Study/Java] - Spring Custom HandlerExceptionResolver 사용하기

RFC 7807 - Problem Details for HTTP APIs

RFC 7807은 API 에러 응답에 대한 규약을 정의한 문서이고 현재 Standard Track으로 표준화되었다고 보면 된다.
https://www.rfc-editor.org/rfc/rfc7807.html
간단하게 소개하면 에러 발생 시 안내할 응답 내용을 문서 세부 정보 개체("type", "title", "status", "detail", "instance")와 확장 멤버로 구성하자는 것이다.
복잡한 구현이 있는 게 아니고 '이렇게 합시다'라는 약속을 정의한 것이다.

spring-web의 ProblemDetail

Spring Framework 6.0.x의 spring-web 모듈에 다음과 같이 RFC 7807을 따르는 ProblemDetail class가 추가되었다.
https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/http/ProblemDetail.java
RFC 7807 Problem Detail for API 문서 이름의 응답 객체 명세 이름을 그대로 사용하여 정의하였다.
다음과 같은 필드를 가지고 있다.
아래의 필드 설명이 RFC 7807의 스펙이라고 보면 된다.

public class ProblemDetail {

	private static final URI BLANK_TYPE = URI.create("about:blank");

	// 문제 유형을 식별하는 URI 참조, 정의하지 않으면 기본 값은 "about:blank"
	private URI type = BLANK_TYPE;

	// 문제 유형에 대한 사람이 읽을 수 있는 간단한 요약
	@Nullable
	private String title;

	// 이 문제의 응답 Http status 코드
	private int status;

	// 문제 유형에 대한 사람이 읽을 수 있는 간단한 설명
	@Nullable
	private String detail;

	// 문제가 발생한 URI
	@Nullable
	private URI instance;

	// 위에 선언한 문서 세부 정보 필드 이외에 추가적으로 사용할 확장 필드를 저장하는 곳
	@Nullable
	private Map<String, Object> properties;

}

ProblemDetail을 사용한 에러처리

기존 ExceptionHandler의 설정을 별도의 ErrorMessage class를 정의하고 사용하였다면 다음과 같은 형태였을 것이다.

@ExceptionHandler
@ResponseStatus(HttpStatus.BAD_REQUEST)	// http status 코드
public ModelAndView handleException(BlueskyException exception) {
    BlueskyErrorMessage errorMessage = new BlueskyErrorMessage();
    errorMessage.setExceptionClassName(exception.getClass().getSimpleName());
    errorMessage.setMessage("에러 설명 메시지");
    errorMessage.setObject("에러 발생한 대상");
    errorMessage.setDisplayableMessage(true);	// 메시지 노출 가능 여부 
    errorMessage.setErrorCode("에러 구분 코드");

    var modelAndView = new ModelAndView("요청이 text/html 이면 이동할 페이지");
    modelAndView.addObject(errorMessage);
    return modelAndView;
}

ProblemDetail 기본 사용

ProblemDetail을 사용하면 다음과 같다.
(간단한 설명을 위해 html view 처리는 제외하였으니 해당 부분은 감안하고 봐야 한다.)

@ExceptionHandler
public ProblemDetail handleException(BlueskyException exception) {
    return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "에러 설명 메시지");
}

해당 에러가 발생하면 다음과 같은 응답을 확인할 수 있다.

{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "에러 설명 메시지",
  "instance": "/problemDetailTest"
}


기존엔 @ResponseStatus 어노테이션을 사용하지 않고 method 내에서 http status를 정의하려는 경우 아래와 같이 ResponseBody로 응답하는 형태로 구현해야 했다.

@ExceptionHandler
@ResponseBody
public ResponseEntity handleException(BlueskyException exception) {
    var body = // ... 에러 관련 응답 body 처리.
    return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
}

ProblemDetail을 사용하는 경우 필수 입력 값인 status의 값이 Http Status를 설정되기 때문에 편리해졌다.
RequestResponseBodyMethodProcessor에 return value가 ProblemDetail instance인 경우 처리가 다음과 같이 추가되어 있다.
https://github.com/spring-projects/spring-framework/blob/main/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessor.java

@Override
public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,
        ModelAndViewContainer mavContainer, NativeWebRequest webRequest)
        throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {

    mavContainer.setRequestHandled(true);
    ServletServerHttpRequest inputMessage = createInputMessage(webRequest);
    ServletServerHttpResponse outputMessage = createOutputMessage(webRequest);

    if (returnValue instanceof ProblemDetail detail) {
        outputMessage.setStatusCode(HttpStatusCode.valueOf(detail.getStatus()));
        if (detail.getInstance() == null) {
            URI path = URI.create(inputMessage.getServletRequest().getRequestURI());
            detail.setInstance(path);
        }
    }

    // Try even with null return value. ResponseBodyAdvice could get involved.
    writeWithMessageConverters(returnValue, returnType, inputMessage, outputMessage);
}

ProblemDetail 확장 멤버 사용

앞서 언급한 ErrorMessage를 따로 구현해서 사용한 경우, 에러 메시지에 ProblemDetail과 다른 필드가 있었다

BlueskyErrorMessage errorMessage = new BlueskyErrorMessage();
errorMessage.setExceptionClassName(exception.getClass().getSimpleName());
errorMessage.setMessage("에러 설명 메시지");
errorMessage.setObject("에러 발생한 대상");
errorMessage.setDisplayableMessage(true);	// 메시지 노출 가능 여부 
errorMessage.setErrorCode("에러 구분 코드");

ProblemDetail에 다른 필드들을 추가하여 사용하기 위해서는 두 가지 방법이 있다.

  1. ProblemDetail class를 확장하여 사용
  2. ProblemDetail의 properties를 사용

1번의 경우 기존의 사용 방법과 다를 바 없는 방식이니 여기서는 생략한다.
2번 방법을 사용하면 ExceptionHandler는 다음과 같이 setProperty로 추가될 확장 멤버를 정의하면 된다.

@ExceptionHandler
public ProblemDetail handleException(BlueskyException exception) {
    var problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "에러 설명 메시지");
    problemDetail.setProperty("exceptionClassName", exception.getClass().getSimpleName());
    problemDetail.setProperty("object", "에러가 발생한 대상");
    problemDetail.setProperty("displayableMessage", true);
    problemDetail.setProperty("errorCode", "에러 구분 코드");
    return problemDetail;
}

응답은 다음과 같다.

{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "에러 설명 메시지",
  "instance": "/problemDetailTest",
  "exceptionClassName": "BlueskyException",
  "object": "에러가 발생한 대상",
  "displayableMessage": true,
  "errorCode": "에러 구분 코드"
}

에러 메시지 처리가 기존에 비해 매우 간편해졌다.

ErrorResponse, ProblemDetailsExceptionHandler 사용

Spring을 ProblemDetail을 사용하여 에러 응답 처리를 하기 위해 ErrorResponse 인터페이스를 사용한다.
ErrorResponse 인터페이스는 다음과 같다.

public interface ErrorResponse {

	/**
	 * Return the HTTP status code to use for the response.
	 */
	HttpStatusCode getStatusCode();

	/**
	 * Return headers to use for the response.
	 */
	default HttpHeaders getHeaders() {
		return HttpHeaders.EMPTY;
	}

	/**
	 * Return the body for the response, formatted as an RFC 7807
	 * {@link ProblemDetail} whose {@link ProblemDetail#getStatus() status}
	 * should match the response status.
	 */
	ProblemDetail getBody();
    
    // ... default method 부분은 생략
}

위 인터페이스는 응답에 대한 헤더 구성과 ProblemDetail을 body로 가진다.
Spring에서 제공하는 web관련 Exception은 6.0.x 이후 대부분 이 ErrorResponse를 상속받도록 변경되었다
이렇게 ErrorResponse를 상속받은 Exception들에 대해 에러 응답에 대해 처리할 수 있도록 Spring은 ResponseEntityExceptionHandler을 제공한다.
대략 다음과 같은 Exception들에 대해 공통으로 ProblemDetail 응답을 처리하고 있다. (아래는 mvc의 경우)

/**
 * Handle all exceptions raised within Spring MVC handling of the request.
 * @param ex the exception to handle
 * @param exchange the current request-response
 */
@ExceptionHandler({
        HttpRequestMethodNotSupportedException.class,
        HttpMediaTypeNotSupportedException.class,
        HttpMediaTypeNotAcceptableException.class,
        MissingPathVariableException.class,
        MissingServletRequestParameterException.class,
        MissingServletRequestPartException.class,
        ServletRequestBindingException.class,
        MethodArgumentNotValidException.class,
        NoHandlerFoundException.class,
        AsyncRequestTimeoutException.class,
        ErrorResponseException.class,
        ConversionNotSupportedException.class,
        TypeMismatchException.class,
        HttpMessageNotReadableException.class,
        HttpMessageNotWritableException.class,
        BindException.class
    })
@Nullable
public final ResponseEntity<Object> handleException(Exception ex, WebRequest request) throws Exception {
	// 구현 부분은 생략
}

이 ExceptionHandler를 사용하고 싶은 경우 다음과 같이 설정하면 된다.

# mvc의 경우
spring.mvc.problemdetails.enabled=true

# webflux의 경우
spring.webflux.problemdetails.enabled=true

BindException의 경우 ErrorResponse를 구현하지 않았다.
BindException의 subclass인 MethodArgumentNotValidException은 ErrorResponse를 구현하였고 기존에 ModelAttributeMethodProcessor에서 발생시키던 BindException은 MethodArgumentNotValidException으로 교체되었다.
BindException 발생 시 응답되는 ProblemDetail의 detail 설정은 아주 간단한 구현 수준이고 이 부분들은 아무래도 현재로선 개별 구현이 더 나은 것 같다.

다국어 처리

다국어 처리는 대략 다음과 같다.

problemDetail.title.org.springframework.web.bind.MethodArgumentNotValidException=argument invalid 오류
problemDetail.org.springframework.web.bind.MethodArgumentNotValidException=argument가 올바르지 않습니다.

"problemDetail." prefix로 시작하고 해당 exception Class의 이름으로 지정하면 각각 detail, title이 설정된다.


Spring Reference Document - Spring Web MVC - 1.8 Error Responses 부분

위에서 설명한 내용에 대한 spring reference document는 아래와 같다.
https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann-rest-exceptions
해당 문서의 내용을 아래 적어두었다.

1.8 ErrorResponses

Spring에서 API 응답에 대한 문제 세부 사항을 어떻게 전달할지에 대해 정의한 RFC 7807에 대한 지원이 추가되었다.
아래와 같은 주요 추상화를 지원한다.

  • ProblemDetail - RFC 7807 문제 세부 사항에 대한 표현, 사양에 정의된 standard field와 non-standard-field에 대한 간단한 container
  • ErrorResponse - HTTP status, response header 및 RFC 7807 형식의 body를 포함한 HTTP error response detail을 노출모든 Spring MVC exception은 이를 구현한다.
  • 이를 통해 exception이 HTTP response에 매핑되는 방법에 대한 세부 정보를 캡슐화하고 노출할 수 있다.
  • ErrorResponseException - 다른 사람들이 편리한 base class로 사용할 수 있는 기본 ErrorRespose 구현
  • ResponseEntityExceptionHandler - 모든 Spring MVC exception 및 ErrorResponseException을 처리하고 body로 error response를 렌더링 하는 @ControllerAdvice의 편리한 base class

1.8.1 Render

RFC 7807 response를 렌더링 하기 위해 모든 @ExceptionHandler 또는 @RequestMapping method에서 ProblemDetail 또는 ErrorResponse를 return 할 수 있다.
이는 다음과 같이 처리된다.

  • ProblemDetail의 status property는 HTTP status를 결정한다.
  • ProblemDetail의 instance property는 아직 설정되지 않은 경우 현재 URL path에서 설정된다.
  • content negotiation을 위해 Jackson HttpMessageConverter는 ProblemDetail을 렌더링 할 때 "application/json" 보다 "application/problem+json"을 선호하고 호환되는 media type이 없는 경우에도 대체한다.

Spring WebFlux exception 및 ErrorResponseExceptino에 대한 RFC 7807 response를 활성화하려면 ResponseEntityExceptionHandler를 확장하고 Spring configuration에서 @ControllerAdvice로 선언한다.
handler에는 모든 내장 web exception을 포함하는 모든 ErrorResponse exception을 처리하는 @ExceptionHandler method가 있다.
더 많은 exception handling method를 추가하고 proteced method를 사용하여 모든 exception을 ProblemDetail에 매핑할 수 있다.

1.8.2 Non-Standard Fields

두 가지 방법 중 하나로 non-standard field로 RFC-7807 response를 확장할 수 있다.
첫째, ProblemDetail의 "properties" Map에 추가한다.
jackson library를 사용할 때 Spring Framework는 "properties" Map이 unwrapp 되어 respnose에서 최상위 JSON property로 렌더링 되도록 보장하는 ProblemDetailJacksonMixin을 등록한다.
마찬가지로 deserialization 중 알 수 없는 property가 이 map에 추가된다.
또한 ProblemDetail을 extend 하여 non-standard properties를 추가할 수 있다.
ProblemDetail의 copy constructor를 사용하면 subclass가 기존 ProblemDetail에서 쉽게 생성할 수 있다.
추가 non-standard field가 있는 subclass로 exception의 ProblemDetail을 재생성하는 ResponseEntityExceptionHandler와 같은 @ControllerAdvice에서 이 작업은 중앙에서 수행할 수 있다.

1.8.3 Internationalization

error response detail을 다국어 처리하는 것은 일반적인 요구사항이며 Spring MVC exception에 대한 문제 세부 정보를 customize 하는 것이 좋다.
이는 다음과 같이 지원된다.

  • 각 ErrorResponse는 message code와 argument를 노출하여 MessageSource를 통해 "detail" field를 확인한다.
    예를 들어 "HTTP method {0} not supported"이 argument에서 확장된다.
  • 실제 message code 값은 placeholder를 사용하여 매개변수화 된다.
  • 각 ErrorResponse는 또한 "title" field를 해결하기 위한 message code를 노출한다.
  • ResponseEntityExceptionHandler는 message code와 argument를 사용하여 "detail" 및 "title" field를 확인한다.

default로 "detail" field의 message code는 "problemDetail." + fully qualified exception class name이다.
일부 excpetion은 default message code에 suffix가 추가되는 경우 추가 메시지 코드를 노출할 수 있다.
아래 표에는 Spring MVC exception에 대한 message argument 및 code가 나열되어 있다.

Exception Message Code Message Code Arguments
AsyncRequestTimeoutException (default)  
ConversionNotSupportedException (default) {0} property name, {1} property value
HttpMediaTypeNotAcceptableException (default) {0} list of supported media types
HttpMediaTypeNotAcceptableException (default) + ".parseError"  
HttpMediaTypeNotSupportedException (default) {0} the media type that is not supported, {1} list of supported media types
HttpMediaTypeNotSupportedException (default) + ".parseError"  
HttpMessageNotReadableException (default)  
HttpMessageNotWritableException (default)  
HttpRequestMethodNotSupportedException (default) {0} the current HTTP method, {1} the list of supported HTTP methods
MethodArgumentNotValidException (default) {0} the list of global errors, {1} the list of field errors. Message codes and arguments for each error within the BindingResult are also resolved via MessageSource.
MissingRequestHeaderException (default) {0} the header name
MissingServletRequestParameterException (default) {0} the request parameter name
MissingMatrixVariableException (default) {0} the matrix variable name
MissingPathVariableException (default) {0} the path variable name
MissingRequestCookieException (default) {0} the cookie name
MissingServletRequestPartException (default) {0} the part name
NoHandlerFoundException (default)  
TypeMismatchException (default) {0} property name, {1} property value
UnsatisfiedServletRequestParameterException (default) {0} the list of parameter conditions

default로 "title" field에 대한 message code는 "problemDetail.title." + fully qualified exception class name이다.

1.8.4 Client Handling

client application은 WebClient를 사용할 경우 WebClientResponseException, RestTemplate을 사용할 경우 RestClientResponseException을 catch 할 수 있고 getResponseBodyAs method를 사용하여 ProblemDetail의 subclass 또는 ProblemDetail과 같은 어떤 target type을 error response body로 decode 할 수 있다.

반응형
profile

파란하늘의 지식창고

@Bluesky_

내용이 유익했다면 광고 배너를 클릭 해주세요