(8) Spring Boot Exception 전략

728x90

통일된 Error Response 객체

Error Response 객체는 항상 동일한 Error Response를 가져야 한다. 그렇지 않으면 클라이언트에서 예외 처리를 항상 동일한 로직으로 처리하기 어렵다.

Error Response 객체를 유연하게 처리하기 위해서 간혹 Map<Key, Value> 형식으로 처리하는데 이는 좋지 않다고 생각한다.

우선 Map 이라는 친구는 런타입시에 정확한 형태를 갖추기 때문에 객체를 처리하는 개발자들도 정확히 무슨 키에 무슨 데이터가 있는지 확인하기 어렵다.

@ExceptionHandler(MethodArgumentNotValidException.class)
protected ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
    log.error("handleMethodArgumentNotValidException", e);
    final ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, e.getBindingResult());
    return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}

위 예제 코드처럼 리턴 타입이 ResponseEntity<ErrorResponse> 으로 무슨 데이터가 어떻게 있는지 명확하게 추론하기 쉽도록 구성하는 게 바람직하다.


Error Response JSON

{
  "message": " Invalid Input Value",
  "status": 400,
  // "errors":[], 비어있을 경우 null 이 아닌 빈 배열을 응답한다.
  "errors": [
    {
      "field": "name.last",
      "value": "",
      "reason": "must not be empty"
    },
    {
      "field": "name.first",
      "value": "",
      "reason": "must not be empty"
    }
  ],
  "code": "C001"
}

ErrorResponse 객체의 JSON

  • message : 에러에 대한 message를 작성
  • status : http status code를 작성. header 정보에도 포함된 정보이니 굳이 추가하지 않아도 된다.
  • errors : 요청 값에 대한 field, value, reason 작성한다. 일반적으로 @Valid 어노테이션으로 JSR 303: Bean Validation에 대한 검증을 진행
    • 만약 errors에 바인인된 결과가 없을 경우 null이 아니라 빈 배열 []을 응답. null 객체는 절대 리턴하지 않는다. null이 의미하는 것이 애매하기 때문이다.
  • code : 에러에 할당되는 유니크한 코드값
@Getter
public class BaseResponse<T> {

    @ApiModelProperty(example = "OK")
    private final String code; // 상태 코드 메시지
    @ApiModelProperty(example = "요청 성공하였습니다.")
    private final String message; // 에러 설명
    @JsonInclude(JsonInclude.Include.NON_NULL)
    private T result;

    // 요청 실패
    public BaseResponse(BaseResponseCode baseResponseCode) {
        this.code = baseResponseCode.getStatus().name();
        this.message = baseResponseCode.getMessage();
    }


    // 요청에 성공한 경우
    public BaseResponse(T result) {
        this.code = OK.getStatus().name();
        this.message = OK.getMessage();
        this.result = result;
    }

    public BaseResponse(String error, String message) {
        this.code = error;
        this.message = message;
    }
}

특정 Exception에 대해서 ErrorResponse 객체를 어떻게 만들 것인가에 대한 책임을 명확하게 갖는 구조로 설계할 수 있다.


@ControllerAdvice로 모든 예외를 핸들링

@ControllerAdvice 어노테이션으로 모든 예외를 한 곳에서 처리할 수 있습니다. 해당 코드의 세부적인 것은 중요하지 않으며 가장 기본적이며 필수적으로 처리하는 코드입니다. 코드에 대한 이해보다 아래의 설명을 참고하는 게 좋습니다.

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {


    /*
     * Developer Custom Exception
     */
    @ExceptionHandler(BaseException.class)
    protected ResponseEntity<BaseResponse> handleBaseException(final BaseException e) {
        return ResponseEntity
                .status(e.getBaseResponseCode().getStatus().value())
                .body(new BaseResponse(e.getBaseResponseCode()));
    }

    /**
     * enum type 일치하지 않아 binding 못할 경우 발생
     * 주로 @RequestParam enum으로 binding 못했을 경우 발생
     */
    // date
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    protected ResponseEntity<BaseResponse> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) {
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST.value())
                .body(new BaseResponse(BaseResponseCode.INVALID_DATE));
    }

    /**
     * javax.validation.Valid or @Validated 으로 binding error 발생시 발생한다.
     * HttpMessageConverter 에서 등록한 HttpMessageConverter binding 못할경우 발생
     * 주로 @RequestBody, @RequestPart 어노테이션에서 발생
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    protected ResponseEntity<BaseResponse> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        BindingResult bindingResult = e.getBindingResult();
        StringBuilder builder = new StringBuilder();
        for (FieldError fieldError : bindingResult.getFieldErrors()) {
            builder.append(fieldError.getDefaultMessage());
        }

        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST.value())
                .body(new BaseResponse(HttpStatus.BAD_REQUEST.name(), builder.toString()));
    }

    // Path variable 입력 안할 때
    @ExceptionHandler(value = HttpMessageNotReadableException.class)
    public ResponseEntity<BaseResponse> handleHttpMessageNotReadableException(HttpMessageNotReadableException e) {
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST.value())
                .body(new BaseResponse(BaseResponseCode.EMPTY_PATH_VARIABLE));
    }

    /*
     * HTTP 405 Exception
     */
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    protected ResponseEntity<BaseResponse> handleHttpRequestMethodNotSupportedException(final HttpRequestMethodNotSupportedException e) {
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST.value())
                .body(new BaseResponse(BaseResponseCode.METHOD_NOT_ALLOWED));
    }

    @ExceptionHandler(MissingServletRequestParameterException.class)
    protected ResponseEntity<BaseResponse> handleMissingServletRequestParameterException(final MissingServletRequestParameterException e) {
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST.value())
                .body(new BaseResponse(BaseResponseCode.EMPTY_DATE));
    }

    @ExceptionHandler(UnexpectedTypeException.class)
    protected ResponseEntity<BaseResponse> handleUnexpectedTypeException(final UnexpectedTypeException e) {
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST.value())
                .body(new BaseResponse(BaseResponseCode.INVALID_ENUM_TYPE));
    }

    @ExceptionHandler(UsernameNotFoundException.class)
    protected ResponseEntity<BaseResponse> handleUnexpectedTypeException(final UsernameNotFoundException e) {
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST.value())
                .body(new BaseResponse(BaseResponseCode.INVALID_ADMIN));
    }

}
  • handleMethodArgumentNotValidException
    • avax.validation.Valid or @Validated 으로 binding error 발생시 발생
    • HttpMessageConverter 에서 등록한 HttpMessageConverter binding 못할경우 발생 주로 @RequestBody, @RequestPart 어노테이션에서 발생
  • handleBindException
    • @ModelAttribut 으로 binding error 발생시 BindException 발생한다.
  • MethodArgumentTypeMismatchException
    • enum type 일치하지 않아 binding 못할 경우 발생
    • 주로 @RequestParam enum으로 binding 못했을 경우 발생
  • handleHttpRequestMethodNotSupportedException :
    • 지원하지 않은 HTTP method 호출 할 경우 발생
  • handleAccessDeniedException
    • Authentication 객체가 필요한 권한을 보유하지 않은 경우 발생
    • Security에서 던지는 예외
  • handleException
    • 그 밖에 발생하는 모든 예외 처리, Null Point Exception, 등등
    • 개발자가 직접 핸들링해서 다른 예외로 던지지 않으면 모두 이곳으로 모인다.
  • handleBusinessException
    • 비즈니스 요규사항에 따른 Exception

추가로 스프링 및 라이브러리 등 자체적으로 발생하는 예외는 @ExceptionHandler 으로 추가해서 적절한 Error Response를 만들고 비즈니스 요구사항에 예외일 경우 BusinessException 으로 통일성 있게 처리하는 것을 목표로 한다. 추가로 늘어날 수는 있겠지만 그 개수를 최소한으로 하는 노력이 필요하다.


Error Code 정의

@Getter
@AllArgsConstructor
public enum BaseResponseCode {
    // common
    /*
    * 400 BAD_REQUEST: 잘못된 요청
    */
    BAD_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."),

    /*
    * 404 NOT_FOUND: 리소스를 찾을 수 없음
    */
    POSTS_NOT_FOUND(HttpStatus.NOT_FOUND, "게시글 정보를 찾을 수 없습니다."),

    /*
    * 405 METHOD_NOT_ALLOWED: 허용되지 않은 Request Method 호출
    */
    METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "허용되지 않은 메서드입니다."),

    /*
    * 500 INTERNAL_SERVER_ERROR: 내부 서버 오류
    */
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "내부 서버 오류입니다."),

    OK(HttpStatus.OK, "요청 성공하였습니다."),

    // 특정 상황
    NON_EXISTENT_ID(HttpStatus.BAD_REQUEST, "존재하지 않는 인덱스입니다."),
    EMPTY_IDX_LIST(HttpStatus.BAD_REQUEST, "견적문의 배분완료할 인덱스 리스트를 입력하세요."),
    INVALID_DATE(HttpStatus.BAD_REQUEST, "yyyy-MM-dd 형식으로 날짜를 입력하세요."),
    EMPTY_PASSWORD(HttpStatus.BAD_REQUEST, "비밀번호를 입력하세요."),
    INVALID_EMAIL(HttpStatus.BAD_REQUEST, "이메일 형식이 올바르지 않습니다."),
    NON_EXISTENT_EMAIL(HttpStatus.BAD_REQUEST, "존재하지 않는 이메일입니다"),
    EMPTY_ADDRESS(HttpStatus.BAD_REQUEST, "주소를 입력하세요."),
    EMPTY_INQUIRY_VEHICLE(HttpStatus.BAD_REQUEST, "문의차량을 입력하세요."),
    EMPTY_KAKAOTALK_ID(HttpStatus.BAD_REQUEST, "카카오톡 아이디를 입력하세요."),
    INVALID_JWT(HttpStatus.BAD_REQUEST, "유효하지 않는 jwt 입니다."),
    EMPTY_JWT(HttpStatus.BAD_REQUEST, "jwt를 입력하세요."),
    EXISTENT_EMAIL(HttpStatus.BAD_REQUEST, "이미 존재하는 이메일입니다."),
    EMPTY_IDX(HttpStatus.BAD_REQUEST, "인덱스를 입력하세요."),
    ...
    
    ;

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

에러 코드는 enum 타입으로 한 곳에서 관리

에러 코드가 전체적으로 흩어져있을 경우 코드, 메시지의 중복을 방지하기 어렵고 전체적으로 관리하는 것이 매우 어렵다.

에러 메시지는 Common과 각 도메인별로 관리하는 것이 효율적일 것 이다.


위와 같은 방식으로 적용

@Getter
@AllArgsConstructor
public class BaseException extends RuntimeException {
    private final BaseResponseCode baseResponseCode;
}
@Getter
public class BaseResponse<T> {

    @ApiModelProperty(example = "OK")
    private final String code; // 상태 코드 메시지
    @ApiModelProperty(example = "요청 성공하였습니다.")
    private final String message; // 에러 설명
    @JsonInclude(JsonInclude.Include.NON_NULL)
    private T result;

    // 요청 실패
    public BaseResponse(BaseResponseCode baseResponseCode) {
        this.code = baseResponseCode.getStatus().name();
        this.message = baseResponseCode.getMessage();
    }


    // 요청에 성공한 경우
    public BaseResponse(T result) {
        this.code = OK.getStatus().name();
        this.message = OK.getMessage();
        this.result = result;
    }

    public BaseResponse(String error, String message) {
        this.code = error;
        this.message = message;
    }
}
@Getter
@AllArgsConstructor
public enum BaseResponseCode {
    // common
    /*
    * 400 BAD_REQUEST: 잘못된 요청
    */
    BAD_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."),

    /*
    * 404 NOT_FOUND: 리소스를 찾을 수 없음
    */
    POSTS_NOT_FOUND(HttpStatus.NOT_FOUND, "게시글 정보를 찾을 수 없습니다."),

    /*
    * 405 METHOD_NOT_ALLOWED: 허용되지 않은 Request Method 호출
    */
    METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "허용되지 않은 메서드입니다."),

    /*
    * 500 INTERNAL_SERVER_ERROR: 내부 서버 오류
    */
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "내부 서버 오류입니다."),

    OK(HttpStatus.OK, "요청 성공하였습니다."),

    // 특정 상황
    NON_EXISTENT_ID(HttpStatus.BAD_REQUEST, "존재하지 않는 인덱스입니다."),
    EMPTY_IDX_LIST(HttpStatus.BAD_REQUEST, "견적문의 배분완료할 인덱스 리스트를 입력하세요."),
    INVALID_DATE(HttpStatus.BAD_REQUEST, "yyyy-MM-dd 형식으로 날짜를 입력하세요."),
    EMPTY_PASSWORD(HttpStatus.BAD_REQUEST, "비밀번호를 입력하세요."),
    INVALID_EMAIL(HttpStatus.BAD_REQUEST, "이메일 형식이 올바르지 않습니다."),
    NON_EXISTENT_EMAIL(HttpStatus.BAD_REQUEST, "존재하지 않는 이메일입니다"),
    EMPTY_ADDRESS(HttpStatus.BAD_REQUEST, "주소를 입력하세요."),
    EMPTY_INQUIRY_VEHICLE(HttpStatus.BAD_REQUEST, "문의차량을 입력하세요."),
    EMPTY_KAKAOTALK_ID(HttpStatus.BAD_REQUEST, "카카오톡 아이디를 입력하세요."),
    INVALID_JWT(HttpStatus.BAD_REQUEST, "유효하지 않는 jwt 입니다."),
    EMPTY_JWT(HttpStatus.BAD_REQUEST, "jwt를 입력하세요."),
    EXISTENT_EMAIL(HttpStatus.BAD_REQUEST, "이미 존재하는 이메일입니다."),
    EMPTY_IDX(HttpStatus.BAD_REQUEST, "인덱스를 입력하세요."),
    ...
    
    ;

    private final HttpStatus status;
    private final String message;
    private int status;
    
    ErrorCode(final int status, final String code, final String message) {
        this.status = status;
        this.message = message;
        this.code = code;
    }
}
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {


    /*
     * Developer Custom Exception
     */
    @ExceptionHandler(BaseException.class)
    protected ResponseEntity<BaseResponse> handleBaseException(final BaseException e) {
        return ResponseEntity
                .status(e.getBaseResponseCode().getStatus().value())
                .body(new BaseResponse(e.getBaseResponseCode()));
    }

    /**
     * enum type 일치하지 않아 binding 못할 경우 발생
     * 주로 @RequestParam enum으로 binding 못했을 경우 발생
     */
    // date
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    protected ResponseEntity<BaseResponse> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) {
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST.value())
                .body(new BaseResponse(BaseResponseCode.INVALID_DATE));
    }

    /**
     * javax.validation.Valid or @Validated 으로 binding error 발생시 발생한다.
     * HttpMessageConverter 에서 등록한 HttpMessageConverter binding 못할경우 발생
     * 주로 @RequestBody, @RequestPart 어노테이션에서 발생
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    protected ResponseEntity<BaseResponse> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        BindingResult bindingResult = e.getBindingResult();
        StringBuilder builder = new StringBuilder();
        for (FieldError fieldError : bindingResult.getFieldErrors()) {
            builder.append(fieldError.getDefaultMessage());
        }

        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST.value())
                .body(new BaseResponse(HttpStatus.BAD_REQUEST.name(), builder.toString()));
    }

    // Path variable 입력 안할 때
    @ExceptionHandler(value = HttpMessageNotReadableException.class)
    public ResponseEntity<BaseResponse> handleHttpMessageNotReadableException(HttpMessageNotReadableException e) {
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST.value())
                .body(new BaseResponse(BaseResponseCode.EMPTY_PATH_VARIABLE));
    }

    /*
     * HTTP 405 Exception
     */
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    protected ResponseEntity<BaseResponse> handleHttpRequestMethodNotSupportedException(final HttpRequestMethodNotSupportedException e) {
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST.value())
                .body(new BaseResponse(BaseResponseCode.METHOD_NOT_ALLOWED));
    }

    @ExceptionHandler(MissingServletRequestParameterException.class)
    protected ResponseEntity<BaseResponse> handleMissingServletRequestParameterException(final MissingServletRequestParameterException e) {
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST.value())
                .body(new BaseResponse(BaseResponseCode.EMPTY_DATE));
    }

    @ExceptionHandler(UnexpectedTypeException.class)
    protected ResponseEntity<BaseResponse> handleUnexpectedTypeException(final UnexpectedTypeException e) {
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST.value())
                .body(new BaseResponse(BaseResponseCode.INVALID_ENUM_TYPE));
    }

    @ExceptionHandler(UsernameNotFoundException.class)
    protected ResponseEntity<BaseResponse> handleUnexpectedTypeException(final UsernameNotFoundException e) {
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST.value())
                .body(new BaseResponse(BaseResponseCode.INVALID_ADMIN));
    }

}

 

728x90