예외 처리 방법에는 두가지가 있다.
1. @ExceptionHandler
2. @RestControllerAdvice
샘플 애플리케이션을 구현해 보면서 만날 수 있는 예외들
만약 DTO 클래스에서 유효성 검증에 실패한다면 예외가 발생할 것이다.
하지만 응답메세지에는 400 Bad Request 인건 알려주어도 요청 데이터 중에서 어떤 항목이 유효성 검증에 실패했는지 알 수가 없다.
클라이언트 쪽에서 에러메시지를 조금 더 구체적으로 알 수 있게 바꾸어 주면 좋을 것 이다.
유효성 검증에 실패했을 때 이를 하나의 예외로 간주하고 예외를 던져서(throw) 예외 처리를 유도하면 Spring이 하던 에러 응답 메세지를 내가 고칠 수 있게 된다.
회원 컨트롤러에 @ExceptionHandler를 이용해서 예외를 받을 수 있다.
@ExceptionHandler
public ResponseEntity handleException(MethodArgumentNotValidException e) {
final List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
return new ResponseEntity<>(fieldErrors, HttpStatus.BAD_REQUEST);
}
이렇게 고치면 응답메세지가 구체적으로 변하지만 전체정보를 담고 있기 때문에 무척 장황한 응답메세지로 변하게 된다.
따라서 문제가 된 프로퍼티는 무엇인지와 에러 메시지만 전달 받을 수 있게 에러 정보를 기반으로 한 Error Response 클래스를 만들어서 필요한 정보만 담은 후에 클라이언트 쪽에 전달하게 코드를 수정한다.
package com.codestates.response
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.List;
@Getter
@AllArgsConstructor
public class ErrorResponse {
private List<FieldError> fieldErrors;
@Getter
@AllArgsConstructor
public static class FieldError {
private String field;
private Object rejectedValue;
private String reason;
}
}
@ExceptionHandler
public ResponseEntity handleException(MethodArgumentNotValidException e) {
final List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
List<ErrorResponse.FieldError> errors =
fieldErrors.stream()
.map(error -> new ErrorResponse.FieldError(
error.getField(),
error.getRejectedValue(),
error.getDefaultMessage()))
.collect(Collectors.toList());
return new ResponseEntity<>(new ErrorResponse(errors), HttpStatus.BAD_REQUEST);
}
Postman으로 확인해보면 잘 처리되는 것을 확인 할 수 있다.
하지만 이렇게 처리를 하면 컨트롤러마다 코드 중복이 발생하고, 예외처리가 다양해질수록 에러처리 핸들러 메소드가 증가해 컨트롤러 클래스가 길어질 것이다.
따라서 두번째 방법인 @RestControllerAdvice 사용하여 문제점들을 개선해보자.
특정 클래스에 @RestControllerAdvice 애너테이션을 추가하면 여러 개의 Controller 클래스에서 @ExceptionHandler, @InitBinder 또는 @ModelAttribute가 추가된 메서드를 공유해서 사용할 수 있어서 예외처리를 공통화 할 수 있다.
우선 앞서서 @ExceptionHandler를 사용한 로직을 전부 제거한다.
advice패키지를 생성하고, Controller 클래스에서 발생하는 예외들을 공통으로 처리할 ExceptionAdvice 클래스를 정의하는데, 클래스이름 위에 @RestControllerAdvice을 붙여준다.
그리고 처리할 Exception 핸들러 메서드를 구현한다.
package com.codestates.advice;
import com.codestates.exception.BusinessLogicException;
import com.codestates.response.ErrorResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import javax.validation.ConstraintViolationException;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionAdvice {
@ExceptionHandler
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleMethodArgumentNotValidException(
MethodArgumentNotValidException e) {
final ErrorResponse response = ErrorResponse.of(e.getBindingResult());
return response;
}
@ExceptionHandler
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleConstraintViolationException(
ConstraintViolationException e) {
final ErrorResponse response = ErrorResponse.of(e.getConstraintViolations());
return response;
}
@ExceptionHandler
public ResponseEntity handleBusinessLogicException(BusinessLogicException e) {
final ErrorResponse response = ErrorResponse.of(e.getExceptionCode());
return new ResponseEntity<>(response, HttpStatus.valueOf(e.getExceptionCode()
.getStatus()));
}
@ExceptionHandler
@ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
public ErrorResponse handleHttpRequestMethodNotSupportedException(
HttpRequestMethodNotSupportedException e) {
final ErrorResponse response = ErrorResponse.of(HttpStatus.METHOD_NOT_ALLOWED);
return response;
}
@ExceptionHandler
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleException(Exception e) {
log.error("# handle Exception", e);
// TODO 애플리케이션의 에러는 에러 로그를 로그에 기록하고, 관리자에게 이메일이나 카카오톡,
// 슬랙 등으로 알려주는 로직이 있는게 좋습니다.
final ErrorResponse response = ErrorResponse.of(HttpStatus.INTERNAL_SERVER_ERROR);
return response;
}
}
이제 에러가 발생되어 던져지면 GlobalExceptionAdvice클래스가 받게된다.
URI 변수로 넘어오는 값의 유효성 검증에 대한 에러(ConstraintViolationException)도 처리할 수 있게 ErrorResponse 클래스가 ConstraintViolationException에 대한 Error Response를 생성할 수 있도록 수정한다.
package com.codestates.response;
import com.codestates.exception.ExceptionCode;
import lombok.Getter;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindingResult;
import javax.validation.ConstraintViolation;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Getter
public class ErrorResponse {
private int status;
private String message;
private List<FieldError> fieldErrors;
private List<ConstraintViolationError> violationErrors;
private ErrorResponse(int status, String message) {
this.status = status;
this.message = message;
}
private ErrorResponse(final List<FieldError> fieldErrors,
final List<ConstraintViolationError> violationErrors) {
this.fieldErrors = fieldErrors;
this.violationErrors = violationErrors;
}
public static ErrorResponse of(BindingResult bindingResult) {
return new ErrorResponse(FieldError.of(bindingResult), null);
}
public static ErrorResponse of(Set<ConstraintViolation<?>> violations) {
return new ErrorResponse(null, ConstraintViolationError.of(violations));
}
public static ErrorResponse of(ExceptionCode exceptionCode) {
return new ErrorResponse(exceptionCode.getStatus(), exceptionCode.getMessage());
}
public static ErrorResponse of(HttpStatus httpStatus) {
return new ErrorResponse(httpStatus.value(), httpStatus.getReasonPhrase());
}
@Getter
public static class FieldError {
private String field;
private Object rejectedValue;
private String reason;
private FieldError(String field, Object rejectedValue, String reason) {
this.field = field;
this.rejectedValue = rejectedValue;
this.reason = reason;
}
public static List<FieldError> of(BindingResult bindingResult) {
final List<org.springframework.validation.FieldError> fieldErrors =
bindingResult.getFieldErrors();
return fieldErrors.stream()
.map(error -> new FieldError(
error.getField(),
error.getRejectedValue() == null ?
"" : error.getRejectedValue().toString(),
error.getDefaultMessage()))
.collect(Collectors.toList());
}
}
@Getter
public static class ConstraintViolationError {
private String propertyPath;
private Object rejectedValue;
private String reason;
private ConstraintViolationError(String propertyPath, Object rejectedValue,
String reason) {
this.propertyPath = propertyPath;
this.rejectedValue = rejectedValue;
this.reason = reason;
}
public static List<ConstraintViolationError> of(
Set<ConstraintViolation<?>> constraintViolations) {
return constraintViolations.stream()
.map(constraintViolation -> new ConstraintViolationError(
constraintViolation.getPropertyPath().toString(),
constraintViolation.getInvalidValue().toString(),
constraintViolation.getMessage()
)).collect(Collectors.toList());
}
}
}
ErrorResponse 클래스의 생성자는 생성자 앞에 private 접근 제한자(Access Modifier)를 지정했기 때문에 New 키워드가 아닌 of() 메서드를 이용해서 ErrorResponse의 객체를 생성한다.
MethodArgumentNotValidException에서 에러 정보를 얻기 위해 필요한 것이 바로 BindingResult 객체이므로 이 of() 메서드를 호출하는 쪽에서 BindingResult 객체를 파라미터로 넘겨주면 되는데 BindingResult 객체를 가지고 에러 정보를 추출하고 가공하는 일은 하단에 있는 static 멤버 클래스인 FieldError 클래스에게 위임하고 있다.
ConstraintViolationException에 대한 ErrorResponse 객체도 마찬가지로 static 멤버 클래스인 ConstraintViolationError 클래스에게 위임하고 있다.