본문 바로가기
Spring

[Spring] API 예외 처리(API exception handling)

by jinjin98 2022. 10. 20.

지난 번 포스팅에서 예외 처리와 오류 페이지에 대해서 알아보았는데요.

이번에는 API 예외 처리에 대해 알아보겠습니다. HTML 페이지의 경우 4xx, 5xx와 같은 오류 페이지만 있으면

HTTP 상태 코드나 예외에 관한 대부분의 문제를 해결할 수 있었습니다. 그런데 API의 경우에는 생각할 내용이 더

많습니다. 오류 페이지는 단순히 클라이언트에게 오류 화면을 보여주고 끝이지만, API는 안드로이드나 ios 에서 앱으로

서버를 호출, 기업들간의 또는 마이크로 서비스의 서버 시스템들끼리 통신하는 등 클라이언트에게 보여주는 화면이

아니기 때문에 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 정확한 데이터를 내려주어야 합니다.

 

바로 코드로 살펴보겠습니다.

 

@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 상태 코드가 404 면 /error-page/404 url 요청 HTTP 상태 코드가 500 이거나 RuntimeExeption 예외가

발생하면 /error-page/500 url 요청을 하도록 설정했습니다.

 

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

 

그리고 /api/members/{id} url 요청을 받는 컨트롤러를 생성하고 json 으로 응답하기 위해

DTO(Data Transfer Object)클래스도 생성했습니다. 이 때  url 에 전달된 id 의 값이 ex 이면 예외가 발생하도록

코드를 작성해놨습니다. 포스트맨으로 테스트를 해보겠습니다.  이 떄 요청 헤더의 Accept 를 application/json 으로

해줍니다

 

 

이렇게 예외를 발생시키는 ex 외에 다른 값을 id 값으로 주면 json 으로 정상 응답하지만

 

 

ex 로 id 값을 전달하면 예외가 발생하며 json 으로 응답하는게 아닌

미리 만들어둔 오류 페이지 HTML 로 응답하게 됩니다.

 

@Slf4j
@Controller
public class ErrorPageController {

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

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

 

 

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<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>

 

WebServerCustomizer 에서 RuntimeExeption 예외가 발생하면 /error-page/500 url 요청을 하도록 설정해

ErrorPageController 에서 요청을 받아 기존에 만들어둔 오류 페이지 /error-page/500 을 호출하도록 했는데요.

이 오류 페이지는 일반 에외가 발생했을 때 호출하기 위한 목적으로 생성했지 API 예외가 발생했을 때 호출하려는

것이 아닙니다.  API 통신에서는 정상 요청이든 오류 요청이든 JSON 이 반환되어야 합니다.

 

 

스프링이 제공하는 BasicErrorController 에서는 위와 같은 문제를 해결할 수 있습니다. BasicErrorController

코드에서는 /error 동일한 경로를 처리하는 errorHtml() , error() 두 메서드를 확인할 수 있습니다.

errorHtml() : produces = MediaType.TEXT_HTML_VALUE 는 클라이언트 요청의 Accept 해더 값이 text/html 인

경우에 errorHtml() 을 호출해서 view 를 제공합니다.

error() : 그외 경우에 호출되고 ResponseEntity 로 HTTP Body에 JSON 데이터를 반환합니다.

이전 포스팅에서도 설명했지만 스프링 부트의 기본 설정은 오류 발생시 /error 를 오류 페이지로 요청합니다.

BasicErrorController 는 이 경로를 기본으로 받으며 클라이언트 요청의 Accept 해더 값이 text/html 인 경우에는

개발자가 생성한 오류 페이지중 파일 이름이 더 구체적인 것을 찾아 호출합니다.

 

BasicErrorController 가 정말 잘 동작하는지 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);
    }
}

 

 

BasicErrorController 가 잘 동작해 예외가 발생해도 json 으로 응답하는 것을 확인할 수 있습니다.

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

다음 옵션들을 application.properties 파일에 설정하면 더 자세한 오류 정보를 추가할 수 있습니다.

server.error.include-binding-errorss=never : errors 포함 여부

server.error.include-exception=false : exception 포함 여부( true , false )

server.error.include-message=never : message 포함 여부

server.error.include-stacktracee=never : trace 포함 여부

기본 값이 never 인 부분은 다음 3가지 옵션을 사용할 수 있습니다.

never, always, on_param never : 사용하지 않음

always :항상 사용

on_param : 파라미터가 있을 때 사용

오류 메시지는 이렇게 막 추가하면 보안상 위험할 수 있기 때문에

간결한 메시지만 노출하고, 로그를 통해서 확인해야 합니다.

 

스프링 부트가 제공하는 BasicErrorController 는 HTML 페이지를 제공하는 경우에는 매우 편리하지만

API 오류 처리는 API 마다, 각각의 컨트롤러나 예외마다 서로 다른 응답 결과를 출력해야 하기 때문에

BasicErrorController 만 사용해서 처리하는 것은 적합하지 않습니다.

예외가 발생해서 서블릿을 넘어 WAS(Web Application Server) 까지 예외가 전달되면 HTTP 상태코드가

500으로 처리됩니다. 발생하는 예외에 따라서 400, 404 등등 다른 상태코드도 처리하고 오류 메시지, 형식등을

API마다 다르게 처리하고 싶다면 어떻게 해야 할까요?

 

@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("잘못된 입력 값");
        }
    }
    
    return new MemberDto(id, "hello " + id);
}

 

 

예를 들어 클라이언트가 잘못된 요청을 해서 IllegalArgumentException 예외가 컨트롤러에서 발생해서 

WAS 까지 전달되고 HTTP 상태코드가 500으로 처리되는데 이 때는  서버 문제가 아닌 클라이언트 문제이므로

 HTTP 상태코드를 400으로 처리하고 싶습니다.

스프링 MVC 는 컨트롤러(핸들러) 밖으로 예외가 던져진 경우 예외를 해결하고, 동작을 새로 정의할 수 있는 방법을

제공합니다. 컨트롤러 밖으로 던져진 예외를 해결하고, 동작 방식을 변경하고 싶으면 HandlerExceptionResolver

사용 하면 됩니다. 줄여서 ExceptionResolver 라고 합니다.

 

 

 

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

postHandle() 은 인터셉터에 있는 메서드로 컨트롤러에서 예외가 발생하면 실행되지 않는 메서드입니다.

 

 

HandlerExceptionResolver 인터페이스의 코드입니다. 

handler : 핸들러(컨트롤러) 정보를 갖고있는 객체입니다.

Exception ex : 핸들러(컨트롤러)에서 발생한 예외 정보를 갖고있는 객체입니다.

 

@Slf4j
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {

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

        log.info("call resolver", 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) {//sendError가 IOException으로 checkedexception으로 되어있어서 잡아줘야함
            log.error("resolver ex", e);
        }

        return null;
    }
}

 

HandlerExceptionResolver 를 구현해  IllegalArgumentException 예외를 잡는 ExceptionResolver 를

생성했습니다. 여기서 resolveException() 메서드를 보면 ModelAndView 가 반환타입인데요.

ExceptionResolver 가 ModelAndView 를 반환하는 이유는 마치 try, catch를 하듯이, 예외를 처리해서 정상 흐름

처럼 변경하는 것이 목적입니다.  이름 그대로 Exception 을 Resolver(해결)하는 것이 목적이죠.

IllegalArgumentException 예외가 발생한다면 HttpServletResponse 응답 객체 response 에 sendError()

메서드로  HTTP 상태 코드를 400 으로 설정하고 빈 ModelAndView 를 반환합니다. response  객체로는

response.getWriter().println("hello"); 처럼 HTTP 응답 바디에 직접 데이터를 넣어주는 것도 가능합니다. 여기에

JSON 으로 응답하면 API 응답 처리를 할 수 있습니다. 빈 ModelAndView 를 반환하면 뷰를 렌더링 하지 않고,

정상 흐름으로 서블릿이 리턴 합니다.  ModelAndView 에 View , Model 등의 정보를 지정해서 반환하면 뷰를

렌더링합니다. null 을 반환하면, 다음 ExceptionResolver 를 찾아서 실행합니다. 만약 처리할 수 있는

ExceptionResolver 가 없으면 예외 처리가 안되고, 기존에 발생한 예외를 서블릿 밖으로 던집니다. 이렇게

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

이후 WAS는 서블릿 오류 페이지를 찾아서 내부 호출합니다. 예를 들어서 스프링 부트가 기본으로 설정한 /error 가

호출됩니다.

 

@Configuration
public class WebConfig implements WebMvcConfigurer {

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

 

생성한 ExceptionResolver 는 WebMvcConfigurer 인터페이스를 구현하고 빈으로 등록되는 클래스에 추가해야

합니다. 이 떄 configureHandlerExceptionResolvers() 메서드로 추가하면 스프링이 기본으로 등록하는

ExceptionResolver 제거되므로 extendHandlerExceptionResolvers() 메서드를 사용해 등록합니다.

 

 

ExceptionResolver 를 생성하고 등록한 후 다시 /api/members/bad 를 요청하니 HTTP 상태 코드가 400으로

응답된 것을 확인할 수 있습니다. 이렇게 컨트롤러에서 예외가 발생했을 떄 개발자가 원하는대로 

HTTP 상태 코드를 설정할  수 있지만 예외가 발생하면 WAS까지 예외가 던져지고, WAS에서 오류 페이지 정보를 찾아서

다시 /error 를 호출하는 과정은 너무 복잡한거 같습니다. ExceptionResolver 를 활용하면 예외가 발생했을 때

이런 복잡한 과정 없이 여기에서 문제를 깔끔하게 해결할 수 있습니다.

 

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

 

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

 

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

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

                } else {
                    return new ModelAndView("error/500");
                }
            }

        } catch (IOException e) {
            log.error("resolver ex", e);
        }

        return null;
    }
}

 

예외 클래스 UserException 를 생성하고  /api/members/user-ex 을 요청하면 컨트롤러에서 UserException 예외가

발생하도록 작성한 후 UserException 예외가 발생하면 처리해주는 UserHandlerExceptionResolver 를

생성했습니다. UserHandlerExceptionResolver 에서는 HTTP 요청 해더의 ACCEPT 값이 application/json 이면

응답 데이터를 직접 설정해서 JSON으로 오류를 내려주고, 그 외 경우에는 error/500에 있는 HTML 오류 페이지를

보여주도록 했습니다.

 

@Configuration
public class WebConfig implements WebMvcConfigurer {

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

 

UserHandlerExceptionResolver 도 HandlerExceptionResolver 처럼 추가해줍니다.

 

 

그리고 /api/members/user-ex 를 요청하면 UserHandlerExceptionResolver 에서 설정한대로

json 데이터가 응답하는걸 확인할 수 있습니다. 보시다시피 ExceptionResolver 를 사용하면 컨트롤러에서 예외가

발생해도 ExceptionResolver 에서 예외를 처리해버립니다. 따라서 예외가 발생해도 서블릿 컨테이너까지 예외가

전달되지 않고, 스프링 MVC 에서 예외 처리는 끝이 납니다. 결과적으로 WAS 입장에서는 정상 처리가 된 것입니다.

ExceptionResolver 를 사용해서 예외처리를 깔끔하게 할 수는 있었지만 직접 ExceptionResolver 를 구현해서

사용하니 직접 응답 데이터를 넣어주는 등 상당히 복잡하고 번거로웠고 API 예외에 필요하지도 않은

ModelAndView 를 반환해야 했습니다. 그래서 지금부터는 이 문제를 해결하기 위해 스프링에서 제공하는

유용한 ExceptionResolver 들을 알아보겠습니다.

 

스프링 부트가 기본으로 제공하는 ExceptionResolver 는 다음과 같습니다.

HandlerExceptionResolverComposite 에 다음 순서로 등록됩니다.

1. ExceptionHandlerExceptionResolver

2. ResponseStatusExceptionResolver

3. DefaultHandlerExceptionResolver 우선 순위가 가장 낮습니다.

ResponseStatusExceptionResolver DefaultHandlerExceptionResolver ExceptionHandlerExceptionResolver

순서로 알아보겠습니다.

 

 ResponseStatusExceptionResolver

 

ResponseStatusExceptionResolver 는 예외에 따라서 HTTP 상태 코드를 지정해주는 역할을 합니다.

@ResponseStatus 가 달려있는 예외와 ResponseStatusException 예외를 처리하는데요.

 

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

 

@ResponseStatus 값에는 HTTP 상태 코드와 에러 메시지를 설정할 수 있는데요.

BadRequestException 예외 클래스를 생성해 @ResponseStatus 를 붙이면 BadRequestException 예외가

컨트롤러 밖으로 넘어갈 때 ResponseStatusExceptionResolver 가 해당 애노테이션을 확인해서 오류 코드를

HttpStatus.BAD_REQUEST (400)으로 변경하고, 메시지를 담습니다. ResponseStatusExceptionResolver 코드를

확인해보면 결국 response.sendError(statusCode, resolvedReason) 를 호출하는 것을 확인할 수 있습니다.

endError(400) 를 호출했기 때문에 WAS에서 다시 오류 페이지( /error )를 내부 요청합니다.

 

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

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

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

 

 

BadRequestException 예외를 발생시키는 컨트롤러 메서드를 추가하고 /api/response-status-ex1 요청을 하니

@ResponseStatus 에서 설정한 HTTP 상태 코드와 메시지가 json 으로 잘 응답된 것을 확인할 수 있습니다.

 

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

 

 message.properties

error.bad=잘못된 요청 오류

 

@ResponseStatus 에서 메시지를 설정하는 속성인 reason 은 MessageSource 에서 메시지를 코드로  찾는 기능도

제공합니다. reason 값에 message.properties 파일에 작성한 코드을 넣으면 코드에 해당하는 값을 메시지로

설정합니다.

 

그런데 @ResponseStatus 는  BadRequestException 예외처럼 개발자가 직접 생성한 예외 클래스이외에는 적용을

시킬 수가 없습니다. 기본적으로 생성되있는 예외 클래스는 코드를 수정할 수가 없기 때문입니다. 또한 애노테이션을

사용하기 때문에 조건에 따라 동적으로 변경하는 것도 어렵습니다. 이때는 ResponseStatusException 예외를

사용하면 됩니다.

 

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

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

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

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

 

ResponseStatusException 예외를 발생시키는 컨트롤러 메서드를 추가했습니다. ResponseStatusException

생성자에는 HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException()를 인자로 넣었는데요.

IllegalArgumentException 예외가 발생하면 HTTP 상태 코드를 404,

에러 메시지는 error.bad 로 설정하겠다는 의미입니다.

 

̱ DefaultHandlerExceptionResolver

 

DefaultHandlerExceptionResolver 는 스프링 내부에서 발생하는 스프링 예외를 해결합니다. 대표적으로 파라미터

바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException 이 발생하는데, 이 경우 예외가 발생했기 때문에

그냥 두면 서블릿 컨테이너까지 오류가 올라가고, 결과적으로 500 오류가 발생한 그런데 파라미터 바인딩은 대부분

클라이언트가 HTTP 요청 정보를 잘못 호출해서 발생하는 문제이므로 HTTP 상태 코드 400을 사용하도록 해야합니다.

DefaultHandlerExceptionResolver 는 이것을 500 오류가 아니라 HTTP 상태 코드를 400 오류로 변경합니다.

스프링 내부 오류를 어떻게 처리할지 수 많은 내용이 정의되어 있습니다.

 

 

DefaultHandlerExceptionResolver 의 일부 코드입니다. 앞에서 설명한대로 TypeMismatchException 예외가

발생하면 HTTP 상태 코드를 400 오류로 변경합니다. 결국 response.sendError() 를 통해서 문제를 해결하기 때문에

WAS에서 다시 오류 페이지( /error )를 내부 요청합니다.

 

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

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

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

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

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

 

 

TypeMismatchException 예외를 발생시키 위해 defaultException 이라는 컨트롤러 메서드를 추가하고

바인딩되는 파라미터 타입이 정수형과 다르게 문자열 값을 요청했습니다. DefaultHandlerExceptionResolve 가

작동해 HTTP 상태 코드 400 에러 메시지는 따로 설정해주지 않아 기존에 설정된 에러 메시지로 응답하는 것을

확인할 수 있었습니다.

 

 ExceptionHandlerExceptionResolver

 

스프링은 API 예외 처리 문제를 해결하기 위해 @ExceptionHandler 라는 애노테이션을 사용하는 매우 편리한

예외 처리 기능을 제공하는데, 이것이 바로 ExceptionHandlerExceptionResolver 입니다.

스프링은 ExceptionHandlerExceptionResolver 를 기본으로 제공하고, 기본으로 제공하는 ExceptionResolver 중에

우선순위도 가장 높습니다. API 예외 처리는 대부분 이 기능을 사용합니다.

 

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

 

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

}

 

API 예외가 발생하면 응답하는 json 스펙을 가진 DTO 클래스를 생성하고 컨트롤러를 새로 생성했습니다.

@ExceptionHandler 예외 처리 방법은 @ExceptionHandler 애노테이션을 선언하고, 해당 컨트롤러에서 처리하고

싶은 예외를 지정해주기만 하면 됩니다. 해당 컨트롤러에서 예외가 발생하면 ExceptionHandlerExceptionResolver

@ExceptionHandler  붙은 메서드중 발생한 예외와 매칭되는 것을 찾아 호출합니다. 참고로 지정한 예외 또는

그 예외의 자식 클래스까지 모두 잡을 수 있습니다. 현재 컨트롤러에서는 @RestController 를 사용해 모든 컨트롤러

메서드에 @ResponseBody 가 적용되어 @ResponseStatus 로 HTTP 상태 코드를 지정할 수 있고 userExHandle()

메서드의 경우 ResponseEntity 를 사용해서 동적으로 HTTP 상태 코드를 지정할 수 있습니다. @ExceptionHandler

에 예외를 지정해주지 않으면 컨트롤러 메서드의 파라미터로 지정한 예외가 발생했을 때 실행하게 됩니다.

exHandle() 메서드는 @ExceptionHandler 에 예외를 지정해주지 않아 메서드 파라미터에 지정한 Exception 예외나

그 자식 예외가 발생했을 때 실행됩니다. 그래서 위 컨트롤러에서 RuntimeException 예외가 발생하면

exHandle() 메서드가 실행됩니다. 그런데 스프링의 우선순위는 항상 자세한 것이 우선권을 가지므로

IllegalArgumentException 예외가 발생하면 illegalExHandle() 메서드가 UserException 예외가 발생하면

userExHandle() 메서드가 실행됩니다.

 

@ExceptionHandler({AException.class, BException.class})
public String ex(Exception e) {
    log.info("exception e", e);
}

 

@ExceptionHandler 는 다음 코드와 같이 다양한 예외를 한번에 처리할 수도 있으며

 

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

 

반환타입으로 ModelAndView 를 사용해서 오류 화면(HTML)을 응답하는데 사용할 수도 있습니다.

 

 

 

 

컨트롤러를 실행해보면 ExceptionHandlerExceptionResolver 와 @ExceptionHandler 가 잘 동작되는 것을

확인할 수 있습니다. @ExceptionHandler 를 사용해서 예외를 깔끔하게 처리할 수 있게 되었지만, 정상 코드와

예외 처리 코드가 하나의 컨트롤러에 섞여 있습니다. @ControllerAdvice 또는 @RestControllerAdvice

사용하면 둘을 분리할 수 있습니다.

 

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

    @GetMapping("/api3/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;
    }

}

 

이렇게  사용해서 컨트롤러와 컨트롤러에서 발생하는 예외를 처리하는 로직을 분리시킬 수가 있습니다.

@ControllerAdvice 는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler , @InitBinder 기능을 부여해주는

역할을 합니다. @ControllerAdvice 에 대상을 지정하지 않으면 모든 컨트롤러에 적용됩니다.

@RestControllerAdvice 는 @ControllerAdvice 와 같고, @ResponseBody 가 추가되어 있습니다.

@Controller , @RestController 의 차이와 같습니다.

 

//@RestController 가 있는 모든 컨트롤러
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}

//controllers 패키지에 있는 컨트롤러
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}

//ControllerInterface AbstractController 컨트롤러
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class ExampleAdvice3 {}

 

위 코드와 같이 어노테이션이나 패키지명을 지정, 또는 특정 클래스를 직접 지정해서 컨트롤러에 적용시킬 수 있습니다.

댓글