본문 바로가기

Spring/MVC

[Spring MVC] API 예외처리 - 1

우리는 지금까지 서블릿을 통한 예외처리(링크)스프링 부트를 통한 예외처리(링크) 에 대해서 알아보았다.
이번 장에서는 화면을 위한 예외처리가 아닌 API 요청에 대한 예외를 처리하는 방법에 대해서 알아본다.
모든 코드는 깃허브(링크) 에 올려두었다.


개요

화면을 위한 HTML 파일 요청에 대한 예외 처리는 오류 페이지만 만들면 대부분의 문제를 처리할 수 있다.
하지만 API 요청에 대한 예외 처리를 하는 경우 오류 응답 스펙을 결정하고 JSON 형식으로 데이터를 내려주어야 한다.

서블릿을 사용하여 API 요청에 대한 예외 처리를 하는 방법에 대해서 알아본다.

서블릿을 사용한 API 예외 처리

WebServerFactoryCustomizer 구현체

ExceptionWAS까지 전달되거나 response.sendError()가 호출된 경우에 예외 처리 컨트롤러를 매핑하는 WebServerCustomizer 클래스를 생성한다.

@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
    @Override
    public void customize(ConfigurableWebServerFactory factory) {
        ErrorPage errorPage404 = new ErrorPage(NOT_FOUND, "/error-page/404");
        ErrorPage errorPage500 = new ErrorPage(INTERNAL_SERVER_ERROR, "/error-page/500");
        ErrorPage errorPageException = new ErrorPage(RuntimeException.class, "/error-page/500");
        factory.addErrorPages(errorPage404, errorPage500, errorPageException);
    }
}

ApiExceptionController

테스트를 위한 ApiExceptionController를 생성한다.
우리가 원하는 상황에 예외를 발생시킬 수 있도록 경로 변수로 exception이라는 아이디가 입력되면 예외를 발생시키도록 구현하였다.

@Slf4j
@RestController
public class ApiExceptionController {
    @GetMapping("/api/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {
        if (id.equals("exception")) {
            throw new RuntimeException("잘못된 사용자 아이디");
        }
        return new MemberDto(id, "id: " + id);
    }
    @Data
    @AllArgsConstructor
    static class MemberDto {
        private String memberId;
        private String name;
    }
}

exception이 아닌 다른 아이디로 요청되는 경우 정상적으로 데이터를 반환하지만 아이디를 exception으로 하여 요청하는 경우 기존에 만들어 두었던 HTML 파일이 반환되는 것을 확인할 수 있다.
API 요청이기 때문에 반환도 HTML이 아닌 JSON 형식으로 할 수 있도록 수정해 본다.

API용 예외 처리 추가

기존에 생성한 ExceptionHandleController 클래스에 API요청에 대한 예외를 처리하는 메서드를 추가한다.

@Slf4j
@Controller
@RequestMapping("/error-page")
public class ExceptionHandleController {
    // ...
    @RequestMapping(value = "/500", produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Map<String, Object>> errorPage500Api(HttpServletRequest request) {
        log.info("call error page api");
        Map<String, Object> result = new HashMap<>();
        Exception exception = (Exception) request.getAttribute(ERROR_EXCEPTION);
        result.put("status", request.getAttribute(ERROR_STATUS_CODE));
        result.put("message", exception.getMessage());
        Integer statusCode = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
        return new ResponseEntity<>(result, HttpStatus.valueOf(statusCode));
    }
    // ...
}

produces 값을 MediaType.APPLICATION_JSON_VALUE로 지정하여 클라이언트가 요청할 때 HTTP 헤더에 Accept값을 application/json으로 지정하는 경우 해당 메서드가 호출된다.
결국 /error-page/500 경로를 가지는 메서드는 두 개 있지만 클라이언트가 결과로 JSON 형식을 원하는 경우 새로 생성한 errorPage500Api메서드가 호출되는 것이다.
응답 데이터를 위해 Map을 사용하였고 응답 형식을 ResponseEntity로 지정하였기 때문에 메시지 컨버터에 의해 기본으로 내장되는 Jackson 라이브러리를 통해 MapJSON으로 변환된다.


스프링 부트를 사용한 API 예외 처리

이번에는 서블릿이 아닌 스프링 부트를 활용하여 API 예외 처리를 진행해본다.
API 예외 처리를 위해서도 HTML 요청에 대한 예외 처리를 위해 사용되었던 BasicErrorController가 사용된다.

BasicErrorController 코드를 확인해보면 /error이라는 경로의 요청을 처리하는 errorHtml(), error() 두 메서드가 있는 것을 확인할 수 있다.

  • errorHtml: produces = MediaType.TEXT_HTML_VALUE와 같이 클라이언트 요청의 Accept 헤더 값이 text/html인 경우에 errorHtml 메서드를 호출하여 View 페이지를 제공한다.
  • error: errorHtml가 호출되는 경우를 제외하고 호출되며 ResponseEntity로 HTTP Body에 JSON 데이터를 반환한다.

스프링이 제공하는 BasicErrorController를 사용하기 위해서 WebServerCustomizer@Component 애노테이션을 주석처리하고 /api/members API를 호출해본다.
경로 변수로 예외를 발생시키기 위해 exception을 입력하면 아래와 같이 예외에 대한 정보가 출력되는 것을 확인할 수 있다.

{
    "timestamp": "2022-06-02T08:12:20.648+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "exception": "java.lang.RuntimeException",
    "trace": "java.lang.RuntimeException: 잘못된 사용자 아이디\n\tat  hello.myexception.api.ApiExceptionController.getMember(ApiExceptionController.java:17)\n\tat 
  // ...
  java.base/java.lang.Thread.run(Thread.java:829)\n",
    "message": "잘못된 사용자 아이디",
    "path": "/api/members/exception"
}

HTML 예외 처리와 동일하게 예시처럼 오류에 대한 상세 정보를 출력하는 것은 보안상 위험하기 때문에 상용으로 배포하는 경우에는 조심해야 한다.

HTML 페이지와 API 오류 처리 차이

BasicErrorController의 기능을 확장하면 JSON 메시지도 우리가 원하는 형태로 변경이 가능하다.

스프링 부트에서 지원하는 BasicErrorController는 HTML 페이지를 제공하는 경우에는 에러 코드에 따라서 우리가 정해놓은 화면을 렌더링 해주기 때문에 편리한 기능이다.
하지만 일반적으로 API의 경우 화면보다 갯수가 많으며 같은 오류라도 API마다 다르게 처리해줘야 할 수도 있기 때문에 매우 세밀하고 복잡한 처리가 필요하다.
결과적으로 HTML 예외 처리를 위해서는 BasicErrorController을 사용하고 API 예외 처리를 위해서는 @ExceptionHandler 애노테이션을 사용하여 해결해야 한다.


HandlerExceptionResolver

예외가 발생하여 WAS까지 예외가 전달되면 HTTP 상태코드가 500인 상태로 클라이언트에게 전달된다.
HandlerExceptionResolver를 사용하면 예외의 종류에 따라서 400, 401, 4002와 같이 우리가 원하는 상태 코드로 변경이 가능하며 메시지의 형식등을 API 마다 다르게 처리할 수 있다.

이번에는 IllegalArgumentException이 발생한 경우 클라이언트에게 상태 코드를 500이 아니라 400으로 변경해서 전달하는 방법에 대해서 알아본다.

HandlerExceptionResolver 란?

HandlerExceptionResolver를 사용하면 컨트롤러(핸들러) 밖으로 예외가 던져진 경우 예외를 해결하고 결과를 재정의 할 수 있다.
HandlerExceptionResolver는 일반적으로 ExceptionResolver로 줄여서 부르기도 한다.

ExceptionResolver 적용 전

핸들러에서 예외가 발생하면 Dispatcher Servlet을 통해서 WAS에게 예외가 전달된다.
이때 postHandle는 호출되지 않으며 afterCompletion은 호출된다.

ExceptionResolver 적용 후

핸들러에서 예외가 발생하면 Dispatcher Servlet으로 예외가 전달된다.
예외가 발생한 경우 Dispatcher Servlet은 예외를 처리할 수 있는 Exception Resolver를 찾는다.
만약 처리가 가능한 Exception Resolver가 있다면 예외를 처리하고 WAS에게 정상적으로 응답한다.
처리가 불가능한 예외라면 핸들러에서 발생한 예외를 WAS에게 전달한다.
예외가 처리되더라도 예외가 발생했다는 사실은 바뀌지 않기 때문에 postHandle 은 호출되지 않는다.

HandlerExceptionResolver 인터페이스를 살펴보면 아래와 같이 resolveException 메서드를 가지고 있으며 ModelAndView를 반환하는 것을 알 수 있다.

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

handler의 경우 예외가 발생한 핸들러를 의미하며 ex는 핸들러에서 발생한 예외를 의미한다.

HandlerExceptionResolver 구현체

예외 처리를 위하여 HandlerExceptionResolver를 구현하는 MyHandlerExceptionResolver 클래스를 생성한다.

@Slf4j
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {
    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        log.info("call resolver exception", ex);
        try {
            if (ex instanceof IllegalArgumentException) {
                log.info("ex is a IllegalArgumentException");
                response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
                return new ModelAndView();
            }
        } catch (IOException e) {
            log.error("resolver ex", e);
        }
        return null;
    }
}

ExceptionResolverModelAndView를 반환하는 이유는 try, catch를 사용하는 것 처럼 Exception을 처리해서 클라이언트에게 정상적으로 화면을 보여주기 위함이다.
예시에서는 IllegalArgumentException이 발생하면 response.sendError(HttpServletResponse.SC_BAD_REQUEST)를 호출하여 HTTP 상태 코드를 400으로 지정한다.
이후 비어있는 ModelAndView를 반환한다.
DispatcherServletHandlerExceptionResolver의 반환 값에 따라서 아래와 같이 동작한다.

  • 빈 ModelAndView: 예시처럼 비어있는 ModelAndView를 반환하면 뷰를 렌더링하지 않고 정상적인 흐름대로 서블릿이 리턴된다.
  • ModelAndView 지정: ModelAndViewView, Model등의 정보를 지정해서 반환하면 지정한 뷰를 렌더링 한다.
  • null: ExceptionResolver는 여러개 등록이 가능하다. null이 반환된 경우 다른 ExceptionResolver를 찾는다.
    만약 처리 가능한 ExceptionResolver가 없다면 최초에 발생한 예외를 WAS에 전달한다.

구성 정보 수정

우리가 만든 MyHandlerExceptionResolver를 사용하려면 아래처럼 구성 정보를 수정해야 한다.

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new MyHandlerExceptionResolver());
    }
    // ...
}

configureHandlerExceptionREsolvers() 를 사용하는 경우 스프링의 기본 ExceptionHandler가 제거되기 때문에 새로운 ExceptionHandler를 추가하기 위해서는 extendHandlerExceptionResolvers를 사용해야 한다.


지금부터 HandlerExceptionResolver를 실제로 사용하는 방법에 대해서 알아본다.
예외가 발생한 경우의 프로세스를 돌아보면 예외가 WAS까지 전달되고, WAS는 오류 페이지 정보를 찾기 위해 다시 /error를 찾기 위해 다시 호출해야 한다.
예외가 발생할 때마다 이렇게 복잡한 프로세스를 진행해야 하는 것은 비효율적이며 ExceptionResolver를 사용하면 이런 복잡한 과정이 불필요해진다.

UserException

RuntimeException을 상속받는 UncheckedExceptionUserException 클래스를 생성한다.

public class UserException extends RuntimeException {
    public UserException() {
        super();
    }
    public UserException(String message) {
        super(message);
    }
    public UserException(String message, Throwable cause) {
        super(message, cause);
    }
    public UserException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }
}

ApiExceptionController 수정

기존 API에서 경로 변수로 "user-exception"이 입력되는 경우 UserException을 발생시키도록 수정한다.

@Slf4j
@RestController
public class ApiExceptionController {
    @GetMapping("/api/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {
        switch (id) {
            case "exception":
                throw new RuntimeException("잘못된 사용자 아이디");
            case "bad-request":
                throw new IllegalArgumentException("잘못된 요청");
            case "user-exception":
                throw new UserException("사용자 정의 예외 발생");
            default:
                return new MemberDto(id, "id: " + id);
        }
    }
    // ...
}

UserHandlerExceptionResolver

UserException을 처리할 수 있는 UserHandlerExceptionResolver 클래스를 생성한다.

@Slf4j
public class UserHandlerExceptionResolver implements HandlerExceptionResolver {
    private static final String APPLICATION_JSON_VALUE = "application/json";
    private final ObjectMapper objectMapper = new ObjectMapper();
    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        try {
            if (ex instanceof UserException) {
                log.info("UserException resolver to 400");
                String acceptHeader = request.getHeader("accept");
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                if (APPLICATION_JSON_VALUE.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_VALUE);
                    response.setCharacterEncoding("utf-8");
                    response.getWriter().write(result);
                    return new ModelAndView();
                } else {
                    return new ModelAndView("error/500");
                }
            }
        } catch (IOException e) {
            log.error("resolver ex", e);
        }
        return null;
    }
}

코드를 보면 알 수 있듯이 복잡해서 이러한 방식으로는 사용되지 않는 방식이므로 이해만 하고 넘어가도록 한다.
HTTP 요청 헤더의 ACCEPT 값이 application/json이면 JSON 형식으로 오류를 반환하고 그 외의 경우에는 error/500.html 오류 페이지를 반환한다.

구성 요소 수정

extendHandlerExceptionResolvers를 통해서 새로 만든 UserHandlerExceptionResolver가 반환될 수 있도록 수정한다.

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new MyHandlerExceptionResolver());
        resolvers.add(new UserHandlerExceptionResolver());
    }
    // ...
}

서버를 재실행시키고 /api/members/user-exception과 같이 요청을 보내면 아래와 같이 우리가 원하는 결과가 나오는 것을 확인할 수 있다.

지금까지의 방법을 적용하면 ExceptionResolver에 의해서 예외 상황이 발생하더라도 WAS까지 예외가 전달되지 않고 스프링 내부에서 예외 처리가 끝나는 것을 확인할 수 있었다.
결과적으로 WAS가 보기에는 정상적으로 처리된 결과를 클라이언트에게 전달한 것이다.
하지만 UserHandlerExceptionResolver 코드를 살펴보면 상당히 지저분하고 중복되는 내용이 반복되는 것을 확인할 수 있다.

스프링이 제공하는 ExceptionResolver를 사용하면 이보다 편하게 예외 처리가 가능하다.


참고한 강의:

참고한 문서: