[Spring] 예외처리

imcool2551·2022년 3월 10일
2

Spring

목록 보기
14/15
post-thumbnail

본 글은 인프런 김영한님의 스프링 완전 정복 로드맵을 기반으로 정리했습니다.

0. 들어가며


예외 처리는 애플리케이션의 매우 중요한 부분입니다.

MVC 예외 처리는 서블릿의 기능을 사용할 수도 있고 스프링 부트가 제공하는 기능을 사용할 수도 있습니다. 먼저, 서블릿이 제공하는 기능을 살펴보며 내부적인 원리를 살펴본 다음, 스프링 부트가 제공하는 기능의 편리함을 이용하는 방향으로 글을 정리했습니다.

API의 경우 예외처리를 할 수 있는 방법이 다양하기 때문에 따로 정리했습니다.

1. MVC 예외처리 with 서블릿


웹 애플리케이션은 사용자 요청마다 쓰레드가 할당되고, 쓰레드는 서블릿 컨테이너 내부의 서블릿을 이용해서 실행된다. 전형적인 스프링 애플리케이션의 경우 서블릿을 디스패쳐 서블릿으로 이해하면 된다. 만약, 쓰레드 실행중에 예외가 발생했는데 try-catch 로 잡지 못하고 서블릿 밖으로 예외가 세어나가면 결국 톰캣같은 WAS 까지 예외가 전달된다. 톰캣과 같은 WAS를 서블릿 컨테이너라고 생각해도 괜찮다.

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

다음과 같이 컨트롤러가 예외를 던졌을 때 아무런 예외처리를 하지 않으면 결국 WAS 까지 예외가 전파된다.

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

또는, HttpServletResponsesendError() 를 호출해서 WAS에게 오류가 발생했다는 점을 전달할 수 있다. 예외가 발생하진 않았지만 WAS는 sendError() 가 호출된 적이 있으면 오류가 발생했음을 감지하고 사용자에게 보여줄 오류 페이지를 찾기 위해 내부적으로 컨트롤러를 재호출 한다.

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

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

@GetMapping("/error-500")
public void error500(HttpServletResponse response) throws IOException {
    response.sendError(500);
}

개발자는 WAS가 처리되지 못한 Exception을 전달받거나, response.sendError() 가 호출되었을 때를 대비해 사용자에게 보여줄 오류 페이지를 준비해야한다. 그렇지 않으면 사용자는 WAS가 제공하는 기본 오류 페이지를 보게된다. 기본 오류 페이지는 보기 좋지 않기 때문에 사용자가 노출하지 않는 것이 좋다.

import org.springframework.boot.web.server.ConfigurableWebServerFactory;
import org.springframework.boot.web.server.ErrorPage;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.http.HttpStatus;


@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);

    }

}

스프링 부트를 사용한다면 위처럼 발생한 HTTP 오류 코드나 발생한 예외 타입에 대해 WAS가 재호출할 경로를 지정할 수 있다. 예를들어, HttpStatus.INTERNAL_SERVER_ERROR(500) 의 경우 /error-page/500 에 매핑했다. RuntimeException과 하위 예외들도 /error-page/500 에 매핑했다. 이제 해당 오류나 예외에 매핑된 경로를 처리할 컨트롤러가 필요하다.

@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";
    }
}

일반적인 컨트롤러와 똑같다. 매핑된 경로에 대해 오류 페이지 View 를 반환하면 된다.

WAS는 처리되지 못한 예외를 전달받거나, sendError() 가 호출된 것을 보고 해당 예외/오류에 대해 매핑된 오류 페이지 정보를 확인한다. 위의 경우, RuntimeException 이 발생하면, new ErrorPage(RuntimeException.class, "/error-page/500") 을 찾는다. 그 다음 /error-page/500 경로로 재요청한다.

정리하면 다음과 같다.

  1. WAS(예외 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외 발생)
  2. WAS(오류 페이지 재요청) -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러 -> View

중요한 점은 WAS가 서버 내부에서 오류를 찾기 위해 추가적인 호출을 한다는 것이다. 이를 리디렉션과 헷갈리면 안된다. 리디렉션과 달리, 웹 브라우저(클라이언트)는 서버 내부에서 이런 재호출이 일어나는지 전혀 모른다.

WAS는 오류 페이지를 요청할 때 HttpServletRequest 객체에 오류에 대한 정보를 추가해서 넘겨준다. 필요하면, 추가된 정보를 오류 페이지 컨트롤러에서 로그를 남기는 작업 등에 사용할 수 있다.

  • javax.servlet.error.exception: 예외
  • javax.servlet.error.exception_type: 예외 타입
  • javax.servlet.error.message: 오류 메시지
  • javax.servlet.error.request_uri: 클라이언트 요청 URI
  • javax.servlet.error.servlet_name: 오류가 발생한 서블릿 이름
  • javax.servlet.error.status_code: HTTP 상태 코드

2. MVC 예외처리 with 서블릿 - 필터+인터셉터 중복 호출


WAS가 오류 페이지 출력을 위해 내부적으로 컨트롤러를 다시 호출하면 필터와 인터셉터가 중복 호출되는 문제가 발생한다. 로그인 인증 체크 필터/인터셉터 같은 경우 오류 페이지 출력을 위해 해당 필터/인터셉터를 한 번더 호출하는 것은 매우 비효율적이다. 서블릿은 이를 막기 위해 HttpServletRequestDispatcherType 이라는 추가 정보를 제공한다. DispatcherType 을 통해 지금 요청이 실제 고객이 요청한 것인지, 서버가 내부에서 오류 페이지를 찾기 위해 재요청한 것인지 구분할 수 있다. javax.servlet.DispatcherType 은 열겨 타입으로 종류는 다음과 같다.

  • REQUEST: 클라이언트 요청
  • ERROR: 오류 요청
  • FORWARD: 서블릿에서 다른 서블릿/JSP를 호출
  • INCLUDE: 서블릿에서 다른 서블릿/JSP의 결과를 포함
  • ASYNC: 서블릿 비동기 호출

REQUESTERROR 를 통해 클라이언트 요청과 서버 내부 요청을 구분할 수 있다는것에 주목하면 된다. 이전 글에서 사용한 LogFilter를 클라이언트 요청과 내부 요청 모두에 적용하고 싶다면 필터를 등록할 때 다음과 같이 하면된다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Bean
    public FilterRegistrationBean logFilter() {
        FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setFilter(new LogFilter());
        filterRegistrationBean.setOrder(1);
        filterRegistrationBean.addUrlPatterns("/*");
        filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
        return filterRegistrationBean;
    }
}

setDispatcherTypes() 는 가변인자를 받기 때문에 필터를 적용할 DispatcherType들을 설정할 수 있다. 아무런 설정도 하지 않으면 기본 값이 DispatcherType.REQUEST 이므로 클라이언트 요청에 대해서만 필터가 적용된다. 특별히 오류 페이지에도 필터를 적용해야 하는 상황이 아니면 기본 값을 그대로 사용하면 된다.

필터의 경우, 적용할 DispatcherType 을 등록할 때 설정을 통해 중복 호출을 막을 수 있지만 인터셉터의 경우 얘기가 다르다. 필터는 서블릿 기술이지만 인터셉터는 스프링이 제공하는 기능이다. 따라서 서블릿의 DispatcherType 과는 무관하게 항상 호출된다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

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

인터셉터는 필터보다 상세한 URL 패턴을 지정할 수 있다. 위처럼 인터셉터는 등록시 중복 호출이 일어나지 않도록 URL 패턴을 지정해주면 된다.

excludePathPetterns() 에 에러 페이지에 매핑된 경로들(/error-page/**)을 지정해주면 해당 경로들에 대해 인터셉터가 호출되지 않는다.

전체 과정을 정리하면 다음과 같다.

  1. WAS(클라이언트 요청, dispatchType=REQUEST) -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러
  2. WAS(예외 전파) <- 필터 <- 서블릿 <- 인터셉터 <- 컨트롤러(예외 발생)
  3. 예외/오류에 매핑된 오류 페이지 경로 확인
  4. WAS(서버 내부 요청, dispatchType=ERROR) -> 필터(x) -> 서블릿 -> 인터셉터(x) -> 컨트롤러 -> View

3. MVC 예외처리 with 스프링 부트


서블릿이 제공하는 기능만으로 MVC 예외처리를 하기는 상당히 복잡하다. WebServerFactoryCustomizer<ConfigurableWebServerFactory> 를 구현해서 예외/오류 종류에 따라 ErrorPage에 경로를 추가하고, 예외 처리용 컨트롤러를 만들어서 경로에 매핑된 View를 반환해야 한다.

스프링 부트는 이런 과정을 모두 기본으로 제공한다. 발생한 예외에 대해 /error 라는 기본 경로로 ErrorPage를 자동으로 등록해준다. 서블릿 밖으로 예외가 나가거나, response.sendError(...) 가 호출된 경우 모든 오류에 대해 /error 를 호출하게 된다. /error 경로를 처리하는 BasicErrorController 라는 컨트롤러도 자동으로 등록한다.

즉, 스프링 부트가 예외 처리 경로와 경로를 처리하는 컨트롤러를 자동으로 등록해주기 때문에, 개발자는 오류페이지만 등록하면 된다. 오류 페이지는 정적HTML로 써도 되고, 뷰 템플릿을 써서 동적으로 표현해도 된다. 그저, 알맞은 경로에 오류 페이지 파일만 만들어두면 스프링이 알맞은 파일을 선택해서 사용한다.

BasicErrorController는 뷰 선택시 다음과 같은 우선순위에 따른다.

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

해당 위치에 HTTP 상태 코드로 뷰 파일을 넣어두면 된다. 뷰 템플릿이 정적 리소스보다 우선순위가 높고, 500과 같이 더 구체적인 이름이 5xx처럼 덜 구체적인 것 보다 우선순위가 높다. 5xx와 같은 파일은 500번대 오류를 처리한다. 예외는 500 오류로 간주해서 처리한다.

BasicErrorController 컨트롤러는 예외/오류와 관련된 다양한 정보를 model 에 담아주기 때문에 뷰 템플릿에서 해당 값들을 활용해서 출력할 수 있다.

  • timestamp: Fri Feb 05 00:00:00 KST 2021
  • status: 400
  • error: Bad Request
  • exception: org.springframework.validation.BindException
  • trace: 예외 trace
  • message: Validation failed for object='data'. Error count: 1
  • errors: Errors(BindingResult)
  • path: 클라이언트 요청 경로 (/hello)

예외/오류 발생 시간, 상태 코드, 예외 타입, 스택 정보, 예외 메시지, 요청 경로 등 다양한 값을 model 에 담아준다.

다만, 오류 관련 내부 정보들을 고객에게 노출하는 것은 좋지 않다. 고객은 내부 정보에 관심도 없을 뿐더러, 악의적인 사용자에게 시스템 내부 정보를 노출하면 보안상 문제가 될 수도 있다. 그래서 스프링 부트는 기본값으로 model에 해당 정보들을 포함하는 설정을 꺼두었다.

server.error.include-exception=false
server.error.include-message=never
server.error.include-stacktrace=never
server.error.include-binding-errors=never

기본값이 never인 부분은 always로 항상 포함하거나, on_param을 통해 http://localhost:8080/error-ex?message=&errors=&trace= 처럼 요청 파라미터 전달시 포함하도록 사용할 수 있지만, 실제 운영서버에서는 절대 노출하면 안된다. 보안상 취약점이 된다. 사용자에게는 간단한 오류 화면을 보여주고 자세한 오류 정보는 로그를 남겨서 확인해야 한다.

이 외에도 오류와 관련된 옵션으로 server.error.whitelabel.enalbed=trueserver.error.path=/error가 있다. 각각, 스프링의 기본 오류 페이지를 사용할지 여부와 스프링이 자동 등록하는 오류 페이지 경로를 나타낸다. 특별한 이유가 없다면 whitelabel 오류 페이지 설정은 꺼두고, 스프링의 기본 오류 경로인 /error를 사용하면 된다.

에러 공통 처리 컨트롤러의 기능을 변경/확장하고 싶다면 ErrorController 인터페이스를 상속해서 구현하거나, BasicErrorController를 상속 받아서 기능을 추가하면 된다. 그러나, 대부분의 경우 스프링 부트가 기본으로 제공하는 오류 페이지 기능을 활용하면 MVC 오류 페이지는 손쉽게 해결할 수 있다.

4. API 예외 처리 with 서블릿


오류 페이지를 제공해야하는 MVC 예외처리의 경우 오류 페에지만 잘 만들어 놓으면 대부분의 문제를 해결할 수 있다. 그러나, API의 경우는 각각 스펙에 맞는 JSON을 생성해서 응답해야되기 때문에 더 복잡하다.

API 예외 처리도 MVC 예외 처리와 마찬가지로 서블릿의 기능을 사용해서 처리할 수 있다. 발생한 예외를 매핑할 경로를 지정하고 해당 경로의 컨트롤러에서 객체나 ResponseEntity 를 반환하면 메시지 컨버터가 동작하면서 클라이언트에게 JSON 이 반환된다.

예제를 통해 살펴보자. 다음과 같은 컨트롤러에서 예외가 발생한 상황이다.

@Slf4j
@RestController
public class ApiExceptionController {

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

        return new MemberDto(id, "hello " + id);
    }

    @Data
    @AllArgsConstructor
    static class MemberDto {
        private String memberId;
        private String name;
    }
}

예외 타입을 처리할 경로를 다음과 같이 "/error-page/500" 으로 매핑한다.

@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {

    @Override
    public void customize(ConfigurableWebServerFactory factory) {

        ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500");

        factory.addErrorPages(errorPageEx);

    }

}

마지막으로 요청 경로 "/error-page/500" 을 처리하는 컨트롤러를 정의하면 된다.

애노테이션의 produces = MediaType.APPLICATION_JSON_VALUE 에 주의하자. Accept: applicatoin/json 을 헤더로 추가해야 해당 컨트롤러가 동작한다. 헤더를 추가하지 않으면 HTML을 반환하는 컨트롤러가 우선순위를 가진다.

request.getAttribute() 으로 가져오는 정보는 서블릿이 예외 상황이 발생하여 서버 내부적으로 재호출 할 때 요청 객체에 추가한 정보다. 해당 정보들로 Map을 생성한 뒤 반환하면 Jackson 라이브러리가 Map을 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("javax.servlet.error.exception");

    result.put("message", ex.getMessage());
    result.put("status", request.getAttribute("javax.servlet.error.status_code"));

    Integer statusCode = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);

    return new ResponseEntity<>(result, HttpStatus.valueOf(statusCode));
}

http://localhost:8080/api/members/exAccept: application/json 헤더를 포함하여 요청을 보내면 아래와 같은 JSON이 응답된다. 응답코드는 statusCode 값인 500이 된다.

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

그러나 서블릿 방법은 MVC의 경우와 마찬가지로 작성해야할 코드가 너무 많기 때문에 실용적이지 않다.

5. API 예외 처리 with 스프링 부트


API 예외 처리도 스프링 부트가 제공하는 BasicErrorController 를 통해 해결할 수 있다. BasicErrorControllerModelAndView 를 반환해서 에러 페이지를 제공하는 컨트롤러와 ResponseEntity를 반환하여 HTTP Body에 JSON을 담아서 반환하는 컨트롤러를 각각 제공한다.

BasicErrorController 는 기본값으로 경로 /error 를 사용하고 server.error.path 프로퍼티를 통해 수정 가능하다.

BasicErrorController 가 생성해주는 JSON을 보기 위해 ErrorPage 를 통해 예외 처리 경로를 직접 등록하는 클래스가 빈으로 등록되지 않도록 하자.

BasicErrorController 는 다음과 같은 모양의 JSON을 응답한다.

{
  "timestamp": "2021-04-28T00:00:00.000+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "exception": "java.lang.RuntimeException",
  "trace": "java.lang.RuntimeException: 잘못된 사용자 at hello.exception.web.api.ApiExceptionController.getMember(ApiExceptionController.java:19...,",
  "message": "잘못된 사용자",
  "path": "/api/members/ex"
}

MVC와 마찬가지로 다음 옵션들을 조정해서 JSON에 더 자세한 오류 정보를 추가할 수 있지만, 자세한 오류 정보를 JSON에 포함하는 것은 보안상 위험할 수 있다. JSON에는 오류와 관련된 간결한 메시지만 노출하고 오류에 대한 자세한 정보는 로그를 남겨서 확인하도록 하자.

server.error.include-exception=false
server.error.include-message=never
server.error.include-stacktrace=never
server.error.include-binding-errors=never

BasicErrorController 는 일관된 형태의 JSON을 응답하지만 API 스펙별로 다른 형태의 JSON을 응답해야 하는 경우가 많은 현실에서는 다른 방법이 필요하다.

6. API 예외 처리 with HandlerExceptionResolver


BasicErrorController 는 일관된 형태의 JSON을 반환한다. API 스펙에 맞는 JSON을 반환하려면 다른 방법이 필요하다.

또한, 예외가 서블릿을 벗어나 WAS까지 전달된 경우 HTTP 상태 코드는 500으로 처리된다. 예를들어, WAS는 전달받은 예외가 RuntimeException 이든 IllegalArgumentException 이든 신경쓰지 않고 서버 내부에서 문제가 발생한 것으로 간주하고 상태 코드 500을 반환한다. 그러나 IllegalArgumentException 이 사용자의 잘못된 입력값으로 발생한 경우 500이 아닌 400을 응답하고 싶을 수 있다.

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

    if (id.equals("bad")) {
        throw new IllegalArgumentException("잘못된 입력 값");
    }

    return new MemberDto(id, "hello " + id);
}

http://localhost:8080/api/members/exhttp://localhost:8080/api/members/bad 는 모두 상태 코드 500을 응답한다. 또, JSON도 다음과 비슷한 모양이다.

{
  "status": 500,
  "error": "Internal Server Error",
  "exception": "java.lang.IllegalArgumentException",
  "path": "/api/members/bad"
}

발생하는 예외에 따라서 상태 코드를 다르게 처리하고 싶거나 API 마다 스펙에 맞는 JSON의 모양이 다를 수 있다. 이 때 HandlerExceptionResolver 을 사용하면 된다. HandlerExceptionResolver 는 흔히 ExceptionResolver 로 줄여서 부르기도 한다.

HandlerExceptionResolver는 예외 처리 동작을 새로 정의할 수 있는 방법을 제공한다. 다음과 같은 작업들을 할 수 있다.

  • 컨트롤러 밖으로 던져진 예외가 서블릿을 벗어나 WAS 에 도달하지 않도록 한다.
  • 500 이외의 오류 코드를 사용하도록 한다.
  • 원하는 형태의 JSON을 응답한다.

HandlerExceptionResolver 는 이름처럼 핸들러에서 던져진 예외를 원하는 방식대로 해결(resolve)하는 역할을 한다. HandlerExceptionResolver 적용 전과 후의 차이를 그림을 통해 살펴보자.

HandlerExceptionResolver 적용 전이라면 예외는 WAS 까지 전달된다. WAS까지 예외가 전파되면 500 코드를 응답한다.

HandlerExceptioinResolver 를 적용하면 WAS 까지 예외가 흘러가지 않도록 할 수 있다. 물론, HandlerExceptionResolver 에서 예외를 해결하지 못하면 예외는 WAS 까지 흘러간다.

public interface HandlerExceptionResolver {
    ModelAndView resolveException(
        HttpServletRequest request,
        HttpServletResponse response,
        Object handler,
        Exception ex
    );
}

HandlerExceptionResolver 는 위의 인터페이스를 구현하고 등록하는 방식으로 사용할 수 있다. resolveException() 의 반환 값에 따라 예외 동작 방식이 달라진다.

  • 빈 ModelAndView

    new ModelAndView()를 리턴하면 뷰가 렌더링 되지 않고 정상 흐름으로 서블릿이 리턴 된다. 예외가 WAS 까지 흘러가지 않는다.

  • ModelAndView

    ModelAndView 에 값을 채워서 반환하면 뷰를 렌더링 한다. 예외가 WAS 까지 흘러가지 않는다.

  • null

    ExceptionResolver 가 예외를 처리할 수 없음을 의미한다. 다음 ExceptionResolver 가 실행되거나, 없다면 예외가 WAS 까지 흘러간다.

반환값 만으론 잘 와닿지 않으니 활용할 수 있는 사례들을 살펴보자.

  • response.sendError(...) 호출

    위 코드를 통해 500 이외의 오류 코드를 지정한 뒤 빈 ModelAndView 를 반환한다. WAS는 예외 처리를 위해 내부적으로 재호출한다. 기본값으론 /error 가 호출될 것이다.

  • response.getWriter().println(...)

    위 코드를 통해 응답 바디에 직접 데이터를 넣어 줄 수 있다. 바로 이 방법을 통해 원하는 형태의 JSON 을 응답할 수 있다. 단, ObjectMapper 등을 사용하여 객체를 JSON 으로 직접 변경해야하는 불편함이 있다. 서블릿을 통해 요청을 처리하는 방법과 비슷하다. 빈 ModelAndView 를 반환한다. 이 경우, WAS 가 예외 처리를 위해 내부적인 재호출을 하지 않고 클라이언트에게 바로 응답한다. WAS 입장에선 마치 어떠한 오류/예외도 발생하지 않은 것처럼 동작한다.

  • 값을 채운 ModelAndView 응답

    ModelAndView에 값을 채워서 예외 상황에 맞는 오류 페이지를 렌더링해서 고객에게 제공할 수 있다.

다양한 활용 방법이 있지만, 뷰가 렌더링 되게 하는 것은 JSON 을 응답해야 하는 API 방식에선 그다지 필요가 없다. HttpServletResponse 로부터 Writer 를 얻어와서 응답 바디에 변환한 JSON을 넣는 방식 또한 코드가 너무 복잡해져서 그다지 실용성이 없다.

response.sendError() 를 호출하고 빈 ModelAndView 를 반환해 오류 코드를 바꾸는 예제만 간단히 살펴보자. 이 방법 또한 마지막에 설명할 더 나은 대안이 존재하기 때문에 가볍게 살펴보면 된다.

@Slf4j
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {

    @Override
    public ModelAndView resolveException(
            HttpServletRequest request,
            HttpServletResponse response,
            Object handler,
            Exception ex) {

        try {
            if (ex instanceof IllegalArgumentException) {
                response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
                return new ModelAndView();
            }
        } catch (IOException e) {
            log.error("resolver ex", e);
        }

        return null;
    }
}

발생한 예외가 IllegalArgumentException 타입이라면 response.sendError() 를 통해 응답코드를 400으로 설정한 뒤 빈 ModelAndView 를 반환한다. WAS 는 응답 객체를 검사한 뒤 sendError() 가 호출된 것을 보고 500 코드를 응답하는 대신, 설정된 오류 경로를 내부적으로 다시 호출한다. 기본값이라면 /error 가 호출될 것이다. 결과적으로 500 대신 400 응답이 나가게 된다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

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

HandlerExceptionResolver를 구현했다면 등록해야 한다. 등록은 WebMvcConfigurer를 통해 한다. configureHandlerExceptionResolvers() 를 사용하면 스프링이 내부적으로 등록하는 ExceptionResolver 들이 제거되므로 extendHandlerExceptionResolvers() 를 사용해야 한다. 이렇게 HandlerExceptionResolver 를 구현하고 등록하면 예외 동작 방식을 바꿀 수 있다.

HandlerExceptionResolver 를 구현해서 컨트롤러에서 발생한 예외를 원하는 방식으로 처리할 수 있음을 보았다. WAS 까지 예외가 흘러가게 하지 않게 한 것이 핵심이다. HandlerExceptionResolver 응답 코드를 바꿀수도 있고 원하는 모양의 JSON을 응답하거나, View를 반환할 수도 있다.

그러나, 이 방법으로 API 예외를 처리하기엔 상당히 복잡하다. 작성해야 하는 코드도 많은데다가 API 예외 처리에서 ModelAndView 를 반환해야하는 것도 어색하다. JSON을 응답하기 위해 HttpServletResponse 로부터 Writer 를 얻어온 후 객체->JSON 변환을 직접 수행해야 한다는 것도 실용성이 없다.

7. API 예외 처리 with @ExceptionResolver, @ControllerAdvice


예외 타입을 검사해서 오류 코드를 설정하는 HandlerExceptionResolver 를 구현해보았다. 필요하다면, 원하는 모양의 JSON을 만들어낼 수도 있다. 그러나, 이 방법은 코드의 양도 많고 실용성이 부족했다. API 예외 처리에서 ModelAndView 가 등장하는 것도 어색하다.

스프링 부트는 API 예외 처리를 보다 쉽게 할 수 있는 궁극의 방법을 제공한다. 바로 ExceptionResolver 를 직접 구현하는 방식이 아닌 스프링 부트가 기본으로 제공하는 ExceptionResolver 를 사용하는 것이다. 기본으로 제공되는 ExceptionResolver 들은 HandlerExceptionResolverComposite 에 다음 순서로 등록된다.

  1. ExceptionHandlerExceptionResolver
  2. ResponseStatusExceptionResolver
  3. DefaultHandlerExceptionResolver

ResponseStatusExceptionResolverDefaultHandlerExceptionResolver 부터 알아본 뒤, ExceptionHandlerExceptionResolver@ControllerAdvice와 함께 알아보도록 하자.

ResponseStatusExceptionResolver 는 다음 두 가지 경우에대해 상태 코드를 지정해준다.

  1. @ResponseStatus 애노테이션이 붙은 예외
  2. ResponseStatusException 예외
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {
}

위와 같이 @ResponseStatus 가 붙은 예외가 컨트롤러 밖으로 넘어가면 ResponseStatusExceptionResolver 가 오류 코드를 BAD_REQUEST(400)으로 변경하고 reason에 지정된 메시지도 담아준다. ResponseStatusExceptionResolver 는 내부적으로 response.sendError(statusCode, resolvedReason) 을 호출한다. 결국, WAS 에서 response.sendError() 가 호출된것을 확인해서 내부적으로 다시 오류 페이지(/error)를 요청하는 것이다.

reason에 문자열 말고도 MessageSource 에 등록된 메시지 코드도 지정할 수 있다.
messaage.properties 파일에 메시지 코드를 넣고 reason 에 메시지 코드를 지정해주면 된다.

error.bad=잘못된 요청입니다.
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "error.bad")
public class BadRequestException extends RuntimeException {
}

@ResponseStatus 애노테이션은 개발자가 코드를 변경할 수 없는 외부 라이브러리가 던지는 같은 예외같은 곳에는 적용할 수 없다. 또한, 애노테이션을 사용하기 때문에 동적으로 코드와 메시지를 변경하기도 어렵다. 그땐 ResponseStatusException 예외를 사용하면 된다.

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

위와 같이 ResponseStatusException를 통해 오류 코드, 메시지, 발생한 이유를 지정해서 예외를 동적으로 처리할 수 있다. 물론, 외부 라이브러리에서 던지는 예외들도 동적으로 처리할 수 있다.

DefaultHandlerExceptionResolver 는 스프링 내부에서 발생하는 스프링 예외를 해결하는 리졸버다. WAS는 기본적으로 어떤 예외가 전파되든 상관없이 500 에러를 반환한다고 했다.

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

위와 같은 컨트롤러가 있을 때 http://localhost:8080/api/default-handler-ex?data=abc 에 요청을 보내면 문자열 "abc"를 정수형으로 매핑하다가 TypeMisMatchException 이 발생한다. 그러나, 오류 코드는 500이 아닌 400이다. 이는, 스프링이 내부에서 발생하는 예외들을 처리하기 위해 DefaultHandlerExceptionResolver 들을 사용하기 때문이다.

이제 가장 많이 사용하는 ExceptionHandlerExceptionResolver 를 알아보자.

HandlerExceptionResolver 를 직접 구현해서도 JSON을 원하는 모양으로 만들거나 오류 코드를 설정해줄 수도 있었다. 그러나, API와 어울리지 않는 ModelAndView 를 반환해야 했고, JSON을 만드는 과정도 쉽지 않았다.

@ExceptionHandler 애노테이션을 사용하면 API 예외 처리가 매우 간단해진다. 마치 @ReqeustMapping 컨트롤러를 작성하는 것과 같이 명확하게 예외 처리를 할 수 있다. 이 @ExceptionHandler 를 처리하는 것이 바로 ExceptionHandlerExceptionResolver다. 코드로 살펴보자.

다음과 같은 JSON 을 응답한다.

@Data
@AllArgsConstructor
public class ErrorResult {
    private String code;
    private String message;
}

@ExceptionHandler 가 포함된 컨트롤러는 다음과 같다.

@Slf4j
@RestController
public class ApiExceptionV2Controller {

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

    @ExceptionHandler
    public ResponseEntity<ErrorResult> userExHandler(UserException e) {
        log.error("[exceptionHandler] ex", 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) {
        log.error("[exceptionHandler] ex", e);
        return new ErrorResult("EX", "내부 오류");
    }

    @GetMapping("/api2/members/{id}")
    public MemberDto getMember(@PathVariable String id) {

        if (id.equals("ex")) {
            throw new RuntimeException("잘못된 사용자");
        }
        if (id.equals("bad")) {
            throw new IllegalArgumentException("잘못된 입력 값");
        }
        if (id.equals("user-ex")) {
            throw new UserException("사용자 오류");
        }

        return new MemberDto(id, "hello " + id);
    }

    @Data
    @AllArgsConstructor
    static class MemberDto {
        private String memberId;
        private String name;
    }
}

한 부분씩 떼어내서 살펴보자.

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

@ExceptionHandler 애노테이션을 통해 해당 컨트롤러에서 처리하고 싶은 예외를 지정한다. 아래의 경우 IllegalArgumentException 과 그 하위 예외들이 처리된다. 반환값은 ErrorResult 객체를 직접 반환할 수 있다. 부모 타입과 자식 타입 각각에 대해 @ExceptionHandler 가 존재하면 더 자세한 것(자식)이 우선권을 가진다. @ExceptionHandler({AException.class, BException.class}) 처럼 다양한 예외를 한 번에 처리할 수도 있다.

예외 처리 코드가 애노테이션을 사용하는 컨트롤러와 매우 유사하다.

@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandler(Exception e) {
    log.error("[exceptionHandler] ex", e);
    return new ErrorResult("EX", "내부 오류");
}

@ExceptionHandler 에 처리하고 싶은 예외를 생략하면 메서드 파라미터 예외를 처리한다. 위의 경우엔 가장 상위 타입 예외인 Exception 이기 때문에 모든 종류의 예외를 처리할 수 있다. 물론, 더 자세한 자식 예외를 처리하는 @ExceptionHandler가 존재하면 실행되지 않는다.

@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandler(UserException e) {
    log.error("[exceptionHandler] ex", e);
    ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
    return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
}

@ResponseStatus 를 사용하면 예외에 대한 오류 코드가 정적으로 결정되어버린다. 오류 코드를 프로그래밍을 통해 동적으로 설정하고 싶으면 위와 같이 ResponseEntity를 사용하면 된다. 이 또한, 애노테이션을 사용한 컨트롤러와 매우 유사하다.

@ExceptionHandler 를 살펴봤는데 @RequestMapping 컨트롤러와 매우 유사하다. 실제로 @ExceptionHandler 는 마치 스프릥의 컨트롤러처럼 매우 다양한 파라미터와 응답을 지정할 수 있다. 자세한 옵션은 공식 메뉴얼을 참고하면 된다.

https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann-exceptionhandler-args

@ExceptionHandler 는 스프링의 컨트롤러와 매우 유사하기 때문에 쉽게 사용할 수 있다. 작성해야 하는 코드의 양도 확연히 줄어드는 동시에 코드의 의도도 명확해진다.

한 가지 마음에 들지 않는건 예외 처리 코드가 컨트롤러에 섞여 있다는 것이다. 예외 처리 코드를 분리하기 위해 사용되는 것이 @ControllerAdvice@RestControllerAdvice다. 둘의 차이는 @Controller@RestController 처럼 @ResponseBody 유무의 차이다.

예외 처리 코드를 분리해보자.

@Slf4j
@RestControllerAdvice
public class ExControllerAdvice {

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

    @ExceptionHandler
    public ResponseEntity<ErrorResult> userExHandler(UserException e) {
        log.error("[exceptionHandler] ex", 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) {
        log.error("[exceptionHandler] ex", e);
        return new ErrorResult("EX", "내부 오류");
    }
}
@Slf4j
@RestController
public class ApiExceptionV2Controller {

    @GetMapping("/api2/members/{id}")
    public MemberDto getMember(@PathVariable String id) {

        if (id.equals("ex")) {
            throw new RuntimeException("잘못된 사용자");
        }
        if (id.equals("bad")) {
            throw new IllegalArgumentException("잘못된 입력 값");
        }
        if (id.equals("user-ex")) {
            throw new UserException("사용자 오류");
        }

        return new MemberDto(id, "hello " + id);
    }

    @Data
    @AllArgsConstructor
    static class MemberDto {
        private String memberId;
        private String name;
    }
}

컨트롤러에서 @ExceptionHandler 애노테이션이 붙은 메서드들을 @RestControllerAdvice를 붙인 클래스로 옮기기만 하면 끝이다.

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

애노테이션, 패키지, 클래스 타입 등 다양한 방법으로 대상 컨트롤러를 지정할 수 있다. 자세한 것은 공식 문서를 참고하자.

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

8. 결론


예외 처리를 하는 방법은 다양하다.

가장 편리하고 명확한 방법은 MVC의 경우 스프링 부트가 제공하는 기본 설정을 사용하고, API 의 경우 @ExceptionHandler@ControllerAdvice를 사용하는 것이다.

그러나, 단순히 기능만 사용하면 예외가 실제로 어떤 과정을 거쳐서 처리되는지 갚이 있는 이해가 불가능하다. 예외가 WAS 까지 전달됐을 때 어떤 일이 일어나는지, ExceptionResolverDispatcherServlet 의 어느 단계에서 어떻게 예외를 해결하는지 등을 깊이 있게 이해하고 넘어가도록 하자.

profile
아임쿨

0개의 댓글