Spring REST 에서의 Global Exception
spring-mvc 에서의 global exception 에 대해 메모해놓으려합니다. 언제나 그랬듯이 spring ref. 문서를 찾는게 가장 좋으니 간략하게 정리하는 수준이 될 것입니다.
Spring 3.2 이전에는 Spring MVC application 에서 예외를 처리하는 두 가지 주요 접근 방식이 있었습니다. 하나는 HandlerExceptionResolver, 또 하나는 @ExceptionHandler annotation 이였습니다. 둘다 분명한 단점이 있습니다.
spring-boot 에서는 WebMvcRegistrations Bean 을 선언하고 이를 재정의하여 custom instance 로 제공하는 방법으로 사용할 수 있습니다.
이에 3.2부터 이전 두 솔루션의 한계를 해결하고 전체 애플리케이션에서 통합된 예외 처리를 증진하기 위해 @ControllerAdvice 어노테이션이 있습니다.
최신 배포버전인 Spring 5는 REST API에서 기본 오류 처리를 위한 빠른 방법인 ResponseStatusException 클래스를 도입하였습니다.
DispatcherServlet 의 Special Bean 형태들
Spring-MVC DispatcherServlet 은 몇 가지 special bean 들을 감지합니다.(https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-servlet-special-bean-types) 이를 통해, Exception 을 해결할 수 있는 전략을 구성할 수 있는데 HandlerExceptionResolver 라는 Bean 으로 정의됩니다.
Exceptions 의 관리
우선 Exception 의 관리를 알아보려합니다. Exception 의 처리를 맡고 있는 HandlerExceptionResolver 는 DispatcherServlet 에서 아래와 같은 경우에 발생합니다.
- request mapping 중에 예외가 발생한 경우
- request handler(@Controller 같은)에서 예외가 발생한 경우
위의 경우에서 DispatcherServlet 은 예외를 해결하고 일반적으로 오류 응답인 대체처리를 제공하기 위해 HandlerExceptionResolver Bean 을 Chain에 추가하여 위임합니다.
HandlerExceptionResolver 의 구현 클래스
아래의 Resolver 들은 기본적으로 DispatcherSevlter 에 의해 활성화 되어 동작합니다.
HandlerExceptionResolver | Description |
SimpleMappingExceptionResolver | Exception class name 과 Error View name 간의 매핑입니다. 브라우저 애플리케이션에서 error page를 렌더링하는 데 유용합니다. REST 서비스와는 관련이 없습니다. Spring 3.2 부터 ExceptionHandlerExceptionResolver 에 의해 deprecated 되었습니다. |
DefaultHandlerExceptionResolver | Spring MVC 에서 발생한 예외를 해결하고 이를 HTTP 상태 코드에 매핑합니다. 대체 ResponseEntityExceptionHandler 및 REST API Exception(https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann-rest-exceptions)도 참조하십시오. 한가지 제한 사항은 Response Body 에는 아무것도 설정하지 않습니다. 그래서 REST API 서비스에 어울리지 않습니다. |
ResponseStatusExceptionResolver | @ResponseStatus annotation 으로 예외를 해결하고 annotation 의 값을 기반으로 HTTP 상태 코드에 매핑합니다. 마찬가지로 Response Body 는 여전히 null 로 처리됩니다. |
ExceptionHandlerExceptionResolver | @Controller 또는 @ControllerAdvice 클래스에서 @ExceptionHandler 메서드를 호출하여 예외를 해결합니다. @ExceptionHandler 메서드를 참조하십시오. |
REST API 를 이용하는 Service 에 대한 일반적인 요구 사항은 Response Body 에 '오류 세부 정보' 를 포함하는 것입니다.
Response Body 의 '오류 세부 정보' 표현이 애플리케이션에 따라 다르기 때문에 Spring Framework는 이를 자동으로 수행하지 않습니다. 그런데, @RestController는 ResponseEntity 반환 값과 함께 @ExceptionHandler 메서드를 사용하여 응답의 상태와 본문을 설정할 수 있습니다. 이러한 메서드는 @ControllerAdvice (@RestControllerAdvice) 클래스에서 선언하여 전역적으로 적용 할 수도 있습니다.
Chain of Resolvers - resolver 체인
DispathcerServlet 은 내부적으로 chain 구조로 구성되어 절차적(Order 가 높으면 나중에 실행)으로 servlet 업무를 수행합니다.
HandlerExceptionResolver의 계약은 다음을 반환할 수 있음을 지정합니다.
- error view 를 가리키는 ModelAndView
- resolver 안에서 예외가 처리된 경우 empty ModelAndView
- 예외가 해결되지 않은 상태로 유지되고 후속 resolver 가 try 할 수 있는 경우 null이며, 예외가 끝에 남아 있으면 Servlet 컨테이너로 버블 업 할 수 있습니다.
MVC Config 은 기본 Spring MVC exception, @ResponseStatus annotation 이 달린 예외 및 @ExceptionHandler 메소드 지원에 대한 내장 resolver 를 자동으로 선언합니다. 이를 개발자는 custom 하거나 바꿀 수 있습니다.
Error Page
예외가 HandlerExceptionResolver 에 의해 해결되지 못하고 그대로 남아 전파될 수 있는 상태이거나, error status(4xx, 5xx) 로 설정된 경우 servlet 컨테이너는 HTML 에서 기본 오류 페이지를 렌더링 할 수 있습니다. 이럴 때 아래와 같이 error-page 를 정의하여 ERROR dispatch 를 수행합니다.
<error-page>
<location>/error</location>
</error-page>
위에 지정된 URL 인 "/error" 를 처리하기 위한 Controller 를 지정하여 해당 오류를 렌더링할 수 있습니다.
@RestController
public class ErrorController {
@RequestMapping(path = "/error")
public Map<String, Object> handle(HttpServletRequest request) {
Map<String, Object> map = new HashMap<String, Object>();
map.put("status", request.getAttribute("javax.servlet.error.status_code"));
map.put("reason", request.getAttribute("javax.servlet.error.message"));
return map;
}
}
아래부터는 몇가지 방식의 예외처리를 위한 방법을 언급할 것입니다. 염두에 두어야 할 사항은 가능하면 서비스 기준으로 항상 한가지 방식으로 특정 종류의 예외 처리 방법을 가져가는 것이 좋습니다.
첫 번째 처리방법
첫 번째는 @Controller 및 @ControllerAdvice 클래스안에서 예외를 처리하는 메서드를 정의하고 @ExceptionHandler annotation 을 추가하는 방법입니다. 그러면 위에서 언급한 ExceptionHandlerExceptionResolver (Spring Dispatcher 에 의해서 기본적으로 활성화 되어있음)에 의해 빌드되어 처리됩니다.
@Controller
public class SimpleController {
// ...
@ExceptionHandler
public ResponseEntity<String> handle(IOException ex) {
// ...
}
}
해당 방법은 top-level exception 과 매치되며, 위의 예에서 직접적으로 메서드 인수로 선언된 IOException 에 대한 예외를 처리합니다. 다른 Exception 안에서 발생한 IOException 이 대상이 되지는 않습니다. 여러 Exception 이 일치하는 경우는 일반적으로 cause exception 보다 root exception 을 우선합니다.(안쪽에서 ExceptionDepthComparator 이 사용됩니다.) 그래서 일반적으로 argument signature 를 가능한 한 구체적으로 지정하여 root 및 cause exception types 간의 불일치 가능성을 줄이는 것이 좋습니다.
annotation 정의에서 다음 예제와 같이 일치하도록 예외 유형을 좁힐 수 있습니다.
@ExceptionHandler({FileSystemException.class, RemoteException.class})
public ResponseEntity<String> handle(IOException ex) {
// ...
}
@ExceptionHandler({FileSystemException.class, RemoteException.class})
public ResponseEntity<String> handle(Exception ex) {
// ...
}
@ExceptionHandler 메서드는 아래와 같은 arguments (method parameter) 를 지원합니다.
- Exception type - 발생한 exception 에 access 하기 위함
- HandlerMethod - 예외를 발생시킨 컨트롤러 메서드에 액세스하기 위함
- WebRequest, NativeWebRequest - Servlet API 가 아닌 request parameters, request and session attributes 액세스
- ServletRequest, ServletResponse - Servlet 기반의 Request, Response 액세스
- java.servlet.http.HttpSession
- java.security.Principal
- HttpMethod
- Locale, timeZone, ZoneId
- java.ioOutputStream, Writer - Servlet API에 의해 노출된 raw response body 에 액세스하기 위함
- RedirectAttributes
- @SessionAttribute - session attribute 에 액세스
- @RequestAttriBute - request attribute 에 액세스
@ExceptionHandler 메서드의 반환형식은 일반적인 Controller 의 반환형식과 유사합니다.(https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann-exceptionhandler-return-values)
이 방법은 Controller 가 다른 jar 에 있거나 직접 수정할 수 없는 다른 기본 class 에서 이미 확장되었거나 직접 수정할 수 없을 경우는 사용할 수 없습니다.
두 번째 처리방법
HandlerExceptionResolver를 정의하여 사용하는 방법이 있습니다. 이러면 application 에서 발생한 모든 예외에 대한 resolver 를 구성할 수 있습니다.
HandlerExceptionResolver 의 재구성
보통은 client 측에서 Accept 헤더를 통해 요청한 정보대로 response 를 내려주는게 가장 이상적인 경우이고, 그렇지 않을 경우 서비스의 장애로 이어질 수 있습니다.
@Component public class RestResponseStatusExceptionResolver
extends AbstractHandlerExceptionResolver {
@Override
protected ModelAndView doResolveException(HttpServletRequest request,
HttpServletResponse response, Object handler, Exception ex) {
try {
// Exception 의 종류별로 return ModelAndView 생성하여 처리
if (ex instanceof IllegalArgumentException) {
return handleIllegalArgument((IllegalArgumentException) ex, response, handler);
}
...
} catch (Exception handlerException) {
logger.warn("Handling of [" + ex.getClass().getName() + "] resulted in Exception", handlerException);
}
return null;
}
// ModelAndView 를 반환하기에 필요한 무엇이든 설정 가능함
private ModelAndView handleIllegalArgument(IllegalArgumentException ex,
HttpServletResponse response) throws IOException {
response.sendError(HttpServletResponse.SC_CONFLICT);
String accept = request.getHeader(HttpHeaders.ACCEPT);
...
// PrintWriter 를 통해 accept 값에 따라 별도의 구성 가능
// writer = response.getWriter();
// writer.write(사용자 정의 body);
// writer.flush();
// writer.close();
return new ModelAndView();
}
}
HttpServletResponse 와 상호작용하고 ModelAndView 를 사용하는 MVC 모델에 적합합니다.
세 번째 처리방법
Spring 3.2는 @ControllerAdvice annotation 으로 전역 @ExceptionHandler 를 지원합니다. 이를 통해 이전 MVC 모델에서 벗어나 @ExceptionHandler 의 형식 안전성 및 유연성과 함께 ResponseEntity 를 사용하는 메커니즘을 사용할 수 있습니다.(https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann-controller-advice)
일반적으로 @ExceptionHandler, @InitBinder, @ModelAttribute 메서드등은 @Controller 클래스안에서 적용됩니다. 이러한 메서드들을 컨트롤러간에 더 전역적으로 적용하려면 @ControllAdvice 또는 @RestControllerAdvice 로 annotation 이 달린 클래스에서 선언할 수 있습니다.
Spring 이 시작할 때 @RequestMapping 및 @ExceptionHandler 메서드의 인프라 클래스는 @ControllerAdvice 로 주석이 달린 Spring Bean을 감지한 다음 런타임에 해당 메서드를 적용합니다. @ControllerAdvice 의 전역 @ExceptionHandler 메서드는 @Controller 의 로컬 메서드 이후에 적용됩니다. 반대로 전역 @ModelAttribute 및 @InitBinder 메서드는 로컬 메서드보다 먼저 적용됩니다.
기본적으로 @ControllerAdvice 메서드는 모든 요청 (즉, 모든 컨트롤러)에 적용되지만 다음 예제와 같이 주석의 속성을 사용하여 컨트롤러의 하위 집합으로 범위를 좁힐 수 있습니다.
// Target all Controllers annotated with
@RestController
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {
}
// Target all Controllers within specific packages
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}
// Target all Controllers assignable to specific classes
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class ExampleAdvice3 {}
네 번째 처리방법
ResponseStatusException (Spring 5 이상) 을 throw 로 던지는 방법이 있습니다. 전체규칙을 적용하는 것은 어렵지만, proto type 을 생성하거나 다른 방법에 비해 선언하기 쉽고, 하나의 Exception 에 대해 메서드마다 다른 응답을 발생할 수 있는 이점이 있습니다.
@GetMapping(value = "/{id}")
public Foo findById(@PathVariable("id") Long id, HttpServletResponse response) {
try {
Foo resourceById = RestPreconditions.checkFound(service.findOne(id));
eventPublisher.publishEvent(new SingleResourceRetrievedEvent(this, response));
return resourceById;
} catch (MyResourceNotFoundException exc) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Foo Not Found", exc);
}
}