Java Category/Spring

[Spring MVC] API 예외 처리

ReBugs 2024. 3. 23.

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


기본 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"); //errorPage404 호출
        ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500"); //errorPage500 호출
        ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500"); //RuntimeException 또는 그 자식 타입의 예외 -> errorPage500 호출

        factory.addErrorPages(errorPage404, errorPage500, errorPageEx); //위에서 설정한 오류페이지 등록
    }
}
  • ErrorPage: 이 클래스는 특정 오류가 발생했을 때 보여줄 페이지를 정의한다. 첫 번째 생성자는 HTTP 상태 코드에 대한 오류 페이지를, 두 번째 생성자는 특정 예외 타입에 대한 오류 페이지를 정의한다.
  • addErrorPages: 이 메서드는 ConfigurableWebServerFactory 인스턴스에 하나 이상의 ErrorPage 인스턴스를 등록한다. 이렇게 등록된 오류 페이지는 해당 오류 상황이 발생했을 때 자동으로 사용자에게 보여진다.
  • HTTP 404(Not Found) 오류가 발생하면, 사용자는 "/error-page/404"에 정의된 오류 페이지를 본다.
  • HTTP 500(Internal Server Error) 오류 또는 RuntimeException (또는 그 자식 타입의 예외)이 발생하면, 사용자는 "/error-page/500"에 정의된 오류 페이지를 본다.

 

API 예외 컨트롤러

@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;
    }
}
  • localhost:8080/api/members/ex 에 들어가면 RuntimeException이 발생하고 잘못된 사용자라는 메시지가 담기게 된다.

 

에러 페이지 컨트롤러

@Slf4j
@Controller
public class ErrorPageController {

    //RequestDispatcher 상수로 정의되어 있음
    public static final String ERROR_EXCEPTION = "jakarta.servlet.error.exception"; //예외
    public static final String ERROR_EXCEPTION_TYPE = "jakarta.servlet.error.exception_type"; //예외 타입
    public static final String ERROR_MESSAGE = "jakarta.servlet.error.message"; //오류 메시지
    public static final String ERROR_REQUEST_URI = "jakarta.servlet.error.request_uri"; //클라이언트 요청 URI
    public static final String ERROR_SERVLET_NAME = "jakarta.servlet.error.servlet_name"; //오류가 발생한 서블릿 이름
    public static final String ERROR_STATUS_CODE = "jakarta.servlet.error.status_code"; //HTTP 상태 코드

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

    @RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE) //같은 URL 매핑이어도 더 자세하기 떄문에 우선순위 높음
    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)); //예외 발생 시의 HTTP 상태 코드를 결과를 맵에 추가
        result.put("message", ex.getMessage()); //발생한 예외의 메시지를 결과를 맵에 추가
        Integer statusCode = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE); //요청에서 오류 상태 코드를 추출
        return new ResponseEntity(result, HttpStatus.valueOf(statusCode)); //결과 맵과 HTTP 상태 코드를 사용하여 ResponseEntity 객체를 생성하고 반환, 이 객체는 스프링 MVC에 의해 JSON 형식으로 클라이언트에게 전송
    }
}
  • produces = MediaType.APPLICATION_JSON_VALUE 의 뜻은 클라이언트가 요청하는 HTTP Header의
    Accept 의 값이 application/json 일 때 해당 메서드가 호출된다는 것이다. 결국 클라어인트가 받고 싶은 미디어 타입이 json이면 이 컨트롤러의 메서드가 호출된다.
  • 응답 데이터를 위해서 Map 을 만들고 status , message 키에 값을 할당한다.
  • Jackson 라이브러리는 Map 을 JSON 구조로 변환할 수 있다.
  • ResponseEntity 를 사용해서 응답하기 때문에 메시지 컨버터가 동작하면서 클라이언트에 JSON이 반환된다.

 

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

스프링 부트가 제공하는 BasicErrorController 의 코드 중에서 중요한 코드 조각은 아래와 같다.

@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
 public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {}
 
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}
  • errorHtml() : produces = MediaType.TEXT_HTML_VALUE : 클라이언트 요청의 Accept 해더 값이 text/html 인 경우에는 errorHtml() 을 호출해서 view를 제공한다.
  • error() : 그외 경우에 호출되고 ResponseEntity 로 HTTP Body에 JSON 데이터를 반환한다.

 

WebServerCustomizer 의 @Componet 어노테이션을 제거하면 BasicErrorController 가 실행되고, 기존에 설정한 뷰 파일이 리턴된다.

 

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

하지만 API 오류 처리는 BasicErrorController 보다는 @ExceptionHandler 를 사용하는 것이 좋다.

이유는 아래와 같다.

 

  1. 세밀한 제어와 커스터마이징
    @ExceptionHandler를 사용하면, 특정 예외나 상황에 대해 매우 세밀한 오류 처리 로직을 구현할 수 있다. 개발자는 예외 유형별로 다른 처리 방식을 적용할 수 있으며, HTTP 상태 코드, 응답 본문 등을 자유롭게 조정할 수 있다.
  2. 컨트롤러 내 예외 처리
    @ExceptionHandler는 특정 컨트롤러 내부 또는 @ControllerAdvice를 사용하여 애플리케이션 전역에서 발생하는 예외를 처리하는 데 사용할 수 있다. 이는 오류 처리 로직을 관련 도메인 또는 API에 더 가까이 위치시켜, 코드의 가독성과 유지 보수성을 향상시킨다.
  3. 응답 본문의 유연성
    API를 통해 클라이언트에게 반환되는 오류 응답의 형태를 자유롭게 정의할 수 있다. JSON, XML 등 클라이언트가 요구하는 형식에 맞춰 유연하게 오류 메시지를 구성할 수 있어, API의 일관성을 유지하며 사용자 경험을 개선할 수 있다.
  4. 프로그래밍적 예외 처리
    @ExceptionHandler는 예외 처리 로직을 직접 프로그래밍하여 구현하기 때문에, 개발자가 실행 흐름을 더 잘 이해하고 제어할 수 있다. 이는 복잡한 요구 사항이나 특별한 처리가 필요한 경우 유용하다.
  5. 테스트와 유지 보수의 용이성
    @ExceptionHandler를 사용한 오류 처리는 테스트하기가 더 쉽다. 각 컨트롤러 또는 @ControllerAdvice 클래스 단위로 테스트를 진행할 수 있으며, 예외 처리 로직이 명확하게 분리되어 있어 유지 보수가 용이하다.
  6. 특정 예외에 대한 직접적인 처리
    특정 예외 유형에 대해 맞춤형 오류 메시지나 처리 로직을 적용하고 싶을 때 @ExceptionHandler가 더 적합하다. 이를 통해 API 사용자에게 더 상세하고 명확한 오류 정보를 제공할 수 있다.

 

HandlerExceptionResolver

HandlerExceptionResolver는 컨트롤러에서 발생한 예외를 중앙에서 처리할 수 있도록 도와준다. 이 인터페이스를 구현함으로써, 애플리케이션 전반에 걸친 예외 처리 전략을 구현하고, 예외가 발생했을 때 일관된 방식으로 응답을 반환할 수 있다.

HandlerExceptionResolver는 특히 API 개발에서 유용하게 사용되며, 예외를 적절한 HTTP 상태 코드와 메시지로 변환하여 클라이언트에 전달하는 데 도움을 준다.

 

작동 방식

디스패처 서블릿(DispatcherServlet)은 컨트롤러에서 발생한 예외를 처리하기 위해 HandlerExceptionResolver 인터페이스를 사용한다. 컨트롤러 메서드 실행 도중 예외가 발생하면, Spring은 등록된 HandlerExceptionResolver 구현체를 차례로 호출하여 예외를 처리한다.

각 HandlerExceptionResolver는 예외를 받아 처리할 수 있는지 여부를 판단하고, 처리할 수 있다면 ModelAndView 객체를 생성하여 예외에 대한 응답 정보를 담는다.

 

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

 

HandlerExceptionResolver 인터페이스

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

 

 

예외가 발생해서 서블릿을 넘어 WAS까지 예외가 전달되면 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);
    }

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

 

localhost:8080/api/members/bad 로 요청이 오면 500번 오류가 아닌 400번으로 아래와 같이 처리 할 수 있다.

@Slf4j
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {
    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        try {
            if (ex instanceof IllegalArgumentException) { //전달된 예외가 IllegalArgumentException 인지 확인
                log.info("IllegalArgumentException resolver to 400");
                response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage()); //클라이언트에게 HTTP 상태 코드 400(Bad Request)와 함께 예외 메시지를 전송
                return new ModelAndView(); //빈 객체를 리턴하여 정상 동작 유도(뷰 처리 건너뜀)
            }
        } catch (IOException e) {
            log.error("resolver ex", e);
        }
        return null;
    }
}

ExceptionResolver가 ModelAndView를 반환하는 이유는 마치 try, catch를 하듯이, Exception을 처리해서 정상 흐름처럼 변경하는 것이 목적이다.

이름 그대로 ExceptionResolver는 Exception을 해결하는 것이 목적이다.

여기서는 IllegalArgumentException이 발생하면 response.sendError(400)를 호출해서 HTTP 상태 코드를 400으로 지정하고, 빈 ModelAndView를 반환한다.

 

 

반환 값에 따른 동작 방식

HandlerExceptionResolver의 반환 값에 따른 DispatcherServlet의 동작 방식은 다음과 같다.

→빈 ModelAndView 반환

new ModelAndView()와 같이 빈 ModelAndView 객체를 반환하는 경우, Spring MVC는 뷰를 렌더링하지 않고, 정상적인 서블릿 응답 흐름으로 돌아간다. 이는 예외가 발생했지만, 특별한 에러 페이지를 보여줄 필요가 없거나, 예외 처리 로직에서 이미 충분한 처리(예: 클라이언트에게 에러 정보를 JSON 형태로 전송)를 수행했음을 의미할 수 있다.

 

ModelAndView 지정

ModelAndView 객체에 뷰 이름이나 모델 정보를 지정해서 반환하는 경우, Spring MVC는 해당 정보를 사용해 뷰를 렌더링한다. 이는 오류에 대한 사용자 친화적인 웹 페이지를 제공하고자 할 때 유용하다. 예를 들어, 오류 메시지와 함께 사용자에게 보여줄 오류 페이지를 지정할 수 있다.

 

null 반환

null을 반환하는 경우, Spring MVC는 현재 HandlerExceptionResolver가 예외를 처리하지 않았음을 의미하며, 다음 HandlerExceptionResolver를 찾아 실행한다. 만약 더 이상 처리할 HandlerExceptionResolver가 없고, 예외가 여전히 처리되지 않은 상태라면, 예외는 DispatcherServlet 밖으로 전파되고, 최종적으로 서블릿 컨테이너의 기본 오류 페이지 처리 메커니즘에 의해 처리된다.



HandlerExceptionResolver 활용 예

예외 상태 코드 변환

특정 예외를 response.sendError(statusCode) 호출을 통해 HTTP 상태 코드로 변환하여 클라이언트에게 전달할 수 있다. 이 경우, 일반적으로 빈 ModelAndView를 반환하며, 서블릿 컨테이너가 해당 상태 코드에 맞는 처리(예: 에러 페이지 렌더링)를 수행하게 한다.

 

뷰 템플릿 처리

오류에 대한 사용자 정의 뷰를 렌더링하여 보다 친절한 사용자 경험을 제공할 수 있다. 이 경우 ModelAndView에 오류 정보와 함께 사용할 뷰 이름을 지정한다.

 

API 응답 처리

REST API와 같이 HTTP 응답 바디에 직접 에러 메시지나 상태 코드를 전송해야 하는 경우, response.getWriter().print(errorResponse)와 같이 처리한 뒤 빈 ModelAndView를 반환하여 추가적인 뷰 렌더링을 방지할 수 있다.

 

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

configureHandlerExceptionResolvers(..) 를 사용하면 스프링이 기본으로 등록하는 ExceptionResolver 가 제거되므로 주의해야 한다.

그렇기 떄문에 extendHandlerExceptionResolvers 를 사용하는 것이 좋다.

 

HandlerExceptionResolver 활용

예외가 발생하면 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;
    }
}

http://localhost:8080/api/members/user-ex 를 호출하면 UserException 이 발생한다.

 

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) { //UserException 이 발생했을 때
                log.info("UserException resolver to 400");
                String acceptHeader = request.getHeader("accept"); //accept 헤더 정보를 가져옴
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST); //HTTP 응답 상태 코드를 400(Bad Request)으로 설정

                if ("application/json".equals(acceptHeader)) { //accept 헤더 정보가 application/json 이라면
                    Map<String, Object> errorResult = new HashMap<>();
                    errorResult.put("ex", ex.getClass()); //예외의 클래스 타입을 넣음
                    errorResult.put("message", ex.getMessage()); //예외 메시지를 넣음
                    String result = objectMapper.writeValueAsString(errorResult); //ObjectMapper 를 사용하여 예외 정보를 문자로 변환

                    response.setContentType("application/json"); //응답의 Content-Type을 application/json으로 설정
                    response.setCharacterEncoding("utf-8"); //문자 인코딩을 UTF-8로 설정
                    response.getWriter().write(result); //응답 본문에 result 를 넣음

                    return new ModelAndView(); //빈 ModelAndView 객체를 반환하여 뷰 렌더링 없이 직접 응답을 처리
                } else {
                    //TEXT/HTML
                    return new ModelAndView("error/400");
                }
            }
        } catch (IOException e) {
            log.error("resolver ex", e);
        }
        return null;
    }
}

HTTP 요청 해더의 ACCEPT 값이 application/json 이면 JSON으로 오류를 내려주고, 그 외 경우에는 error/ 500에 있는 HTML 오류 페이지를 보여준다.

 

물론 WebConfig에 UserHandlerExceptionResolver 를 추가해주어야 한다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

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

 

정리

ExceptionResolver 를 사용하면 컨트롤러에서 예외가 발생해도 ExceptionResolver 에서 예외를 처리해버린다. 따라서 예외가 발생해도 서블릿 컨테이너까지 예외가 전달되지 않고, 스프링 MVC에서 예외 처리는 끝이난다.

결과적으로 WAS 입장에서는 정상 처리가 된 것이다. 이렇게 예외를 이곳에서 모두 처리할 수 있다는 것이 핵심이다. 서블릿 컨테이너까지 예외가 올라가면 복잡한 추가 프로세스가 실행된다. 반면에 ExceptionResolver 를 사용하면 예외처리가 상당히 깔끔해진다.

 

스프링이 제공하는 ExceptionResolver

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

 

HandlerExceptionResolverComposite 에 다음 순서로 등록되어 있다.

  1. ExceptionHandlerExceptionResolver
  2. ResponseStatusExceptionResolver
  3. DefaultHandlerExceptionResolver (우선순위가 가장 낮음)

 

HandlerExceptionResolverComposite는 Spring MVC에서 다양한 HandlerExceptionResolver 인스턴스들을 하나의 컨테이너 안에 결합하여 관리하는 클래스이다. 이 클래스는 HandlerExceptionResolver 인터페이스를 구현함으로써, 여러 예외 해결자들 사이에서 발생하는 예외를 효과적으로 처리할 수 있는 중앙 집중식 방식을 제공한다. 등록된 모든 예외 Resolver들 중에서 해당 예외를 처리할 수 있는 첫 번째 Resolver를 찾아서 예외 처리를 위임한다. 처리가 완료되면, 해당 Resolver는 처리 결과를 나타내는 ModelAndView 객체를 반환한다.

Spring MVC의 DispatcherServlet은 애플리케이션에서 발생하는 예외를 HandlerExceptionResolverComposite에게 전달한다. 그러면 HandlerExceptionResolverComposite는 자신이 관리하는 해결자 목록을 순회하면서 각 Resolver가 해당 예외를 처리할 수 있는지 확인한다. 처리할 수 있는 Resolver가 발견되면, 그 Resolver에게 예외 처리를 맡긴다. 만약 등록된 Resolver 중 어떤 것도 예외를 처리할 수 없다면, 예외는 처리되지 않은 채로 남게 되며, Spring MVC 외부로 전파된다.

 

  • ExceptionHandlerExceptionResolver
    @ExceptionHandler 을 처리한다. API 예외 처리는 대부분 이 기능으로 해결한다.
  • ResponseStatusExceptionResolver
    HTTP 상태 코드를 지정해준다.
    예) @ResponseStatus(value = HttpStatus.NOT_FOUND)
  • DefaultHandlerExceptionResolver
    스프링 내부 기본 예외를 처리한다.

 

ResponseStatusExceptionResolver

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

예외 발생 시 HTTP 상태 코드와 메시지를 클라이언트에게 동적으로 반환할 수 있도록 해주는 HandlerExceptionResolver의 한 구현체이다.

이 Resolver는 예외 클래스에 @ResponseStatus 어노테이션이 붙어 있는 경우, 해당 어노테이션에 정의된 HTTP 상태 코드와 이유(설명)를 사용하여 응답을 구성한다.

이 리졸버는 다음 두 가지 경우를 처리한다.

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

 

@ResponseStatus

예외에 다음과 같이 @ResponseStatus 어노테이션을 적용하면 HTTP 상태 코드를 변경해준다.

@GetMapping("/api/response-status-ex1")
public String responseStatusEx1() {
    throw new BadRequestException();
}
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {
}

BadRequestException 예외가 컨트롤러 밖으로 넘어가면 ResponseStatusExceptionResolver 예외가 해당 어노테이션을 확인해서 오류 코드를 HttpStatus.BAD_REQUEST (400)으로 변경하고, 메시지도 담는다.

 

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

 

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

 

또한 ResponseStatusExceptionResolver 는 MessageSource 에서 찾는 기능도 제공한다.

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

 

 

 

ResponseStatusException

이 예외는 런타임에 ResponseStatusExceptionResolver에 의해 처리되며, 개발자가 예외 발생 시점에 상태 코드와 메시지를 유연하게 지정할 수 있게 해준다.

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

 

 

DefaultHandlerExceptionResolver

DefaultHandlerExceptionResolver 는 스프링 내부에서 발생하는 스프링 예외를 해결한다.

대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException 이 발생하는데, 이 경우 예외가 발생했기 때문에 그냥 두면 서블릿 컨테이너까지 오류가 올라가고, 결과적으로 500 오류가 발생한다.

그런데 파라미터 바인딩은 대부분 클라이언트가 HTTP 요청 정보를 잘못 호출해서 발생하는 문제이다. HTTP 에서는 이런 경우 HTTP 상태 코드 400을 사용하도록 되어 있다.

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

 

작동 원리

DefaultHandlerExceptionResolver는 다음과 같은 방식으로 작동한다

  • 예외 매핑: 컨트롤러나 다른 컴포넌트에서 발생한 예외를 캐치하고, 해당 예외 타입에 따라 미리 정의된 HTTP 상태 코드로 매핑한다. 예를 들어, HttpRequestMethodNotSupportedException은 405 Method Not Allowed로 매핑된다.
  • HTTP 상태 코드 설정: 매핑된 HTTP 상태 코드를 사용하여 HTTP 응답의 상태 코드를 설정한다. 이 과정에서, 필요에 따라 추가적인 응답 헤더를 설정할 수도 있다.
  • 응답 반환: 처리된 예외에 대해 적절한 HTTP 상태 코드를 포함하는 응답을 클라이언트에게 반환한다. 일반적으로 별도의 응답 본문 없이 상태 코드만으로 응답이 구성된다.

 

 

주요 특징

  • 자동 등록: Spring MVC 설정의 일부로 DefaultHandlerExceptionResolver는 기본적으로 자동으로 등록되어 있으며, 개발자가 별도로 구성할 필요가 없다.
  • 기본 예외 처리: Spring MVC와 관련된 예외에 대해 기본적인 처리를 제공한다. 이는 애플리케이션의 예외 처리 전략의 기본 레이어로 작동한다.
  • 커스터마이징: 필요한 경우, 개발자는 DefaultHandlerExceptionResolver를 상속받아서 더 세밀한 예외 처리 로직을 구현할 수 있다. 또는, HandlerExceptionResolverComposite에 DefaultHandlerExceptionResolver와 함께 사용자 정의 예외 리졸버를 추가하여 예외 처리 전략을 확장할 수 있다.

 

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

 

 

 

ExceptionHandlerExceptionResolver(@ExceptionHandler)

스프링은 API 예외 처리 문제를 해결하기 위해 @ExceptionHandler 라는 어노테이션을 사용하는 매우 편리한 예외 처리 기능을 제공하는데, 이것이 바로 ExceptionHandlerExceptionResolver 이다.

 

스프링은 ExceptionHandlerExceptionResolver 를 기본으로 제공하고, 기본으로 제공하는 ExceptionResolver 중에 우선순위도 가장 높다. 실무에서 API 예외 처리는 대부분 이 기능을 사용한다.

 

이 어노테이션을 사용하면, 특정 메서드를 예외 처리기로 지정하여 해당 예외가 발생했을 때 자동으로 호출되게 할 수 있다. 이를 통해 개발자는 예외별로 맞춤형 로직을 구현하여 예외 처리를 보다 세밀하게 관리할 수 있다.

작동 방식

  • 지정 예외 처리: @ExceptionHandler 어노테이션이 적용된 메서드는 해당 메서드의 파라미터로 지정된 예외 타입을 처리한다. 다수의 예외 타입을 처리하고자 할 경우, 어노테이션의 값으로 예외 클래스 배열을 지정할 수 있다.
  • 컨트롤러 범위: @ExceptionHandler가 컨트롤러 내에 선언된 경우, 해당 컨트롤러 내에서 발생하는 예외에 대해서만 처리한다.
  • 전역 범위: @ControllerAdvice 또는 @RestControllerAdvice 어노테이션이 적용된 클래스 내에 @ExceptionHandler를 사용하면, 애플리케이션 전역에서 발생하는 해당 예외를 처리할 수 있다.

 

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

 

API 오류 처리 컨트롤러

package hello.exception.exhandler;

import hello.exception.exception.UserException;
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.*;
import org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver;

@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()); //ErrorResult 객체를 JSON으로 반환

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

    @ExceptionHandler //처리할 예외를 생략할 수 있음
    public ResponseEntity<ErrorResult> userExHandle(UserException e) { //UserException 예외 처리
        log.error("[exceptionHandle] ex", e);
        ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage()); //ErrorResult 에 오류 메시지를 담음
        return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST); //ResponseEntity에 오류 메시지와 상태코드를 담고 전송(JSON 형태)

//        @ExceptionHandler 에 예외를 지정하지 않으면 해당 메서드 파라미터 예외를 사용한다.
//        여기서는 UserException 을 사용한다.
//        ResponseEntity 를 사용해서 HTTP 메시지 바디에 직접 응답한다. 물론 HTTP 컨버터가 사용된다.
//        ResponseEntity를 사용하면 HTTP 응답 코드를 프로그래밍해서 동적으로 변경할 수 있다. @ResponseStatus는 애노테이션이므로 HTTP 응답 코드를 동적으로 변경할 수 없다.
    }

    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) //상태코드 설정
    @ExceptionHandler
    public ErrorResult exHandle(Exception e) { //Exception 처리 -> 이전에 처리하지 못한 예외나 공통 예외 처리는 여기서 담당
        log.error("[exceptionHandle] ex", e);
        return new ErrorResult("EX", "내부 오류"); //ErrorResult 객체를 JSON으로 반환

//        throw new RuntimeException("잘못된 사용자")이 코드가 실행되면서, 컨트롤러 밖으로 RuntimeException 이 던져진다.
//        RuntimeException은 Exception 의 자식 클래스이다. 따라서 이 메서드가 호출된다. 
//        @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) 로 HTTP 상태 코드를 500으로 응답한다.
    }

    @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 애노테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다. 해당 컨트롤러에서 예외가 발생하면 이 메서드가 호출된다.

→ 지정한 예외 또는 그 예외의 자식 클래스는 모두 잡을 수 있다.

 

우선순위

스프링의 우선순위는 항상 자세한 것이 우선권을 가진다. 예를 들어서 부모, 자식 클래스가 있고 다음과 같이 예외가 처리된다.

@ExceptionHandler(부모예외.class)
public String 부모예외처리()(부모예외 e) {}

@ExceptionHandler(자식예외.class)
public String 자식예외처리()(자식예외 e) {}


@ExceptionHandler 에 지정한 부모 클래스는 자식 클래스까지 처리할 수 있다. 따라서 자식예외가 발생하면 부모 예외처리() , 자식예외처리() 둘다 호출 대상이 된다.

→그런데 둘 중 더 자세한 것이 우선권을 가지므로 자식예외처리() 가 호출된다. 물론 부모예외가 호출되면 부모예외처리()만 호출 대상이 되므로 부모예외처리() 가 호출된다.

 

예외 생략

@ExceptionHandler 에 예외를 생략할 수 있다. 생략하면 메서드 파라미터의 예외가 지정된다.

 @ExceptionHandler
 public ResponseEntity<ErrorResult> userExHandle(UserException e) {}

 

 

파리미터와 응답

@ExceptionHandler 에는 마치 스프링의 컨트롤러의 파라미터 응답처럼 다양한 파라미터와 응답을 지정할 수 있다.

자세한 파라미터와 응답은 다음 공식 메뉴얼을 참고

https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann- exceptionhandler-args

 

HTML 오류 화면

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

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

 

 

@ControllerAdvice

@ExceptionHandler 를 사용해서 예외를 깔끔하게 처리할 수 있게 되었지만, 정상 코드와 예외 처리 코드가 하나의 컨트롤러에 섞여 있다. @ControllerAdvice 또는 @RestControllerAdvice 를 사용하면 둘을 분리할 수 있다.

 

정상적인 흐름 로직이 작성된 컨트롤러

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

 

예외 처리를 담당하는 컨트롤러

@Slf4j
@RestControllerAdvice(assignableTypes = ApiExceptionV3Controller.class) //@ControllerAdvice에 추가적으로 @ResponseBody 어노테이션의 기능이 포함, ApiExceptionV3Controller 클래스에만 적용
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와 @RestControllerAdvice

공통의 처리 로직(예외 처리, 데이터 바인딩, 모델 속성 추가 등)을 애플리케이션의 모든 컨트롤러 또는 REST 컨트롤러에 적용하기 위해 사용된다. 이 두 어노테이션은 기능적으로 유사하지만, 사용되는 컨텍스트에 따라 선택할 수 있다.

 

→@ControllerAdvice

@ControllerAdvice는 모든 @Controller에 대한 전역 설정을 제공한다. 이는 주로 웹 애플리케이션에서 HTML 뷰를 반환하는 컨트롤러에 적합하다.

 

예외 처리(@ExceptionHandler), 모델 속성 추가(@ModelAttribute), 바인딩 설정(@InitBinder)과 같은 공통 로직을 정의할 때 사용된다.
예외가 발생했을 때, 에러 페이지로 리다이렉트하거나, 특정 뷰를 반환하는 등의 처리를 할 때 적합하다.

 

@ControllerAdvice 에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다. (글로벌 적용)

 

@InitBinder

컨트롤러에서 요청이 들어올 때마다 특정 필드에 대한 바인딩이나 포매팅 설정을 커스터마이즈할 수 있도록 해주는 어노테이션이다. 이 어노테이션을 메서드에 적용하면, Spring MVC의 바인딩 프로세스가 시작될 때 해당 메서드가 자동으로 호출되어 요청 파라미터를 Java 객체에 바인딩하기 전에 필요한 설정을 할 수 있다.

 

→@RestControllerAdvice

@RestControllerAdvice는 @ControllerAdvice에 추가적으로 @ResponseBody 어노테이션의 기능이 포함되어 있다. 즉, 이 어노테이션을 사용하면 반환되는 객체는 자동으로 JSON이나 XML과 같은 응답 본문으로 변환된다.

 

RESTful 웹 서비스를 개발할 때, JSON이나 XML로 데이터를 반환하는 @RestController에 대한 전역 설정을 제공하는 데 사용된다.
예외 처리를 통해 클라이언트에게 JSON 형식의 에러 응답을 보낼 때 유용하다.

 

대상 컨트롤러 지정 방법

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

https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann- controller-advice

(스프링 공식 문서 참고)

 

스프링 공식 문서 예제에서 보는 것 처럼 특정 애노테이션이 있는 컨트롤러를 지정할 수 있고, 특정 패키지를 직접 지정 할 수도 있다. 패키지 지정의 경우 해당 패키지와 그 하위에 있는 컨트롤러가 대상이 된다. 그리고 특정 클래스를 지정할 수도 있다.

대상 컨트롤러 지정을 생략하면 모든 컨트롤러에 적용된다.

댓글