그동안 흐린눈 하면서 지나쳤던 예외처리를 한번 각잡고 싹 정리했다. DB 구조나 기획상 요청이 실패하는 경우를 제외하고, 다뤄야할 예외처리는 총 3개다.
3번 Authorization은 다음 포스팅에 하고, 이번에는 1번 Validation과 2번 TypeMismatch를 다뤄보도록 하겠다.
요청 데이터의 타당성을 검증하기 위해서는 요청 DTO를 먼저 살펴봐야 한다.
// DTO
public class ReviewRequestDTO {
@Getter
@Setter
public static class Create {
@NotNull
private Long tutoringId;
@NotBlank
private String body;
@NotNull
private Long tagId;
}
...
}
// Controller
@RestController
public class ReviewController {
@PostMapping("")
public ResponseEntity<?> createReview (@Valid @RequestBody ReviewRequestDTO.Create createReview){
return reviewService.createReview(createReview);
}
...
}
Entity 부분에 @Column(nullable = false)
어노테이션을 쓰긴 했지만, 얘가 요청 Validation을 하는데까지 영향을 끼치진 못했다. Workbench에서 바로 데이터를 insert할때는 원하는대로 null값이 들어가지 않았지만, 내가 만든 API를 통해 데이터를 생성할때는 null값도 잘들어갔었다.
그래서 요청을 보내는 DTO 단에서 null이나 blank를 허용하지 않기 위해 각각의 요청 필드에 @NotNull
과 @NotBlank
어노테이션을 붙였다. (❗Controller 단에서도 @RequestBody
앞에 @Valid
를 붙여야 적용된다.) 그랬더니 요청에 맞지 않는 값을 보냈을 때 예외처리가 되면서 널이어서는 안됩니다
, 공백일 수 없습니다
라는 아주 못쉥긴 기본 메세지가 반환되었다.
메세지 커스텀을 위해 어노테이션 안에 message
인자를 주었다.
@NotNull(message = "수업번호는 필수 입력 항목입니다.")
private Long tutoringId;
그럼 디폴트 메세지로 기본 메세지 대신 커스텀 메세지인 수업번호는 필수 입력 항목입니다.
가 반환된다.
그런데 똑같은 어노테이션마다 똑같은 문구를 여러번 반복해서 코딩하는게 마음에 너무 안들어서 아래처럼 수정했다.
근데 이것도 별로 마음에 안들어서 다른 방법을 찾다가 스프링에서 예외처리는 BindingResult
를 이용해서 한다는 것을 알게 되었다. 이 방법을 최종적으로 쓰진 않았지만 필요한 개념이기에 간단하게 정리만 하겠다.
BindingResult
는 검증오류를 보관하는 객체로, 핸들러 매개변수에서 자신이 검증할 객체 바로 다음에 위치해야 한다. 에러가 발생했을때 스프링이 자동으로 넣어줄 수도 있고(바인딩 실패의 경우), 개발자가 직접 검증해서 오류를 추가해줄 수도 있다.
// Controller
@PostMapping("")
public String addItem(@ModelAttribute Item item, BindingResult bindingResult)
{
...
}
public FieldError(String objectName, String field, String defaultMessage);
public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage)
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1_000_000) {
bindingResult.addError( // bindingResult에 오류 추가
new FieldError( // 필드 에러를 추가할 것임
"item", // item 객체에서 오류 발생
"price", // price 필드에서 오류 발생
item.getPrice(), // 사용자가 입력한 값
false, // 검증 실패
new String[] {"range.item.price"}, // 메시지 코드
new Object[]{1000, 1_000_000}, // 메시지 코드에서 사용하는 인자
null // 기본 오류 메시지 없음
)
);
}
public ObjectError(String objectName, String defaultMessage)
public ObjectError(String objectName, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage)
if (item.getPrice() != null && item.getQuantity() != null) {
int totalPrice = item.getQuantity() * item.getPrice();
if (totalPrice < 10000) {
bindingResult.addError( // bindingResult에 오류 추가
new ObjectError( // 객체 에러를 추가할 것임
"item", // item 객체에서 오류 발생
"가격 * 수량의 합은 10,000원 이상이여야 합니다. 현재 값 = " + totalPrice // 기본 오류 메시지
)
);
}
}
❗하지만 위의 방법은 생성자의 매개변수가 너무 많다. 그리고 BindingResult는 자신이 검증해야할 객체 바로 다음에 오기 때문에 자신이 검증할 객체에 대해 이미 알고있어서 굳이 쓸 필요가 없다. 그래서
FieldError
는rejectValue()
로,ObjectError
는reject()
를 통해 단순화할 수 있다.
void rejectValue(@Nullable String field, String errorCode, String defaultMessage);
void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1_000_000) {
bindingResult.rejectValue(
"price", // price 필드에서 오류 발생
"range", // 오류 코드
new Object[]{1000, 1_000_000}, // 오류 메시지 인자
null // 기본 오류 메시지 없음
);
}
void reject(String errorCode, String defaultMessage);
void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject(
"totalPriceMin", // 오류코드
new Object[]{10000, resultPrice}, // 오류 메시지 인자
null // 기본 오류 메시지 없음
);
}
}
그리고 마지막으로 다음 코드를 통해 검증에 실패했음을 확인한다.
if (bindingResult.hasErrors()) {
return "검증 실패";
}
BindingResult에 FieldError나 ObjectError 를 추가하면 MessageCodesResolver가 메시지 코드들을 생성하는데, 여기에도 규칙이 있다.
1. code + "." + object name
2. code
1. code + "." + object name + "." field
2. code + "." + field
3. code + "." field type
4. code
위의 공식에 맞춰 errors.properties
를 작성한다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
totalPriceMin=전체 가격은 {0}원 이상이어야 합니다. 현재 값 = {1}
💡
application.properties
에spring.messages.basename=errors
를 추가해야 한다.
그러면
<1>에서는 new String[] {"range.item.price"}, new Object[]{1000, 1_000_000}
을 보냈으므로 가격은 1000 ~ 1_000_000 까지 허용합니다.
가 나올 것이다.
<3>에서는 "range", new Object[]{1000, 1_000_000}
를 보냈으므로 range.item.price
를 모두 보내진 않았지만 구체적인 것에서 덜 구체적인 것의 순서로 메시지 코드들을 생성하는 규칙에 따라 code인 range
에서 걸려 <1>과 마찬가지로 가격은 1000 ~ 1_000_000 까지 허용합니다.
가 나올 것이다.
그리고 <4>에서는 "totalPriceMin", new Object[]{10000, resultPrice}
를 보냈으므로 code인 totalPriceMin
에 걸려 전체 가격은 10000원 이상이어야 합니다. 현재 값 = ?
이 나올 것이다.
그런데 이런식으로 bindingResult를 사용해서 하나하나 예외처리를 하는건 내가 원하는 방식이 아니었고, 나는 한방에 해결하고 싶었다. 더군다나 기껏 @NotNull
과 @NotBlank
어노테이션을 붙였는데 예외처리 조건을 또 코딩하는건 이상하다고 생각했다. 그러다가 @ExceptionHandler
와 @ControllerAdvice
를 통해 전역예외처리를 하는 방법을 발견했다.
@Controller
, @RestController
가 적용된 Bean내에서 발생하는 예외를 잡아서 하나의 메서드에서 처리해준다. 이 어노테이션 안에 인자로 캐치하고 싶은 예외클래스를 등록하면, 해당 Bean에서 그 예외클래스가 발생했을 때 이 어노테이션이 붙은 메소드가 실행된다.
예를 들어 아래와 같은 코드가 있을 때,
@RestController
public class MyRestController {
...
...
@ExceptionHandler(NullPointerException.class)
public Object nullex(Exception e) {
System.err.println(e.getClass());
return "myService";
}
}
MyRestController
의 Bean에서(=>MyRestController
안에 선언된 메소드들에서) NullPointerException
이 발생하면, nullex
메소드가 실행된다.
반면에 @ExeptionHandler
는 이를 등록한 컨트롤러에만 적용되기 때문에, 다른 컨트롤러에서는 NullPointerException
이 발생해도 nullex
메소드가 실행되지 않는다.
2개 이상의 예외클래스를 등록하고 싶을 때는 {}
로 묶고 ,
로 연결하면 된다.
@ExceptionHandler({ Exception1.class, Exception2.class})
프로젝트 내에 존재하는 모든 컨트롤러, 즉 전역에서 발생할 수 있는 예외를 잡아 처리하는 어노테이션이다.
새로운 클래스 파일을 만들어서 @ControllerAdvice
를 붙여주고, 그 안에 @ExceptionHandler
를 붙여서 처리하고 싶은 예외메소드들을 정리해주면 된다.
@RestControllerAdvice
public class MyAdvice {
@ExceptionHandler(Exception1.class)
public String exception1() {
return "hello exception1";
}
@ExceptionHandler(Exception2.class)
public String exception2() {
return "hello exception2";
}
...
}
@RestControllerAdvice
는 @ControllerAdvice
와 기능은 똑같지만 @ResponseBody
를 통해 객체를 리턴할 수 있다. (@Controller
와 @RestController
의 차이와 비슷)
그래서 나도 전역예외처리컨트롤러를 만들었다.
// src/main/java/springbeam/susukgwan/ApiControllerAdvice.java
@RestControllerAdvice
public class ApiControllerAdvice {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex){
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors()
.forEach(c -> {
errors.put(((FieldError) c).getField(), c.getDefaultMessage());
});
return ResponseEntity.badRequest().body(errors);
}
}
@NotNull
이나 @NotBlank
등의 어노테이션에 걸려 생기는 예외클래스가 MethodArgumentNotValidException
이기 때문에 인자로 MethodArgumentNotValidException.class
를 넣었다. 이 예외가 발생하면, handleValidationExceptions
가 실행된다.
코드를 요약하자면, 이때 발생한 예외를 ex
라고 했을 때, ex에 스프링이 자동으로 넣어준 오류들을 ex.getBindingResult().getAllErrors()
로 가지고 와서, 내 입맛대로 에러를 구성한 다음에 그 에러들을 반환해주는 것이다. 여기서 bindingResult를 이해해야 코드가 잘 이해되기 때문에 굳이 위에 직접 쓰지도 않은 방법들인 rejectValue, reject 등등을 정리한 것이다.
이렇게 쓰면 DTO에 검증 어노테이션만 써도 내가 원했던, Validation 전역예외처리가 가능하다. 그런데 여기서 또 문제점이 있다. 바로 c.getDefaultMessage()
이다.
나는 에러메세지를 errors.properties
에서 관리하고 싶었지만, c.getDefaultMessage()
는 또다시 스프링의 기본메세지만을 가지고 왔다. 어노테이션 안에 message 인자로 내가 만든 메시지를 넣으면, 그 메시지를 가지고 오긴 했다. 그런데 이렇게 대충 해결하면 또 반복되는 코드가 있을 것 같아서, errors.properties
에서 에러메시지를 가지고 오는 함수를 따로 작성했다.
// errors.properties
NotNull={0} 은(는) 필수 입력 항목입니다.
NotBlank={0} 은(는) 필수 입력 항목이며 공백을 제외한 문자를 하나 이상 포함해야 합니다.
// ApiControllerAdvice.java
@RestControllerAdvice
public class ApiControllerAdvice {
private final MessageSource messageSource;
public ApiControllerAdvice(MessageSource messageSource) {
this.messageSource = messageSource;
}
private String getErrorMessage(ObjectError error) {
String[] codes = error.getCodes();
for (String code : codes) {
try {
return messageSource.getMessage(code, error.getArguments(), Locale.KOREA);
} catch (NoSuchMessageException ignored) {}
}
return error.getDefaultMessage();
}
}
그리고 handleValidationExceptions
에서 메시지 가져오는 코드를 이렇게 바꾸면,
// ApiControllerAdvice.java
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex){
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors()
.forEach(c -> {
errors.put(((FieldError) c).getField(), getErrorMessage(c));
});
return ResponseEntity.badRequest().body(errors);
}
드디어 원하는대로 성공이다.
똑같은 흐름으로 TypeMismatch도 핸들링한다.
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<Map<String, String>> handleTypeMismatchExceptions(HttpMessageNotReadableException ex){
Map<String, String> errors = new HashMap<>();
Pattern errorFieldPattern = Pattern.compile("\\[[\"](.*?)[\"]\\]");
Matcher errorFieldMatcher = errorFieldPattern.matcher(ex.getCause().getMessage());
String errorField = errorFieldMatcher.find() ? errorFieldMatcher.group(1) : "FAIL";
Pattern rightTypePattern = Pattern.compile("[`](.*?)[`]");
Matcher rightTypeMatcher = rightTypePattern.matcher(ex.getMessage());
String rightType = rightTypeMatcher.find() ? rightTypeMatcher.group(1) : "?";
String errorMessage = messageSource.getMessage("typeMismatch", new Object[] {rightType}, Locale.KOREA);
errors.put(errorField, errorMessage);
log.error(ex.toString());
return ResponseEntity.badRequest().body(errors);
}
// errors.properties
typeMismatch=잘못된 타입입니다. {0} 형으로 입력해주세요.
TypeMismatch가 발생했을 때는 HttpMessageNotReadableException
가 발생하기 때문에 이 예외클래스를 받는다. 그리고 기본 에러메시지에서 Pattern과 Matcher를 사용해 에러가 난 필드와 올바른 자료형을 추출해내서 에러메시지를 구성한다.
💡원래 타입이 맞지 않으면 스프링이
typeMismatch
라는 오류 코드를 만들어내는데,@RequestBody
를 통해 Json을 파싱하는 경우에는 타입이 맞지 않을경우HttpMessageNotReadableException
가 발생해서, 이 경우에는MessageCodesResolver
가 작동하지 않아 에러코드가 생성되지 않는다.
에러필드와 올바른 자료형을 기본메세지에서 추출해내는 패턴을 쓰는것도 어려웠다. 하필 문자열 패턴을 찾는 키워드인 []
하고 ""
안에 있는 값을 가져와야 해서...
에러메시지 : Cannot ~중략~ $Create["tutoringId"])
=> [""] 안에 있는 tutoringId 추출
=> Pattern.compile("\\[[\"](.*?)[\"]\\]")
에러메시지 : JSON parse error: ~중략~ `java.lang.Long` ~중략~
=> `` 안에 있는 java.lang.Long 추출
=> Pattern.compile("[`](.*?)[`]")
어쨌든 해냄
@ControllerAdvice, @ExceptionHandler를 이용한 예외처리 분리, 통합하기(Spring에서 예외 관리하는 방법, 실무에서는 어떻게?)
[Spring] 검증(1) - BindingResult, MessageCodesResolver
@Valid 를 이용해 @RequestBody 객체 검증하기
Account_exceptionHandler
[MVC] Bean Validation - 검증