[SpringBoot] Request로 오는 객체 검증(Validation) 알아보기
객체 검증은 정말 자주 하는 작업이지만 정말 중요한 작업이기도 하다. Annotation만 붙일 것이 아니라, 어떻게 동작하는지 또 어떻게 깔끔한 유효성 검사를 할 수 있는지 알아보자.
Intro
일반적 애플리케이션의 Validation 검시 시, 아래와 같은 문제를 갖고 있다.
- 애플리케이션 전체에 분산되어 있다.
- 코드 중복이 심하다.
- 비즈니스 로직에 섞여있어 로직 추적이 어렵고, 복잡해진다.
Bean Validation
위와 같은 문제를 해결하기 위해 Java에서는 Bean Validation이라는 유효성 검사 프레임워크를 제공해준다. 도메인 모델에 어노테이션으로 정의할 수 있다.
dependency 추가
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
사용하기
Controller 에서는 @Valid 어노테이션을 추가하면 된다.
@PostMapping("/form")
public void PostForm(@Valid CreateForm createForm) {
// ..
}
Service나 Bean에서 사용하려면 Class 위에 @Validated도 추가해줘야 한다.
@Validated
@Service
public class FormService {
@PostMapping("/form")
public void PostForm(@Valid CreateForm createForm) {
// ..
}
}
여기서 데이터 유효성 검사가 여러 번 실행될 경우, 성능에 영향을 줄 수 있기 때문에 주의해야 한다.
오류 처리
데이터 유효성 검사 실패 시, ConstraintViolationException을 발생시킨다. ConstraintViolationException은 실패 정보를 담고 있는 ConstraintViolation 객체들을 가지고 있다. 이를 이용해 ExceptionHandler로 적절한 오류 응답을 만들 수 있다.
// 참고 : https://meetup.toast.com/posts/223
@ControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(value = ConstraintViolationException.class) // 유효성 검사 실패 시 발생하는 예외 처리
@ResponseBody
protected Response handleException(ConstraintViolationException exception) {
return Response
.builder()
.header(Header
.builder()
.isSuccessful(false)
.resultCode(-400)
.resultMessage(getResultMessage(exception.getConstraintViolations().iterator())) // 오류 응답을 생성
.build())
.build();
}
protected String getResultMessage(final Iterator<ConstraintViolation<?>> violationIterator) {
final StringBuilder resultMessageBuilder = new StringBuilder();
while (violationIterator.hasNext() == true) {
final ConstraintViolation<?> constraintViolation = violationIterator.next();
resultMessageBuilder
.append("['")
.append(getPopertyName(constraintViolation.getPropertyPath().toString())) // 유효성 검사가 실패한 속성
.append("' is '")
.append(constraintViolation.getInvalidValue()) // 유효하지 않은 값
.append("'. ")
.append(constraintViolation.getMessage()) // 유효성 검사 실패 시 메시지
.append("]");
if (violationIterator.hasNext() == true) {
resultMessageBuilder.append(", ");
}
}
return resultMessageBuilder.toString();
}
protected String getPopertyName(String propertyPath) {
return propertyPath.substring(propertyPath.lastIndexOf('.') + 1); // 전체 속성 경로에서 속성 이름만 가져온다.
}
}
위와 같은 ExceptionHandler를 작성하면, 아래와 같은 응답을 받을 수 있다.
{
"header" : {
"isSuccessful" : false,
"resultCode" : -400,
"resultMessage" : "['title' is 'null'. must not be blank], ['body' is 'null'. must not be null]"
}
}
@PathVariable, @RequestParam에 대한 유효성 검사
개인적으로 개발하면서 궁금증을 가졌던 부분이다. DTO안에 primitive type이나 String 외에 다른 클래스가 있다면 어떻게 해야할까? 해당 클래스를 Validation하려면 그 DTO에 @Valid를 적용하고 다시 어노테이션을 적용하면 된다 !
StudentDto
@Getter
@Setter
public class StudentDto {
@NotBlank
private String name;
@Valid
private TestInfo testInfo; //Dto내에 또다른 class를 가진 객체가 있다면 @Valid를 설정한다.
}
TestInfo
@Getter
@Setter
public class TestInfo {
@NotNull
@Max(value=100)
@Min(value=0)
private int score;
}