본문 바로가기

카테고리 없음

스프링 인터셉터에서 발생한 모든 예외는 ControllerAdvice가 잘 처리해줄까?

안녕하세요 브로콜리입니다.

 

오늘은 인터셉터를 가지고 실험하다가 발견한 재미있는 부분에 대해 공유하고자 합니다.

 

1. 개요

인터셉터에서 발생한 모든 예외를 스프링 ControllerAdvice/ExceptionHandler가 처리해준다는 오해를 바로잡습니다.

인터셉터 preHadle, postHandle, afterCompletion에서 발생한 예외에 대하여 각각 Controller, RestController인 상황에서 어떻게 처리되는지 살펴봅니다.


상황은 이렇습니다. 인터셉터 vs 필터에 대해 알아보다가 많은 글에서 크게 두 가지 차이점을 기술하고 있음을 발견했습니다.

 

출처 : https://mangkyu.tistory.com/173

 

차이점1) 스프링에 의한 예외 처리

- 인터셉터는 스프링 컨텍스트이기 때문에 스프링에 의해 예외처리 됨

- 필터는 스프링 컨텍스트에서 벗어나 있기 때문에 스프링에 의해 예외처리 되지 않음

 

차이점2) 요청 응답 조작 편의성

- 인터셉터는 반복문을 통해 호출하므로 한번 스트림 소진 시 캐싱이 필요하며 요청/응답 조작이 불편함

- 필터는 체이닝을 통해 호출되므로 요청/응답 조작이 상대적으로 용이함.

 

이 중 차이점1을 직접 눈을 확인하고 싶었고 직접 간단한 애플리케이션을 만들어 테스트해보았습니다.

 

2. 실험 세팅

- TestController : GET /hello?test={파라미터} 요청 처리

- GlobalExceptionHandler : RuntimeException 예외 처리

@RestController
public class TestController {

    @GetMapping("/hello")
    public String hello(@RequestParam(value = "test") String test) {
        return "ok";
    }
}

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(RuntimeException.class)
    public String handleRuntimeException(RuntimeException e) {
        return "[ControllerAdvice 처리됨] " + e.getMessage();
    }
}

 

그리고 ExampleInterceptor를 인터셉터로 등록해주었습니다.

@Component
public class ExampleInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception {
        System.out.println("postHandle");
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
        System.out.println(LocalDateTime.now() + "afterCompletion");
    }
}

 


3. 실험 케이스 별 결과

 

Case1) preHandle 예외 > ControllerAdivce 예외 처리

먼저 preHandle에서 예외가 발생하는 경우입니다.

파라미터 값을 "preHandle"로 넘겨준 경우 preHandle에서 예외를 발생시키도록 하였습니다

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String test = request.getParameter("test");
        if("preHandle".equals(test)) {
            throw new RuntimeException("preHandle 에서 발생한 예외");
        }
        return true;
    }

.

이 경우, ControllerAdvice가 예외를 잘 처리해줍니다.

 

이 경우, DispatcherServlet 내부의 doDispatch 메서드를 보면 더 명확해지는데 applyPreHandle 메서드에서 오류가 나면 catch문이 잡아 dispatchException을 ex로 초기화하고 processDispatchResult를 호출합니다.

 

processDispatchResult에서 에러가 존재하는 케이스를 타고 들어가보면 processHandlerException을 호출하는 것을 볼 수 있습니다. 그리고 processHandlerException은 HandlerExceptionResolver 들을 순회하면서 예외 처리가 가능한 HandlerExcepionResolver 들에게 예외 처리를 위임합니다. 이곳에는 우리가 애플리케이션에서 선언한 ExceptionHandler도 포함됩니다.

 

 

Case2) postHandle 예외 + @RestController > ControllerAdivce 예외 처리 X

두 번째 케이스부터가 이번 글을 작성한 계기입니다. 이번에는 postHandle에서 예외를 발생시켜보았는데 ControllerAdvice에 의해 예외가 반환되지 않고 응답이 정상반환되었습니다.

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception {
        String test = request.getParameter("test");
        if("postHandle".equals(test)) {
            throw new RuntimeException("postHandle 에서 발생한 예외");
        }
    }

 

애플리케이션 로그를 보면 분명 postHandle에서 예외는 발생한 상황입니다.

 

분명 인터셉터는 스프링 컨텍스트 안에서 다루어지기 때문에 postHandle이라 하더라도 ControllerAdvice에서 잡아 예외를 처리할 것 같은데 왜 이런 일이 발생하는 것일까요?

 

그 이유는 응답 커밋시점에 있습니다. 스프링의 ServletRespons는 commit을 통해 웹 응답을 발송합니다. 커밋이 발생하면 statusCode와 더불어 응답의 일부를 발송합니다.  이미 발송된 응답의 경우 수정하지 못합니다. 200 OK가 이미 클라이언트 측으로 발송되었기 때문에 postHandle로 인해 에러가 발생하였다고 하더라도 응답을 수정하여 보내지 못하는 것입니다.

 

그리고 RestController는 로직상 핸들러 내에서 커밋이 발생한 이후에 postHandle 로직을 수행합니다.

 

실제로 RestController에 바인딩된 요청은 postHandle이 시작되기 이전에 응답이 이미 커밋되어 있는 것을 볼 수 있습니다.

 

디버깅을 찍어보아도 outputMessage에 보내는 값이 ControllerAdvice에 의해 처리된 값이 아닌 핸들러인 Controller가 반환한 "ok"임을 알 수 있습니다.

 

 

Case3) postHandle 예외 + @Controller > ControllerAdivce 예외 처리 O

그럼 Controller로 선언되었을 때는 어떨까요?

 

Controller는 preHandle > handler.handle > postHandle 이후에 생성된 ModelAndView를 기반으로 ViewResolver에게 뷰 생성 위임을 부탁해야 하기 때문에 커밋 시점이 늦습니다.

 

실제로도 commited= false 인 것을 볼 수 있습니다.

 

따라서 Controller 핸들러를 대상으로 postHandle에서 에러가 발생하면 제대로 ControllerAdvice가 처리를 해줍니다.

 

즉, RestController와 Controller는 응답을 언제 커밋하느냐의 차이가 있고 커밋 이후의 변경은 허용되지 않기 때문에 이미 ok를 발송한 상황이면 ControllerAdvice를 통한 응답본문 재수정이 불가한 것입니다. RestController는 postHandle 이전에 미리 응답을 커밋하기 때문에 스프링에 의한 예외 처리 결과를 반환하지 못하는 한편, Controller는 뷰 생성을 위해 커밋 시점이 postHandle 이후이기 때문에 클라이언트가 예외가 처리된 결과를 받아볼 수 있습니다.

 

Case4) afterCompletion 예외  + @RestController > ControllerAdivce 예외처리 X

마지막으로 afterCompletion에서 예외가 터지는 경우를 살펴보겠습니다.

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
        String test = request.getParameter("test");
        if("afterCompletion".equals(test)) {
            throw new RuntimeException("afterCompletion 에서 발생한 예외");
        }
    }

 

RestController는 마찬가지로 handler 단에서 커밋을 하기 때문에 예외 처리된 결과가 보이지 않고 그대로 "ok" 응답이 발송됩니다.

 

그러나, triggerAfterCompletions 코드에서 예외에 대한 로깅을 하고 있기 떄문에 ERROR 레벨의 로그가 남습니다.

 

Case5) afterCompletion 예외  + @Controller  > 에러 페이지

마지막으로 가장 흥미로웠던 afterCompletion 예외 + Controller 조합입니다.

 

이경우에는 afterCompletion이 호출될때 까지 아직 응답 커밋이 되지 않은 것을 볼 수 있습니다. 

 

문제는 DisplatcherServlet 코드 상 triggerAfterCompletion에서 예외가 발생한 이후에 어떠한 예외 회복 로직이 없다는 것입니다.

실제로 코드를 보면 예외 발생시 ExceptionHandler를 찾아 예외를 처리하는 processDispatchResult를 이미 거친 이후에 triggerAfterCompletion을 호출하는 것을 볼 수 있습니다.

 

따라서 afterCompletion 예외 + Controller 조합에서는 아직 응답이 커밋되지 않았음에도 불구하고 ExceptionHandler를 거치지 못하므로 그대로 에러 페이지가 뜨게 됩니다.

 


4. 요약

 

결과를 요약하면 다음과 같습니다.

  Controller RestController
preHandle 예외 스프링 예외처리 O 스프링 예외처리 O
postHandle 예외 커밋 시점이 늦으므로 스프링 예외처리 O postHandle 이전에 응답 커밋
afterCompletion 예외 에러 이후에 회복로직이 없어 에러 페이지 반환 afterCompletion 이전에 응답 커밋

 

 

실험을 하며 예상과 다른 결과에 당황했지만 디버깅 툴과 DispatcherServlet 내부를 파고들며 왜 그런지 이해하는데 재미있던 시간이었습니다. 앞으로 무언가 정론을 받아들일 때에도 직접 실험하는 태도를 키우는 것이 얼마나 중요한지 알게되는 계기이기도 하였습니다.

 

한줄 요약 : 스프링 인터셉터도 예외 처리의 적용이 각 문맥마다 다르다.