서론
최근 tdd 방식으로 사이드프로젝트를 진행중이다.
tdd 방식은 시간도 더 걸리고 귀찮지만 이미 테스트코드가 있으니까 리팩터링 시 chat gpt를 적극 활용할 수 있다. 코드를 수정할 때도 테스트 딸깍으로 사이드 이펙트를 확인할 수 있어서 좋다.
또, 실패 테스트 작성 과정에서 예외 처리에 대해 신경쓰게 되어 꼼꼼한 예외처리가 가능해지는 것 같다.
컨트롤러에서 요청값을 받을 때 적용한 예외처리에 대해 간단하게 정리해보고자 한다.
Bean Validation
@GetMapping("/{encryptedCongressmanId}")
public ResponseEntity<RatingListDTO> ratingList(
@PathVariable String encryptedCongressmanId,
@PageableDefault(page = 0, size = 20, sort = {"topicality"}, direction = Direction.DESC) Pageable pageable,
@Validated @ModelAttribute CountCursor cursor) {
SortValidator.validateSortProperties(pageable.getSort(), List.of("like", "dislike", "topicality"));
return ResponseEntity.ok(ratingService.validateAndGetRecentRatings(encryptedCongressmanId, pageable, cursor));
}
컨트롤러에서 @ModelAttrivute , @RequestBody 등의 파라미터에 @Validated 를 붙여주면 bean validation을 사용할 수 있다.
public class CountCursor {
private String idCursor;
@Min(value = 0, message = "countCursor는 0 이상이어야 합니다.")
private Integer countCursor;
@AssertTrue(message = "일부 커서만 유효할 수 없습니다.")
public boolean isCursorValid() {
if (isAllFieldInvalid()) { // 모든 필드 inValid
return true;
}
if (isSomeFieldInvalid()) { // 일부 필드만 inValid
return false;
}
return true; // 모든 필드 valid
}
대상 객체의 필드에는 @Min, @NotBlank 같은 애노테이션을 붙여 검증할 수 있다.
@AssertTrue 로 단일 필드가 아닌 메서드의 반환값으로 검증할 수 있다.
검증에 실패하면 애노테이션의 message 속성의 메세지를 담은 예외를 던지게 되며, 이것은 @RestControllerAdvice 에서 전역적으로 처리해 줄 수 있다.
타입 캐스팅 실패
Bean Validation은 필드에 값이 세팅되어야 가능하다. 하지만 Integer 필드에 "abc" 값이 들어오면 타입 불일치로 값이 세팅되지 않는다. 이 때는 typemismatch에 대한 default 에러 메세지(영어)가 제공된다.
나는 type mismatch인 경우에도 에러 메세지를 한글로 예쁘게 커스텀하고 싶었다.
아래에서 소개하는 @RestControllerAdvice에서 타입 캐스팅에 대해 커스텀 메세지를 만들어 처리해주면 된다.
@RestControllerAdvice
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
public static final String TYPE_MISMATCH_ERROR_MESSAGE_FORMAT = "입력값 %s 를 %s 타입으로 변환할 수 없습니다.";
// 모든 예외 처리
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleAllException(Exception e) {
e.printStackTrace();
return handleExceptionInternal(new InternalServerException(InternalServerErrorCode.INTERNAL_SERVER_ERROR));
}
// 커스텀 예외 처리
@ExceptionHandler(CustomException.class)
public ResponseEntity<ErrorResponse> handleCustomException(CustomException e) {
e.printStackTrace();
return handleExceptionInternal(e);
}
private ResponseEntity<ErrorResponse> handleExceptionInternal(CustomException e) {
return ResponseEntity.status(e.getHttpStatus()).body(makeErrorResponse(e.getErrorCode()));
}
private ErrorResponse makeErrorResponse(ErrorCode errorCode) {
return ErrorResponse.builder().code(errorCode.name()).message(errorCode.getMessage()).build();
}
// @Vaild 필드 검증 실패 처리
// @ModelAttribute 자료형 불일치로 바인딩 실패 처리 (isBindingFailure = true)
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
HttpHeaders headers, HttpStatusCode status,
WebRequest request) {
ex.printStackTrace();
return handleExceptionInternal(ex, CommonErrorCode.INVALID_INPUT_VALUE);
}
private ResponseEntity<Object> handleExceptionInternal(BindException e, ErrorCode errorCode) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(makeErrorResponse(e, errorCode));
}
private ErrorResponse makeErrorResponse(BindException e, ErrorCode errorCode) {
List<ValidationError> validationErrorList = e.getBindingResult().getFieldErrors().stream()
.map(fieldError -> {
if (fieldError.isBindingFailure()) {
// 바인딩 실패
return ValidationError.of(
fieldError, String.format(TYPE_MISMATCH_ERROR_MESSAGE_FORMAT,
fieldError.getRejectedValue(),
fieldError.getField().getClass().getSimpleName())
);
} else {
// Validation 실패
return ValidationError.from(fieldError);
}
})
.collect(Collectors.toList());
return ErrorResponse.builder().code(errorCode.name()).message(errorCode.getMessage())
.errors(validationErrorList).build();
}
// @PathVariable, @RequestParam 자료형 불일치로 바인딩 실패 처리
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ErrorResponse> handleMethodArgumentTypeMismatchException(
MethodArgumentTypeMismatchException e) {
return handleExceptionInternal(e);
}
private ResponseEntity<ErrorResponse> handleExceptionInternal(MethodArgumentTypeMismatchException e) {
ErrorResponse errorResponse = ErrorResponse.builder()
.code(CommonErrorCode.TYPE_MISMATCH.name())
.message(String.format(TYPE_MISMATCH_ERROR_MESSAGE_FORMAT, e.getValue(),
e.getRequiredType().getSimpleName()))
.build();
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
// @RequestBody json 형식 예외 처리
// @ReqeustBdoy 자료형 불일치로 바인딩 실패 처리
@Override
protected ResponseEntity<Object> handleHttpMessageNotReadable(
HttpMessageNotReadableException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
return handleExceptionInternal(ex);
}
private ResponseEntity<Object> handleExceptionInternal(HttpMessageNotReadableException e) {
if (e.getCause() instanceof InvalidFormatException) {
return handleInvalidFormatException(e);
}
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(makeErrorResponse(CommonErrorCode.INVALID_REQUEST_BODY_FORMAT));
}
// @RequestBody 바인딩 에러 (타입 불일치)
private ResponseEntity<Object> handleInvalidFormatException(HttpMessageNotReadableException e) {
InvalidFormatException cause = (InvalidFormatException) e.getCause();
// 상세 메시지 생성
String customMessage = String.format(TYPE_MISMATCH_ERROR_MESSAGE_FORMAT,
cause.getValue().toString(), cause.getTargetType().getSimpleName());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(makeErrorResponse(CommonErrorCode.INVALID_REQUEST_BODY_FORMAT, customMessage));
}
private ErrorResponse makeErrorResponse(ErrorCode errorCode, String message) {
return ErrorResponse.builder().code(errorCode.name()).message(message).build();
}
}
@RestControllerAdvice 에서 전역적으로 예외처리를 해줄 수 있다. 커스텀 예외에 대해 내가 정의한 에러 응답 형태 ErrorResponse 형태로 처리한다. Validation 예외의 경우 여러 필드 또는 메서드에 대해 발생할 수 있어서 리스트 형태로 응답한다.
ResponseEntityExceptionHandler를 상속하고 있다. ResponseEntityExceptionHandler는 스프링 기본 예외에 대한 예외처리를 구현해 놓았다.
내가 만든 커스텀 예외에 대한 처리, 스프링 기본 예외에 대한 처리를 같은 ErrorResponse 형태로 내려야 통일성있다. 따라서 ResponseEntityExceptionHandler에서 기본 예외를 처리하는 메서드를 오버라이딩해서 ErrorResponse 형태로 응답해준다.
@ModelAttribute
1) 타입 불일치 : MethodArgumentNotValidException -> fieldError.isBindingFailure() : true
2) Valid 실패 : MethodArgumentNotValidException -> fieldError.isBindingFailure() : false
@RequestBody
1) 타입 불일치 HttpMessageNotReadableException -> e.getCause() instanceof InvalidFormatException : true
2) Valid 실패 : MethodArgumentNotValidException
@PathVariable , @RequestParam
1) 타입 불일치 : MethodArgumentTypeMismatchException
예외 상황 별 발생하는 예외 타입은 위와 같다.
GlobalExceptionHandler에서 isBindingFailuer() 또는 instanceof InvalidFormatExcetion 의 true, false 여부에 따라 타입 불일치에 의해 발생한 예외인지 구분하고, 타입 불일치 예외라면 커스텀 예외 메세지를 갖고 ErrorResponse를 만들어 응답한다. 커스텀 메세지에 필요한 정보 (예외 필드, 입력 값, 요구 자료형 등)은 모두 예외에 포함되어 있으므로 가져다 처리한다.
결론
간단하게 Validation 검증과 예외 처리 방식을 알아보았다.
이번 사이드프로젝트에 추가로 적용한 내용은 객체에서 @AssertTrue 를 활용한 메서드 검증과 typemismatch 인 경우 메세지를 커스텀하는 것이다.
추가로 Pageable에 관해서도 커스텀 예외 처리를 구현하고 글을 써 보겠다.
'스프링' 카테고리의 다른 글
테스트 시 @Value 사용 (0) | 2024.11.04 |
---|---|
dto를 사용해야 하는 이유 (0) | 2024.09.01 |
ApplicationContext (0) | 2024.08.28 |
서블릿과 MVC 패턴과 프론트 컨트롤러 패턴 (1) | 2024.08.27 |
카카오 로그인 (0) | 2024.08.02 |