스프링 웹 MVC 4. 컨트롤러 예외 처리

2024. 4. 2. 02:42Spring/Spring 웹 MVC

[ 들어가기 전에 ]

 앞의 1편부터 3편까지의 글을 작성하면서 컨트롤러에서 공통적인 로직을 처리하기 위해서 필터로 로깅이나 인증/인가를, 검증을 따로 지원하는 javax.validation을 사용해서 컨트롤러와 분리하는 방법에 관해서 알아보았다. 이제는 컨트롤러 단에서 예외가 발생했을 때 어떻게 처리해야 하는가에 관해서 이야기를 해보고자 한다. 우선 이걸 알아보기 전에 컨트롤러에서 예외를 일으켜보고 응답을 보면서 시작해보자. 

@GetMapping("/controller/exception")
public void exception(){
    throw new IllegalArgumentException();
}

 

 위의 코드를 포스트맨을 통해서 작동시켜보면 아래와 같이 에러가 뜰 것이다. 분명히 아무런 작업을 해주지 않았는데, 스프링 내의 무언가가 이렇게 응답에서 에러와 일어난 경로 등을 표현해서 찍어내고 있다. 이것은 누가한 행동인지에 관해서 한 번 알아보고, 예전에 필터를 설정해놓은게 있었는데 어떻게 못하나에 관해서도 알아볼 예정이다. 마지막으로 컨트롤러 예외처리를 어떻게 하면 좋을지 찾은 내용을 공유하고 마치려고 한다.


[ 필터로 어떻게 못하나? ]

 1편에서 보였던 이미지를 자세히 보면 WAS에서 컨트롤러까지 단방향으로 흐르는게 아니라 양방향으로 흐르고 있다. 이렇게 그림을 그린 이유는 WAS에서 컨트롤러로 간 요청은 응답을 처리하고 WAS까지 다시 간다. 그리고 그 과정에서 기존에 필터, 서블릿, 인터셉터 순으로 들렀던 걸 반대로 인터셉터, 서블릿, 필터 순으로 방문하며 다시 간다.

 

 위의 설명을 듣고 나면, 어?? 그럼 필터 체인을 다시 거치니까 앞에서 예외가 터진 걸 이전에 했던 것처럼 처리하면 되겠네하고 필터를 다시 등록해보자.

@Slf4j
public class ExceptionFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        try{
            chain.doFilter(request, response);
        } catch(IllegalArgumentException e){
            httpResponse.setStatus(HttpStatus.BAD_REQUEST.value());
            return;
        } catch(RuntimeException e){
            httpResponse.setStatus(HttpStatus.BAD_REQUEST.value());
            return;
        } finally {
            log.info("Dispatcher Type: [{}]", request.getDispatcherType());
        }
    }
}

 

 다시 서버를 재시작하고 요청을 보내면 이전에 보았던 디폴트값에서 바뀌지 않는다. 왜냐하면 저 필터를 다시 가지 않았기 때문이다. 요청과 응답에 흐름에는 DispatcherType이라는 상태가 있다. 총 5가지 상태가 있는데 상태는 아래와 같다. 

public enum DispatcherType{
	FORWARD,
	INCLUDE,
	REQUEST,
	ASYNC,
	ERROR
}

 

글에서는 API와 관련된 요청만을 볼 것이기 때문에 JSP나 ModelAndView와 관련이 있는 것들은 제외할 것이다. 그러니 REQUEST와 ERROR에 관해서만 알면 된다. 정상적으로 예외가 발생하지 않은 상태의 경우 REQUEST로 요청이 계속 이동한다. 그러다가 예외가 발생하면 요청이 ERROR로 바뀌고 그 시점부터 WAS까지 다시 돌아가는데, 예외를 잡지 못하고 도착하면 Default 설정에 의해 500번으로 바뀌는 것이다.

 

 이 때, 아래처럼 예외처리용으로 만든 필터는 아무런 DispatcherType에 관한 설정을 해주지 않았으니 REQUEST에 관해서만 처리를 한다. 그래서 반대로 컨트롤러에서 WAS로 넘어오는 과정에서 필터에서 ERROR 상태의 요청을 처리하지 않은 것이다.  

@Bean
public FilterRegistrationBean ExceptionFilter(){
    FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
    filterFilterRegistrationBean.setFilter(new ExceptionFilter());  // 추가하려는 필터
    filterFilterRegistrationBean.setOrder(1); // Filter 순서
    filterFilterRegistrationBean.addUrlPatterns("/*"); // 필터 URL 매칭 패턴

    return filterFilterRegistrationBean;
}

 

 그러니 위의 코드에서 ERROR에도 동작하게 만들면 ERROR 상태로 들어온 요청 로그가 찍히는 걸 볼 수 있을 것이다. 

@Bean
public FilterRegistrationBean ExceptionFilter(){
    FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
    filterFilterRegistrationBean.setFilter(new ExceptionFilter());  // 추가하려는 필터
    filterFilterRegistrationBean.setOrder(1); // Filter 순서
    filterFilterRegistrationBean.addUrlPatterns("/*"); // 필터 URL 매칭 패턴
    filterFilterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);

    return filterFilterRegistrationBean;
}

 

 그런데, 이것도 어떻게 보면 분리라고 볼 수 있겠지만 필터에서 처리하는 예외는 최대한 요청에 들어오기 전에 처리하려는 공통 관심사에 관한 예외만 처리하는 게 맞다고 생각한다. 그러니 필터에 관한 예외만 처리하게 설계하는 게 좋다. 또한 저렇게 처리하면 필터에서 생긴 예외를 처리한건지 컨트롤러에서 생긴 예외를 처리하는 건지 모호해진다. 가장 큰 문제는 컨트롤러에서 같은 예외 종류라도 조금 더 세밀하게 메시지를 내보내고 싶을 것이다. 예를 들면, 회원 가입창에서 ID 형식이 안 맞는 오류랑 식당 검색창에서 값이 안 맞는걸 둘 다 IllegalArgumentException으로 내보내는 경우 잡아서 이걸 동일한 메시지로 처리하려 드는 건 좋지 않은 선택이다. 따라서, 컨트롤러와 필터를 퉁쳐서 한번에 처리하려고 하면 안되고 필터에서는 필터 예외를 처리하거나 컨트롤러나 인터셉터 등에서 잡지 못한 예외에 관해서만 Default 메시지를 내보내는 용으로 사용하는 게 좋다.


[ 스프링 프레임워크가 제공하는 예외처리 ]

 스프링에서는 BasicErrorController라는 기본 예외처리를 해주는 컨트롤러로 개발자가 만든 컨트롤러에서 예외가 발생해서 WAS로 넘어온 ERROR로 된 요청을 잡아서 REQUEST로 다시 BasicErrorController로 요청을 보낸다. 그러면 헤더 값과 Status 값을 보고 Json 타입으로 응답을 내보내라는 것임을 알고 아래의 메서드를 호출하는데 그게 우리가 보는 응답 결과이다. 실제로 이 코드가 동작했는지 확인해보고 싶다면 디버거로 breakpoint를 걸어서 확인해보면 확인이 가능하다.

@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
    HttpStatus status = getStatus(request); // breakpoint 걸면 확인 가능.
    if (status == HttpStatus.NO_CONTENT) {
       return new ResponseEntity<>(status);
    }
    Map<String, Object> body = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL));
    return new ResponseEntity<>(body, status);
}

@ExceptionHandler(HttpMediaTypeNotAcceptableException.class)
public ResponseEntity<String> mediaTypeNotAcceptable(HttpServletRequest request) {
    HttpStatus status = getStatus(request);
    return ResponseEntity.status(status).build();
}

 

 이 방식으로 예외가 처리되면 문제는 모든 예외를 다 500번으로 결과를 내보내고 원하는 방식으로 응답 Json을 바꿀 수가 없다. 다른 방법이 필요하다. 

 

 이를 위해서 스프링에서는 DispatcherServlet 단에서 ExceptionResolver라는 것을 제공해서 컨트롤러에서 발생한 예외를 인터셉터를 건너뛰고 DispatcherServlet으로 넘어가서 등록한 Exception Resolver를 호출해 컨트롤러 단에서 발생한 예외를 잡아 sendError로 표시만 해놓고 WAS로 요청을 넘기는 방식으로 처리한다.

 

이 방법으 구현된 총 3개의 기본 Exception Resolver가 있다.

 

1. ExceptionHandlerExceptionResolver

2. ResponseStatusExceptionResolver

3. DefaultExceptionResolver

 

 일단 2번은 몰라도 된다. 왜냐하면 개발자가 직접 만든 예외가 아닌 경우 처리가 어렵기 때문이다. 3번은 검증 때의 기억을 떠올려보자. 파라미터 바인딩이 실패했을 때나 검증에 실패했을 때, 당시에 분명히 예외 코드에 관해서 설정한 게 없는데 예외가 500번이 아니라 400번으로 나갔던 기억이 날 것이다. 파라미터 바인딩 시점, 검증 단계에서의 잘못은 개발자의 잘못이 아닌 요청을 보낸 클라이언트의 잘못이다. 따라서, 이 에러들을 모아서 처리해주는 코드가 DefaultHandlerExceptionResolver 내부의 doResolveException이라는 메서드에 있다. 너무 길어서 코드를 올리지는 않겠다.

 

 1번을 주로 사용할텐데, 사용방법은 아래의 방법을 보면 Controller와 비슷하게 동작한다. 정말 편한게 컨트롤러 내부 API와 비슷한 방식으로 작성해도 돼서 좋다. 그런데 한 가지 단점이 있다. 해당 메서드를 보면 @ResponseStatus라는 곳에 에러코드를 재정의하는 부분이 있다. 왜 그런 걸까?

@RestController
public class ValidationController {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseDto illegalArgExHandler(IllegalArgumentException e){
        return new ResponseDto(HttpStatus.BAD_REQUEST.value() ,e.getMessage());
    }

    @GetMapping("/controller/exception")
    public void exception(){
        throw new IllegalArgumentException("Exception이 발생했습니다.");
    }
}

 

 그 이유는 해결 방법이 컨트롤러에서 발생한 예외를 Exception Resolver가 잡아서 처리해 적절한 반환 값을 선택하는 것까지만 해주기 때문이다. 그래서 응답에 관해 정확한 코드를 명시하지 않으면 WAS는 예외를 받은 게 아니고 정상 흐름을 반환 받았으므로 ResponseDto를 바디로 응답을 보내지만 200번 코드를 준다. 아마도 Default로 설정해서 준다고 이득을 보는 부분도 없고 사용자가 직접 정의하게 만들거라서 이렇게 설계한 것 같다.

 

 참고로 아래의 코드를 추가해서 실행하면? 스프링에서 Exception의 하위 클래스까지 잡아서 처리해주기 때문에 만약에 위에 있는 IllegalArgumentException이 없었다면 아래의 코드가 동작을 했을 것이다. 하지만 있기 때문에 아래의 코드보다 더 구체적인 기존의 예외처리가 동작하게 된다. 

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(Exception.class)
public ResponseDto exHandler(IllegalArgumentException e){
    return new ResponseDto(HttpStatus.BAD_REQUEST.value() ,null);
}

 

 그런데 위의 @ExceptionHandler를 통한 예외처리도 정말 편한 방법이다. 예외 잡는 것까지는 그렇다쳐도 각각의 상황에 맞는 Json을 ObjectMapper로 다 만들어서 넣어줄 필요없는게 정말 큰 것이다. 하지만 저 방법도 약간 아쉬움이 있다. 첫 번째 아쉬움은 컨트롤러 개별 메서드 코드에는 녹아들어가지 않았지만 컨트롤러 클래스에 예외를 처리하고 싶은게 많아질수록 엄청나게 커진다. 두 번째는 다른 컨트롤러에서도 재활용하면 좋겠는데 저건 재활용이 불가능하다.

 

 이 문제를 해결할 수 있는 방법이 @ControllerAdvice를 활용한 방법이다. 사용방법은 다음처럼 클래스를 하나 더 만들어서 기존의 코드를 옮겨주면 된다.

@Slf4j
@RestControllerAdvice
public class ValidationAdvice {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(Exception.class)
    public ResponseDto exHandler(IllegalArgumentException e){
        return new ResponseDto(HttpStatus.BAD_REQUEST.value() ,null);
    }

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseDto illegalArgExHandler(IllegalArgumentException e){
        return new ResponseDto(HttpStatus.BAD_REQUEST.value() ,e.getMessage());
    }
}

 

 아무런 지정을 하지 않았는데, 일단 @RestControllerAdvice가 작동이 되는 걸 확인했다. 그 이유는 모든 컨트롤러에 관해 적용시켜놓았기 때문이다. 그래서 범위를 바꾸고 싶으면 어떻게 하는지를 알아보면서 마치자. 

 

  순서대로 맨위는 어떤 애노테이션을 기반으로 적용시킬지, 그 다음은 패키지명으로 어떻게 적용시킬지, 마지막은 개별 컨트롤러 별로 어떻게  적용시킬지를 결정하는 방법이다.

// 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 {}

 

 이상으로 모든 컨트롤러에서 일어날 수 있는 예외처리에 관해서 알아보았다. 인터셉터를 사용한 방법도 있는데, 스프링 시큐리티를 쓰면서 필터로 대부분 처리를 해서 사실 써본 적이 없는 것 같다. 나중에 쓸 일이 생기면 추가로 정리해서 올리도록 하겠다.

 

[ 참고 자료 ]

https://spring.io/blog/2013/11/01/exception-handling-in-spring-mvc

https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-advice.html

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 by 김영한

https://mangkyu.tistory.com/204