Servlet과 Spring 예외처리

...·2024년 6월 20일

spring

목록 보기
2/6

Exception

자바 직접 실행

자바의 메인 메서드를 직접 실행하는 경우 main이라는 이름의 쓰레드가 실행된다.
실행 도중에 예외를 잡지 못하고 처음 실행한 main() 메서드를 넘어서 예외가 던져지면, 예외 정보를 남기고 쓰레드가 종료된다.

웹 어플리케이션

웹 어플리케이션은 사용자 요청별로 별도의 쓰레드가 할당되고, 서블릿 컨테이너 안에서 실행된다.
애플리케이션에서 예외가 발생했는데, 어디선가 try-catch로 예외를 잡아서 처리하면 아무런 문제가 없다. 그런데 만약에 애플리케이션에서 예외를 잡지 못하고, 서블릿 밖으로 까지 예외가 전달되면 어떻게 동작할까?

WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)

	@GetMapping("/error-ex")
    public void errorEx() {
		throw new RuntimeException("예외 발생!"); 
    }

Exception의 경우 서버 내부에서 처리할 수 없는 오류가 발생한 것으로 생각해서 HTTP 상태 코드 500을 반환한다.

	@GetMapping("/error-404")
 	public void error404(HttpServletResponse response) throws IOException {
    	response.sendError(404, "404 오류!");
 	}

response.sendError()를 호출하면 response 내부에는 오류가 발생했다는 상태를 저장해둔다.
그리고 서블릿 컨테이너는 고객에게 응답 전에 response에 sendError()가 호출되었는지 확인한다. 그리고 호출되었다면 설정한 오류 코드에 맞추어 설정된 오류 페이지를 보여준다.

서블릿 예외 처리 - 오류 페이지 작동 원리

서블릿은 Exception(예외)가 발생해서 서블릿 밖으로 전달되거나 또는 response.sendError()가 호출되었을 때 설정된 오류 페이지를 찾는다.

WAS(sendError 호출 기록 확인) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러
(response.sendError())

WAS 'error page' 다시 요청 -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러 -> View

오직 서버 내부에서 오류 페이지를 찾기 위해 추가적인 호출을 한다.

오류 정보 추가

WAS는 오류 페이지를 단순히 다시 요청만 하는 것이 아니라, 오류 정보를 request의 attribute에 추가해서 넘겨준다. request.getAttribute("상수")로 특정 값을 확인할 수 있다.

서블릿 예외 처리 - 필터

서버 내부에서 오류 페이지를 호출한다고 해서 해당 필터나 인터셉트가 한 번 더 호출되는 것은 매우 비효율적이다.
결국 클라이언트로부터 발생한 정상 요청인지, 아니면 오류 페이지를 출력하기 위한 내부 요청인지 구분할 수 있어야 한다. 서블릿은 이런 문제를 해결하기 위해 DispatcherType이라는 추가 정보를 제공한다.

Dispatcher

request.getDispatcherType()

클라이언트가 처음 요청하면 dispatcherType=REQUEST이다.
error로 인해 내부에서 요청하는 것은 dispatcherType=ERROR이다.
이 외에도 몇몇의 dispatcherType이 더 존재한다.

서블릿 예외 처리 - 인터셉터

필터의 경우에는 필터를 등록할 때 어떤 DispatcherType인 경우에 필터를 적용할 지 선택할 수 있었다. 그런데 인터셉터는 서블릿이 제공하는 기능이 아니라 스프링이 제공하는 기능이다. 따라서 DispatcherType과 무관하게 항상 호출된다.

대신에 인터셉터는 요청 경로에 따라 추가하거나 제외하기 쉽게 되어 있기 때문에, 이러한 설정을 사용해서 오류 페이지 경로를 excludePathPatterns를 사용해서 빼주면 된다.

API 예외처리

API는 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야 한다.

spring boot가 제공하는 BasicErrorController에서 JSON 타입의 데이터를 파악하면 데이터 타입에 맞는 error 메시지를 반환해준다. BasicController를 확장해서 JSON 오류 메시지를 변경할 수도 있다.

spring boot가 제공하는 BasicController는 HTML 페이지를 제공하는 경우에는 매우 편리하다. 그러나 API의 경우에는 API 마다, 각각의 컨트롤러나 예외마다 서로 다른 응답 결과를 출력해야 할 수도 있다. 때문에 BasicError Controller를 사용하기에는 한계가 존재한다.

HandlerExceptionResolver

스프링 MVC는 컨트롤러(핸들러) 밖으러 예외가 던져진 경우 예외를 해결하고, 동작을 새로 정의할 수 있는 방법을 제공한다. 컨트롤러 밖으로 던져진 예외를 해결하고, 동작 방식을 변경하고 싶으면 HandlerExceptionResolver를 사용하면 된다.

dispatcher servlet으로 전달된 예외를 ExceptionResolver가 받아서 해결을 시도하고, 해당 결과를 반환한다. 이때 ExceptionResolver로 예외를 해결해도 postHandle()은 호출되지 않는다.

public interface HandlerExceptionResolver {
	
    ModelAndView resolveException(
    	HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex);
}
  • handler: 핸들러(컨트롤러) 정보
  • Exception ex: 핸들러(컨트롤러)에서 발생한 예외

만약 예외가 발생한다면, 빈 ModelAndView 객체를 반환해주면 된다. 그러면 dispatcher servlet은 정상적인 응답으로 간주하고 빈 ModelAndView를 응답으로 인식한다. 서블릿 컨테이너는 해당 response를 받고, response.sendError()의 정보를 바탕으로 error처리를 하게 된다.

ExceptionResolver가 ModelAndView를 반환하는 이유는 마치 try, catch를 하듯이, Exception을 처리해서 정상 흐름처럼 변경하는 것이 목적이다.

@Override
 public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
     resolvers.add(new MyHandlerExceptionResolver());
}

사용하려면 WebMvcConfigurer 구현체에 등록해줘야 한다.

반환 값에 따른 동작 방식

HandlerExceptionResolver의 반환 값에 따른 DispatcherServlet의 동작 방식은 다음과 같다.

  • 빈 ModelAndView: new ModelAndView() 처럼 빈 ModelAndView를 반환하면 뷰를 렌더링 하지 않고, 정상 흐름으로 서블릿이 리턴된다.
  • ModelAndView 지정: ModelAndView에 View, Model 등의 정보를 지정해서 반환하면 뷰를 렌더링 한다.
  • null: null을 반환하면, 다음 ExceptionResolver를 찾아서 실행한다. 만약 처리할 수 있는 ExceptionResolver가 없으면 예외 처리가 안되고, 기존에 발생한 예외를 서블릿 밖으로 던진다.

예외를 마무리

ExceptionResolver를 사용하면 컨트롤러에서 예외가 발생해도 ExceptionResolver에서 예외를 처리해버린다.
따라서 예외가 발생해도 서블릿 컨테이너까지 예외가 전달되지 않고, 스프링 MVC에서 예외 처리는 끝이 난다.
결과적으로 WAS 입장에서는 정상 처리가 된 것이다.

스프링이 제공하는 ExceptionResolver

ResponseStatusExceptionResolver

예외에 따라서 HTTP 상태 코드를 지정해주는 역할을 한다.

@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {
}

BadRequestException 예외가 컨트롤러 밖으로 넘어가면 ResponseStatusExceptionResolver 예외가 해당 애노테이션을 확인해서 오류 코드를 HttpStatus.BAD_REQUEST(400)으로 변경하고, 메시지도 담는다.

ResponseStatusExceptionResolver 코드를 확인해보면 결국 response.sendError(statusCode, resolveReason)를 호출하는 것을 확인할 수 있다.

reason을 MessageSource에서 찾는 기능도 제공한다.

@ResponseStatus는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다.(애노테이션을 직접 넣어야 하는데, 내가 코드를 수정할 수 없는 라이브러리의 예외 코드 같은 곳에는 적용할 수 없다.)
추가로 애노테이션을 사용하기 때문에 조건에 따라 동적으로 변경하는 것도 어렵다. 이때는 ResponseStatusException 예외를 사용하면 된다.

동작 원리는 애노테이션과 동일하다.

@GetMapping("/api/response-status-ex2")
public String responseStatus() {
	throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
}

DefaultHandlerExceptionResolver

DefaultHandlerExceptionResolver는 스프링 내부에서 발생하는 스프링 예외를 해결한다.
대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException이 발생하는데, 이 경우 예외가 발생했기 때문에 그냥 두면 서블릿 컨테이너까지 오류가 올라가고, 결과적으로 500 오류가 발생한다.
DefaultHandlerExceptionResolver는 이것을 500 오류가 아니라 HTTP 상태 코드 400 오류로 변경한다.

HandlerExceptionResolver를 직접 사용하기는 복잡하다. API 오류 응답의 경우 response에 직접 데이터를 넣어야 해서 매우 불편하고 번거롭다. ModelAndView를 반환해야 하는 것도 API에는 잘 맞지 않는다.

@ExceptionHandler

스프링은 API 예외 처리 문제를 해결하기 위해 @ExceptionHandler라는 애노테이션을 사용하는 매우 편리한 예외 처리 기능을 제공하는데, 이것이 바로 ExceptionHandlerExceptionResolver이다. 스프링은 ExceptionHandlerExceptionResolver를 기본으로 제공하고, 기본으로 제공하는 ExceptionResolver 중에 우선순위도 가장 높다.

@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
    log.error("[exceptionHandle] ex", e);
    return new ErrorResult("BAD", e.getMessage());
}

@ExceptionHandler 애노테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다. 해당 컨트롤러에서 예외가 발생하면 이 메서드가 호출된다. 지정한 예외 또는 그 예외의 자식 클래스는 모두 잡을 수 있다.

우선순위

스프링의 우선순위는 항상 자세한 것이 우선권을 가진다.
@ExceptionHandler에 지정한 부모 클래스는 자식 클래스까지 처리할 수 있다. 따라서 자식예외가 발생하면 부모예외처리(), 자식예외처리() 둘 다 호출 대상이 된다. 그런데 둘 중 더 자세한 것이 우선권을 가지므로 자식예외처리()가 호출된다. 물론 부모예외가 호출되면 부모예외처리()만 호출 대상이 되므로 부모예외처리()가 호출된다.

@ControllerAdvice

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

@RestControllerAdvice
public class ExControllerAdvice {
	...
	
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandle(IllegalArgumentException e) {
        log.error("[exceptionHandle] ex", e);
        return new ErrorResult("BAD", e.getMessage());
    }
    
    ...
}

@ControllerAdvice는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler, @InitBinder 기능을 부여해주는 역할을 한다. 대상을 지정하지 않으면 모든 컨트롤러에 적용된다.

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

참고: 스프링 MVC (김영한)

profile
주니어 백엔드 개발자

0개의 댓글