[Spring] 서블릿 컨테이너와 Spring에서의 예외 처리 방법

Rupee·2023년 3월 15일
1
post-thumbnail

1️⃣ 서블릿 컨테이너 예외 처리

서블릿은 다음의 두 가지 방법으로 예외 처리를 지원한다.

  1. Exception : 서블릿 밖으로 전달
  2. response.sendError() : 직접 예외 커스텀 가능

☁️ Servlet Exception

자바 직접 실행

웹 어플리케이션

웹 애플리케이션은 사용자 요청별로 별도의 쓰레드가 할당되고, 서블릿 컨테이너 안에서 실행된다. 그래서 예외가 발생 시 애플리케이션에서 try-catch를 통해 잡지 못해, 서블릿 밖에까지 예외가 전달될 경우 다음과 같은 흐름이 이루어진다.

WAS <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)

1. Exception

Exception 예외가 터졌을 때, WAS(톰캣)는 서버 내부에서 처리할 수 없는 에러가 발생한 것으로 간주하여 서버 500 에러를 나타내는 기본 페이지를 보여준다.

public void errorEx() {
    throw new RuntimeException("예외 발생!");  //500 status
}

2. response.sendError()

HttpServletResponse 에서 제공하는 기능으로 서블릿 컨테이너에게 오류 발생 정보를 전달하지만, 당장 예외가 터진것이 아님에 주의하자.

  • response.sendError(HTTP 상태 코드)
  • response.sendError(HTTP 상태 코드, 오류 메시지)

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

response.sendError() 는 응답 내부에 예외가 터졌다는 것을 기록해두기 때문에, 이후 정상 흐름으로 다시 돌아오는 과정에서 서블릿 컨테이너는 고객에게 응답 전에 response에 메소드가 호출되었는지 확인 후 설정한 오류 코드에 맞춰 오류 페이지를 제공할 수 있게 된다.

☁️ Servlet Exception: 예외 페이지

서블릿 컨테이너가 제공하는 기본 오류 페이지 화면은 사용하기 적절하지 않기 때문에, Spring Boot 에서 제공하는 기능을 활용해 직접 오류 화면을 커스텀할 수 있다.

@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {

    @Override
    public void customize(ConfigurableWebServerFactory factory) {
        ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/404");
        ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500");
        // 해당 예외와 자식 타입의 오류까지 함께 처리
        ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500");  

        factory.addErrorPages(errorPage404, errorPage500, errorPageEx);
    }
}

특정 예외 발생시, 오류를 처리 가능한 컨트롤러를 호출한다. 중요한 점은 오류가 WAS 까지 되돌아 간후, 다시 처음부터 작동 하여 오류 페이지를 처리하는 컨트롤러를 호출하게 된다는 것이다.


@Controller
public class ErrorPageController {  // 오류 처리 컨트롤러

    @RequestMapping("/error-page/404")
    public String errorPage404(HttpServletRequest request, HttpServletResponse response){
        return "error-page/404";
    }

    @RequestMapping("/error-page/500")
    public String errorPage500(HttpServletRequest request, HttpServletResponse response){
        return "error-page/500";
    }
}

오류 페이지 작동 원리

자세한 동작 흐름은 다음과 같다.

  1. WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
  2. WAS /error-page/500 다시 요청 -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(/error-page/500) -> View

Exception 예외가 WAS 까지 전달되면, WAS오류 페이지 경로를 찾아서, 또 다시 내부 오류 페이지를 호출하는 과정을 거친다.

예를 들어 RuntimeException 이 발생했는데 다음과 같이 ErrorPage(RuntimeException.class, "/error-page/500"); 설정 했다면 /error-page/500 경로로 호출이 일어나게 되는 것이다.

중요한 점은 이때 오류 페이지 경로로 Filter, Servlet, Interceptor,Controller 모두 다시 호출되며, 오직 서버 내부에서의 추가적인 호출일 뿐, 클라이언트는 알지 못한다.

오류 정보 추가

WAS 는 오류 페이지 요청 뿐만 아니라, 오류 정보를 requestattribute에 추가해서 넘겨준다. 따라서 필요하면 전달된 오류 정보를 꺼내서 확인이 가능하다.

☁️ Servlet Exception: Filter

예외 처리 흐름에서, 오류 페이지 출력을 위해 필터가 내부적으로 두 번 호출되어 비효율적일 수 있는 문제가 발생한다. 예시로 로그인 인증 필터 로직을 한번의 요청에 두 번 호출하는 것은 매우 비효율적이다.

따라서 클라이언트로 부터 발생한 정상 요청인지, 오류 페이지를 출력하기 위한 내부 요청인지 구분 가능해야하는데, 서블릿은 이를 위해 DispatcherType이라는 추가 요청 정보 제공한다.

DispatcherType

고객이 요청한 것인지, 서버 내부에서 오류 페이지를 요청하는 것인지 DispatcherType 을 통해 구분 가능하다.

  1. REQUEST : 클라이언트 정상 요청
  2. ERROR : 서버 내부의 오류 요청
  3. FORWARD: MVC에서의 서블릿에서 다른 서블릿 / JSP를 호출할 때
  4. INCLUDE : 서블릿에서 다른 서블릿이나 JSP의 결과를 포함할 때
  5. ASYNC : 서블릿 비동기 호출

WebConfig 필터 설정

filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);

setDispatcherTypes 를 통해 클라이언트 요청과 오류 페이지 요청 모두에서 필터가 호출되도록 설정할 수 있다.

기본값은 클라이언트 요청에만 필터가 적용되는DispatcherType.REQUEST 이기 때문에, 중복으로 두 번 필터가 적용되는 문제를 방지할 수 있다.

☁️ Spring Exception: Interceptor

그렇다면 인터셉터는 어떻게 중복 호출을 방지할 수 있을까?

인터셉터 같은 경우 스프링의 제공 기능이므로, DispatcherType 과 무관하게 항상 호출되게 된다. 하지만 대신, excludePathPatterns 사용해서 오류 페이지 경로를 뺄 수가 있다.

Webconfig 설정

  @Override
  public void addInterceptors(InterceptorRegistry registry) {
      registry.addInterceptor(new LogInterceptor())
      .order(1)
      .addPathPatterns("/**")
      .excludePathPatterns(
      "/css/**", "/*.ico"
      , "/error", "/error-page/**"    //오류 페이지 경로
      );
  }

💫 전체 흐름 정리

  1. /hello 정상 요청

WAS(/hello, dispatchType=REQUEST) -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러 -> View

  1. /error-ex 오류 요청
  • 필터: DispatchType 으로 중복 호출 제거 ( dispatchType=REQUEST )
  • 인터셉터: 경로 정보로 중복 호출 제거( excludePathPatterns("/error-page/**") )
  1. WAS(/error-ex, dispatchType=REQUEST) -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러
  2. WAS(여기까지 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외발생)
  3. WAS 오류 페이지 확인
  4. WAS(/error-page/500, dispatchType=ERROR) -> 필터(x) -> 서블릿 -> 인터셉터(x) -> 컨트롤러(/error-page/500) -> View

☁️ Spring boot: 오류 페이지

WebServerCustomizer 를 만들어서, 예외 종류에 따라 페이지를 추가하고 오류 페이지 처리 컨트롤러까지 만드는 작업은 너무 복잡하다. 따라서, 스프링 부트에서는 이 세가지 과정을 기본으로 제공한다.

스프링 부트는 따로 상태코드와 예외를 설정하지 않았을 때 기본 오류 처리 페이지/error 경로로 설정해놓았다. 또한, /error 로 등록된 ErrorPage 를 처리하는 BasicErrorController 오류 페이지 처리 컨트롤러를 자동으로 등록해준다.

즉, 개발자는 뷰 선택 우선순위에 따라 resources/templates/error 패키지에서 오류 페이지만 등록하면 끝이므로 매우 편리해진다.

뷰 선택 우선순위

  1. 뷰 템플릿
    resources/templates/error/500.html
    resources/templates/error/5xx.html
  2. 정적 리소스( static , public )
    resources/static/error/400.html
    resources/static/error/404.html
    resources/static/error/4xx.html
  3. 적용 대상이 없을 때 뷰 이름( error )
    resources/templates/error.html

2️⃣ API 예외 처리

각 예외 상황에 맞는 오류 응답 스펙을 정하고, 어떤 예외인지 JSON 형태로 데이터를 내려주어야 한다. 따로 정해주지 않는다면 예외가 발생했을 때 응답으로 미리 만들어둔 예외 페이지 html 이 반환되기 때문이다.

따라서 오류 페이지 컨트롤러가 JSON 형태를 처리할 수 있도록 수정해야 한다.

{
  "message" : "잘못된 사용자",
  "status": 500
}

☁️ Servlet 오류 페이지

produces = MediaType.APPLICATION_JSON_VALUE) 을 통해 JSON 반환 타입을 지정해줄 수 있다. 즉, 클라이언트 요청 HTTP HeaderAccept 의 값이 application/json 일 때 해당 메서드가 호출된다.

    @RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)  //우선순위를 가짐
    public ResponseEntity<Map<String, Object>> errorPage500Api(HttpServletRequest request, HttpServletResponse response){
        Map<String, Object> result = new HashMap<>(); 
        
        Exception ex = (Exception) request.getAttribute(ERROR_EXCEPTION);
        result.put("status", request.getAttribute(ERROR_STATUS_CODE));   
        result.put("message", ex.getMessage());          
        Integer statusCode = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
        return new ResponseEntity<>(result, HttpStatus.valueOf(statusCode));
    }

☁️ Spring boot 예외 처리

기본 설정 메커니즘은, 아무것도 등록하지 않아도 오류 발생시 /error 를 오류 페이지로 요청하는 것이었다. 그리고 오류 경로와 오류 페이지를 매핑하는 작업은 BasicErrorController 가 처리한다.

BasicErrorController 의 소스 코드를 보면, HTTP headeraccept 필드가 text/html이 아닌 경우에 대해 JSON 형식으로 보내주고 있는 것을 확인할 수 있다.

  1. accept : text/html

  2. accept : application-json

BasicErrorController 단점

BasicErrorControllerHTML 오류 페이지를 제공하는 경우에는 편리하다.

하지만 만약 API 마다 오류를 제공하는 JSON 형식 스타일이 다르거나, 무조건 Exception 이면 상태 코드가 500이 아닌 발생하는 예외에 따라서 상태 코드를 변경하고 싶다면, 이러한 복잡한 상황은 처리하기 매우 힘들다.

그렇다면 복잡한 API 오류 처리는 과연 어떻게 해야 할까?

☁️ HandlerExceptionResolver

위에서 언급했듯이, WAS 는 발생한 모든 예외를 HTTP 상태코드 500으로 처리한다.

@GetMapping("/api/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id){
        if(id.equals("ex")){
            throw new RuntimeException("잘못된 사용자");
        }

        if(id.equals("bad")){    //자동으로 상태 코드 500
            throw new IllegalArgumentException("잘못된 입력 값");
        }
        return new MemberDto(id, "hello " + id);
    }

따라서 오류 메시지와 형식 등을 API 마다 다르게 처리하고 싶다면, HandlerExceptionResolver 를 사용하자.

HandlerExceptonResolver 동작 방식

ExceptionResolver컨트롤러 밖으로 던져진 예외를 해결하고, 동작을 새로 정의할 수 있는 방법을 제공한다.

컨트롤러에서 발생한 예외를 중간에서 잡아서 해결하고, WAS로는 정상 응답으로 나가게 된다. 즉 예외를 완전히 처리한 경우, 다시 컨테이너에서 요청을 하지 않는 방식이다.

@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());
                return new ModelAndView();    //흐름 return, 예외 먹고 새로운 400 예외 전달
            }
        }catch(IOException e){
            e.printStackTrace();
        }
        return null;
    }
}

빈 모델앤뷰를 반환함으로써 try-catch 와 같이 기존의 500 예외를 먹고 WAS에 정상 흐름으로 변경하는 역할을 한다. 여기서는 response.sendError() 가 되었으니 WAS 가 새롭게 지정된 400 코드를 인식하고 오류 페이지 경로로 다시 요청을 할 것이다.

만약 null 이 반환될 경우, 다음 ExceptionResolver 를 찾는데 없을 경우 기존 예외를 서블릿 밖으로 던진다.

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

ExceptionResolver 활용

서블릿 컨테이너까지 예외가 던져지고, 오류 페이지 정보를 찾아서 다시 /error 를 호출하는 과정은 너무 복잡하다. ExceptionResolver 를 활용하면, 스프링 MVC에서 예외처리를 완전히 끝낼 수 있다.

  1. 예외 상태 코드 변환
    예외를 response.sendError(xxx) 호출로 변경해서 서블릿에서 상태 코드에 따른 오류를 처리하도록 위임한다.

  2. 뷰 템플릿 처리
    ModelAndView 에 값을 채워서 예외에 따른 새로운 오류 화면 뷰 렌더링 해서 고객에게 제공할 수 있다.

  3. API 응답 처리
    response.getWriter().println("hello") 처럼 HTTP 응답 바디에 직접 데이터를 넣어 줄 수가 있어서 JSON 형식의 API 응답 처리 또한 가능하다.

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) {
                String acceptHeader = request.getHeader("accept");
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);

                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();   // 예외 먹고 정상 전달(다시 요청 X)
                }else{
                    // TEXT/HTML
                    return new ModelAndView("error/500");   // 정상 뷰 전달(다시 요청 X)
                }
            }
        }catch(IOException e){
            log.error("resolver ex", e);
        }
        return null;
    }
}
  if (id.equals("user-ex")) {
       throw new UserException("사용자 오류");
   }

☁️ Spring 제공 ExceptionResolver

하지만 위 과정은 너무 복잡하다. 역시, 스프링 부트는 기본으로ExceptionResolver 를 제공한다. 위에서부터 순서대로 예외를 해결할 수 있는지 체크하고 안되면 서블릿 컨테이너까지 예외가 던져진다.

  1. ExceptionHandlerExceptionResolver
  2. ResponseStatusExceptionResolver
  3. DefaultHandlerExceptionResolver(우선 순위 가장 낮음)

1. ResponseStatusExceptionResolver

예외에 따라서, HTTP 상태 코드를 지정해주는 역할을 한다. 다음 두 가지 경우에 처리가 된다.

  • @ResponseStatus 가 달려있는 예외
  • ResponseStatusException 예외

@ResponseStatus

ResponseStatusExceptionResolver 는 해당 어노테이션을 다 찾아서, response.sendError() 를 통해 예외를 변환시키고 return new ModelAndView() 를 통해 정상 응답으로 반환시킨다.

@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason="error.bad")  //response.sendError()
public class BadRequestException extends RuntimeException {
}

내부에서 response.sendError 호출되었으니, WAS에서는 오류 페이지를 다시 (/error) 내부적으로 요청하게 될 것이다.

ResponseStatusException

@ResponseStatus 는 개발자가 예외를 직접 변경하지 못하고 (ex)라이브러리 예회) 조건에 따라 동적으로 변경 가능이 어렵다. 그럴때는 ResponseStatusException 를 사용하면 된다.

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

2. DefaultHandlerExceptionResolver

스프링 내부에서 발생하는 스프링 예외를 해결하는 리졸버이다. 예를 들어 TypeMisMatchException 과 같은 경우 500 서버 오류가 발생하지만, 클라이언트 잘못이므로 HTTP 상태 코드 400 이 맞기 때문에 변환해야 하는 경우가 생긴다.

@GetMapping("/api-default-handler-ex")
public String defaultException(@RequestParam Integer Data){
     return "ok";
}

DefaultHandlerExceptionResolver 역시 response.sendError() 호출해 상태 코드를 변경하였으므로, WAS에서 오류 페이지(/error)를 내부적으로 다시 요청하게 된다.

두 Resolver의 단점

하지만, 두 방식 모두 API 오류 응답의 경우에도 ModelAndView 를 반환해, API 스타일에 맞지 않으며 HttpServletResponse 에 서블릿을 사용했을 때 처럼 직접 데이터를 변환해 넣어야하는 문제가 존재한다.

그래서 나온 것이 바로, ExceptionHandlerExceptionResolver 이다.

☁️ ExceptionHandlerExceptionResolver

@ExceptionHandler 는 위에서 언급한 API 예외 처리 문제점의 해결책이며, 실무에서 사용하는 방식이다.

ExeptionResolver 중 에서 우선순위에 따라 제일 첫 번째로 동작하는 것이 바로 ExceptionHandlerExceptionResolver 였으니, 해당 리졸버는 컨트롤러에 어노테이션이 붙었는지 확인하고 해당 컨트롤러를 실행시킨다. 따라서 해당 컨트롤러에 처리하고 싶은 예외를 지정해주기만 하면 된다.

또한 두 리졸버와 달리 ExceptionHandlerExceptionResolver정상 흐름으로 반환이 되어서 서블릿 컨테이너까지 갔다가 다시 오류를 처리하러 돌아오는 일이 벌어지지 않는다.

  @Data
  @AllArgsConstructor
  public class ErrorResult {  // 예외 발생시 API 응답으로 사용하는 객체
      private String code;
      private String message;
  }
  @ResponseStatus(HttpStatus.BAD_REQUEST)
  @ExceptionHandler(IllegalArgumentException.class)  // 하위 클래스도 적용
  public ErrorResult illegalExHandle(IllegalArgumentException e) {  
      return new ErrorResult("BAD", e.getMessage()); 
  }

상태 코드를 따로 지정해주지 않는다면, 정상 흐름 처리되어 200이 되기 때문에 의도한 대로 동작하려면 @ResponseStatus 를 붙여야 한다.

ResponseEntity 이용

@ExceptionHandler 
public ResponseEntity<ErrorResult> userHandler(UserException e){   //생략 가능
     ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
     return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
}

ResponseEntity 를 이용하면, @ResponseStatus 를 붙이지 않고도 상태 코드를 200에서 기존 상태 코드로 바꿀 수 있다. ResponseEntityHTTP Converter 사용해 HTTP 메시지 바디에 직접 응답한다.

실행 흐름

  1. IllegalArgumentException 예외가 컨트롤러 밖으로 전달된다.
  2. ExceptionResolver 가 작동하고 우선순위가 가장 높은 ExceptionHandlerExceptionResolver 가 실행되며, 컨트롤러에 해당 예외를 처리 가능한 @ExceptionHandler 가 있는지 확인한다.
  3. @Responsebody 가 적용되어, HTTP Converter 를 이용해 응답이 JSON 으로 반환된다.
  4. @ResponseStatus 를 통해 HTTP 상태 코드를 지정하며, 여기서는 400으로 응답이 된다.

처리되지 않는 예외 처리하기

@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)    //상태 코드 500
@ExceptionHandler
public ErrorResult exHandler(Exception e){  //위에서 처리 못하는 에러들(공통 처리 에러)
     return new ErrorResult("EX", "내부 오류");
}

Exception 은 예외의 부모 클래스이기 때문에, 구체적 자식 클래스에서 처리 못하는 예외들이 해당 컨트롤러로 넘어오게 된다. 예를 들어 RuntimeException이 예외로 던져지면, 이 메서드가 호출된다.

☁️ @ControllerAdvice

🔖 기존 방식의 문제점
1. 정상 처리 코드와 예외 코드가 섞여있다.
2. 컨트롤러마다 작성해주어야 해서 불편하다.

@ControllerAdvice 를 사용한다면, 하나의 컨트롤러에 모여있던 정상 코드와, 예외 처리 코드를 분리할 수 있다.

@RestControllerAdvice // `@ResponseBody` + `@ControllerAdvice`
public class ExControllerAdvice {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandler(IllegalArgumentException e){
        return new ErrorResult("BAD", e.getMessage());  //정상 흐름(200)
    }

    @ExceptionHandler
    public ResponseEntity<ErrorResult> userHandler(UserException e){
        ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
        return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
    }

    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler
    public ErrorResult exHandler(Exception e){  //위에서 처리 못하는 에러들(공통 처리 에러)
        return new ErrorResult("EX", "내부 오류");

    }
}

대상을 지정하지 않으면, 모든 컨트롤러에 적용되므로 지역적으로 설정하고 싶다면 특정 컨트롤러만 처리하거나, 패키지를 지정할 수 있다.

  • @ControllerAdvice(annotations = RestController.class)
  • @ControllerAdvice("org.example.controllers")

출처
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술

profile
개인용으로 공부하는 공간입니다. 잘못된 부분은 피드백 부탁드립니다!

0개의 댓글