본문 바로가기
Spring

스프링 부트 예외처리 방식

by myeongil 2023. 12. 9.

Spring에서 클라이언트로부터 요청이 들어오면 어떻게 처리될까요? 일반적인 요청의 경우 아래와 같은 흐름으로 컨트롤러에 도달하고, 응답은 반대로 거슬러갑니다.

 

클라이언트(요청) -> WAS -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러

 

기본 예외 처리 방식

스프링부트를 공부하다보면 흔히 WhiteLabel 페이지를 한번쯤은 보게 됩니다. 이 페이지는 에러가 발생했을 때 보여줄 수 있는 페이지가 없는 경우 스프링부트에서 기본적으로 제공하는 view를 의미합니다.

스프링부트에서 예외처리를 위해 기본적으로 등록하는 빈들은 ErrorMvcAutoConfiguration에서 확인할 수 있습니다. WhiteLabel에 처리를 위한 빈은 WhitelabelErrorViewConfiguration에서 확인할 수 있습니다.

 

@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(prefix = "server.error.whitelabel", name = "enabled", matchIfMissing = true)
@Conditional(ErrorTemplateMissingCondition.class)
protected static class WhitelabelErrorViewConfiguration {

    private final StaticView defaultErrorView = new StaticView();


    @Bean(name = "error")
    @ConditionalOnMissingBean(name = "error")
    public View defaultErrorView() {
        return this.defaultErrorView;
    }

    // If the user adds @EnableWebMvc then the bean name view resolver from
    // WebMvcAutoConfiguration disappears, so add it back in to avoid disappointment.
    @Bean
    @ConditionalOnMissingBean
    public BeanNameViewResolver beanNameViewResolver() {
        BeanNameViewResolver resolver = new BeanNameViewResolver();
        resolver.setOrder(Ordered.LOWEST_PRECEDENCE - 10);
        return resolver;
    }
}

코드로 보는 것처럼 server.error.whitelabel.enabled: false를 통해 WhiteLabel 페이지를 비활성화시킬 수 있습니다.

 

스프링부트에서는 어떻게 WhiteLabel 페이지를 보여주는 걸까요?

자바에서는 예외가 발생하면 따로 try-catch를 통해 처리하지 않는다면 메서드를 호출한 쪽으로 처리를 위임합니다. 그러다 결국 마지막 메서드를 실행한 곳에서 마저 예외가 처리되지 못한다면 예외정보를 남기고 스레드는 종료됩니다.

마찬가지로 Spring을 사용할 때, Controller에서 예외가 발생하면 예외는 호출한 쪽으로 처리가 위임이 되며 상위로 이동합니다. 즉, 컨트롤러에서 발생한 예외를 따로 처리하지 않으면 WAS까지 이동하게 되고 WAS에서 예외에 대한 처리를 하게 됩니다.

WAS에서의 기본 예외처리 동작을 살펴보면 다음과 같습니다.

  • 예외를 받게 되면 500 INTERNAL SERVER ERROR를 전달하게 된다.
  • response.sendError()를 사용한 경우, 상태코드에 따라 적절한 예외페이지가 표시됩니다.
  • 등록된 예외 페이지가 있고, 예외를 받거나 response.sendError()를 통해 예외를 감지했을 때, 현재 처리 가능하다면 해당 예외 페이지를 호출하게 됩니다.

 

스프링부트에서는 Tomcat을 기본적으로 사용하고 있으며 TomcatServletWebServerFactory에 등록된 예외 페이지가 있고, 처리 가능하다면 해당 예외페이지를 호출하게 됩니다.

WebServerFactoryCustomizer의 구현체를 구현해 빈으로 등록해 주면 커스텀하게 예외페이지를 등록할 수 있습니다.
예시)

@Component
class WebServerCustomizer : WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
    override fun customize(factory: ConfigurableWebServerFactory) {
        // IllegalArgumentException이 WAS에 도달하면 error/400 컨트롤러를 호출한다.
        val illegalArgumentExceptionPage = ErrorPage(IllegalArgumentException::class.java, "/error/400")

        // InternalServerError가 전달되면 error/500 핸들러를 호출한다.
        val internalServerErrorPage = ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error/500")

        factory.addErrorPages(internalServerErrorPage, illegalArgumentExceptionPage)
    }
}

 

 

위에서 언급한 ErrorMvcAutoConfiguration에는 WebPageCustomizerBaicErrorController 또한 빈으로 등록해주고 있습니다.

 

기본설정은 WebPageCustomizer를 통해 예외가 발생한 경우 무조건 /error 페이지를 호출하도록 TomcatServletWebServerFactory에 예외페이지를 등록하고, /error 페이지에 대한 요청을 담당하는 BasicErrorController에서 적절한 응답을 다시 반환합니다.

즉, 예외가 발생해 WAS까지 이동 후, 다시 BaiscErrorController에 예외처리를 요청하는 것입니다. BasicErrorController에서 적절한 에러페이지를 찾는데 실패하면 기본으로 뷰이름이 error인 페이지를 응답으로 제공하도록 반환합니다.

 

클라이언트(요청) 
-> WAS -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(예외발생)
-> 언티셉터 -> 서블릿 -> 필터 -> WAS(/error 에서 처리해줄거야) 
-> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(BasicErrorController)
-> 인터셉터 -> 서블릿 -> 필터 -> WAS -> 클라이언트(응답)

 

이때, error 페이지가 존재한다면 해당페이지를 사용하고 존재하지 않는다면 위에서 bean으로 등록한 WhiteLabel페이지를 렌더링 하게 됩니다.

 

ResponseStatus와 ResponseStatusException

 

@ResponseStatus 어노테이션이나 ResponseStatusException을 사용하면 쉽게 상태코드를 포함한 응답을 보낼 수 있습니다.

 

@GetMapping
fun test(response: HttpServletResponse) {
    throw ResponseStatusException(HttpStatus.BAD_REQUEST, "test")
}

@ResponseStatus(code = HttpStatus.NOT_FOUND)
class LinkNotFoundException(s: String) : Throwable()

ExceptionHandler와 ControllerAdvice

 

위에서 언급한 방법 외에도 ExceptionHanlder와 ControllerAdvice를 사용한 방법이 있습니다. 컨트롤러 내부에서 ExceptionHandler를 사용한 경우, 해당 Controller에서 발생하는 특정 예외에 대해 핸들링을 처리해 줍니다. 아래와 같은 경우, LinkController 내부에서 발생하는 LinkNotFoundException에 대해서 handler가 처리해 주게 됩니다. 위에서 방법들과 달리 손쉽게 예외 발생 시 원하는 json 응답 형태로 변경할 수 있는 것이죠.

 

@RestController
class LinkController {

    // ...


    @ExceptionHandler(LinkNotFoundException::class)
    fun handler(exception: LinkNotFoundException): ApiResponse<Nothing> {
        return ApiResponse.notFound(exception.message ?: "not found exception", null)
    }

    // ...
}

 

여러 Controller 기준으로 동일한 핸들러를 지정하고 싶은 경우에는 ControllerAdivce를 사용할 수 있습니다.

 

@ControllerAdvice
class GlobalExceptionHandler {

    // ...

    @ExceptionHandler(LinkNotFoundException::class)
    fun handler(exception: LinkNotFoundException): ApiResponse<Nothing> {
        return ApiResponse.notFound(exception.message ?: "not found exception", null)
    }

    // ...
}

 

ControllerAdvice는 패키지 path 기준이나 어노테이션 기준 등 원하는 컨트롤러만 적용시키는 것도 가능합니다.

HandlerExceptionResolver

 

위에서 언급한 ResponseStatus, ResopnseStatusException이나 ExceptionHandler는 어떻게 처리되는 것일까요?

스프링에는 HanlderExceptionResolver가 존재합니다. 이는 예외가 발생했을 때, 해당 예외를 어떻게 처리할 것인지 선택할 수 있는데요. 여러 개의 HandlerExceptionResolver를 거치면서 예외에 대한 처리가 되었다면 ModelAndView를 반환하고 처리하지 못한다면 null을 반환하여 다음 HandlerExceptionResolver로 넘어가게 됩니다.

 

public interface HandlerExceptionResolver {

    @Nullable
    ModelAndView resolveException(
            HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex);

}

 

스프링부트에서는 기본적으로 3가지 HandlerExceptionResolver를 제공하고 있습니다.

  • ExceptionHandlerExceptionResolver
  • ResponseStatusExceptionResolver
  • DefaultHandlerExceptionResolver

ExcepionHandlerExceptionResolver는 ExceptionHandler 어노테이션에 대한 처리를 담당합니다.

컨트롤러 내부의 ExceptionHandler 어노테이션이 존재하는지 체크하고 없다면 ControllerAdvice를 확인합니다. 이때, 예외를 삼키고 response.sendError()로 처리하지 않기 때문에, WAS에서 다시 BasicErrorController로 돌아오지 않고 바로 응답을 보낼 수 있습니다.

 

ResponseStatusExceptionResolver는 ResponseStatus 어노테이션과 ResponseStatusException에 대한 처리를 담당합니다.

내부적으로 response.sendError()를 사용해 처리하고 있기 때문에, WAS에서 BasicErrorController로 보내 에러 응답이 처리됩니다.

 

DefaultHandlerExceptionResolver는 스프링에서 제공하는 예외들에 대한 처리를 담당합니다.

예를 들어, 스프링에서는 PathVariable로 Long 타입 id를 받기로 했는데, String 값이 들어온 경우 MethodArgumentTypeMismatchException을 발생시킵니다. 이러한 스프링에서 제공하는 예외처리에 대한 적절한 처리를 수행합니다. 마찬가지로 response.sendError()를 사용하고 있어 BasicErrorController를 거치게 됩니다.

원한다면 HandlerExceptionResolver 구현체를 직접 빈으로 등록하여 예외에 대한 처리도 가능합니다.

정리

스프링에서는 기본적으로 ErrorMvcAutoConfiguration을 통해 예외 처리를 위한 빈들을 자동으로 등록합니다.

 

기본적으로 예외가 발생하면 아래와 같은 플로우에 따라 처리됩니다.

클라이언트(요청) 
-> WAS -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(예외발생)
-> 언티셉터 -> 서블릿 -> 필터 -> WAS(/error 에서 처리해줄거야) 
-> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(BasicErrorController)
-> 인터셉터 -> 서블릿 -> 필터 -> WAS -> 클라이언트(응답)

 

예외 처리 방법에는 대략적으로 아래와 같은 방법들이 있습니다.

  1. WebServerFactoryCustomizer의 구현체를 만들어 커스텀하게 WAS에서 예외처리를 위한 호출을 정의합니다.
  2. HandlerExceptionResolver 구현체를 만들어 커스텀하게 처리합니다.
  3. ResponseStatus, ResponseStatusException을 사용해 예외를 처리합니다.
  4. ExceptionHanlder와 ControllerAdvice를 사용해 예외를 처리합니다.

1번 방법의 경우, 각각 예외에 대한 에러 페이지 설정 및 Controller를 추가로 만들어줘야 하기 때문에 예외 처리를 위해 많은 개발 시간이 필요합니다. 또한 WAS에 예외가 도달한 이후, 다시 컨트롤러에 처리를 요청하기 때문에 리소스가 사용됩니다.

 

2번 방법의 경우, 직접 구현하는 방법도 좋은 방법이지만 HandlerExceptionResolver에서 ModelAndView를 직접 만들어서 처리해야 하기 때문에, 구현체를 만드는 게 쉽지 않습니다.

 

3번 방법의 경우, 쉽게 사용할 수 있지만, WAS에서 다시 BasicErrorController를 거쳐야 하기 때문에 불필요한 리소스가 사용됩니다. 또 BasicErrorController를 거치기 때문에 예외별로 동일한 응답을 제공하게 됩니다. (ErrorAttributes를 빈으로 등록하면 응답을 수정할 수 있지만 쉽지 않습니다.)

 

4번 방법의 경우, 사용도 쉽고 컨트롤러 별, 예외 별 세세하게 응답 값을 쉽게 처리할 수 있습니다. 예외 발생 시 BasicErrorController를 거치지 않고 바로 WAS에서 응답을 제공하기 때문에 불필요한 리소스가 사용되지 않습니다.

결론

ExceptionHandler와 ControllerAdvice를 사용하자.