본문 바로가기

IT/Spring

[Spring Boot] @ControllerAdvice을 이용한 Exception 처리

오류 처리는 프로그램을 개발하는데 있어서 매우 큰 부분을 차지한다. 오류를 예측해서 비정상적인 상황이 발생하지 않게 하는 것은 정말 중요하다.

1. @ControllerAdvice 란?

  • @Controller나 @RestController에서 발생한 예외를 한 곳에서 관리하고 처리할 수 있게 도와주는 어노테이션이다. 

2. @ControllerAdvice 예제 코드

  • 동일한 유형의 Error Response 반환
  • 확장성이 용이한 Custom Exception의 사용

1) ErrorCode.class

예외에 대한 정보를 담고있는 enum class 이다.
public enum ErrorCode {

    INVALID_PARAMETER(400, null, "Invalid Request Data"),
    COUPON_EXPIRATION(410, "C001", "Coupon Was Expired"),
    COUPON_NOT_FOUND(404, "C002", "Coupon Not Found");

    private final String code;
    private final String message;
    private final int status;

    public String getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }

    public int getStatus() {
        return status;
    }

    ErrorCode(final int status, final String code, final String message) {
        this.status = status;
        this.message = message;
        this.code = code;
    }
}

2) ErrorResponse.class

예외에 대한 응답 정보를 저장하는 클래스이다. 
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import org.springframework.validation.Errors;
import org.springframework.validation.FieldError;

public class ErrorResponse {

    private LocalDateTime timestamp = LocalDateTime.now();
    
    private String message; //예외 메시지 저장

    private String code; // 예외를 세분화하기 위한 사용자 지정 코드,

    private int status; // HTTP 상태 값 저장 400, 404, 500 등..

    //@Valid의 Parameter 검증을 통과하지 못한 필드가 담긴다.
    @JsonInclude(JsonInclude.Include.NON_NULL)
    @JsonProperty("errors")
    private List<CustomFieldError> customFieldErrors; 
    

    public ErrorResponse() {
    }

    static public ErrorResponse create() {
        return new ErrorResponse();
    }

    public ErrorResponse code(String code) {
        this.code = code;
        return this;
    }

    public ErrorResponse status(int status) {
        this.status = status;
        return this;
    }

    public ErrorResponse message(String message) {
        this.message = message;
        return this;
    }

    public ErrorResponse errors(Errors errors) {
        setCustomFieldErrors(errors.getFieldErrors());
        return this;
    }

/*
getter 생략
*/

	//BindingResult.getFieldErrors() 메소드를 통해 전달받은 fieldErrors 
    public void setCustomFieldErrors(List<FieldError> fieldErrors) {

        customFieldErrors = new ArrayList<>();

        fieldErrors.forEach(error -> {
            customFieldErrors.add(new CustomFieldError(
                    error.getCodes()[0],
                    error.getRejectedValue(),
                    error.getDefaultMessage()
            ));
        });
    }

	//parameter 검증에 통과하지 못한 필드가 담긴 클래스이다.  
    public static class CustomFieldError {

        private String field;
        private Object value;
        private String reason;

        public CustomFieldError(String field, Object value, String reason) {
            this.field = field;
            this.value = value;
            this.reason = reason;
        }

        public String getField() {
            return field;
        }

        public Object getValue() {
            return value;
        }

        public String getReason() {
            return reason;
        }
    }
}

 

1) CustomException.class

쿠폰 만료 예외, 존재하지 않는 쿠폰 예외, 쿠폰 중복 참여 예외 등 여러가지 예외의 기본이 되는 클래스이다. 모든 예외 정보는 ErrorCode를 통해서 전달 받는다.
public class CustomException extends RuntimeException {

    private static final long serialVersionUID = 1L;

    private ErrorCode errorCode;

    public CustomException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }

    public ErrorCode getErrorCode() {
        return errorCode;
    }
}

2) CouponExpirationException.class

쿠폰이 만료 되었을 경우, 발생하는 예외 클래스이다. 
public class CouponExpirationException extends CustomException {

    private static final long serialVersionUID = -2116671122895194101L;

    public CouponExpirationException() {
        super(ErrorCode.COUPON_EXPIRATION);
    }
}

3) CouponNotFoundException.class

입력한 쿠폰을 찾을 수 없을 경우, 발생하는 예외 클래스이다.

public class CouponNotFoundException extends CustomException {

    private static final long serialVersionUID = -2116671122895194101L;

    public CouponNotFoundException() {
        super(ErrorCode.COUPON_NOT_FOUND);
    }
}

4) InvalidParameterException.class

public class InvalidParameterException extends CustomException {

    private static final long serialVersionUID = -2116671122895194101L;

    private final Errors errors;

    public InvalidParameterException(Errors errors) {
        super(ErrorCode.INVALID_PARAMETER);
        this.errors = errors;
    }

    public Errors getErrors() {
        return this.errors;
    }

}

5) ControllerExceptionHandler.class

@ControllerAdvice
public class ControllerExceptionHandler {

    private final Logger logger = LoggerFactory.getLogger(ControllerExceptionHandler.class);

    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    protected ResponseEntity<ErrorResponse> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) {
        logger.error("handleHttpRequestMethodNotSupportedException", e);

        final ErrorResponse response
                = ErrorResponse
                        .create()
                        .status(HttpStatus.METHOD_NOT_ALLOWED.value())
                        .message(e.getMessage());

        return new ResponseEntity<>(response, HttpStatus.METHOD_NOT_ALLOWED);
    }

    //@Valid 검증 실패 시 Catch
    @ExceptionHandler(InvalidParameterException.class)
    protected ResponseEntity<ErrorResponse> handleInvalidParameterException(InvalidParameterException e) {
        logger.error("handleInvalidParameterException", e);

        ErrorCode errorCode = e.getErrorCode();

        ErrorResponse response
                = ErrorResponse
                        .create()
                        .status(errorCode.getStatus())
                        .message(e.toString())
                        .errors(e.getErrors());

        return new ResponseEntity<>(response, HttpStatus.resolve(errorCode.getStatus()));
    }

    //CustomException을 상속받은 클래스가 예외를 발생 시킬 시, Catch하여 ErrorResponse를 반환한다.
    @ExceptionHandler(CustomException.class)
    protected ResponseEntity<ErrorResponse> handleCustomException(CustomException e) {
        logger.error("handleAllException", e);

        ErrorCode errorCode = e.getErrorCode();

        ErrorResponse response
                = ErrorResponse
                        .create()
                        .status(errorCode.getStatus())
                        .code(errorCode.getCode())
                        .message(e.toString());

        return new ResponseEntity<>(response, HttpStatus.resolve(errorCode.getStatus()));
    }
    
    //모든 예외를 핸들링하여 ErrorResponse 형식으로 반환한다.
    @ExceptionHandler(Exception.class)
    protected ResponseEntity<ErrorResponse> handleException(Exception e) {
        logger.error("handleException", e);

        ErrorResponse response
                = ErrorResponse
                        .create()
                        .status(HttpStatus.INTERNAL_SERVER_ERROR.value())
                        .message(e.toString());

        return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}
  • @ControllerAdvice : Controller에서 발생한 예외를 Catch하여 처리하는 클래스이다. 
    basePackageClasses, basePackages 속성을 이용하여 특정 클래스에서 발생하는 예외만 받아서 처리할 수 있다.
  • @ExceptionHandler : 메소드 위에 선언되며, 등록한 예외를 Catch하여 처리할 수 있다.
  • ResponseEntity : Header와 Body에 응답 객체를 저장하여 반환할 수 있는 클래스이다.

6) Member.class

public class Member {

    @NotBlank(message = "name은 필수 값입니다.")
    private String name;

    @NotBlank(message = "email은 필수 값입니다.")
    private String email;

    @Min(value = 18, message = "18살 이상만 입장 가능합니다.")
    private int age;

    @NotNull(message = "금액은 필수 값 입니다.")
    @Min(value = 10000, message = "10000원 이상 소지해야합니다.")
    private Integer money;
    
    //getter,setter 생략
    
    
}

 

7) HomeController.class

@Controller
@RequestMapping(value = "/")
public class HomeController {

    @GetMapping("/member")
    public String memberException(@Valid Member dto, BindingResult result) {
        if (result.hasErrors()) {
            throw new InvalidParameterException(result);
        }

        return "page/home";
    }

    @GetMapping("/exception")
    public String exceptionTest(String code) {
        switch (code) {
            case "1":
                throw new CouponExpirationException();
            case "2":
                throw new CouponNotFoundException();
            case "3":
                int a = 3 / 0;
                break;
        }
        return "page/home";
    }
}

 

3. 테스트

1) GET http://localhost:8080/SpringBootException/member

에러 응답

{
    "timestamp": "2020-06-23T17:42:35.51",
    "message": "com.example.bamdule.exception.InvalidParameterException: Invalid Request Data",
    "status": 400,
    "fieldErrors": [
        {
            "field": "NotBlank.member.name",
            "value": null,
            "reason": "name은 필수 값입니다."
        },
        {
            "field": "Min.member.age",
            "value": 0,
            "reason": "18살 이상만 입장 가능합니다."
        },
        {
            "field": "NotBlank.member.email",
            "value": null,
            "reason": "email은 필수 값입니다."
        },
        {
            "field": "NotNull.member.money",
            "value": null,
            "reason": "금액은 필수 값 입니다."
        }
    ]
}
2) GET http://localhost:8080/SpringBootException/exception?code=2

에러 응답

{
    "timestamp": "2020-06-23T17:46:25.628",
    "message": "com.example.bamdule.exception.CouponNotFoundException: Coupon Not Found",
    "status": 404,
    "code": "C002"
}

 

참조 : https://cheese10yun.github.io/spring-guide-exception/