BasicErrorController로 에러 전달하지 않는 방법

nayoung
35 min readApr 10, 2024

--

여러 방식으로 예외를 처리할 수 있다.

  • @ResponseStatus, ResponseStatusException: BasicErrorController로 에러 전달
  • @ExceptionHandler: 에러가 BasicErrorController로 전달되지 않음

어떤 방식을 사용하냐에 따라 에러가 BasicErrorController로 전달되거나 그렇지 않은데 ResponseEntityExceptionHandler를 사용하면 Spring MVC Exception도 BasicErrorController로 전달하지 않고 처리할 수 있다.

BasicErrorController

예외에 대한 별도의 설정이 없다면 BasicErrorController로 전달된다.

@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController {

private final ErrorProperties errorProperties;

@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
HttpStatus status = getStatus(request);
if (status == HttpStatus.NO_CONTENT) {
return new ResponseEntity<>(status);
}
Map<String, Object> body = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL));
return new ResponseEntity<>(body, status);
}

WAS로부터 에러 요청을 받으면 그 상태를 ResponseEntity로 응답한다.

server:
error:
path: /error
[nio-1234-exec-2] o.s.web.servlet.DispatcherServlet        : 
"ERROR" dispatch for GET "/error", parameters={}, headers={masked} in DispatcherServlet 'dispatcherServlet'

에러 경로의 기본 설정이 /error 이므로 예외가 발생하면 /error로 에러 요청을 전달한다. 이는 스프링 부트의 WebMvcAutoConfiguration를 통해 자동 설정되는 WAS의 설정이다.

public class WebMvcAutoConfiguration {

@Override
protected ExceptionHandlerExceptionResolver createExceptionHandlerExceptionResolver() {
if (this.mvcRegistrations != null) {
ExceptionHandlerExceptionResolver resolver = this.mvcRegistrations
.getExceptionHandlerExceptionResolver();
if (resolver != null) {
return resolver;
}
}
return super.createExceptionHandlerExceptionResolver();
}

일반적인 요청 흐름과 그 과정에서 에러가 발생해 BasicErrorController로 전달되는 흐름은 다음과 같다.

일반적인 요청 흐름은 다음과 같다.

  • WAS ➝ filter ➝ DispatcherServlet ➝ interceptor ➝ controller(요청 처리)

예외가 Controller를 넘어가면 그 흐름은 다음과 같다.

  • contoller ➝ interceptor ➝ DispatcherServlet ➝ filter ➝ WAS

WAS로 전달된 예외는 다음 과정을 통해 처리된다.

  • WAS ➝ filter ➝ DispatcherServlet ➝ interceptor ➝ controller(BasicErrorController)

즉, 별도로 예외 처리를 하지 않으면 에러를 받은 WAS는 다시 BasicErrorController로 전달하는 과정이 발생해 컨트롤러로 총 2번 접근하게 된다.

ExceptionResolver

우선순위가 높은 순서대로 예외 처리기는 다음과 같다.

https://mangkyu.tistory.com/204
  • ExceptionHandlerExceptionResolver: @ExceptionHandler 처리
  • ResponseStatusExceptionResolver: @ResponseStatus 또는 ResponseStatusException으로 처리
  • DefaultHandlerExceptionResolver: 스프링 내부의 기본 예외 처리

ExceptionHandlerExceptionResolver를 사용해 예외를 처리하면 에러가 BasicErrorController로 전달되지 않는다.

ResponseStatusExceptionResolver, DefaultHandlerExceptionResolver는 response.sendError를 통해 에러를 전달하므로 WAS는 /error 오류 페이지를 다시 요청한다. 즉, BasicErrorController로 에러를 전달한다.

아래 작성한 모든 테스트는 예외가 컨트롤러 밖으로 넘어가면 어떻게 처리되는지 확인한다.

ExceptionHandlerExceptionResolver

ExceptionResolver 중 가장 우선순위가 높으며 @ExcetpionHandler에 추가된 예외를 처리한다.

@ExcetpionHandler

@RestController
public class TestController {

@GetMapping("/exception-test")
public ResponseEntity<?> exceptionTest() {
throw new CustomException(ExceptionCode.NOT_FOUND_ACCOUNT);
}

@ExceptionHandler(CustomException.class)
public ResponseEntity<?> handleCustomException(CustomException e) {
return handleExceptionInternal(e.getExceptionCode());
}

@ExceptionHandler는 전체 애플리케이션에 대해 전역적으로 활성화되지 않고 특정 컨트롤러에서만 작동한다. @ControllerAdvice를 추가로 사용하면 여러 @ExceptionHandler를 global error handling component로 통합할 수 있다.

@ControllerAdvice, @RestControllerAdvice

@ControllerAdvice, @ResetControllerAdvice는 예외를 전역적으로 처리한다. 뒤에 붙은 Advice는 Spring AOP에서의 부가 기능을 의미하는 것 같다.

@ControllerAdvice는 @ExceptionHandler, @InitBinder 또는 @ModelAttribute로 정의된 메서드가 모든 @Controller 클래스를 공유할 수 있도록 한다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ControllerAdvice
@ResponseBody
public @interface RestControllerAdvice {...}

@RestControllerAdvice 코드를 보면 @ControllerAdvice가 추가되어있어 같은 기능을 제공하며, @ResponseBody가 추가되어 JSON으로 응답한다.

@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(CustomException.class)
public ResponseEntity<?> handleCustomException(CustomException e) {
return handleExceptionInternal(e.getExceptionCode());
}

private ResponseEntity<?> handleExceptionInternal(ExceptionCode exceptionCode) {
return ResponseEntity.status(exceptionCode.getHttpStatus())
.body(createExceptionResponse(exceptionCode));
}

위와 같이 작성하고 ExceptionHandler에 추가한 CustomException(RuntimeException)을 던지면 ExceptionHandlerExceptionResolver가 예외를 처리한다.

[nio-1234-exec-1] .m.m.a.ExceptionHandlerExceptionResolver : 
Using @ExceptionHandler com...GlobalExceptionHandler#handleCustomException(CustomException)

[nio-1234-exec-1] o.s.web.method.HandlerMethod :
Arguments: [com...exception.CustomException]

[nio-1234-exec-1] o.s.w.s.m.m.a.HttpEntityMethodProcessor :
Writing [com...response.ExceptionResponse@3529a301]

[nio-1234-exec-1] .m.m.a.ExceptionHandlerExceptionResolver :
Resolved [com...exception.CustomException]

[nio-1234-exec-1] o.s.web.servlet.DispatcherServlet :
Completed 400 BAD_REQUEST, headers={masked}

ExceptionHandlerExceptionResolver가 예외를 처리하며 BasicErrorController로 전달되지 않고, 작성한 handleCustomException 메서드로 전달된다.

Failed to complete request

만약 CustomException(Runtime Exception)을 ExcetpionHandler에 등록하지 않고 던지면 해당 예외를 처리할 handler를 찾을 수 없어서 에러가 발생하고 BasicErrorController로 전달된다.

[nio-1234-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : 
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception
[Request processing failed: com...CustomException] with root cause

com...exception.CustomException: null

[nio-1234-exec-1] o.a.c.c.C.[Tomcat].[localhost] :
Processing ErrorPage[errorCode=0, location=/error]

BasicErrorController에서 500 (Internal Server Error) 상태를 리턴한다.

ResponseEntityExceptionHandler

Handle all exceptions raised within Spring MVC handling of the request.

public abstract class ResponseEntityExceptionHandler implements MessageSourceAware {

@ExceptionHandler({
HttpRequestMethodNotSupportedException.class,
HttpMediaTypeNotSupportedException.class,
HttpMediaTypeNotAcceptableException.class,
MissingPathVariableException.class,
MissingServletRequestParameterException.class,
MissingServletRequestPartException.class,
ServletRequestBindingException.class,
MethodArgumentNotValidException.class,
HandlerMethodValidationException.class,
NoHandlerFoundException.class,
NoResourceFoundException.class,
AsyncRequestTimeoutException.class,
ErrorResponseException.class,
MaxUploadSizeExceededException.class,
ConversionNotSupportedException.class,
TypeMismatchException.class,
HttpMessageNotReadableException.class,
HttpMessageNotWritableException.class,
MethodValidationException.class,
BindException.class
})
@Nullable
public final ResponseEntity<Object> handleException(Exception ex, WebRequest request) throws Exception {
if (ex instanceof HttpRequestMethodNotSupportedException subEx) {
return handleHttpRequestMethodNotSupported(subEx, subEx.getHeaders(), subEx.getStatusCode(), request);
} else if (ex instanceof HttpMediaTypeNotSupportedException subEx) {
return handleHttpMediaTypeNotSupported(subEx, subEx.getHeaders(), subEx.getStatusCode(), request);
} ... 생략

HttpHeaders headers = new HttpHeaders();
if (ex instanceof ConversionNotSupportedException theEx) {
return handleConversionNotSupported(theEx, headers, HttpStatus.INTERNAL_SERVER_ERROR, request);
} else if (ex instanceof TypeMismatchException theEx) {
return handleTypeMismatch(theEx, headers, HttpStatus.BAD_REQUEST, request);
} ... 생략
else {
// Unknown exception, typically a wrapper with a common MVC exception as cause
// (since @ExceptionHandler type declarations also match nested causes):
// We only deal with top-level MVC exceptions here, so let's rethrow the given
// exception for further processing through the HandlerExceptionResolver chain.
throw ex;
}
}

ResponseEntityExceptionHandler 코드 내 ExceptionHandler에 추가한 예외를 handleException 메서드에서 처리시키는데 예외에 맞는 세부 처리를 진행한 후 handleExceptionInternal 메서드에서 ResponseEntity 객체를 생성한다.

@Nullable
protected ResponseEntity<Object> handleExceptionInternal(
Exception ex, @Nullable Object body,
HttpHeaders headers, HttpStatusCode statusCode, WebRequest request) {

if (request instanceof ServletWebRequest servletWebRequest) {
HttpServletResponse response = servletWebRequest.getResponse();
// 생략
if (statusCode.equals(HttpStatus.INTERNAL_SERVER_ERROR) && body == null) {
request.setAttribute(WebUtils.ERROR_EXCEPTION_ATTRIBUTE, ex, WebRequest.SCOPE_REQUEST);
}
return createResponseEntity(body, headers, statusCode, request);
}

protected ResponseEntity<Object> createResponseEntity(
@Nullable Object body, HttpHeaders headers,
HttpStatusCode statusCode, WebRequest request) {

return new ResponseEntity<>(body, headers, statusCode);
}

ResponseEntity를 return 시켜, BasicErrorController로 에러를 보내지 않는다.

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

@ExceptionHandler(AccountCustomException.class)
public ResponseEntity<?> handleCustomException(AccountCustomException e) {
return handleExceptionInternal(e.getExceptionCode());
}

ResponseEntityExceptionHandler를 상속한 상태에서 ResponseEntityExceptionHandler 내 ExceptionHandler에 등록된 예외를 던지면 처리 과정은 다음과 같다.

[nio-1234-exec-2] .m.m.a.ExceptionHandlerExceptionResolver : 
Using @ExceptionHandler com...GlobalExceptionHandler#handleException(Exception, WebRequest)

[nio-1234-exec-2] o.s.web.method.HandlerMethod :
Arguments: [org.springframework.web.context.request.async.AsyncRequestTimeoutException,
ServletWebRequest: uri=/exception-test;client=0:0:0:0:0:0:0:1]

[nio-1234-exec-2] o.s.w.s.m.m.a.HttpEntityMethodProcessor :
Writing [ProblemDetail[type='about:blank', title='Service Unavailable',
status=503, detail='null', instance='/exception-test', properties='null']]

[nio-1234-exec-2] .m.m.a.ExceptionHandlerExceptionResolver :
Resolved [org.springframework.web.context.request.async.AsyncRequestTimeoutException]

[nio-1234-exec-2] o.s.web.servlet.DispatcherServlet :
Completed 503 SERVICE_UNAVAILABLE, headers={masked}

ExceptionHandlerExceptionResolver가 예외를 처리하며 BasicErrorController로 전달되지 않는다.

만약 ResponseEntityExceptionHandler를 상속하지 않고 AsyncRequestTimeoutException.class를 던지면 DefaultHandlerExceptionResolver가 처리한다.

DefaultHandlerExceptionResolver

여러 ExceptionResolver 중 DefaultHandlerExceptionResolver가 가장 우선순위가 낮으며, 지원하는 예외는 다음과 같다.

ResponseEntityExceptionHandler 내 ExceptionHandler에 등록된 예외와 동일하다. AsyncRequestTimeoutException이 발생하면 500 상태로 리턴하는 것이 아니라 DefaultHandlerExceptionResolver가503 상태로 변경해 리턴한다.

public class DefaultHandlerExceptionResolver extends AbstractHandlerExceptionResolver {

protected ModelAndView handleErrorResponse(ErrorResponse errorResponse,
HttpServletRequest request, HttpServletResponse response, @Nullable Object handler) throws IOException {

if (!response.isCommitted()) {
HttpHeaders headers = errorResponse.getHeaders();
headers.forEach((name, values) -> values.forEach(value -> response.addHeader(name, value)));

int status = errorResponse.getStatusCode().value();
String message = errorResponse.getBody().getDetail();
if (message != null) {
response.sendError(status, message);
}
else {
response.sendError(status);
}
}
else if (logger.isWarnEnabled()) {
logger.warn("Ignoring exception, response committed already: " + errorResponse);
}
return new ModelAndView();
}

DefaultHandlerExceptionResolver가 예외를 처리하면 response.sendError()를 호출한다.

[nio-1234-exec-1] .w.s.m.s.DefaultHandlerExceptionResolver : 
Resolved [org.springframework.web.context.request.async.AsyncRequestTimeoutException]

[nio-1234-exec-1] o.s.web.servlet.DispatcherServlet :
Completed 503 SERVICE_UNAVAILABLE, headers={}

[nio-1234-exec-1] o.s.web.servlet.DispatcherServlet :
"ERROR" dispatch for GET "/error", parameters={}, headers={masked} in DispatcherServlet 'dispatcherServlet'

[nio-1234-exec-1] s.w.s.m.m.a.RequestMappingHandlerMapping :
2 matching mappings: [{ [/error]}, { [/error], produces [text/html]}]

[nio-1234-exec-1] o.s.b.f.s.DefaultListableBeanFactory :
Returning cached instance of singleton bean 'basicErrorController'

[nio-1234-exec-1] s.w.s.m.m.a.RequestMappingHandlerMapping :
Mapped to org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController#error(HttpServletRequest)

[nio-1234-exec-1] o.s.w.s.m.m.a.HttpEntityMethodProcessor :
Writing [{timestamp=..., status=503, error=Service Unavailable, message=No message available, path=/exception-test}]

[nio-1234-exec-1] o.s.web.servlet.DispatcherServlet :
Exiting from "ERROR" dispatch, status 503, headers={masked}

DefaultHandlerExceptionResolver가 예외를 처리하며 BasicErrorController를 통해 에러를 응답한다. 중요한 것은 ResponseEntityExceptionHandler가 존재하지 않을 때다.

Failed to complete request

ResponseEntityExceptionHandler 또는 DefaultHandlerExceptionResolver가 관리하지 않은 Exception을 던지면 처리 과정은 다음과 같다.

[nio-1234-exec-2] o.s.web.servlet.DispatcherServlet : 
Failed to complete request
java.lang.IllegalArgumentException: null

[nio-1234-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet] :
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception
[Request processing failed: java.lang.IllegalArgumentException] with root cause
java.lang.IllegalArgumentException: null

[nio-1234-exec-2] o.a.c.c.C.[Tomcat].[localhost] :
Processing ErrorPage[errorCode=0, location=/error]

[nio-1234-exec-2] o.s.web.servlet.DispatcherServlet :
"ERROR" dispatch for GET "/error", parameters={}, headers={masked} in DispatcherServlet 'dispatcherServlet'

[nio-1234-exec-2] o.s.b.f.s.DefaultListableBeanFactory :
Returning cached instance of singleton bean 'basicErrorController'

[nio-1234-exec-2] s.w.s.m.m.a.RequestMappingHandlerMapping :
Mapped to org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController#error(HttpServletRequest)

그 어떤 ExcetpionResolver도 예외를 처리하지 않고 DispatherServlet으로 넘겨진다.

ResponseStatusExceptionResolver

@ResponseStatus 또는 ResponseStatusException를 처리한다.

@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "test-reason")
public class CustomException extends RuntimeException {}

@ResponseStatus를 사용하면 CustomException을 던질 때마다 같은 상태와 메시지를 반환한다. 이는 ResponseStatusException으로 해결할 수 있다.

throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "test-reason");

ResponseStatusException을 사용하면 HttpStatus, Reason을 자유롭게 선택할 수 있다.

public class ResponseStatusExceptionResolver extends AbstractHandlerExceptionResolver implements MessageSourceAware {

protected ModelAndView applyStatusAndReason(int statusCode, @Nullable String reason, HttpServletResponse response)
throws IOException {

if (!StringUtils.hasLength(reason)) {
response.sendError(statusCode);
}
else {
String resolvedReason = (this.messageSource != null ?
this.messageSource.getMessage(reason, null, reason, LocaleContextHolder.getLocale()) :
reason);
response.sendError(statusCode, resolvedReason);
}
return new ModelAndView();
}

ResponseStatusExceptionResolver로 예외를 처리하면 response.sendError()를 호출한다.

[nio-1234-exec-1] .w.s.m.a.ResponseStatusExceptionResolver : 
Resolved [org.springframework.web.server.ResponseStatusException: 400 BAD_REQUEST "test-reason"]
// Resolved [com.kurly.kurlyaccount.exception.CustomException] -> @ResponseStatus 사용 시

[nio-1234-exec-1] o.s.web.servlet.DispatcherServlet :
Completed 400 BAD_REQUEST, headers={}

[nio-1234-exec-1] o.a.c.c.C.[Tomcat].[localhost] :
Processing ErrorPage[errorCode=0, location=/error]

[nio-1234-exec-2] o.s.web.servlet.DispatcherServlet :
"ERROR" dispatch for GET "/error", parameters={}, headers={masked} in DispatcherServlet 'dispatcherServlet'

[nio-1234-exec-2] o.s.b.f.s.DefaultListableBeanFactory :
Returning cached instance of singleton bean 'basicErrorController'

[nio-1234-exec-2] s.w.s.m.m.a.RequestMappingHandlerMapping :
Mapped to org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController#error(HttpServletRequest)

[nio-1234-exec-2] o.s.w.s.m.m.a.HttpEntityMethodProcessor :
Writing [{timestamp=..., status=400, error=Bad Request, message=test-reason, path=/exception-test}]

@ResponseStatus를 설정한 예외 또는 ResponseStatusException을 던지면 ResponseStatusExceptionResolver가 예외를 처리하며 BasicErrorController를 통해 응답하는 것을 확인할 수 있다.

ResponseEntityExceptionHandler + ResponseStatusException

@ResponseStatus를 설정한 예외 또는 ResponseStatusException을 던지면 ResponseStatusExceptionResolver가 예외를 처리한다.

만약 ResponseEntityExceptionHandler를 상속한 상태에서 @ResponseStatus를 설정한 예외 또는 ResponseStatusException을 던지면 ExceptionHandlerExceptionResolver가 예외를 처리해 BasicErrorController로 에러가 전달되지 않는다.

[nio-1234-exec-1] .m.m.a.ExceptionHandlerExceptionResolver : 
Using @ExceptionHandler com...exception.GlobalExceptionHandler#handleException(Exception, WebRequest)

[nio-1234-exec-1] o.s.web.method.HandlerMethod :
Arguments: [org.springframework.web.server.ResponseStatusException: 400 BAD_REQUEST "test-reason", ServletWebRequest: uri=/exception-test;client=0:0:0:0:0:0:0:1]

[nio-1234-exec-1] o.s.w.s.m.m.a.HttpEntityMethodProcessor :
Writing [ProblemDetail[type='about:blank', title='Bad Request', status=400, detail='test-reason', instance='/exception-test', properties='null']]

[nio-1234-exec-1] .m.m.a.ExceptionHandlerExceptionResolver :
Resolved [org.springframework.web.server.ResponseStatusException: 400 BAD_REQUEST "test-reason"]

[nio-1234-exec-1] o.s.web.servlet.DispatcherServlet :
Completed 400 BAD_REQUEST, headers={masked}

ResponseEntityExceptionHandle.handleException에서 ResponseStatusException를 처리한다.

public abstract class ResponseEntityExceptionHandler implements MessageSourceAware {

@ExceptionHandler({
HttpRequestMethodNotSupportedException.class,
HttpMediaTypeNotSupportedException.class,
...
})
@Nullable
public final ResponseEntity<Object> handleException(Exception ex, WebRequest request) throws Exception {
if (ex instanceof HttpRequestMethodNotSupportedException subEx) {
return handleHttpRequestMethodNotSupported(subEx, subEx.getHeaders(), subEx.getStatusCode(), request);
} else if (ex instanceof HttpMediaTypeNotSupportedException subEx) {
return handleHttpMediaTypeNotSupported(subEx, subEx.getHeaders(), subEx.getStatusCode(), request);
} ... 생략

HttpHeaders headers = new HttpHeaders();
if (ex instanceof ConversionNotSupportedException theEx) {
return handleConversionNotSupported(theEx, headers, HttpStatus.INTERNAL_SERVER_ERROR, request);
} else if (ex instanceof TypeMismatchException theEx) {
return handleTypeMismatch(theEx, headers, HttpStatus.BAD_REQUEST, request);
} ... 생략
else {
// Unknown exception, typically a wrapper with a common MVC exception as cause
// (since @ExceptionHandler type declarations also match nested causes):
// We only deal with top-level MVC exceptions here, so let's rethrow the given
// exception for further processing through the HandlerExceptionResolver chain.
throw ex;
}
}

--

--