728x90
반응형
SMALL

참고자료

 

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

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

www.inflearn.com

 

이것도 결론부터 말하자면, @ExceptionHandler, @ControllerAdvice로 API 예외 처리를 하면 된다. 근데 이 것들을 사용하기 앞서, API 예외 처리를 해 온 역사를 하나씩 알아보면서 이 두개의 애노테이션이 어떻게 끝판왕이 될 수 있는지를 좀 이해해보자.

API 예외 처리 - 시작

저번 포스팅에서는 뷰(오류 페이지 화면)예외 처리에 대해 알아보았다. 스프링 부트를 사용해서 아주 간단하게 오류 페이지 관련된 HTML 파일만 만들면 됐었다. API는 어떻게 처리해야 할까?

 

API는 생각할 내용이 더 많다. 오류 페이지는 단순히 고객에게 오류 화면을 보여주고 끝이지만, API는 각 오류 상황에 맞는 오류 응답 스펙을 정의하고, JSON으로 데이터를 내려주어야 한다.

 

이 역시 서블릿이 처리하는 예외 처리 방식부터 한번 시작해보자.

WebServerCustomizer

package cwchoiit.exception;

import org.springframework.boot.web.server.ErrorPage;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.boot.web.servlet.server.ConfigurableServletWebServerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;

@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableServletWebServerFactory> {

    @Override
    public void customize(ConfigurableServletWebServerFactory 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);
    }
}

이 코드를 다시 살려서, 등록해 놓으면 WAS에 예외가 전달되거나, response.sendError(...)가 호출되면 위 코드에서 등록한 예외 페이지 경로가 호출된다. 이제 API 관련 컨트롤러 하나를 만들자.

 

ApiExceptionController

package cwchoiit.exception.api;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
public class ApiExceptionController {

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

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

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

단순히 회원을 조회하는 기능을 하나 만들고, 예외 테스트를 위해 URL에 전달된 `id`값이 `ex`라면 예외가 발생하도록 코드를 심어두었다.

 

Postman으로 테스트해보자.

HTTP HeaderAcceptapplication/json인 것을 꼭 확인하자.

 

정상 케이스

정상 호출의 경우 위 사진과 같이 결과가 잘 출력된다. 이번엔 예외 케이스로 호출해보자.

 

예외 케이스

API를 요청했는데, 정상의 경우 API로 JSON 형식으로 데이터가 정상 반환된다. 그런데 오류가 발생하면 우리가 미리 만들어둔 오류 페이지 HTML이 반환된다. 이것은 기대하는 바가 아니다. 클라이언트는 정상 요청이든, 오류 요청이든 JSON이 반환되기를 기대한다. 웹 브라우저가 아닌 이상 HTML을 직접 받아서 할 수 있는 것은 별로 없다. 

 

왜 이런 일이 발생하냐면, 서블릿 오류 처리 방식으로 WebServerCustomizer를 등록했고, 거기서 500에러인 경우`/error-page/500`을 재호출하게 정의했다. 그리고 이 경로는 우리가 이전에 만든 이 컨트롤러의 경로이다.

package cwchoiit.exception.servlet;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

import java.util.HashMap;
import java.util.Map;

@Slf4j
@Controller
public class ErrorPageController {

    //RequestDispatcher 상수로 정의되어 있음
    public static final String ERROR_EXCEPTION = "javax.servlet.error.exception";
    public static final String ERROR_EXCEPTION_TYPE = "javax.servlet.error.exception_type";
    public static final String ERROR_MESSAGE = "javax.servlet.error.message";
    public static final String ERROR_REQUEST_URI = "javax.servlet.error.request_uri";
    public static final String ERROR_SERVLET_NAME = "javax.servlet.error.servlet_name";
    public static final String ERROR_STATUS_CODE = "javax.servlet.error.status_code";

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

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

    private void printErrorInfo(HttpServletRequest request) {
        log.info("ERROR_EXCEPTION: {}", request.getAttribute(ERROR_EXCEPTION));
        log.info("ERROR_EXCEPTION_TYPE: {}", request.getAttribute(ERROR_EXCEPTION_TYPE));
        log.info("ERROR_MESSAGE: {}", request.getAttribute(ERROR_MESSAGE));
        log.info("ERROR_REQUEST_URI: {}", request.getAttribute(ERROR_REQUEST_URI));
        log.info("ERROR_SERVLET_NAME: {}", request.getAttribute(ERROR_SERVLET_NAME));
        log.info("ERROR_STATUS_CODE: {}", request.getAttribute(ERROR_STATUS_CODE));
        log.info("dispatchType={}", request.getDispatcherType());
    }
}

그런데 이 컨트롤러의 경우 `error-page/500`은 HTML 뷰를 반환한다. 그래서 저런 결과가 나온것이다.

문제를 해결하려면 오류 페이지 컨트롤러도 JSON 응답을 할 수 있도록 수정해야 한다.

@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_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를 추가했다. 이건 어떤거냐면, 요청할 때 헤더에 Acceptapplication/json으로 요청할 때 호출이 된다. 클라이언트가 내가 받을 응답은 JSON 형태인것만 이해할 수 있다라고 서버에 요청할 때 던져주면 그것을 처리하는 컨트롤러가 호출되는 것이다. 

 

그래서 이대로 다시 실행해보면 아래와 같이 JSON 결과를 얻는다.

 

응답 데이터를 위해 Map을 만들고 status, message키에 값을 할당했다. Jackson 라이브러리는 Map을 JSON 구조로 변환할 수가 있다. 근데 ResponseEntity를 사용해서 응답하기 때문에 메시지 컨버터가 동작해서 클라이언트에 JSON이 반환된다. 

 

이렇게 헤더에 Accept로 어떤 값을 주냐에 따라 컨트롤러에 produces를 지정해서 그때 그때 적절한 호출이 가능하다. 참고로 Accept`*/*`로 넣어 호출하면 그냥 아무것도 없는 기존에 원래 있던 경로가 호출되서 다시 HTML 화면이 보일 것이다. 왜냐하면, 둘 중 하나는 아예 딱 지정해서 APPLICATION/JSON만 가능한 경로이기 때문에 그 외 Accept는 호출되지가 않는다. 그래서 더 넓은 범위의 처리 가능한 경로를 찾기 때문에 produces가 없는 HTML 화면을 반환하는 메서드가 실행되는 것이다. 

 

이렇게 가장 원시적인 방법으로 API 예외를 처리할 수가 있는데 솔직히 너무 불편하다. 어떤 에러일때 어떤 경로로 호출할 지 정의하는 WebServerCustomizer부터 시작해서 오류 화면을 반환해야 하는 경우와 JSON을 반환해야 하는 경우를 나눠서 처리하는 과정 등 여간 불편한게 아니다. 역시 이럴땐 스프링 부트가 도와주기 마련이다. 스프링 부트가 도와주는 방법을 하나씩 알아보자.

 

API 예외 처리 - 스프링 부트 기본 오류 처리

이제 스프링 부트가 기본으로 해주는 오류 처리를 한번 알아보자. 우선 서블릿 방식의 예외 처리를 위한 WebServerCustomizer를 다시 주석처리 하자. 아래처럼.

//@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableServletWebServerFactory> {

    @Override
    public void customize(ConfigurableServletWebServerFactory 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였다. 그래서 이 컨트롤러의 기본 경로는 `/error`였고 `src/main/resources/templates/error` 안에 404.html, 4xx.html만 만들어두면 저 컨트롤러가 알아서 호출해주는 아주 강력한 기능이었다. 스프링 부트는 API도 기본 오류 처리를 저 BasicErrorController가 해주는데 한번 보자.

 

BasicErrorController 일부

...

@RequestMapping(
    produces = {"text/html"}
)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
    HttpStatus status = this.getStatus(request);
    Map<String, Object> model = Collections.unmodifiableMap(this.getErrorAttributes(request, this.getErrorAttributeOptions(request, MediaType.TEXT_HTML)));
    response.setStatus(status.value());
    ModelAndView modelAndView = this.resolveErrorView(request, response, status, model);
    return modelAndView != null ? modelAndView : new ModelAndView("error", model);
}

@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
    HttpStatus status = this.getStatus(request);
    if (status == HttpStatus.NO_CONTENT) {
        return new ResponseEntity(status);
    } else {
        Map<String, Object> body = this.getErrorAttributes(request, this.getErrorAttributeOptions(request, MediaType.ALL));
        return new ResponseEntity(body, status);
    }
}

...

여기보면, errorHtmlerror라는 두 개의 메서드가 있다. 에러가 발생해서 이 컨트롤러를 호출하면 HTML을 호출해야 하는 경우 저 errorHtml 메서드가 실행되는거고, API 예외를 처리하는 경우 저 error 메서드가 실행되는 것이다. 보면 errorHtml 메서드에 produces"text/html" 이라고 되어 있다. 그럼 다시 한번 아까 예외 테스트를 할 때 호출했던 경로로 호출해보면 어떻게 될까?

바로 이렇게 보여진다. 이게 스프링 부트의 기본 오류 처리이다. 이게 저 BasicErrorControllererror() 메서드가 반환하는 값이 되는 것이고. 그리고 여기 보여지는 값도 오류 화면 페이지에서 막 이것저것 보여줄 수 있게 설정한 것 처럼 더 추가할 수 있다.

application.properties

server.error.include-exception=true
server.error.include-message=always
server.error.include-stacktrace=always
server.error.include-binding-errors=always

 

이것들을 추가한 다음 다시 호출해보면, 

위와 같이 쭉 뭐가 더 중요한 정보들이 나온다. 이건 사용하면 안된다고 했다. 다시 돌려 놓자.

 

스프링 부트가 기본으로 제공하는 BasicErrorController를 사용하면 뭐 다른것을 하지 않아도 이렇게 API 예외 처리도 가능하다. 그런데 HTML 파일을 반환하는 경우는 아주아주 유용하고 거의 이 기능 그대로를 가져다가 사용해서 그냥 templates/error 경로에 404, 4xx, 500.html 파일만 만들면 되지만 API는 조금 다르다. API는 공통으로 예외를 하나로 처리할 수 있는 경우가 거의 없고 회원 조회, 상품 등록, 상품 삭제 등등 각각의 API는 스펙이 천차만별이다. 그래서 각각의 API 마다마다 세밀한 스펙 정의와 함께 에러가 발생한 경우 그 에러 스펙 역시도 굉장히 가지각색이다. 

 

그래서 API 예외 처리를 BasicErrorController로 하기에는 무리가 있고, 이 기능을 사용하지 않는다. 대신에 아주 매우 강력한 기능이 있다. 그 기능을 사용할건데 그 기능을 사용하기 위해 발전해 온 역사 하나하나를 살펴보자. 

 

API 예외 처리 - HandlerExceptionResolver 시작

예외가 발생해서 서블릿을 넘어 WAS까지 예외가 전달된 경우, HTTP 상태 코드가 500으로 처리된다. 당연한 것이 WAS 입장에서는 어떤 예외가 발생했는지는 모르지만, 서버가 이 에러를 처리하지 못했으니 결국 나까지 에러가 올라왔구나 싶어서 서버의 내부 문제라고 판단하고 500으로 모든 에러를 처리해 버린다. 

 

그러나, 발생하는 예외에 따라 400, 404 등 다른 상태코드로 처리하고 싶다. 뿐만 아니라 오류 메시지, 형식등을 API마다 다르게 처리하고 싶다. 

 

상태 코드 변환

예를 들어, IllegalArgumentException을 처리하지 못해서 컨트롤러 밖으로 넘어가는 일이 발생하면 HTTP 상태 코드를 400으로 처리하고 싶다. 어떻게 해야 할까? 우선, 이 에러를 던질 수 있어야 한다. 컨트롤러를 살짝 수정해보자.

ApiExceptionController

package cwchoiit.exception.api;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
public class ApiExceptionController {

    @GetMapping("/api/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {
        if (id.equals("ex")) {
            throw new RuntimeException("ex");
        }
        if (id.equals("bad")) {
            throw new IllegalArgumentException("잘못된 입력값");
        }

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

    @Data
    @AllArgsConstructor
    static class MemberDto {
        private String memberId;
        private String name;
    }
}
  • 예외 테스트 용 컨트롤러에서 PathVariable`bad`로 넘어온 경우 IllegalArgumentException을 터트린다.

이 상태에서 그냥 바로 실행해보면 이렇게 500 에러로 발생한다.

내가 원하는건 500에러가 아니라 사용자가 입력값을 잘못 넣었다고 말해주고 싶다. 400에러를 던지고 싶다.

이럴때 사용할 수 있는 것 중 하나가 HandlerExceptionResolver이다.

 

HandlerExceptionResolver를 사용하면, 다음 흐름으로 진행된다.

  • 컨트롤러에서 예외가 발생한다.
  • 예외가 DispatcherServlet까지 올라온다.
  • 이 예외를 처리하기 위한 HandlerExceptionResolver를 찾는다.
  • 찾았다면 해당 HandlerExceptionResolver에서 처리하는 예외 처리를 수행한다.
  • 예외를 처리했으니 WAS에는 정상 응답으로 반환된다.
참고로, 이 HandlerExceptionResolver를 사용해도 인터셉터의 postHandle은 호출되지 않는다. 이 postHandle은 컨트롤러 레이어에서 예외가 발생하면 그냥 무조건 호출이 안되는 메서드이다.

 

사실 말로만 하면 무슨말인지 백퍼센트 감이 오지 않는다. 직접 사용해보자.

MyHandlerExceptionResolver

package cwchoiit.exception.resolver;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;

import java.io.IOException;

@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;
    }
}
  • HandlerExceptionResolver를 구현하는 클래스를 만든다.
  • 여기서 구현해야 하는 메서드인 resolveException을 구현한다.
  • 이 메서드 안에서 올라온 예외 타입마다 처리하는 방식을 분개한다. (한 클래스에서 해도 되고 클래스를 여러개 나누어서 모두 등록해도 상관은 없다)
  • 만약, 올라온 예외 타입이 IllegalArgumentException인 경우, response.sendError(400, 예외 메시지)를 호출하여 WAS에게 전달한다.
  • 비어있는 ModelAndView 객체를 반환한다.

WAS에게 response 객체 안에 400에러를 담아서 전달했으니 WAS는 이제 내부적으로 이 400에러를 처리할 수 있는 설정을 찾을 것이다. 지금 우리는 WebServerCustomizer와 같이 따로 커스텀하여 구현한 예외 처리 방식이 없기 때문에 스프링 부트가 기본으로 제공하는 BasicErrorController가 처리하게 된다. 그리고 이때, 요청 헤더의 AcceptAPPLICATION/JSON이므로 API 예외 처리 방식으로 원하는 상태코드와 메시지가 나가게 되는 것이다.

 

이 메서드는 반환 할 수 있는 방식이 3가지이다.

  • new ModelAndView(): 빈 ModelAndView 객체를 반환하면, API 예외 처리 방식으로 진행된다.
  • 꽉 찬 ModelAndView(): 실제로 ModelView를 꽉 채워서 반환하면 우리가 원하는 뷰를 반환한다. 예를 들면 4xx.html을 반환하게 할 수 있다. 
  • null: null을 반환하는 경우, 이 HandlerExceptionResolver가 해결할 수 없는 예외라 판단하고 WAS까지 예외가 올라간다. 그래서 기존 예외가 그대로 던져진다.

HandlerExceptionResolver를 이제 등록만 하면 된다.

WebConfig

package cwchoiit.exception;

import cwchoiit.exception.interceptor.LogInterceptor;
import cwchoiit.exception.resolver.MyHandlerExceptionResolver;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

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

    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new MyHandlerExceptionResolver());
    }
}
  • WebMvcConfigurer를 구현하는 구현체에서 extendHandlerExceptionResolvers 메서드를 구현한다. 여기서 원하는 HandlerExceptionResolver를 추가하면 된다.

이렇게 한 후 실행해보면, 원하는 상태코드로 반환이 가능해진다.

API 예외 처리 - HandlerExceptionResolver 활용

그런데, 보면 알겠지만 WAS까지 예외가 던져지고, WAS에서 오류 페이지 정보를 찾아서 다시 `/error`를 호출하는 과정은 생각해보면 너무 비효율적이며 복잡하다. HandlerExceptionResolver를 활용하면 예외가 발생했을 때 이런 복잡한 과정 없이 여기에서 문제를 깔끔하게 해결할 수 있다. 

 

커스텀 예외 하나를 추가해보자.

UserException

package cwchoiit.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

package cwchoiit.exception.api;

import cwchoiit.exception.exception.UserException;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
public class ApiExceptionController {

    @GetMapping("/api/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {
        if (id.equals("ex")) {
            throw new RuntimeException("ex");
        }
        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;
    }
}
  • 이제 PathVariable `user-ex`가 들어오는 경우 방금 새로 만든 UserException이 호출되도록 했다.

이렇게 해 두고 HandlerExceptionResolver를 어떻게 사용할 수 있냐면, 다음과 같이 작성해보자.

UserHandlerExceptionResolver

package cwchoiit.exception.resolver;

import com.fasterxml.jackson.databind.ObjectMapper;
import cwchoiit.exception.exception.UserException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@Slf4j
public class UserHandlerExceptionResolver implements HandlerExceptionResolver {

    private static 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 accept = request.getHeader("accept");
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);

                if (accept != null && accept.contains("application/json")) {
                    Map<String, Object> errorResults = new HashMap<>();
                    errorResults.put("message", ex.getMessage());
                    errorResults.put("ex", ex.getClass());

                    response.setContentType("application/json");
                    response.setCharacterEncoding("UTF-8");
                    response.getWriter().write(objectMapper.writeValueAsString(errorResults));
                    return new ModelAndView();
                } else {
                    return new ModelAndView("error/500");
                }
            }
        } catch (IOException e) {
            log.error("Error while resolving exception", e);
        }
        return null;
    }
}
  • 던져진 예외가 UserException인 경우를 체크한다.
  • 요청 헤더의 Accept값에 따라, 오류 페이지를 보여줄지 API 예외JSON 응답을 할지를 선택한다.
  • JSON의 경우:
    • 응답 상태 코드를 400으로 지정한다.
    • JSON으로 처리해야 하는 경우, 응답 메시지로 `message`, `ex`정보를 담는다. 그리고 Content-Type, CharacterEncoding 정보를 각각 `application/json`, `UTF-8`로 지정한다.
    • getWriter()를 사용해서 그냥 여기서 응답 결과를 다 만들어서 WAS에게 전달한다.
    • ModelAndView()를 반환한다.
  • 오류 페이지를 보여주는 경우:
    • 그냥 ModelAndView `error/500` 페이지를 호출하면 된다. (만약, 400으로 던지고 싶다면 `error/400` 또는 `error/4xx` 던지면 된다)

이렇게 이 HandlerExceptionResolver 안에서 응답 처리를 모두 다 해서 WAS에게 전달을 해주면, WAS는 만들어져있는 그 응답 처리 결과를 그대로 내보낸다. 그러면 sendError(...)와 달리, 다시 필터부터 서블릿으로 거슬러 올라가는 그 행위를 하지 않아도 된다.

 

이제 이 HandlerExceptionResolver를 또 등록해야 한다.

package cwchoiit.exception;

import cwchoiit.exception.interceptor.LogInterceptor;
import cwchoiit.exception.resolver.MyHandlerExceptionResolver;
import cwchoiit.exception.resolver.UserHandlerExceptionResolver;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

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

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

이렇게 하고 실행해보자.

응답 결과는 우리가 원하는대로 잘 나온다. 그리고 이게 정말 다시 거슬러 올라가서 예외를 처리하는 컨트롤러인 BasicErrorController를 재호출하는 과정이 없는지 보자. 예전에 로그를 찍는 인터셉터를 등록했으니까 그 결과를 보면 된다.

afterCompletion에서 예외가 발생한 경우 예외를 로그로 출력했는데 그 로그가 출력되지 않았다. 즉, 예외를 그냥 HandlerExceptionResolver에서 먹어버리고 WAS에게 어떻게 응답을 내보낼지 모든것을 다 정의해서 전달한 것이다.

 

이렇게 활용해서 비효율적인 서버 내부적인 재호출을 막을 순 있다. 그러나, 일단 이 방식은 사용하지 않을 것이다. 영원히. 왜냐하면 이것 또한 너무 귀찮다. 요청 헤더에서 Accept 값을 찾고, 인코딩 정보, Content-Type 등등 getWriter()로 응답 메시지 처리 등 귀찮다. 이럴때 역시 스프링이 도와준다. 지금부터 스프링이 제공하는 ExceptionResolver를 알아보자.

 

API 예외 처리 - 스프링이 제공하는 ExceptionResolver 1

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

HandlerExceptionResolverComposite에 다음 순서로 등록

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

 

우선순위가 가장 높은 ExceptionHandlerExceptionResolver@ExceptionHandler를 처리하는 녀석이다. 가장 중요하고 거의 모든 에러 처리를 이것으로 할 것이기 때문에 제일 마지막에 알아보겠다. 

 

 

ResponseStatusExceptionResolver는 Http 상태 코드, 원인을 지정해서 사용하는 @ResponseStatus 애노테이션을 처리한다.

@ResponseStatus(value = HttpStatus.NOT_FOUND, reason = "못찾음")

 

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

 

ResponseStatusExceptionResolver

이 녀석은 다음 두 가지 경우를 처리한다.

  • @ResponseStatus가 달려있는 예외
  • ResponseStatusException 예외

다음과 같은 예외 하나를 만들어보자.

package cwchoiit.exception.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

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

}

이 예외를 컨트롤러 밖으로 던져버리면 ResponseStatusExceptionResolver가 해당 예외의 애노테이션을 확인해서 오류 코드를 400으로 변경하고 메시지도 담는다. 

 

테스트를 위해 컨트롤러에 아래 경로 하나를 추가하자.

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

이렇게 간단하게 응답 코드와 메시지를 출력할 수 있다. 아 참고로, 메시지를 출력하려면, application.properties에서 이 코드 추가해야 한다.

server.error.include-message=always

 

근데, 이게 뭐 별게 아니다. 직접 ResponseStatusExceptionResolver에 들어가보면, 이런 코드가 있다.

@Nullable
protected ModelAndView doResolveException(HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex) {
    try {
        ...
        ResponseStatus status = (ResponseStatus)AnnotatedElementUtils.findMergedAnnotation(ex.getClass(), ResponseStatus.class);
        if (status != null) {
            return this.resolveResponseStatus(status, request, response, handler, ex);
        }
		...
    } catch (Exception var8) {
        ...
    }
    return null;
}
  • 여기서 지금 반환 타입 ModelAndView이다. 우리가 직접 해봤던 그 HandlerExceptionResolver랑 똑같은 짓을 하고 있는거다.
  • 그리고 AnnotatedElementUtils.findMergedAnnotation(...)이걸 사용해서 ResponseStatus 애노테이션이 달려있는지 체크를 해서, 있으면 그 안에 정보들을 가져올 뿐이다. 
  • 그리고 호출하는 메서드가 resolveResponseStatus(...)이다. 
protected ModelAndView resolveResponseStatus(ResponseStatus responseStatus, HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex) throws Exception {
    int statusCode = responseStatus.code().value();
    String reason = responseStatus.reason();
    return this.applyStatusAndReason(statusCode, reason, response);
}
  • 여기 보면, 그 ResponseStatus 애노테이션에 속성값으로, `code`, `reason`이 있었고 거기에 넣은 값 가져오는 코드이다.
  • 그리고 applyStatusAndReason(...)을 호출한다.
protected ModelAndView applyStatusAndReason(int statusCode, @Nullable String reason, HttpServletResponse response) throws IOException {
    if (!StringUtils.hasLength(reason)) {
        response.sendError(statusCode);
    } else {
        String resolvedReason = this.messageSource != null ? this.messageSource.getMessage(reason, (Object[])null, reason, LocaleContextHolder.getLocale()) : reason;
        response.sendError(statusCode, resolvedReason);
    }

    return new ModelAndView();
}
  • 결국 그 메서드는 그냥 우리 했던거랑 똑같이 response.sendError(...) 호출하는 것 뿐이다. 즉, 이 방식도 WAS까지 갔다가 다시 서버 내부적으로 재호출하는 코드인 것.

 

직접 HandlerExceptionResolver를 구현해보니까, 스프링이라는 위대하고 거대한 프레임워크가 만든 코드가 이해가 되는 것이다. 그래서 이 과정이 굉장히 소중하다고 생각하는데.. 여튼, 이 ResponseStatusExceptionResolver가 또 재밌는게 있다. 메시지 기능을 사용할 수가 있다. 아래 처럼 `reason`값에 메시지 코드를 넣어보자.

@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "error.bad")
public class BadRequestException extends RuntimeException {

}

messages.properties

error.bad=오우 에러가 났어요!

 

 

이렇게 적용을 하면, 메시지 사용을 할 수 있다.

 

이것도 사실 뭐 별게 아니다. 위 코드보면 이런 부분이 있다.

String resolvedReason = this.messageSource != null ? this.messageSource.getMessage(reason, (Object[])null, reason, LocaleContextHolder.getLocale()) : reason;

결국 MessageSource 객체로 메시지가 있는지 확인해서 있으면 사용하고 없으면 기본값 그대로 사용하는 코드 그냥 호출할 뿐이다.

 

아무튼 예외 객체에 @ResponseStatus 애노테이션을 달아서 사용하는 이런 방식이 ResponseStatusExceptionResolver이다. 

근데 이건 우리가 만든 예외는 가능한데 라이브러리 내부에 있는 예외 객체나, 기존에 자바가 가지고 있는 예외 객체에는 우리가 코드를 수정할 수가 없다. 그러니까 @ResponseStatus 애노테이션을 달 수가 없다는 것이다. 그런데 그 예외를 사용하고 싶을 때가 있다. 그런 경우에 어떤게 가능하냐면, ResponseStatusException 예외를 던져도 ResponseStatusExceptionResolver가 처리해준다. 다음 처럼.

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

 

DefaultHandlerExceptionResolver

이 녀석은 스프링 내부에서 발생하는 스프링 예외를 해결한다. 대표적인 게, 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException이 발생하는데, 이 경우 예외가 발생했기 때문에 그냥 두면 서블릿 컨테이너까지 오류가 올라가고, 결과적으로 500 오류가 발생한다. 그런데 파라미터 바인딩은 대부분 클라이언트가 HTTP 요청 정보를 잘못 호출해서 발생하는 문제이다. HTTP에서는 이런 경우는 400에러로 내보내야 맞다. DefaultHandlerExceptionResolver는 이것을 500오류가 아니라 400 오류로 변경해준다.

 

아래와 같이 컨트롤러에 경로 하나를 추가해보자.

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

 

이 상태에서 `data`라는 쿼리 파라미터에 숫자를 넣으면 아무런 문제가 안되는데 문자를 넣는 경우, 이제 TypeMismatchException이 발생하게 된다.

그래서 호출을 해보면, 이렇게 에러가 발생하는데 400으로 반환된다. 이것을 DefaultHandlerExceptionResolver이 녀석이 해준 것이다. 이 코드도 내부로 들어가보면, 이렇게 무수히 많은 스프링 내부적 오류를 처리하는 코드가 있다.

...
@Nullable
protected ModelAndView doResolveException(HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex) {
    try {
        if (ex instanceof ErrorResponse errorResponse) {
            ModelAndView mav = null;
            if (ex instanceof HttpRequestMethodNotSupportedException theEx) {
                mav = this.handleHttpRequestMethodNotSupported(theEx, request, response, handler);
            } else if (ex instanceof HttpMediaTypeNotSupportedException theEx) {
                mav = this.handleHttpMediaTypeNotSupported(theEx, request, response, handler);
            } else if (ex instanceof HttpMediaTypeNotAcceptableException theEx) {
                mav = this.handleHttpMediaTypeNotAcceptable(theEx, request, response, handler);
            } else if (ex instanceof MissingPathVariableException theEx) {
                mav = this.handleMissingPathVariable(theEx, request, response, handler);
            } else if (ex instanceof MissingServletRequestParameterException theEx) {
                mav = this.handleMissingServletRequestParameter(theEx, request, response, handler);
            } else if (ex instanceof MissingServletRequestPartException theEx) {
                mav = this.handleMissingServletRequestPartException(theEx, request, response, handler);
            } else if (ex instanceof ServletRequestBindingException theEx) {
                mav = this.handleServletRequestBindingException(theEx, request, response, handler);
            } else if (ex instanceof MethodArgumentNotValidException theEx) {
                mav = this.handleMethodArgumentNotValidException(theEx, request, response, handler);
            } else if (ex instanceof HandlerMethodValidationException theEx) {
                mav = this.handleHandlerMethodValidationException(theEx, request, response, handler);
            } else if (ex instanceof NoHandlerFoundException theEx) {
                mav = this.handleNoHandlerFoundException(theEx, request, response, handler);
            } else if (ex instanceof NoResourceFoundException theEx) {
                mav = this.handleNoResourceFoundException(theEx, request, response, handler);
            } else if (ex instanceof AsyncRequestTimeoutException theEx) {
                mav = this.handleAsyncRequestTimeoutException(theEx, request, response, handler);
            }

            return mav != null ? mav : this.handleErrorResponse(errorResponse, request, response, handler);
        }

        if (ex instanceof ConversionNotSupportedException theEx) {
            return this.handleConversionNotSupported(theEx, request, response, handler);
        }

        if (ex instanceof TypeMismatchException theEx) {
            return this.handleTypeMismatch(theEx, request, response, handler);
        }

        if (ex instanceof HttpMessageNotReadableException theEx) {
            return this.handleHttpMessageNotReadable(theEx, request, response, handler);
        }

        if (ex instanceof HttpMessageNotWritableException theEx) {
            return this.handleHttpMessageNotWritable(theEx, request, response, handler);
        }

        if (ex instanceof MethodValidationException theEx) {
            return this.handleMethodValidationException(theEx, request, response, handler);
        }

        if (ex instanceof BindException theEx) {
            return this.handleBindException(theEx, request, response, handler);
        }

        if (ex instanceof AsyncRequestNotUsableException) {
            return this.handleAsyncRequestNotUsableException((AsyncRequestNotUsableException)ex, request, response, handler);
        }
    } catch (Exception var19) {
        Exception handlerEx = var19;
        if (this.logger.isWarnEnabled()) {
            this.logger.warn("Failure while trying to resolve exception [" + ex.getClass().getName() + "]", handlerEx);
        }
    }

    return null;
}
...

 

이 중에 딱 이 부분을 보자.

if (ex instanceof TypeMismatchException theEx) {
    return this.handleTypeMismatch(theEx, request, response, handler);
}

보면, handleTypeMismatch 라는 메서드를 실행한다. 들어가보면 이렇게 되어 있다.

protected ModelAndView handleTypeMismatch(TypeMismatchException ex, HttpServletRequest request, HttpServletResponse response, @Nullable Object handler) throws IOException {
    response.sendError(400);
    return new ModelAndView();
}
  • 마찬가지로 ModelAndView를 반환하는 우리가 직접 해봤던 HandlerExceptionResolver랑 똑같이 생긴 것이다.
  • 거기다가 sendError(400)을 호출해서, WAS에게 400에러임을 알리고 WAS는 처리할 수 있는 녀석을 찾아 재호출할 뿐이다.
  • ModelAndView()를 반환해서 API 예외임을 명시하고 있다.
  • 아무것도 커스텀 하지 않았다면 BasicErrorController를 호출해서 상태코드 400을 적용하고, 스프링의 기본 오류 메시지를 출력할 것이다.

 

이렇게 하나씩 알아봤는데, 지금까지 한 내용은 결국 HandlerExceptionResolver를 사용하는 방식이다. 근데 이 방식의 경우, 직접 구현해봐서 알겠지만 response 객체에 직접 데이터를 넣어야 했다. 서블릿 코드 짜듯이 말이다. 이건 일단 너무 불편하고, ModelAndView를 반환하는 것부터 약간 이상하다. API 예외 인데 왜 ModelAndView를 반환하나? 그래서 실질적으로 HandlerExceptionResolver를 사용해서 API 예외 처리를 하기엔 좀 별로다. 

 

이런 불편함을 한방에 해결하는 혁신적인 기능을 스프링이 제공한다. @ExceptionHandler @ControllerAdvice. 이것을 드디어 알아보자.

 

API 예외 처리 - @ExceptionHandler

HTML 화면 오류 vs API 오류

웹 브라우저에 HTML 화면을 제공할 땐 오류가 발생하면 BasicErrorController를 사용하는 게 편하다. 그냥 단순히 5xx, 4xx 관련된 오류 화면을 `src/main/resources/templates/error` 경로에 만들면 되니까. 그 이후는 BasicErrorController가 알아서 다 해준다.

 

그런데, API는 각 시스템 마다 응답의 모양도 다르고, 스펙도 모두 다르다. 매우 세밀한 제어가 필요한데 공통의 모양을 처리하는 건 적합하지 않다. 그래서 BasicErrorController가 처리해주는 API 예외도 원하는 모양이 되지 않고, 그렇다고 HandlerExceptionResolver를 직접 구현하는건 매우 불편하고 귀찮다. 그리고 이건 모든 개발자들이 동시에 느낀 현상일 것이다. 그렇기 때문에 끝판왕이 등장했으니까.

 

스프링은 이 API 예외 처리 문제를 해결하기 위해, @ExceptionHandler라는 애노테이션을 사용하는 매우 강력하고 편리한 예외 처리 기능을 제공한다. 그리고 이 애노테이션을 처리해주는 ExceptionHandlerExceptionResolver가 있다. 스프링은 이 ExceptionHandlerExceptionResolver를 기본으로 제공하고, 기본으로 제공하는 ExceptionResolver중에 우선순위도 제일 높다. 실무에서는 API 예외 처리는 대부분 이 기능을 사용한다. 

 

이제 사용해보자. 우선 예외 응답을 위한 객체를 하나 만들자.

ErrorResult

package cwchoiit.exception.exhandler;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class ErrorResult {

    private String code;
    private String message;
}

 

새로운 컨트롤러 하나도 만들자.

ApiExceptionV2Controller

package cwchoiit.exception.api;

import cwchoiit.exception.exception.UserException;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

@Slf4j
@RestController
@RequestMapping("/api/v2")
public class ApiExceptionV2Controller {

    @GetMapping("/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {
        if (id.equals("ex")) {
            throw new RuntimeException("ex");
        }
        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/v2/members/bad`로 요청한 누군가에게 적절하게 API 예외 응답을 보여주려면 어떻게 하면 되냐? 아래 코드를 보자.

package cwchoiit.exception.api;

import cwchoiit.exception.exception.UserException;
import cwchoiit.exception.exhandler.ErrorResult;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@Slf4j
@RestController
@RequestMapping("/api/v2")
public class ApiExceptionV2Controller {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalArgumentExceptionHandler(IllegalArgumentException ex) {
        log.error("Illegal argument", ex);
        return new ErrorResult("Illegal argument", ex.getMessage());
    }

    @GetMapping("/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {
        if (id.equals("ex")) {
            throw new RuntimeException("ex");
        }
        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 애노테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다. 해당 컨트롤러에서 예외가 발생하면 이 메서드가 호출된다. 참고로 지정한 예외 또는 그 예외의 자식 클래스는 모두 잡을 수 있다. 
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalArgumentExceptionHandler(IllegalArgumentException ex) {
    log.error("Illegal argument", ex);
    return new ErrorResult("Illegal argument", ex.getMessage());
}

그러니까, 이렇게 만들어두면 이 컨트롤러에서 발생한 IllegalArgumentException과 그 하위 예외는 모두 얘가 처리해버리는 것이다.

이번엔 UserException을 잡는 @ExceptionHandler를 만들어보자.

@ExceptionHandler
public ResponseEntity<ErrorResult> userExceptionHandler(UserException ex) {
    log.error("UserException", ex);
    ErrorResult errResult = new ErrorResult("UserException", ex.getMessage());
    return new ResponseEntity<>(errResult, HttpStatus.BAD_REQUEST);
}
  • 이번엔 생김새가 좀 다르다. @ExceptionHandler에 어떤 예외 클래스인지 정의하지 않아도 된다. 파라미터에 넣은 예외로 그 부분을 대체할 수 있다.
  • 그리고 반환 타입을 ResponseEntity로 지정하면, 리턴할 때 내가 원하는 상태코드를 직접 넣을 수 있다. @ResponseStatus를 사용하지 않아도 된다.
  • 둘 중 어떤 생김새로 만들어도 무방하다는 얘기이다.

이번엔 Exception이라는 가장 상위의 예외를 처리하는 @ExceptionHandler를 만들어보자.

@ExceptionHandler
public ResponseEntity<ErrorResult> exceptionHandler(Exception ex) {
    log.error("Exception", ex);
    ErrorResult errResult = new ErrorResult("Exception", ex.getMessage());
    return new ResponseEntity<>(errResult, HttpStatus.INTERNAL_SERVER_ERROR);
}
  • 이렇게 만들어 두면, 자세하게 명시한 IllegalArgumentException, UserException을 제외한 예외가 발생했을 때 이 @ExceptionHandler가 호출된다.
  • 그러니까 RuntimeException이 발생하면 이 녀석이 호출되는 것이다.

그리고 또 이 방식의 좋은점은, response.sendError(...)를 호출하는 것처럼 WAS까지 왔다가 다시 서버 내부적으로 호출이 한번 더 일어나는 그런 방식이 아니라 여기서 그냥 바로 끝난다는 점이다. 그래서 실행 흐름을 보자.

 

실행흐름

  • 컨트롤러 호출 결과 IllegalArgumentException 예외가 발생했다.
  • 예외가 발생했으므로 ExceptionResolver가 작동한다. 가장 우선순위가 높은 ExceptionHandlerExceptionResolver가 실행된다. 
  • ExceptionHandlerExceptionResolver는 해당 컨트롤러에 IllegalArgumentException을 처리할 수 있는 @ExceptionHandler가 있는지 확인한다.
  • 해당 메서드가 있으므로 실행한다. @RestController이므로 이 메서드 또한 @ResponseBody가 적용된다. 따라서 HTTP 컨버터가 사용되고 응답이 JSON으로 반환된다.
  • @ResponseStatus(HttpStatus.BAD_REQUEST)를 지정했으므로 400으로 반환된다.

 

그리고 이 @ExceptionHandler를 사용해서, 뷰 처리도 가능하긴 하다. 다음 코드를 참고해보자. (물론, 이건 그냥 가능하다는 것이지 그냥 오류 화면 처리는 BasicErrorController를 이용하는게 제일 좋다.)

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

 

 

최종 코드를 한번 보자.

package cwchoiit.exception.api;

import cwchoiit.exception.exception.UserException;
import cwchoiit.exception.exhandler.ErrorResult;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@Slf4j
@RestController
@RequestMapping("/api/v2")
public class ApiExceptionV2Controller {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalArgumentExceptionHandler(IllegalArgumentException ex) {
        log.error("Illegal argument", ex);
        return new ErrorResult("Illegal argument", ex.getMessage());
    }

    @ExceptionHandler
    public ResponseEntity<ErrorResult> userExceptionHandler(UserException ex) {
        log.error("UserException", ex);
        ErrorResult errResult = new ErrorResult("UserException", ex.getMessage());
        return new ResponseEntity<>(errResult, HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler
    public ResponseEntity<ErrorResult> exceptionHandler(Exception ex) {
        log.error("Exception", ex);
        ErrorResult errResult = new ErrorResult("Exception", ex.getMessage());
        return new ResponseEntity<>(errResult, HttpStatus.INTERNAL_SERVER_ERROR);
    }

    @GetMapping("/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {
        if (id.equals("ex")) {
            throw new RuntimeException("ex");
        }
        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로 등록만 해놓으면 끝인것이다. 너무 편리하다. 근데 한가지 아쉬운 점이 보인다. 이 컨트롤러 뿐 아니라 모든 컨트롤러에 적용하고 싶다. 그럴때 바로 @ControllerAdvice를 사용하면 된다.

 

API 예외 처리 - @ControllerAdvice

@ExceptionHandler를 사용해서 예외를 깔끔하게 처리할 수 있었다. 근데 불편한 점이 두가지 있다.

  • 정상 처리 코드와 예외 처리 코드가 섞여있다. 
  • 여러 컨트롤러에서 동시에 사용하고 싶다. 

이런 불편함을 이 @ControllerAdvice가 해결해준다. 

ExControllerAdvice

package cwchoiit.exception.exhandler.advice;

import cwchoiit.exception.exception.UserException;
import cwchoiit.exception.exhandler.ErrorResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Slf4j
@RestControllerAdvice
public class ExControllerAdvice {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalArgumentExceptionHandler(IllegalArgumentException ex) {
        log.error("Illegal argument", ex);
        return new ErrorResult("Illegal argument", ex.getMessage());
    }

    @ExceptionHandler
    public ResponseEntity<ErrorResult> userExceptionHandler(UserException ex) {
        log.error("UserException", ex);
        ErrorResult errResult = new ErrorResult("UserException", ex.getMessage());
        return new ResponseEntity<>(errResult, HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler
    public ResponseEntity<ErrorResult> exceptionHandler(Exception ex) {
        log.error("Exception", ex);
        ErrorResult errResult = new ErrorResult("Exception", ex.getMessage());
        return new ResponseEntity<>(errResult, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}
  • @RestControllerAdvice 애노테이션이 달린 클래스 하나를 만든다. @RestControllerAdvice이거는 @ControllerAdvice + @ResponseBody가 합쳐진 애노테이션이다. 즉, REST API를 위한 ControllerAdvice라고 생각하면 된다.
  • 아까 컨트롤러에서 작성했던 @ExceptionHandler 메서드들을 모두 여기로 이동시킨다.

그리고 아까 컨트롤러의 모습은 이렇게 변경됐다.

ApiExceptionV2Controller

package cwchoiit.exception.api;

import cwchoiit.exception.exception.UserException;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

@Slf4j
@RestController
@RequestMapping("/api/v2")
public class ApiExceptionV2Controller {

    @GetMapping("/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {
        if (id.equals("ex")) {
            throw new RuntimeException("ex");
        }
        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 기능을 부여해주는 역할을 한다.
  • 대상을 지정하지 않으면 모든 컨트롤러에 적용된다 (글로벌 적용)

대상 컨트롤러 지정 방법

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

 

정리를 하자면

길고 긴 시간 끝에 결국 API 예외 처리하는 방법을 완벽하게 정리했다. @ExceptionHandler, @ControllerAdvice를 통해서 예외 처리를 깔끔하고 완벽하게 할 수 있었다. 

728x90
반응형
LIST

+ Recent posts