프로그래밍 일기 — ExceptionHandler
Java Spring에서 에러를 처리하는 방법
프로젝트를 진행하면서 내가 신경쓰지 못한 또 하나의 부분은 예외/에러 처리다. 이 부분에 있어서 테스트 코드를 짜는 것 이외에 코드를 하나하나 작성할 때마다 염두해 두고 있어야했는데 그렇게 하지 못해서 시간을 낭비한 경험이 있다.
예를 들어 특정한 값이 들어오면 특정한 출력값을 내보내야한다고하자. 그렇게해야하는데 프론트와 연결시 예외 처리를 명시적으로 해 주지 않으면 프론트엔드 콘솔이나 IntelliJ IDEA 콘솔 상에서 확인할 수 없던 경우가 있었다. 그래서 특정 값을 받으면 내가 원하는 값을 출력하게끔 하고, 그렇지 않으면 “의도한 값을 받지 못했습니다.”라는 메시지를 프론트엔드나 백엔드의 콘솔에서 출력하게끔 처리를 해주어야했다. 이것을 하지 못해 에러 메시지를 제대로 확인할 수 없어 곤혹을 치른 적이 있다.
“에러"라는 것은 태양과 같다. 이 태양을 피해 시원한 그늘에서 내 프로그램을 동작하게 하려면, 반드시 내가 어디 쯤에 있는지, 얼마나 그늘과 멀어졌는지를 측정해야한다. 그것을 가능케하는 것이 에러처리다. 에러를 태양에 비유한 이유는, 에러가 우리에게 많은 가르침을 주기 때문이다. 우리의 실패가 그 원인을 분석하게 만들고 점점 더 결과를 내기 위한 방법을 개선하게끔 해 주기때문에 우리는 에러를 우리에게 자양분을 제공하는 고마운 존재로 여길 수 있다. 에러가 났다고해서 크게 자책하거나 싫어할 필요가 없다. 그만큼 우리가 새로 배울 수 있는 것들이 있다는 것이고, 우리는 한번 실패 했으니 이제 성공에 좀 더 가까이 다가갈 수 있게 되었다고 기뻐할 수 있다. 그러므로 이제 에러를 만났다면 “와우! 나는 이제 한 발자국 더 가까이 다가갔어!”라고 쾌재를 부를 수 있다.
그래서 오늘 글에서는 Java Spring에러 처리를 할 수 있는 ExceptionHanlder라는 것에 대해 알아보려한다. RESTful한 웹 어플리케이션에서 에러 처리를 할 수 있는 가장 효율적인 방법으로써, 앞으로 백엔드 엔지니어로 일하는데 있어 꽤 유용한 도구가 될 것이다.
ExceptionHandler(2)
Spring의 REST API에 대한 에러처리를 할 수 있는 도구 중 가장 보편적으로 활용되는 것이다. Spring 3.2 버전 이전에서는 HandlerExceptionResolver
과 @ExceptionHandler
Annotation을 주로 활용했는데 매번 테스트를 작성할때마다 별도로 선언을 해 주어야한다는 단점이 있었다. 로컬(local)하게 사용할 수 있지만 전역(global)적으로 사용이 어렵다는 단점이 있었다.
따라서 이후 이러한 단점을 개선하여 단일화된 에러처리 기법이 등장하게 되었고 이를 반영한 것이 @ControllerAdvice
Annotation이다. Spring 5 부터는 ResponseStatusException
클래스를 도입하여 보다 더 빠른 에러처리를 가능케했다. 이 두 가지 도구는 각 문제를 독립적으로 해결할 수 있게 해준다는데 있다. 어플리케이션으로 하여금 특정한 에러에 대해 Exception을 던지게 끔하여 그것이 독립적으로 해결될 수 있도록 지원하는 것이다.
1. Controller 단게에서의 @ExceptionHandler
첫 번째 에러 처리 방식은 @Controller
단에서의 처리다. 먼저 처리하고자하는 에러에 대한 행위(메서드)를 정의하고 그 위에 @ExceptionHandler
Annotation을 입히는 것이다. 이 방식의 단점은 Annotation을 사용한 특정 컨트롤러에 대해서만 유효하기에 어플리케이션 전역에 대해서 처리가 불가능하다는 것이다. 당연하게도 이 방식을 모든 컨트롤러에 개별적으로 적용한다는 것 또한 비효율적일 수 있다.
// Local한 ExceptionHandler 예제
public class FooController{
//...
@ExceptionHandler({ CustomException1.class, CustomException2.class })
public void handleException() {
//
}
}
이 문제를 해결하는 방법 중 하나는 모든 컨트롤러가 Base 컨틀롤러 클래스를 상속(extend)하게 끔하는 것이다. 그러나 이 방법 역시 적용이 불가능한 경우가 존재한다. 예를 들어, 컨트롤러가 이미 다른 클래스로 부터 상속을 받는다면, 객체지향의 기본 원칙에 따라 하나의 자녀클래스가 여러개의 부모클래스를 가질 수 없기 때문에 문제가 될 수 있다. 이 경우 부모클래스를 수정할 수 있다면 좋겠지만, 그것이 쉽지 않은 경우가 있을 수 있다.
2. HandlerExceptionResolver 활용
따라서, 특정 컨트롤러의 변화를 필요로하지 않고 전역으로 에러를 처리할 수 있는 방법이 필요하다. 이 중 하나가 HandlerExceptionResolver
인데, 어플리케이션이 던지는 그 어떤 Exception도 Resolve할 수 있다는 장점을 가지고 있다. 또한 모든 REST API에 대해 일괄적으로 공통된(uniform) 에러 처리 방식을 구현할 수 있다는 장점이 있다. 완전하게 커스터마이징이 가능한 유형을 확인하기에 앞서, 현재 제공되는 모든 기본(Default) Resolver들을 알아보자.
ExceptionHandlerExceptionResolver
Spring 3.1 버전 부터 도입되었으며, 기본(default)적으로 DispatcherServlet
에서 활성화된다. 이 것은 사실@ExceptionHandler
가 최초에 도입되었을 때부터 활용된 메인 컴포넌트라 할 수 있다.
DefaultHandlerExceptionResolver
이 역시 Spring 3.0에서 도입되어 기본적으로 DispatcherServlet
에서 활성화된다. 표준화된 Spring Exception을 해당하는 HTTP Status Code에 Resolve하는데 활용되며, 주로 4xx(클라이언트 에러) 및 5xx(서버 에러)에 활용된다. 이 것이 지원하는 모든 Spring Exception타입에 대해 알고 싶다면 참조(3)을 확인할 수 있다.
이 방법이 응답의 Status Code를 적절하게 지정한다는 장점이 있지만, Response의 body에는 아무것도 지정하지 않는다는 단점이 있다. 이는 REST API에 대해서, Status Code가 클라이언트에게 전달하는 에러 정보로써 그 자체로 충분치 않기 때문에, response가 해당 에러 내용을 표시할 body를 가지는 것이 좋다고 본다면, 이 방법은 충분히 에러에 대한 정보를 전달하지 못한다고 볼 수 있다.
이 문제는 ModelAndView
를 활용해 View에 대한 resolution 및 에러에 대한 rendering을 설정하는 방법으로 해결 가능한데, 그럼에도 불구하고 최선은 아니다. 따라서 Spring 3.2에서는 이를 해결할 다른 방법을 도입했다. 이에 대해서는 이후에 확인할 수 있다.
ResponseStatusExceptionResolver
이 resolver는 Spring 3.0에서부터 도입되었으며 DispatcherServlet
에서 기본적으로 활성화 되어있다. 이 것의 주요한 기능은 커스터마이징할 수 있는 예외사항(exception)에 대해 @ResponseStatus
Annotation을 사용해 이러한 예외사항들을 HTTP Status Code에 지정하는 것이다. 이러한 커스텀 Exception의 예는 아래와 같다.
이 Resolver는 DefaultHandlerExceptionResolver
와 같이, response의 body의 값을 지정할 수 없고 Status Code만 지정이 가능하다.
@ResponseStatus(value = HttpStatus.NOT_FOUND)
public class MyResourceNotFoundException extends RuntimeException {
public MyResourceNotFoundException() {
super();
}
public MyResourceNotFoundException(String message, Throwable cause) {
super(message, cause);
}
public MyResourceNotFoundException(String message) {
super(message);
}
public MyResourceNotFoundException(Throwable cause) {
super(cause);
}
}
HandlerExceptionResolver
DefaultHandlerExceptionResolver
및 ResponseStatusExceptionResolver
의 조합은 RESTful한 Spring 서비스의 에러를 다루는데 있어 매우 유용하다. 이 방식의 단점은 역시 response의 body를 다룰 수 없다는데 있다.
여기서 주목할 것은 request
자체에 접근이 가능하다는 것이며, 그러므로 클라이언트에서 전송된 Accept
헤더의 값을 고려할 수 있다. 예를 들어, 클라이언트가 application/json
를 요청할 때는 에러의 조건에서 반드시 application/json
으로 인코딩된 response body를 리턴하도록 하길 원할 것이다.
또 하나의 중요한 구현 정보는 ModelAndView
리턴한다는 것인데, 이는 response의 body이며, 우리가 원하는 그 어떤 것도 설정이 가능하다. 이러한 방식은 Spring REST 서비스에 대한 에러를 다루는데 있어 일관되고 설정이 용이하다는 장점이 있다.
그러나 이 방식의 단점은 HtttpServletResponse
의 low-level과 상호작용이 가능하지만 ModelAndView
객체를 사용하는 구식의 MVC 모델에 적합하다는 것이다. 따라서 여전히 개선이 필요하다.
@Component
public class RestResponseStatusExceptionResolver extends AbstractHandlerExceptionResolver {
@Override
protected ModelAndView doResolveException(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
try {
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;
}
private ModelAndView
handleIllegalArgument(IllegalArgumentException ex, HttpServletResponse response)
throws IOException {
response.sendError(HttpServletResponse.SC_CONFLICT);
String accept = request.getHeader(HttpHeaders.ACCEPT);
...
return new ModelAndView();
}
}
이상적으로는 클라이언트의 요청에 따라 Accept
헤더를 통해 JSON혹은 XML형태의 출력값을 받는 것이지만, response body에 접근하는데 있어 한계가 있다면, 당연하게도 이를 커스터마이징할 수 있는 exception resolver가 필요할 것이다.
3. @ControllerAdvice 활용
Spring 3.2에서 부터는 @ControllerAdvice
Annotation과 함께 전역(global)에서 활용가능한 @ExceptionHandler
를 지원한다.
이 방법은 구식의 MVC모델에서 독립을 가능케하며 @ExceptionHandler
의 타입안정성(type safety)와 유연성을 가져가면서 ResponseEntity
를 활용할 수 있게한다.
@ControllerAdvice
Annotation은 이전에 사용되던 복수의 흩어진 @ExceptionHandler
를 하나의 전역 에러 처리 컴포넌트로 통합시켜준다. 이러한 방식은 매우 단순하면서도 유연하다는 장점이 있다.
일단 status code는 물론이고, response의 body에 대한 총체적인 제어를 허용한다.또한 여러개의 예외 사항들을 같은 메서드에 매핑하여 같이 처리할 수 있다. 마지막으로 새롭게 생성되는 RESTful한 ResponseEntity
응답을 효과적으로 사용할 수 있게한다.
@ControllerAdvice
public class RestResponseEntityExceptionHandler
extends ResponseEntityExceptionHandler {
@ExceptionHandler(value
= { IllegalArgumentException.class, IllegalStateException.class })
protected ResponseEntity<Object> handleConflict(
RuntimeException ex, WebRequest request) {
String bodyOfResponse = "This should be application specific";
return handleExceptionInternal(ex, bodyOfResponse,
new HttpHeaders(), HttpStatus.CONFLICT, request);
}
}
중요한 것은 @ExceptionHandler
Annotation 을 입힌 예외사항들을 메서드의 인자(argument)로 활용되는 예외사항에 매칭할 수 있다는 것이다. 만약 매칭이 되지 않는다면, 컴파일러는 에러를 던지지 않으며(사실 던질 만한 이유가 없기도하다.) Spring 자체도 에러를 인식하지 못한다. 그러나, 런타임시 예외가 실제로 나온다면, 예외 resolving은 아래와 같은 메시지와 함께 에러를 던질 것이다.
java.lang.IllegalStateException: No suitable resolver for argument [0] [type=...]
HandlerMethod details: ...
4. ResponseStatusException
Spring 5 부터는 ResponseStatusException
클래스를 도입하여, HttpStatus
및 reason
과 cause
를 함께 전달하는 인스턴스를 생성할 수 있다. 이 방법의 장점은 아래와 같다.
- 프로토타이핑(Prototyping)에 매우 좋다: 기본적인 해결책을 매우 빠르게 구현할 수 있다.
- 하나의 타입을 가진 다수의 status code를 생성가능: 하나의 예외 타입이 여러개의 응답으로 이어질 수 있으며, 이는
@ExceptionHandler
와 비교해 매우 밀접하게 커플링되어있다. - 수동으로 그 만큼의 예외 클래스를 만들 필요가 없다.
- 예외 사항들이 프로그램으로 생성될 수 있어 예외 처리에 대해 더 많은 설정이 가능하다.
단점은 아래와 같다.
- 단일화(unified)된 예외 처리가 불가능하다: 일부 어플리케이션에서
@ControllerAdvice
와는 다르게 매우 넓은 컨벤션이 존재하여 모든 어플리케이션에 공통적으로 적용되는 컨벤션을 가져가기 쉽지 않다. - 코드 중복성: 다수의 컨트롤러에서 코드를 중복 활용할 가능성이 있다.
그러나 위 단점에도 불구하고, 여러 다른 방법들을 하나의 어플리케이션에 묶는 것이 가능하다. 예를 들어, @ControllerAdvice
를 전역적으로 적용하면서 로컬하게 ResponseStatusExceptions
적용하는 것이 가능하다.
그러나 조심할 것이 있다. 만약 같은 예외 사항이 여러가지 방법으로 처리될 경우, 예상치 못한 행위를 할 수 있는 위험이 있다. 이를 위해서 이 기술을 사용할 때는 특정한 종류의 예외 사항들에 대해서는 하나의 방식으로 처리를 하도록 컨벤션을 가져가는 것이 좋다.
이 방식에 대한 더 많은 정보 및 튜토리얼 학습은 참조(4)에서 확인 가능하다.
@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);
}
}
5. Spring Security에서 접근 제한된 (Access Denied) 처리를 하는 방식
접근 제한은 허용된 유저들이아닌 개인이 리소스에 대한 접근을 하려할 때 발생된다. 이러한 상황을 다룰 수 있는 REST 및 메서드 수준의 Security 구현방법이 있다.
REST 및 메서드 단계 Security
@PreAuthorize
및 @PostAuthorize
, @Secure
등의 메서드 단계 Security Annotation을 활용해 접근 제한 예외를 던질 수 있다. 여기에서도 전역 예외 사항 처리 방법을 활용하여 AccessDeniedException
를 처리할 수 있다.
@ControllerAdvice
public class RestResponseEntityExceptionHandler
extends ResponseEntityExceptionHandler {
@ExceptionHandler({ AccessDeniedException.class })
public ResponseEntity<Object> handleAccessDeniedException(
Exception ex, WebRequest request) {
return new ResponseEntity<Object>(
"Access denied message here", new HttpHeaders(), HttpStatus.FORBIDDEN);
}
...
}
6. Spring Boot 지원
Spring Boot는 ErrorController
구현을 제공하여 에러 처리를 현명하게 할 수 있는 방법을 제공한다. 즉, 이는 브라우져에 대한 Fallback 에러 페이지(Whitelabel Error Page)및 RESTful하면서도 비HTML(non-HTML)한 요청에 대한 JSON 형태의 응답의 기능을 한다.
// 에러 발생시 전달되는 JSON 형태의 응답
{
"timestamp": "2019-01-17T16:12:45.977+0000",
"status": 500,
"error": "Internal Server Error",
"message": "Error processing the request!",
"path": "/my-endpoint-with-exceptions"
}
일반적으로 Spring Boot는 아래와 같은 속성들의 정의를 할 수 있도록 허용한다.
server.error.whitelabel.enabled
: Whitelabel Error Page를 비활성화하고 Servlet 컨테이너가 직접 HTML 에러 메시지를 제공할 수 있도록 한다.server.error.include-stacktrace
:always
값을 활용해 HTML 및 JSON 기본 응답에 있는 stacktrace를 포함할 수 있다.server.error.include-message
: 버전 2.3 부터 Spring Boot는 response의message
필드를 숨겨 민감한 정보를 피할 수 있는 방법을 제공한다.always
값과 이 속성을 사용해 활성화 할 수 있다.
이 속성들 외에도, /error
를 위한 자신만의 view-resolver 매핑을 스스로 할 수 있는데, 이를 통해 Whitelabe Page를 override할 수 있다. 또한 response에 보여줄 속성들 또한 컨텍스트 상의 ErrorAttributes
bean을 포함하여 커스터마이징이 가능하다. 이는 Spring Boot에서 제공하는 DefaultErrorAttributes
클래스를 상속해 이를 용이하게 할 수 있다.
@Component
public class MyCustomErrorAttributes extends DefaultErrorAttributes {
@Override
public Map<String, Object> getErrorAttributes(
WebRequest webRequest, ErrorAttributeOptions options) {
Map<String, Object> errorAttributes =
super.getErrorAttributes(webRequest, options);
errorAttributes.put("locale", webRequest.getLocale()
.toString());
errorAttributes.remove("error");
//...
return errorAttributes;
}
}
만약 여기서 좀 더 나아가서 특정 타입의 컨텐츠에대해 어플리케이션이 에러를 처리하는 방식을 정의 및 override할 경우, ErrorController
bean을 등록할 수 있다.
또한 Spring Boot가 제공하는 기본적인(default) BasicErrorController
를 활용할 수 있다.
예를 들어, 어플리케이션이 XML엔드포인트에서 발생된 에러를 어떻게 처리할 지에 대해 커스터마이즈하고 싶다면, 오직@RequestMapping
을 사용해 공용 메서드를 정의하고, application/xml
타입을 생성하도록 명기하면된다.
이 방법에서는 여전히 프로젝트에서 정의한 server.error
라는 Boot 속성에 의존한다는 점에 주목할 필요가있다. 이는 ServerProperties
Bean과 묶여(bound)있다.
@Component
public class MyErrorController extends BasicErrorController {
public MyErrorController(
ErrorAttributes errorAttributes, ServerProperties serverProperties) {
super(errorAttributes, serverProperties.getError());
}
@RequestMapping(produces = MediaType.APPLICATION_XML_VALUE)
public ResponseEntity<Map<String, Object>> xmlError(HttpServletRequest request) {
// ...
}
}
요약
- Spring REST API의 에러를 처리하는 방법에는
@ExceptionHandler
,@ControllerAdvice
,HandlerExceptionResolver
등을 활용할 수 있다. - 접근제한(Access Denied) 예외처리를 위해 Spring Security를 REST API 메서드에 맞게 설정할 수 있다.
- Spring Boot에서는 Whitelabel Page Error나 비HTML 요청예외 처리에 대해 JSON등의 형태로 처리할 수 있게 지원한다.
- Spring 3.2 버전 이후 부터는 전역에서 활용가능한
@ExceptionHandler
,@ControllerAdvice
등을 지원한다. - Spring 5 이후 부터는
ResponseStatusException
클래스를 지원하여 Http에러에 대한 더 많은 내용을 명기할 수 있게 되었다. - 4xx는 클라이언트 에러, 5xx는 서버 에러이다.
위 모든 코드는 참조 (5)에서 확인 가능하다. Spring Security 관련 코드는 참조 (6)에서 확인 가능하다.
참조:
(1) https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSlsmwWFol3AzUlR-QloYg3Sa5WuACQ_67nBQ&usqp=CAU
(2) https://www.baeldung.com/exception-handling-for-rest-with-spring
(4) https://www.baeldung.com/spring-response-status-exception
(5) https://github.com/eugenp/tutorials/tree/master/spring-boot-rest
(6) https://github.com/eugenp/tutorials/tree/master/spring-security-modules/spring-security-web-rest