Backend/Spring

서블릿의 예외 처리, 스프링의 예외 처리(HandlerExceptionResolver)

olsohee 2024. 1. 4. 15:49

서블릿의 예외 처리

서블릿의 예외 처리 흐름

먼저 스프링이 아닌 순수 서블릿 컨테이너는 어떻게 예외 처리를 하는지 알아보자. 서블릿은 다음 2가지 방식의 예외 처리를 지원한다.

  1. Exception
    • 순수 자바 프로그램의 경우, 자바의 main 메서드를 직접 실행하면 main이라는 이름의 스레드가 실행된다. 그리고 실행 도중에 발생한 예외를 잡아서 처리하지 않으면 처음에 실행한 main 메서드까지 예외가 넘어가고, 예외 정보를 남기며 해당 스레드는 종료된다.
    • 반면, 웹 애플리케이션은 사용자 요청별로 별도의 스레드가 할당되고 서블릿 컨테이너 안에서 실행된다. 만약 컨트롤러에서 예외가 발생했는데 이를 잡지 못하면 어떻게 될까? 예외 발생시 흐름은 다음과 같다.
      • WAS(여기까지 예외 전파) ⬅️ 필터 ⬅️ 서블릿 ⬅️ 인터셉터 ⬅️ 컨트롤러(예외 발생)
    • 예를 들어 컨트롤러에서 throw new RuntimeException()을 통해 예외를 발생시켰다고 할 때, 예외를 잡지 않으면 예외가 WAS까지 전달된다. 그리고 WAS 예외를 받으면 모든 예외에 대해 HTTP 상태 코드를 500으로 지정하고, 기본 오류 페이지를 보여준다.
  2. response.sendError(HTTP 상태 코드, 오류 메시지)
    • 오류가 발생했을 때 예외를 발생시키는 방법 외에 HttpServletResponse가 제공하는 response.sendError()를 활용하는 방법도 있다. 이를 호출한다고 해서 예외가 발생하는 것은 아니지만, WAS에게 예외가 발생했다는 것을 알릴 수 있다. 
    • Exception이 발생하면 WAS는 모든 예외에 대한 HTTP 상태 코드를 500으로 처리했다. 반면, response.sendError()를 사용하면 HTTP 상태 코드도 지정할 수 있다.
      • response.sendError(HTTP 상태 코드)
      • response.sendError(HTTP 상태 코드, 오류 메시지)
    • response.sendError() 호출시 흐름은 다음과 같다.
      • WAS(sendError 호출 기록 확인) ⬅️ 필터 ⬅️ 서블릿 ⬅️ 인터셉터 ⬅️ 컨트롤러(response.sendError())
    • response.sendError()를 호출하면 HttpServletResponse는 내부에 오류가 발생했다는 상태를 저장한다. 그리고 WAS는 고객에게 응답하기 전에 HttpServletResponse에 sendError()가 호출됐었는지를 확인하고, 호출됐었다면 설정한 오류 코드에 맞춰 기본 오류 페이지를 보여준다. 

스프링의 오류페이지 기능

Exception이든 response.sendError()이든, WAS가 기본으로 제공하는 오류 페이지는 사용자 친화적이지 않다. 따라서 개발자가 원하는 오류 페이지를 등록해서 보여줄 수도 있다. 그리고 이때 스프링 부트를 사용하면 스프링이 오류 페이지를 등록하는 과정 중 일부를 자동으로 해주기 때문에(ErrorPage와 BasicErrorController를 자동으로 등록), 개발자는 오류 페이지 등록의 기본적인 로직은 작성할 필요가 없으며 오류 페이지 화면만 BasicErrorController가 제공하는 규칙에 따라 등록하면 된다. 

스프링의 예외 처리(HandlerExceptionResolver)

앞에서 WAS에 예외(Exception)이 전달되는 경우를 생각해보자. WAS는 예외를 받으면 모든 예외에 대해 HTTP 상태 코드를 500으로 지정하고, 그에 맞는 오류 페이지를 찾아서 반환한다. 그러나 발생하는 예외에 따라 HTTP 상태 코드를 다르게설정하고 싶을 수 있다. 이때 스프링이 제공하는 HandlerExceptionResolver(ExceptionResolver)를 사용하면 이를 해결할 수 있다.

HandlerExceptionResolver을 적용했을 때 흐름

ExceptionResolver는 컨트롤러 밖으로 넘어온 예외를 처리하여, WAS까지 예외가 넘어가지 않게 해준다. 그리고 예외를 처리할 때 HTTP 상태 코드를 지정하는 등 동작을 새로 정의할 수도 있다. 

 

즉, ExceptionResolver를 사용하면 다음과 같은 흐름으로 진행된다.

  • ExceptionResolver 사용 전
    • 컨트롤러에서 예외 발생 ➡️ postHandle() 호출 X ➡️ afterCompletion() 호출 O ➡️ WAS까지 예외 전달 ➡️ WAS는 모든 예외에 대해 HTTP 상태 코드를 500으로 지정하고, 오류 페이지를 찾아서 보여준다. 

  • ExceptionResolver 사용 후
    • 컨트롤러에서 예외 발생 ➡️ postHandle() 호출 X ➡️ ExceptionResolver에서 예외 처리 ➡️ 뷰 렌더링 ➡️ afterCompletion() 호출 O ➡️ WAS는 정상 응답을 한다. 

HandlerExceptionResolver 인터페이스 

HandlerExceptionResolver 인터페이스는 다음과 같다.

public interface HandlerExceptionResolver {

	ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex);
}
  • request, response: HTTP 요청, 응답 정보인 HttpServletRequest와 HttpServletResponse 객체
  • handler: 컨트롤러 
  • ex: 컨트롤러에서 발생한 예외 

HandlerExceptionResolver 적용

HandlerExceptionResolver를 적용하려면 HandlerExceptionResolver 인터페이스를 구현하고 등록하면 된다.

HandlerExceptionResolver 인터페이스 구현

@Slf4j
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {
    
    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        try {
            if (ex instanceof IllegalArgumentException) {
                log.info("IllegalArgumentException resolver to 400");
                response.sendError(HttpServletResponse.SC_BAD_REQUEST,ex.getMessage()); // response.sendError()
                return new ModelAndView(); // ModelAndView 반환
            }
        } catch (IOException e) {
            log.error("resolver ex", e);
        }
        return null;
    }
}
  • HandlerExceptionResolver을 통해 다음과 같은 일을 할 수 있다.
    • response.sendError()를 통해 예외에 따라 적절한 HTTP 상태 코드를 지정해 줄 수 있다. 이를 통해 WAS에 정상 응답이 나갈 때, WAS는 response.sendError() 호출 여부를 확인하고, 호출되었으므로 500이 아닌 지정된 HTTP 상태 코드에 맞게 응답한다.
    • ModelAndView에 값을 채워서 반환하면, 새로운 오류 화면을 보여줄 수 있다. 
    • response.getWriter().write()와 같이 HTTP 응답 바디에 직접 데이터를 넣어줄 수 있다.
  • HandlerExceptionResolver은 ModelAndView를 반환하는데, DispatcherServlet은 HandlerExceptionResolver의 반환 값에 따라 다르게 동작한다.
    • 빈 ModelAndView 반환: 뷰를 렌더링하지 않고, WAS에 정상 응답이 나가게 된다.
    • ModelAndView에 Model, View 등의 정보를 지정해서 반환: 뷰를 렌더링하고, WAS에 정상 응답이 나가게 된다. 따라서 WAS는 렌더링 된 뷰로 새로운 오류 화면을 보여준다.
    • null 반환: 다음 ExceptionResolver를 찾아서 실행한다. 더이상 처리할 수 있는 ExceptionResolver가 없으면 예외 처리가 안되고 WAS로 예외가 전달된다. 따라서 이 경우 WAS는 HTTP 상태 코드를 500으로 응답한다.
  • 여기서 try~catch는 resonse.sendError() 때문에 작성한 코드이다. try~catch 코드와 무관하게 HandlerExceptionResolver 인터페이스를 구현하고 ModelAndView를 반환하면, 해당 예외를 처리한 것으로 간주된다.

HandlerExceptionResolver 등록

@Configuration
public class WebConfig implements WebMvcConfigurer {
    
    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new MyHandlerExceptionResolver());
    }
}
  • WebMvcConfigurer를 구현한 클래스에서 extendHandlerExceptionResolvers() 메소드를 구현하면 된다. 
  • WebMvcConfigurer.configureHandlerExceptionResolvers()를 사용하면 스프링이 기본으로 등록하는 HandlerExceptionResolver가 제거되므로, WebMvcConfigurer.extendHandlerExceptionResolvers()를 구현해야 한다.

HandlerExceptionResolver 사용 예제

이렇게 스프링이 제공하는 HandlerExceptionResolver을 사용하면, 발생하는 예외에 따라 우리가 원하는 형태로 응답을 내려줄 수 있다. 다음 예제는 HTTP 요청 헤더의 accept 값이 json이면 json 형식으로 응답을 내려주고, 그 외의 경우에는 error/500에 위치한 html 형식으로 오류 페이지를 보여준다.

@Slf4j
public class UserHandlerExceptionResolver implements HandlerExceptionResolver {

    private final ObjectMapper objectMapper = new ObjectMapper();
  
    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        try {
            if (ex instanceof UserException) {
                log.info("UserException resolver to 400");
                String acceptHeader = request.getHeader("accept");
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                
                // json 응답
                if ("application/json".equals(acceptHeader)) {
                    Map<String, Object> errorResult = new HashMap<>();
                    errorResult.put("ex", ex.getClass());
                    errorResult.put("message", ex.getMessage());
                    String result = objectMapper.writeValueAsString(errorResult);
                    response.setContentType("application/json");
                    response.setCharacterEncoding("utf-8");
                    response.getWriter().write(result);
                    return new ModelAndView();
                } 
                // html 응답
                else {
                    return new ModelAndView("error/500");
                }
            }
        } catch (IOException e) {
            log.error("resolver ex", e);
        }
        return null;
    }
}
  • HTTP 요청 헤더의 accept 값이 json인 경우
    1.  빈 ModelAndView을 반환한다.
    2. DispatcherServlet은 뷰를 렌더링하지 않고, WAS에 정상 응답을 보낸다.
    3. 정상 응답을 받은 WAS는 HttpServletResponse 객체에 지정된 값들을 참고하여 HTTP 응답 메시지를 반환한다.
  • 그 외의 경우
    1. "error/500"으로 지정한 ModelAndView를 반환한다.
    2.  DispatcherServlet은 뷰를 렌더링하고, WAS에 정상 응답을 보낸다.
    3. 정상 응답을 받은 WAS는 렌더링 된 뷰에 맞게 오류 페이지를 보여준다.

스프링이 기본으로 제공하는 HandlerExceptionResolver 구현체

위와 같이 직접  HandlerExceptionResolver를 구현하려고 하면 복잡하다. 따라서 스프링은 기본으로 HandlerExceptionResolver 구현체들을 제공한다. 

 

스프링이 기본으로 제공하는 HandlerExceptionResolver 구현체는 다음과 같다. 이들은 순서대로 HandlerExceptionResolverComposite에 등록되어 있다.

  1. ExceptionHandlerExceptionResolver
  2. ResponseStatusExceptionResolver
  3. DefaultHandlerExceptionResolver

ResponseStatusExceptionResolver

ResponseStatusExceptionResolver가 처리하는 예외는 다음과 같다.

  • @ResponseStatus가 붙어있는 예외
  • ResponseStatusException 예외

@ResponseStatus가 붙어있는 예외

다음과 같이 예외에 @ResponseStatus를 붙이고 HTTP 상태 코드를 지정할 수 있다.

@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류") 
public class BadRequestException extends RuntimeException {}
  1. BadRequestException이 컨트롤러에서 발생하고 예외가 컨트롤러 밖으로 넘어가면
  2. 스프링에 기본으로 등록되어 있는 ResponseStatusExceptionResolver가 이를 처리한다.
  3. ResponseStatusExceptionResolver는 지정된 상태 코드대로 HTTP 상태 코드를 400으로 변경한다. 이때 ResponseStatusExceptionResolver 역시 response.sendError()를 호출하여 HTTP 상태 코드를 변경한다. 

ResponseStatusException 예외

@ResponseStatus 방법은 @ResponseStatus 어노테이션을 예외에 직접 넣어주어야 하기 때문에, 개발자가 코드를 변경할 수 없는 예외에는 적용할 수 없다. 이 경우에는 ResponseStatusException 예외를 사용하면 된다.

throw new ResponseStatusException(HttpStatus.NOT_FOUND, "잘못된 요청 오류", new IllegalArgumentException());

DefaultHandlerExceptionResolver

DefaultHandlerExceptionResolver는 스프링 내부에서 발생하는 스프링 예외를 해결한다. 대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException이 발생한다. 만약 TypeMismatchException 예외를 처리하지 않으면 WAS까지 예외가 올라가고 결과적으로 500 오류가 발생한다. 그런데 파라미터 바인딩은 대부분 클라이언트가 요청 정보를 잘못 입력해서 발생한 문제이기 때문에 이 경우 HTTP 상태 코드를 400으로 응답해야 한다. 

 

따라서 DefaultHandlerExceptionResolver는 이를 500이 아닌 400으로 응답하도록 HTTP 상태 코드를 변경한다. DefaultHandlerExceptionResolver의 코드를 보면, DefaultHandlerExceptionResolver 역시 TypeMismatchException 예외가 발생했을 때 response.sendError()를 호출하여 HTTP 상태 코드를 400으로 지정하는 것을 알 수 있다. 그리고 빈 ModelAndView를 반환하는 것을 알 수 있다. 그러면 예외 처리되어 WAS에는 정상 응답이 나가게 된다. 그리고 WAS는 response.sendError()를 확인하고 이에 맞게 HTTP 응답을 한다.

public class DefaultHandlerExceptionResolver extends AbstractHandlerExceptionResolver {

    protected ModelAndView handleTypeMismatch(TypeMismatchException ex,
			HttpServletRequest request, HttpServletResponse response, @Nullable Object handler) throws IOException {

        response.sendError(HttpServletResponse.SC_BAD_REQUEST);
        return new ModelAndView();
    }
}

ExceptionHandlerExceptionResolver(@ExceptionHandler, @ControllerAdvice)

api 예외 처리가 어려운 점은 다음과 같다.

  • HandlerExceptionResolver를 구현하는 방법을 떠올려 보면, ModelAndView를 반환해야 했다. 이는 api 응답에 불필요하다.
  • HttpServletResponse에 직접 응답 데이터를 하나하나 넣어주는 방법은 매우 불편하다.
  • 특정 컨트롤러에서만 발생하는 예외를 별도로 처리하기 어렵다. 예를 들어서 회원을 처리하는 컨트롤러에서 발생하는 RuntimeException 예외와 상품을 관리하는 컨트롤러에서 발생하는 RuntimeException 예외를 서로 다른 방식으로 처리하고 싶다면 어떻게 해야할까?

그러나 스프링이 제공하는 ExceptionHandlerExceptionResolver를 사용하면 편리하게 api 예외 처리를 할 수 있다. 이는 스프링이 제공하는 HandlerExceptionResolver 중에 가장 우선순위가 높으며 api 예외 처리에 주로 사용된다.

 

ExceptionHandlerExceptionResolver를 사용하는 방법은 다음과 같이, @ExceptionHandler 어노테이션을 선언하며 해당 컨트롤러에서 처리할 예외를 지정해주면 된다. 따라서 해당 컨트롤러에서 예외가 발생하면 해당 예외로 지정된 @ExceptionHandler 어노테이션이 붙은 메소드가 호출된다. 

@Slf4j
@RestController
public class ApiExceptionController {

    // IllegalArgumentException이 발생했을 때 예외를 처리하는 메소드
    @ExceptionHandler(IllegalArgumentException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResult illegalExHandle(IllegalArgumentException e) {
        return new ErrorResult("BAD", e.getMessage()); // ErrorResult: 예외 응답 dto
    }

    @GetMapping("/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {
        if (id.equals("bad")) {
            throw new IllegalArgumentException("잘못된 입력 값");
        }

        ...
    }
}
  • 컨트롤러에서 IllegalArgumentException이 발생하면 예외가 컨트롤러 밖으로 넘어간다.
  • HandlerExceptionResolver이 작동하고, 가장 우선순위가 높은 ExceptionHandlerExceptionResolver가 실행된다.
  • ExceptionHandlerExceptionResolver는 해당 컨트롤러에 IllegalArgumentException를 처리할 수 있는 @ExceptionHandler가 있는지 확인한다.
  •  @ExceptionHandler가 있으므로 illegalExHandle() 메소드를 실행하여 예외를 처리한다.
  • illegalExHandle() 메소드는 ErrorResult라는 dto를 반환하는데, @RestController(@ResponseBody)이므로 ErrorResult 객체가 json 형식으로 HTTP 응답 바디에 담긴다.
  • 그런데 이는 ExceptionHandlerExceptionResolver가 정상적으로 예외를 처리했으므로, WAS에 정상 응답이 나가게 된다. 따라서 결과적으로 HTTP 상태 코드가 200으로 반환된다.
  • 따라서 상태 코드를 변경하고 싶으면 @ResponseStatus(HttpStatus.BAD_REQUEST)를 통해 HTTP 상태 코드를 변경할 수 있다.

@ControllerAdvice + @ExceptionHandler

@ExceptionHandler를 통해 예외를 깔끔하게 처리할 수 있게 되었지만, 컨트롤러에 정상 코드와 예외 처리 코드가 섞이게 되었다. 따라서 이때 @ControllerAdvice 또는 @RestControllerAdvice를 사용하면 예외 처리 코드를 분리할 수 있다.

 

@ControllerAdvice는 대상으로 지정한 컨트롤러에 @ExceptionHandler 기능을 부여한다. 특정 어노테이션이 있는 컨트롤러를 지정할 수 있고, 특정 패키지의 컨트롤러를 지정할 수도 있다(이 경우 해당 패키지와 하위 패키지의 컨트롤러가 대상이 된다).

// @RestController이 있는 모든 컨트롤러 지정
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}
 
// org.example.controllers 패키지의 모든 컨트롤러 지정
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}

// 특정 컨트롤러 지정
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class ExampleAdvice3 {}

만약 대상을 지정하지 않으면 모든 컨트롤러에 적용된다. 참고로 @RestControllerAdvice@ControllerAdvice에 @ResponseBody가 추가된 것이다.


Reference

  • 인프런, 스프링 MVC 2편, 김영한