본문 바로가기
Web/spring study

[Spring] API 예외처리 (서블릿부터 @ExceptionHandler까지)

by 장인이 2024. 4. 17.

1. 개요

 지난 게시물에서는 MVC 패턴에서 오류가 발생할경우, 오류 페이지로 안내하는 방법에 대해서 알아보았다. 사실 4xx, 5xx와 같은 오류 페이지만 있으면, 거의 모든 문제를 해결할 수 있었다.

 하지만 API의 경우, 고려해야 할 내용이 더 많다. API는 오류 상황에 따라 그에 맞는 오류 응답 스펙을 정하고, 데이터를 JSON 형식으로 내려주어야 한다.

 지금까지 그래왔듯이, 서블릿 오류 페이지 방식부터 스프링이 제공하는 편리한 방법까지 순서대로 살펴보자.

 

 

2. API 서블릿 기본 예외 처리

 우선 기본적인 서블릿을 사용해서 예외처리를 해보자. 마찬가지로 지난 게시물 "3. 서블릿 오류 페이지"에서 만든 WebServerCustomizer를 다시 동작시키자.

 

WebServerCustomizer

@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 errorPage404 = new ErrorPage(RuntimeException.class, "/error-page/500");
        factory.addErrorPages(errorPage404, errorPage500, errorPageEx);
    }
}

 

 자, 이제 WAS까지 예외가 전달되거나, response.sendError()가 호출될 경우 등록한 예외페이지 경로가 호출된다.

 

ApiExceptionController

@Slf4j
@RestController
public class ApiExceptionController {
    @GetMapping("/api/members/{id}")
    public MemberDto getMember(@PathVariable("id") 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;
    }
}

 

 회원을 조회하고, 예외 테스트를 위해 id의 값이 "ex"라면, RuntimeException이 발생하도록 해두었다. 이후 Rest API를 테스트해 볼 수 있는 프로그램으로 확인해보자. 여기서는 Postman으로 테스트해보았다. 이때, HTTP Header에 Accept가 application/json임을 꼭!! 확인하자.

 

http://localhost:8080/api/members/test

{
    "memberId" : "test",
    "name" : "hello spring"
}

 

http://localhost:8080/api/members/ex

 

<!DOCTYPE HTML>
<html>
<head>
</head>
<body>
...
</body>

 

 어라? 분명 API를 요청했는데, 정상의 경우에는 JSON 형식으로 정상 반환되지만, 오류가 발생하면 지난 게시물에서 만들어둔 오류 페이지 HTML이 반환된다! 따라서 클라이언트가 JSON값을 요청할 경우, 컨트롤러도 JSON 응답을 할 수 있도록 수정해야 한다. 마찬가지로 지난 게시물에서 구현한 ErrorPageController에 새로운 메서드를 추가해보자.

 

ErrorPageController - 메서드 추가

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

 

 produces = MediaType.APPLICATION_JSON_VALUE를 통해 클라이언트 요청 중 HTTP Header Accept 값이 application/json 일떄만 호출되는 메서드를 만들었다.

 

 응답 데이터를 위해 Map을 만들었고, 에러 메시지와 상태 코드를 입력하였다. 다시 테스트해보자.

 

http://localhost:8080/api/members/ex

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

 

 

3. API 스프링 부트 기본 오류 처리

 지난 게시물 4. 스프링부트 오류 페이지에서 스프링부트가 제공하는 BasicErrorController를 통해 기본 오류페이지가 실행된다고 학습하였다. 이번에는 한번 코드를 자세히 살펴보자.

 

BasicErrorController 코드

@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {...}

@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {...}

 

 스프링 부트의 기본 설정 오류 발생 시 /error를 오류 페이지로 요청하고, 해당 동일한 경로를 처리하는 errorHtml(), error() 두 가지 메서드가 있다는 점을 확인할 수 있다.

 구분 방식은 클라이언트의 Accept 해더 값이 text/html이면 errorHtml()그 외이면 error() 메서드가 실행된다. 그러면 BasicErrorController를 사용하기 위해 WebServerCustomizer의 @Component를 주석처리한 후, 예외처리를 확인해 보자.

 

GET http://localhost:8080/api/members/ex

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

 

 위와 같이 스프링 부트는 BasicErrorController가 제공하는 정보를 활용하여 오류 API를 생성해준다. 아래 옵션들을 설정하면 더 자세한 정보를 확인 할 수 있지만, 보안상 위험할 수 있다. (클라이언트가 해당 서버가 어떤 라이브러리를 사용하는지 눈치챌 수 있음) 따라서 되도록이면 로그를 통해서 확인하자!

 

 이런식으로 BasicErrorController를 활용하여 API 오류도 처리할 수 있다. 하지만, 실제로는 각각 컨트롤러, 예외마다 서로 다른 응답 결과를 출력하는 경우가 많다. 예외 케이스가 매우 세밀하기 때문에, 실제 API 오류 처리는 뒤에서 나올 @ExceptionHandler를 사용하자!

 

 

4. HandlerExceptionResolver

 만일 BasicErrorController만 가지고 예외 처리를 하는 경우, 예외가 발생해서 WAS까지 전달되면 HTTP 상태코드가 500으로 처리된다. 하지만, 우리는 예외의 종류에 따라 400, 404 등 다른 상태코드로 처리하고 싶은 경우가 많다. 이를 처리하기 위한 수단 중 하나인 "HandlerExceptionResolver"를 사용해보자.

 

 위에서 만들었던 ApiExceptionController를 수정해보자.

 

ApiExceptionController 코드 추가

@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable("id") 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/bad

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

 

 실행해보면 상태 코드가 500임을 확인할 수 있다. 하지만, IllegalArgumentException은 클라이언트가 잘못된 값을 입력하는 경우 발생하므로 HTTP 프로토콜에 의하여 4xx 상태코드임이 더 알맞다

 

 이를 바꾸기 위한 수단 중 하나인 ExceptionResolver에 대해 알아보자.

 

ExceptionResolver 적용 전 코드 흐름

 

ExceptionResolver 적용 후 코드 흐름

 

 그러면 HandlerExceptionResolver를 구현해보자.

 

MyHandlerExceptionResolver

@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();
            }
        } catch (IOException e) {
            log.error("resolver ex", e);
        }
        
        return null;
    }
}

 

 위 그림에서 볼 수 있듯이 핸들러(컨트롤러)에서 예외가 발생하여 DispatcherServlet 까지 넘어오면, HandlerExceptionResolver를 찾아서 실행시킨다. 해당 메서드는 예외가 IllegalArgumentException일 경우, response.sendError(400)을 호출해서 상태코드를 400으로 지정한 후, 빈 ModelAndView를 반환한다.

 

 resolveException() 메서드를 보면, ModelAndView를 반환한다. 반환 값의 종류에 따라 DispatcherServlet의 동작 방식이 달라지며, 다음과 같다.

 

빈 ModelAndView: 비어있는 ModelAndView를 반환하면 뷰를 렌더링 하지 않고정상 흐름으로 서블릿이 반환된다.

ModelAndView 지정: ModelAndView에 정보를 지정해서 반환하면, 해당 뷰를 렌더링 한다.

null: null이 반환될 경우, 다음 ExceptionResolver를 찾아본다. 처리할 수 있는 ExceptionResolver가 없는 경우, 발생한 예외를 서블릿 밖으로 던진다.

 

 실제로 사용하기 위해서 등록해보자.

 

WebConfig

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

 

 configureHandlerExceptionResolvers()를 사용하면 스프링이 기본으로 등록하는 ExceptionResolver가 제거되므로, 위의 메서드를 사용하자.

 

예외를 HandlerExceptionResolver에서 마무리하기

 사실 위 방식을 사용해도 결국 WAS까지 에러 정보가 전달된 후, 오류 페이지 정보를 찾아 다시 호출하게 된다. 이렇게 한번 왔다갔다 하는 방식은 너무 복잡하다. 따라서, ExceptionResolver에서 response.sendError()하는 대신에, 메서드 자체에서 문제를 해결할 수도 있다!

 

MyHandlerExceptionResolver - 수정

@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.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);
                } else {
                    return new ModelAndView("error/400");
                }
                return new ModelAndView();
            }
        } catch (IOException e) {
            log.error("resolver ex", e);
        }
        
        return null;
    }
}

 

 HTTP 요청 헤더 ACCEPT 값이 application/json일 경우 JSON으로 반환하고, 그 외의 경우에는 400 오류페이지를 반환한다. 이러면 예외가 발생해도 서블릿 컨테이너까지 전달되지 않고, 끝이 난다. 그러나 직접 구현하려고 하니 너무 복잡하다...

 

 

5. @ResponseStatus, ResponseStatusException

 스프링부트는 다음 순서로 ExceptionResolver를 처리한다!

 

1. ExceptionHandlerExceptionResolver

- @ExceptionHandler를 처리한다. 사실 대부분 이 기능을 사용하며, 뒤쪽에서 자세히 설명할 예정이다.

 

2. ResponseStatusExceptionResolver

- @ResponseStatus, ResponseStatusException에 의해 HTTP 상태 코드를 지정한다.

 

3. DefaultHandlerExceptionResolver

- 스프링 내부 기본 예외를 처리한다.

 

 이 중에서 ResponseStatusExceptionResolver가 처리하는 기능을 살펴보자.

 

@ResponseStatus

 예외에 @ResponseStatus 애노테이션을 사용하면, HTTP 상태 코드를 변경해준다.

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

 

 이를 처리하는 ResponseStatusExceptionResolver 코드를 살펴보면, 결국 내부적으로 response.sendError()를 통해 HTTP 상태코드를 지정해주는 것을 확인할 수 있다.

 

 

ApiExceptionController 추가

@GetMapping("/api/response-status-ex1")
public String responseStatusEx1() {
    throw new BadRequestException();
}

 

http://localhost:8080/api/response-status-ex1

{
    "status": 400,
    "error": "Bad Request",
    "exception": "hello.exception.exception.BadRequestException",
    "path": "/api/response-status-ex1"
}

 

 그런데, @ResponseStatus는 개발자가 변경할 수 없는 예외에는 적용할 수 없다. (코드를 수정할 수 없는 라이브러리의 예외 코드 경우) 따라서 이때는 ResponseStatusException 예외를 사용하자!

 

ApiExceptionController 추가

@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
    throw new ResponseStatucException(HttpStatus.NOT_FOUND, "잘못된 요청입니다.", new IllegalArgumentException());
}

 

http://localhost:8080/api/response-status-ex2

{
    "status": 404,
    "error": "Not Found",
    "exception": "org.springframework.web.server.ResponseStatusException",
    "path": "/api/response-status-ex2"
}

 

 

6. @ExceptionHandler, @ControllerAdvice

 결국 지금까지 BasicErrorController, HandlerExceptionResolver, @ResponseStatus 로 API 예외를 다뤄보았으나, 여러모로 쉽지 않은 점이 많았다.

 드디어 스프링이 제공하는 @ExceptionHandler를 사용해서, 예외처리를 해보자. 위에서도 언급했듯이 스프링은 ExceptionHandlerExceptionResolver를 기본으로 제공하고, 이는 우선순위가 가장 높다. 실제로도 이 기능을 대부분 사용한다.

 

 @ExceptionHandler가 붙은 메서드를 컨트롤러 클래스 안에 넣어서 사용해도 되고, @ControllerAdvice를 사용하여 예외 처리 코드와 정상 코드를 분리할 수도 있다. 같은 예외여도 컨트롤러마다 별도의 처리를 하고 싶다면 전자를, 그게 아니라면 후자를 사용하자.

 

ErrorResult

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

 

예외 발생 시, API 응답으로 사용할 객체를 정의하자.

 

ExControllerAdvice

@Slf4j
@RestControllerAdvice
public class ExControllerAdvice {
	
    //1.
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandle(IllegalArgumentException e) {
        log.error("[exceptionHandle] ex", e);
        return new ErrorResult("BAD", e.getMessage());
    }
    
    //2.
    @ExceptionHandler
    public ResponseEntity<ErrorResult> userExHandler(UserException e) {
        log.error("[exceptionHandle] ex", e);
        ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
        return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
    }
    
    //3.
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler
    public ErrorResult exHandle(Exception e) {
        log.error("[exceptionHandle] ex", e);
        return new ErrorResult("EX", "내부 오류");
    }
}

 

 @ExceptionHandler 애노테이션을 선언하고, 처리하고 싶은 예외를 지정하자. 해당 예외가 발생할 경우, 그에 맞는 메서드가 호출된다.

 또한, 지정한 예외와 그 예외의 자식 클래스는 모두 잡을 수 있다. 물론 부모 클래스 예외, 자식 클래스 예외 둘 다 있을 경우 자식 클래스 예외 처리가 우선적으로 호출된다.

 

 //2.의 경우를 보면, @ExceptionHandler에 예외를 생략할 수 있다. 이 경우, 메서드 파라미터의 예외가 지정된다. 컨트롤러의 응답과 같이, @ExceptionHandler 또한 다양한 파라미터와 응답을 지정할 수 있다! 자세한건 공식 메뉴얼을 참고하자.

 

 @ControllerAdvice지정한 여러 컨트롤러에 @ExceptionHandler, @InitBinder 기능을 부여해주는 역할을 한다. 위 예시처럼 대상을 지정하지 않을 경우, 모든 컨트롤러에 적용된다.

 위 예시에서 사용한 @RestControllerAdvice@ControllerAdvice와 같은 기능을 하며, @ResponseBody를 모든 메서드에 붙여준다. @Controller와 @RestController의 차이와 같다고 생각하면 된다.

 

@ControllerAdvice(annotations = RestController.class)
public class Ex1 {}

@ControllerAdvice("org.test.controller")
public class Ex2 {}

@ControllerAdvice(assignableTypes = {ExController.class, ExInterface.class})
public class Ex3 {}

 

 위와 같은 방식으로 특정 애노테이션이 있는지, 패키지를 직접 지정하든지, 혹은 특정 클래스를 지정할 수 있다. 위에서 언급했지만, 대상 컨트롤러 지정을 하지 않을 경우 글로벌 적용된다.

 

 

7. 정리

 이렇게 API 예외 처리를 서블릿부터 @ExceptionHandler, @ControllerAdvice를 조합해서 해결하는 방법까지 정리해보았다. 이를 통해 API 예외가 발생할 경우, 개발자가 원하는 방향으로 오류 스팩을 지정할 수 있게 되었다!!

 

 

 


위 내용은 김영한 님의 인프런 강의 "스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술"의 내용과 강의자료를 토대로 작성된 게시글입니다.

강의 링크:

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2

 

댓글