관리 메뉴

나구리의 개발공부기록

API 예외처리 - 스프링이 제공하는 ExceptionResolver, @ExceptionHandler, @ControllerAdvice 본문

인프런 - 스프링 완전정복 코스 로드맵/스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술

API 예외처리 - 스프링이 제공하는 ExceptionResolver, @ExceptionHandler, @ControllerAdvice

소소한나구리 2024. 9. 8. 20:12

  출처 : 인프런 - 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술 (유료) / 김영한님  
  유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용  

https://inf.run/GMo43


1. 스프링이 제공하는 ExceptionResolver - 1

HandlerExceptionResolverComposite에 다음 순서대로 등록됨

  1. ExceptionHandlerExceptionResolver - 제일 중요함
  2. ResponseStatusExceptionResolver - HTTP 응답 코드 변경
  3. DefaultHandlerExceptionResolver - 스프링 내부 예외 처리 -> 우선순위가 가장 낮음

1) ResponseStatusExceptionResolver

  • 예외에 따라서 HTTP 상태 코드를 지정해주는 역할
  • @ResponseStatus가 달려있는 예외, ResponseStatusException예외 두가지의 경우를 처리함

(1) BadRequestException 생성

  • code = HttapStatus.BAD_REQUEST : 400 오류코드로 변경
  • reason = "잘못된 요청 오류" : 에러 이유 등록
  • 생성한 예외가 컨트롤러 밖으로 넘어가면 ResponseStatusExceptionResolver @ResponseStatus애노테이션을 확인해서 오류코드를 400으로 변경하고 메세지를 담게 됨
  • 내부 코드를 확인해보면 response.sendError(statusCode, resolvedReson)를 호출 -> WAS에서 다시 오류 페이지 /error를 내부 요청함(여기서 끝낸 것이 아니라 기존 처럼 다시 요청을 한 것임)
package hello.exception.exception;

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

 

(2) ApiExceptionCeontrooler 추가 등록 후 실행

@GetMapping("/api/response-status-ex1")
public String responseStatusEx1() {
    throw new BadRequestException();
}
  • api/response-status-ex1로 get 요청하면 400에러로 오류 메세지와 상태코드가 적용되어있음

 

(2) 메시지 기능

  • reason을 MessageSource에서 찾는 기능을 제공함(코드화가 가능함)
  • reason을 "error.bad"로 변경하고 messages.properties에 내용을 남기면 해당 내용으로 메시지가 출력됨
  • 단 메시지를 보려면 server.error.include-message=always 해당 옵션을 켜주면 됨
//@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "error.bad")
public class BadRequestException extends RuntimeException {
}
  • messages.properties 등록
error.bad = 잘못된 요청 오류 입니다. 메시지를 사용했어요
  • postman으로 결과를 보면 message에 messages.properties 안에 작성한 오류 메시지가 출력됨
  • ResponseStatusExceptionResolver의 구현된 내부를 쭉 따라가다 보면 messagesSource.getMessage()로 메시지를 찾아서 반환해주고, 못찾으면 디폴트 메시지를 반환하는 코드가 구현되어있음

 

(3) ResponseStatusException

  • @ResponseStatus는 개발자가 직접 변경할 수 예외에는 적용할 수 없음 (애노테이션을 직접 넣어야 하는데 내가 코드를 수정할 수 없는 라이브러리의 예외 코드같은 곳에는 적용할 수 없음)
  • 또한 애노테이션을 사용하기 때문에 조건에 따라 동적으로 변경하는 것도 어려운데 이때 ResponseStatusException을 터트려서 사용하면 됨

 

(4) ApiExceptionController에 추가 responseStatusEx2() 추가

  • 404 에러코드 발생, error.bad의 에러 메세지 활용
  • ResponseStatusException은 직접 터트릴 예외를 적용할 수 있음 -> 여기서는 IllegalArgumentException을 터트림
  • ResponseStatusExceptionResolver가 ResponseStatusException일때 처리하는 코드가 구현되어 있음
@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
    throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad",
    					new IllegalArgumentException());
}
  • 적용 후 postman으로 테스트해보면 404 Found가 적용 되어 오류 메세지가 출력됨


2. 스프링이 제공하는 ExceptionResolver - 2

1) DefaultHandlerExceptionResolver

  • 스프링 내부에서 발생하는 스프링 예외를 해결
  • 대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에 TypeMismatchException이 발생하고 500 에러를 발생되는데, 파라미터 바인딩은 대부분 클라이언트가 HTTP 요청 정보를 잘못 호출해서 발생하는 문제이므로 상태코드를 400으로 내려야 함
  • DefaultHandlerExceptionResolver는 이것을 500이 아닌 400오류로 변경하는데, 이런 스프링 내부 오류를 어떻게 처리할지 수 많은 내용이 정의 되어 있음

(1) ApiExceptionController 추가

@GetMapping("/api/default-handler-ex")
public String defaultException(@RequestParam Integer data) {
    return "ok";
}
  • postman으로 확인해보면 500 에러가 아닌 400 에러(Bad Request)로 에러메시지가 반환됨


3. API 예외 처리 - @ExceptionHandler

1) ExceptionHandlerExceptionResolver

  • API 예외를 처리하기 위해 HandlerExcpetionResolver 직접 사용하기에는 코드가 복잡하고 매우 불편하며 ModelAndView를 반환해야 하는것도 API에는 잘 맞지 않음
  • 이런 문제를 해결하기 위해 스프링은 @ExceptionHandler라는 매우 혁신적인 예외처리를 제공하는데 이것이 가장 먼저 등록되는 ExceptionHandlerExceptionResolver임

(1) API 예외 처리의 어려운점

  • HandlerExceptionResolver를 보면 ModelAndView를 반환해야 하는데 이것은 API 응답에는 필요하지 않음
  • API 응답을 위해서 HttpServletResponse에 직접 응답 데이터를 넣어 주었는데 컨트롤러를 과거의 서블릿을 이용하던 시절과 같은 것 처럼 처리하는 것과 비슷함
  • 회원을 처리하는 컨트롤러와 상품을 처리하는 컨트롤러에서 동일한 RuntimeException 예외를 서로 다른 방식으로 처리해야 한다고 할 때 처리하기가 어려움 -> 즉, 특정 컨트롤러에서만 발생하는 예외를 별도로 처리하기가 어려움

(2) @ExceptionHandler

  • 이런 API 예외 처리 문제를 해결하기 위해 해당 애노테이션을 제공하여 매우 편리한 예외 처리 기능을 제공함
  • 기본으로 제공하는 ExceptionResolver 중에서 우선순위가 가장 높고 실무에서 API 예외 처리는 대부분 이기능을 사용함

2) 예제 코드들

(1) ErrorResult

  • 예외가 발생했을 때 API 응답으로 사용하는 객체 정의
package hello.exception.exhandler;

@Data
@AllArgsConstructor
public class ErrorResult {

    private String code;
    private String message;
}

 

(2) ApiExceptionV2Controller

 

예제 1 실행 흐름

 

  • IllegalArgumentException 예외가 컨트롤러 밖으로 던져지고 예외가 발생하여 ExceptionResolver가 작동 -> 우선순위가 높은 ExceptionHandlerExceptionResolver가 실행됨
  • 해당 컨트롤러에 IllegalArgumentException을 처리할 수 있는 @ExceptionHandler가 있는지 확인 후 있다면 해당 메서드를 실행
  • @RestController 이므로 @ResponseBody가 적용되어 HTTP 컨버터가 사용되고 응답은 JSON으로 반환
  • @ResponseStatus(HttpStatus.BAD_REQUEST)를 지정하여 HTTP 상태코드를 400으로 응답

예제 2 실행 흐름

 

  • @ExceptionHandler에 예외를 지정하지 않으면 해당 메서드의 파라미터에 지정한 예외를 사용함(여기에서는 직접 지정한 UserException을 사용)
  • ResponseEntity를 사용해서 HTTP 메세지 바디에 직접 응답하도록 할 수있음(HTTP 컨버터가 사용됨)
  • ResponseEntity를 사용하면 HTTP 응답 코드를 프로그래밍해서 동적으로 변경할 수 있음(@ResponseStatus는 애노테이션이므로 HTTP 상태코드를 동적으로 변경할 수 없음)

예제 3 실행 흐름

 

  • 상세하게 처리하지 않은 모든 예외를 처리 -> ~/api2/members/ex에 접속하면 RuntimeException이 발생되는데 Exception의 ㅈ자식 클래스이므로 해당 메서드가 호출됨
  • @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)로 HTTP 상태코드를 500으로 응답
package hello.exception.api;

@Slf4j
@RestController
public class ApiExceptionV2Controller {

    /**
     @ExceptionHandler 사용 -> 흐름을 정상흐름으로 바꿨기 때문에 에러코드가 200이됨
     @ResponseStatus 사용 : 에러코드를 지정
     예외가 정상 흐름으로 바뀌었기 때문에 여기서 예외가 처리가 되어버리기 때문에 다시 오류를 요청하는 복잡한 흐름이 발생하지 않음
     */

    // 예제1 - IllegalArgumentException 과 자식의 예외까지 모두 처리
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandler(IllegalArgumentException e) {
        log.error("[exceptionHandler] ex", e);
        return new ErrorResult("BAD", e.getMessage());
    }

    // 예제2 - 사용자 정의한 UserException 과 그의 자식까지 예외까지 모두 처리
    @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);
    }

    // 예제3 - 모든 예외의 조상 Exception 처리, 상세하게 처리하지 않은 모든 예외를 해당 컨트롤러에서 처리
    // 공통 or 실수로 놓친 모든 예외를 처리할 수 있음
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler
    public ErrorResult exHandler(Exception e) {
        log.error("[exceptionHandler] 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;
    }
}

 

(3) 실행 결과

  • API 예외 처리가 JSON으로 쉽게 처리 됨

좌) IllegalArgumentException 예외 처리 / 중) 사용자정의한 예외 처리 / 우) Exception(모든 예외) 처리

3) @ExceptionHandler 예외 처리 방법

  • @ExceptionHandler 애노테이션을 선언하고 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 예외가 발생 했을 때 해당 메서드가 호출되고, 지정한 예외와 그 예외의 자식 클래스는 모두 처리할 수 있음

(1) 우선순위

  • 스프링의 우선순위는 항상 자세한 것이 우선권을 가짐
  • 부모클래스 - 부모예외처리메서드() - 부모 예외, 자식클래스 - 자식예외처리메서드() - 자식 예외가 있다고 가정했을때, 자식 예외가 발생되면 둘다 호출대상이지만 더 자세한 자식예외처리메서드()가 호출됨
  • 부모예외가 호출되면 부모예외처리메서드()만 호출 대상이 되므로 부모예외처리메서드()가 호출 됨

(2) 다양한 예외처리 및 예외 생략

  • {}로 여러 예외를 한번에 처리할 수 있음
@ExceptionHandler({IllegalArgumentException.class, RuntimeException.class})
  • 예외 생략시 메서드 파라미터의 예외가 지정됨
@ExceptionHandler   // ()의 값을 생략하면 메서드 파라미터의 예외가 적용됨
public ResponseEntity<ErrorResult> userExHandler(UserException e) {
	// ...
}

 

(3) 파라미터와 응답

(4) HTML 오류 화면처리도 가능

  • ModelAndView를 반환해서 오류 화면을 응답할 수도 있음(당연히 @Controller로 사용해야함)
@ExceptionHandler(ViewException.class)
public ModelAndView ex(ViewException e){
	log.info("exception e", e);
    return new ModelAndView("error");
}

4. API 예외 처리 - @ControllerAdvice

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

1) 적용

(1) ExControllerAdvice

  • 기존  ApiExceptionV2Controller에 예외를 처리하는 부분의 코드만 가져와서 복사 붙혀넣기
  • 기존의 ApiExceptionV2Controller에 예외를 처리했던 @ExceptionHandler 애노테이션을 모두 제거하거나 주석처리
  • 이렇게 사용하면 마치 AOP를 적용한 것처럼 컨트롤의 코드는 깔끔해지고 예외를 처리하는 부분을 분리할 수 있음
package hello.exception.exhandler.advice;

@Slf4j
@RestControllerAdvice
public class ExControllerAdvice {

    // 기존의 ApiExceptionV2Controller 에서 예외를 처리하는 부분을 가져옴(기존 코드에서는 주석처리)
    @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", "내부 오류");
    }

}

 

(2) @ControllerAdivce, @RestControolerAdvice

  • 대상으로 여러 컨트롤러에 @ExceptionHandler, @InitBinder 기능을 부여해주는 역할을 함
  • @ControllerAdvice에 대상을 지정하지 않으면 모든 컨트롤러에 적용됨(글로벌 적용)
  • @RestControllerAdvice는 @ControllerAdvice에 @ResponseBody가 적용된 것(@Controller와 @RestController의 차이와 같음)

(3) 대상 컨트롤러 지정방법

  • 스프링 공식문서: https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-advice.html
  • annotations = 애노테이션.class : 특정 애노테이션이 붙은 컨트롤러를 대상으로 지정
  • basePackages = "패키지 경로" : 특정 패키지를 지정하여 해당 패키지와 그 하위에 있는 컨트롤러를 대상으로 지정
  • assignableTypes = 클래스 or 인터페이스.class : 특정 클래스나 부모 클래스 혹은 인터페이스를 대상으로 지정
// @RestController가 붙은 모든 컨트롤러에 예외처리를 적용
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}

// 특정 패키지의 하위에 있는 모든 컨트롤러에 예외처리를 적용
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}

// 부모의 클래스 혹은 인터페이스나, 특정 클래스에도 예외처리를 적용할 수 있음
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class ExampleAdvice3 {}