본문 바로가기
Backend/Spring | SpringBoot

[SpringBoot] 예외 처리 - API

by 2245 2023. 9. 30.

목차

     

    서론

    • 예외가 발생했을 때 HTML 페이지를 반환하는 경우 이외에 JSON으로 오류와 관련된 정보를 반환해야하는 경우가 있습니다.
    • 오류 페이지의 경우 4xx, 5xx와 같은 오류 페이지를 단순히 고객에게 전달하면 끝이지만, API는 각 오류 상황에 맞는 오류 응답 스펙을 정의하고, 해당 데이터를 JSON으로 내려주어야 합니다.
    • 지금부터 API의 경우 어떻게 예외를 처리하는지 알아보겠습니다.
    • 먼저 서블릿이 어떻게 처리하는지 알아본 후, 스프링부트가 처리하는 방법에 대해 알아보겠습니다. 

     

    서블릿 - API 예외 처리

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

    ApiExceptionController - API 예외 발생 컨트롤러

    • 전달된 id가 ex일 경우, RuntimeException 발생
    @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;
        }
    }

    정상 호출

    http://localhost:8080/api/members/spring

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

    예외 발생 호출

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

    <!DOCTYPE HTML>
    <html>
    <head>
        <meta charset="utf-8">
    </head>
    <body>
    <div class="container" style="max-width: 600px">
        <div class="py-5 text-center">
            <h2>500 오류 화면</h2>
        </div>
        <div>
            <p>오류 화면 입니다.</p>
        </div>
        <hr class="my-4">
    </div> <!-- /container -->
    </body>
    </html>

     

    문제점: JSON이 아닌 HTML 반환 → JSON을 반환하도록 수정

    ErrorPageController - API 응답 추가

    @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(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));
    }
    • produces = MediaType.APPLICATION_JSON_VALUE : 클라이언트가 요청하는 HTTP Header의 Accept의 값이 application/json이라면, 해당 메서드가 호출됩니다.
    • 응답 데이터를 위해 Map을 만들고 status, message 키에 값을 할당했습니다. Jackson 라이브러리는 Map을 JSON 구조로 변환할 수 있습니다. 
    • ResponseEntity를 사용하여 응답하기 때문에, 메시지 컨버터가 동작하여 클라이언트에게 JSON이 반환됩니다. 

    실행

    • http://localhost:8080/api/members/ex
    • accept: application/json
    {
        "message": "잘못된 사용자",
        "status": 500
    }

     

     

    스프링 부트 -  BasicErrorController

    스프링 부트의 경우, BasicErrorController가 발생한 예외를 처리합니다. 

    BasicErrorController

    @RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
    public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {}
    
    @RequestMapping
    public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}
    • errorHtml와 error 두 메서드를 확인할 수 있습니다.
    • errorHtml() : produces = MediaType.TEXT_HTML_VALUE 이므로 클라이언트 요청의 Accept 헤더의 값이 text/html인 경우에 해당 메서드가 호출이 되고, view를 제공합니다.
    • error() : 그 이외의 경우 호출이 됩니다. ResponseEntity로 HTTP Body에 JSON 데이터를 반환합니다.

    옵션 설정

    스프링 부트는 BasicErrorController가 제공하는 기본 기능들을 활용해 오류 API를 생성합니다.

    다음 옵션들을 사용하여 더 자세한 오류 정보를 추가할 수 있습니다.

    application.properties

    erver.error.include-binding-errors=always
    server.error.include-exception=true
    server.error.include-message=always
    server.error.include-stacktrace=always
    • 물론, 오류 메시지는 보안상 위험할 수 있으므로 간결한 메시지만 노출하고, 로그를 통해 확인해야 합니다.
    참고 스프링 부트의 BasicErrorController를 실행하기 위해선 WebServerCustomizer의 @Component를 주석처리해야 합니다. 

    실행

    • GET
    • http://localhost:8080/api/members/ex
    • Accept: application/json
    {
        "timestamp": "2023-09-26T10:30:22.520+00:00",
        "status": 500,
        "error": "Internal Server Error",
        "exception": "java.lang.RuntimeException",
        "trace": "java.lang.RuntimeException: 잘못된 사용자\r\n\tat hello.exception.api.ApiExceptionController...,     
        "message": "잘못된 사용자",
        "path": "/api/members/ex"
    }

     

     

    HandlerExceptionResolver

    • BasicErrorController가 제공하는 오류 메시지를 수정하기 위해서 BasicErrorController를 확장해서 변경할 수 있습니다.
    • 하지만 그것보다, @ExceptionHandler를 사용하는 것이 더 나은 방법입니다.
    • 스프링 부트가 제공하는 BasicErrorController는 HTML 페이지를 제공하는 용도로 매우 편리하기 때문에 해당 용도로만 사용하고, API 오류 처리는 각각의 컨트롤러의 예외마다 다른 응답 결과를 출력해야 할 수 있을 만큼 매우 세밀하고 복잡하므로 @ExceptionHandler를 사용하는 것이 더 좋습니다.
    • (ex. 회원과 관련된 API에서 발생한 예외와 상품과 관련된 API에서 발생한 예외 처리 방법이 다를 수 있습니다.) 

     

    지금부터 복잡한 API 오류는 어떻게 처리하는지 알아봅시다.

    상태 코드 변환

    • 예외가 발생해서 서블릿을 넘어 WAS까지 전달되면, HTTP 상태코드는 무조건 500으로 처리가 됩니다.
    • 하지만, IllegalArgumentException의 경우 클라이언트에서 잘못된 매개변수를 전달하는 경우가 대다수이므로 이 경우엔 400으로 처리하고 싶습니다. 어떻게 해야 할까요?

    ApiExceptionController - IllegalArugmentException 발생

    @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

    {
        "timestamp": "2023-09-26T10:39:49.426+00:00",
        "status": 500,
        "error": "Internal Server Error",
        "exception": "java.lang.IllegalArgumentException",
        "path": "/api/members/bad"
    }
    • 상태 코드가 500인 것을 확인할 수 있습니다.

     

    ExceptionResolver

    • 컨트롤러 밖으로 던져진 예외를 WAS에 도달하기 전에 가로채 동작 방식을 변경하여 전달합니다.
    • 줄여서 ExceptionResolver라고 합니다.

    ExceptionResolver 적용 전

    컨트롤러에서 예외가 발생하면, 서블릿을 넘어 WAS에게 전달됩니다. 

    ExceptionResolver 적용 후

    컨트롤러에서 발생한 예외를 서블릿이 ExceptionResolver를 호출하여 해결을 한 후 WAS에게 전달됩니다.

    (ExceptionResolver로 예외를 해결해도, postHandle()은 호출되지 않습니다.) 

     

    HandlerExceptionResovler 인터페이스

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

     

    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");  
                    //예외 먹음, sendError로 직접 정의한 예외 전달
                    response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
                    return new ModelAndView();
                }
            } catch (IOException e) {
                log.error("resolver ex", e);
            }
            
            return null;
        }
    }

    반환값에 따른 동작 방식

    return ModelAndView()
    • ModelAndView를 반환하는 이유는 해당 예외는 정상 흐름처럼 변경하기 위해서입니다. 
    • 빈 ModelAndView() : 뷰를 렌더링하지 않고, 정상 흐름으로 서블릿이 리턴됩니다.
    • ModelAndView 지정 : ModelAndView에 지정된 View, Model 의 정보를 통해 View를 렌더링합니다.
    • null : 다음 ExceptionResolver를 찾아 실행합니다. 만약, 처리할 수 있는 ExceptionResolver가 없다면 예외 처리가 되지 않고 기존에 발생한 예외를 서블릿 밖인 WAS로 전달합니다.

     

    ExceptionResolver 기능

    • 예외 상태 코드를 변환해서 반환합니다.
      • response.sendError(xxx)를 호출하고 현재 예외는 정상으로 처리하여 상태 코드에 따른 오류를 처리하도록 위임합니다.
      • 이후 WAS는 서블릿 오류 페이지를 찾아 내부 호출합니다. (ex) /error 호출)
    • 뷰 템플릿을 처리합니다.
      • ModelAndView에 값을 채워 반환하면, 새로운 오류 화면 뷰를 렌더링하여 제공합니다.
    • API 응답을 처리합니다
      • response.getWriter().println("hello"); 와 같이 HTTP 응답 바디에 직접 데이터를 넣어 반환할 수 있습니다. JSON을 담으면 API 응답 처리를 할 수 있습니다

     

    WebConfig - ExceptionResolver 등록

    /**
     * 기본 설정을 유지하면서 추가
     */
    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new MyHandlerExceptionResolver());
    }
    참고 extendHandlerExceptionResolver()가 아닌 configureHandlerExceptionResolvers()를 사용하면 스프링이 기본으로 등록하여 사용하던 ExceptionResolver가 제거되므로 주의해야 합니다. 

    실행

    • http://localhost:8080/api/members/ex → HTTP 상태 코드 500
    • http://localhost:8080/api/members/bad → HTTP 상태 코드 400 (MyExceptionResolver 동작)

     

    HandlerExceptionResolver - 예외 마무리하기

    • 현재 작성한 코드는 sendError를 통해 WAS까지 예외를 던지고, WAS가 다시 오류 페이지 정보를 찾기 위해 /error를 호출합니다.
    • 번거로운 해당 과정없이 ExceptionHandler에서 예외 처리를 마무리할 수 있습니다.

    UserException - 사용자 정의 예외 생성

    예시를 위해 사용자 정의 예외를 생성합니다. 

    package hello.exception.exception;
    
    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(Throwable cause) {
            super(cause);
        }
        
        protected UserException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
            super(message, cause, enableSuppression, writableStackTrace);
        }
    }

    ApiExceptionController - UserException 발생

    @Slf4j
    @RestController
    public class 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("잘못된 입력 값");
            }
            
            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;
        }
    }

    실행

    http://localhost:8080/api/members/user-ex → 500 에러 (BasicErrorController)

     

    UserHandlerExceptionResolver - UserException 처리

    @Slf4j
    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) {
                    log.info("UserException resolver to 400");
    
                    //http header에 따른 두 가지 처리 (html or application/json)
                    String acceptHeader = request.getHeader("accept");
                    response.setStatus(HttpServletResponse.SC_BAD_REQUEST); //400
    
                    if("application/json".equals(acceptHeader)) {
                        Map<String, Object> errorResult = new HashMap<>();
                        errorResult.put("ex", ex.getClass());
                        errorResult.put("message", ex.getMessage());
    
                        //객체를 json으로 변경
                        String result = objectMapper.writeValueAsString(errorResult);
    
                        response.setContentType("application/json");
                        response.setCharacterEncoding("utf-8");
                        response.getWriter().write(result);
                        return new ModelAndView();
                    } else {
                        //TEXT/HTML
                        return new ModelAndView("error/500");
                    }
                }
            } catch (IOException e) {
                log.error("resolver ex", e);
            }
    
            return null;
        }
    }

     

    WebConfig - UserHandlerExceptionResolver 등록

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

     

    실행

    http://localhost:8080/api/memebers/user-ex

     

    Accept: application/json

    {
        "ex": "hello.exception.exception.UserException",
        "message": "사용자 오류"
    }

     

    Accept: text/html

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

     

    정리

    • sendError가 아닌 해당 ExceptionResolver에서 예외를 처리합니다.
    • 따라서, 예외가 발생해도 WAS까지 예외가 전달이 되지 않고 스프링 MVC에서 처리가 끝나므로 WAS 입장에서는 정상 처리된것으로 인식합니다.
    • 이렇게 예외를 ExceptionResolver에서 모두 처리한다는 것이 핵심입니다.
    • 하지만, 매번 ExceptionResolver를 구현하기엔 번거롭고 복잡하므로 스프링은 ExceptionResolver를 제공합니다.

     

    스프링이 제공하는 ExceptionResolver

    스프링 부트가 기본으로 제공하는 ExceptionResolver의 종류는 다음과 같이 3가지입니다.

    (HandlerExcpetionResolverComposite에 다음 순서와 우선순위로 등록합니다.)

    1. ExceptionHandlerExceptionResolver (가장 중요)
    2. ResponseStatusExceptionResolver
    3. DefaultHandlerExceptionResolver → 우선 순위가 가장 낮다.

     

    ResponseStatusExceptionResolver

    • 해당 ExceptionResolver는 예외의 HTTP 상태 코드를 변경해줍니다.
    • 다음 두 가지 경우를 처리합니다.
      1. @ResponseStatus가 붙어있는 예외
      2. ResponseStatusException 예외

    1. @ResponseStatus

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

    ApiController - BadRequestException 발생

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

    실행

    http://localhost:8080/api/response-status-ex1?message=

    {
        "timestamp": "2023-09-27T12:23:00.043+00:00",
        "status": 400,
        "error": "Bad Request",
        "exception": "hello.exception.exception.BadRequestException",
        "message": "잘못된 요청 오류",
        "path": "/api/response-status-ex1"
    }

    동작 방식

    • BadRequestException 예외가 발생하여 컨트롤러 밖으로 전달되면, ResponseStatusExceptionResolver가 해당 애노테이션을 확인하여 오류 코드를 HttpStatus.BAD_REQUEST(400) 으로 변경하고, reason에 해당하는 값도 메시지에 담습니다.
    • ResponseStatusExceptionResolver 코드를 확인해보면, response.sendError(statusCode, resolvedReason)를 호출하는 것을 확인할 수 있습니다.
    • sendError(400)을 호출했기 때문에 WAS에서 다시 오류 페이지 (/error)를 내부 요청합니다.(accept 헤더가 application/json이라면 json을 리턴합니다.)

     

    부가 기능: 메시지 코드화

    reason을 MessageSource에서 찾는 기능을 제공합니다.

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

    resources/messages.properties

    error.bad=잘못된 요청 오류입니다. 메시지 사용

    실행

    {
        "timestamp": "2023-09-27T12:31:39.803+00:00",
        "status": 400,
        "error": "Bad Request",
        "exception": "hello.exception.exception.BadRequestException",
        "message": "잘못된 요청 오류입니다. 메시지 사용",
        "path": "/api/response-status-ex1"
    }

     

    문제점

    • @ResponseStatus는 개발자가 직접 정의한 예외에만 넣을 수 있습니다. 즉, 개발자가 수정할 수 없는 라이브러리 예외 코드에는 적용할 수 없습니다.
    • 이 때, ResponseStatusException 을 사용하면 됩니다.

     

    2. ResponseStatusException

    ApiExceptionController

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

    실행

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

    {
        "timestamp": "2023-09-27T12:41:30.061+00:00",
        "status": 404,
        "error": "Not Found",
        "exception": "org.springframework.web.server.ResponseStatusException",
        "message": "잘못된 요청 오류입니다. 메시지 사용",
        "path": "/api/response-status-ex2"
    }

     

     

    DefaultHandlerExceptionResolver

    • DeafultHandlerExceptionResolver는 스프링 내부에서 발생한 예외를 일부 해결합니다.
    • 예를 들어 바인딩 타입이 맞지 않을 때 발생하는 TypeMismatchException의 경우, WAS로 예외가 전달되면 500오류가 발생합니다. 
    • 하지만 파라미터 바인딩은 대부분 클라이언트가 HTTP 요청 호출을 잘못하여 발생하는 경우가 많으므로, HTTP 스펙에 따르면 상태 코드가 400이 더 적절합니다.
    • DefaultHandlerExceptionResolver는 이런 경우 500이 아니라 400오류 코드로 변경합니다.
    • 이외에도 DefaultHandlerExceptionResolver에는 스프링 내부 오류를 어떻게 처리할지 많은 내용이 정의되어 있습니다.

    내부 코드

    response.sendError(HttpServletResponse.SC_BAD_REQUEST) (400)
    • 결국, response.sendError()를 통해 문제를 해결합니다.
    • sendError(400)을 호출했기 때문에, WAS에서 다시 오류 페이지(/error)를 요청합니다.

     

    ApiController - TypeMismatchException 발생

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

    실행

    • Integer 타입인 data에 String 타입인 "hello" 입력
    • http://localhost:8080/api/default-handler-ex?data=hello&message=
    {
        "timestamp": "2023-09-27T12:59:09.678+00:00",
        "status": 400, "error": "Bad Request",
        "exception": "org.springframework.web.method.annotation.MethodArgumentTypeMismatchException",
        "message": "Failed to convert value of type 'java.lang.String' to required type 'java.lang.Integer'; nested exception is java.lang.NumberFormatException: For input string: \"hello\"",
        "path": "/api/default-handler-ex" }

     

    실행 결과를 보면 DeafultHandlerExceptionResolver가 동작해, 500이 아닌 400 상태 코드인 것을 확인할 수 있습니다.

     

     

    ExceptionHandlerExceptionResolver (가장 중요)

    직접 정의한 MyExceptionResolver의 문제점

    • ModelAndView 반환
      • API 응답에 필요하지 않은 ModelAndView를 생성해 반환해야 합니다.
    • HttpServeltResponse에 응답값 직접 입력
      • 마치 과거 서블릿을 사용하면 시절처럼 직접 Map을 생성하고, objectMapper를 통해 json으로 변환하는 등 번거로운 작업을 거쳤습니다.
    • 특정 컨트롤러에서 발생한 예외 별도 처리 불가
      • 예를 들어 회원 컨트롤러에서 발생한 RuntimeException과 상품 컨트롤러에서 발생한 RuntimeException을 다르게 처리하고 싶을 경우 방법이 없습니다.

     

    해결책: @ExceptionHandler

    • 스프링 부트가 제공하는 @ExceptionHandler를 통해 위의 문제를 해결할 수 있습니다.
    • 이것이 ExceptionHandlerExceptionResolver입니다.
    • 스프링 부트가 기본으로 제공하는 ExceptoinResolver 중 우선 순위가 가장 높습니다.
    • 실무에서 API 예외 처리는 대부분 이 기능을 사용합니다.

    ErrorResult - API 응답 객체 생성

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

    ApiExceptionV2Controller

    @Slf4j
    @RestController
    public class ApiExceptionV2Controller {
    
        /**
         * 예외 처리
         */
        @ResponseStatus(HttpStatus.BAD_REQUEST)
        @ExceptionHandler(IllegalArgumentException.class)
        public ErrorResult illegalExHandle(IllegalArgumentException e) {
            log.error("[exceptionHandle] ex", e);
            return new ErrorResult("BAD", e.getMessage());
        }
        
        @ExceptionHandler
        public ResponseEntity<ErrorResult> userExHandle(UserException e) {
            log.error("[exceptionHandle] 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 exHandle(Exception e) {
            log.error("[exceptionHandle] ex", e);
            return new ErrorResult("EX", "내부 오류");
        }
        
        /**
         * 예외 호출
         */
        @GetMapping("/api2/members/{id}")
        public MemberDto getMember(@PathVariable("id") 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

    • 해당 컨트롤러에서 처리할 예외 타입을 지정합니다. (해당 컨트롤러에서 벗어나 발생한 예외는 처리하지 않습니다.)
    • 해당 메서드 매개변수에 지정한 예외 타입와 같을 경우 생략할 수 있습니다.
    • 지정한 예외와 자식 클래스 모두 처리할 수 있습니다.

     

    우선순위

    스프링의 우선순위는 항상 자세한 것이 우선권을 갖습니다. 

    @ExceptionHandler(부모예외.class)
    public String 부모예외처리()(부모예외 e) {}
    
    @ExceptionHandler(자식예외.class)
    public String 자식예외처리()(자식예외 e) {}
    • 자식 예외가 호출된다면, 부모 예외 처리(), 자식 예외 처리() 둘 다 호출 대상이 되지만, 더 자세한 자식 예외 처리()가 호출됩니다.
    • 부모 예외가 호출되면, 당연히 부모 예외 처리()만 호출 대상이 되므로 부모 예외 처리()가 호출됩니다.

     

    다양한 예외

    다음과 같이 다양한 예외를 한 번에 처리할 수 있습니다.

    @ExceptionHandler({AException.class, BException.class})
    public String ex(Exception e) {
        log.info("exception e", e);
    }
    참고 파라미터와 응답
    @ExceptionHandler에는 마치 스프링의 컨트롤러처럼 다양한 파라미터와 응답을 지정할 수 있습니다.
    공식 메뉴얼 - https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-annexceptionhandler-args

    실행

    http://localhost:8080/api2/members/bad

    400Bad Request
    {
        "code": "BAD",
        "message": "잘못된 입력 값"
    }

    실행 흐름

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandle(IllegalArgumentException e) {
        log.error("[exceptionHandle] ex", e);
        return new ErrorResult("BAD", e.getMessage());
    }
    • 컨트롤러에서 IllegalArgumentException 예외가 발생합니다.
    • 서블릿에게 전달이 되고 ExceptionResolver가 동작합니다. 그 중, 가장 우선 순위가 높은 ExceptionHandlerExceptionResolver가 실행됩니다.
    • ExceptoinHandlerExceptionResolver는 해당 컨트롤러에 IllegalArgumentException을 처리할 수 있는 @ExceptionHandler가 있는지 확인합니다.
    • 있다면, illegalHandle()를 실행합니다. 해당 메서드는 @RestController이므로 @ResponseBody가 적용되어 HTTP 컨버터가 사용이 되므로 JSON을 반환합니다.
    • 또한, @ResponseStatus(HttpStatus.BAD_REQUEST)를 지정했으므로, HTTP 상태 코드 400을 응답합니다. 

     

    기타 - HTML 오류 화면

    다음과 같이 ModelAndView를 사용해 오류 화면(HTML)을 응답하는데 사용할 수도 있습니다.

    @ExceptionHandler(ViewException.class)
    public ModelAndView ex(ViewException e) {
        log.info("exceptoin e", e);
        return new ModelAndView("error")
    }

     

    @ControllerAdvice

    • @ExceptionHandler를 통해 예외를 깔끔하게 처리할 수 있게 되었지만, 한 가지 문제점이 있습니다.
    • 정상 코드와 예외 처리 코드가 하나의 컨트롤러에 섞여 있습니다.
    • 이를 @ControllerAdvice 또는 @RestControllerAdvice를 사용하여 해결할 수 있습니다.
    • @RestControllerAdvice는 @RestController + @ControllerAdvice 입니다.

     

    ExControllerAdvice - @RestControllerAdvice

    @Slf4j
    @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());
        }
    
        @ExceptionHandler
        public ResponseEntity<ErrorResult> userExHandle(UserException e) {
            log.error("[exceptionHandle] 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 exHandle(Exception e) {
            log.error("[exceptionHandle] ex", e);
            return new ErrorResult("EX", "내부 오류");
        }
    }
    • @ControllerAdvice는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler, @InitBinder 기능을 부여하는 역할을 합니다.
    • @ControllerAdvice에 대상을 지정하지 않으면 모든 컨트롤러에 적용됩니다. (글로벌 적용)
    • @RestControllerAdvice는 @RestController + @ControllerAdvice입니다.

     

    대상 컨트롤러 지정 방법

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

     

     

    결론

    @ExceptionHandler와 @ControllerAdvice 조합을 사용하여 예외를 깔끔하게 해결할 수 있습니다.

     


    출처

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

     

    스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 인프런 | 강의

    웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습할 수 있

    www.inflearn.com