컨트롤러에서 직접 검증하는 것이나 Validator를 별도로 분리하는 방법은 상당히 번거롭다. 이를 위해 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화한 것이 바로 Bean Validation이다. 이를 잘 활용하면, 애너테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있다.
Bean Validation이란?
Bean Validation은 특정 구현체가 아닌 Bean Validation 2.0(JSR-380)이라는 기술 표준인데, 여기엔 검증 애노테이션과 여러 인터페이션이 모여있다. Bean Validation을 구현한 기술 중 일반적으로 사용하는 구현체는 `하이버네이트 Validator`이다.
하이버네이트 Validator 관련 링크
- 공식 사이트 : https://hibernate.org/validator/
- 공식 매뉴얼 : https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/
- 검증 애너테이션 모음 : https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/#validator-defineconstraints-spec
Bean Validation 사용법
Bean Validation 의존관계 추가
먼저 Bean Validation을 사용하기 위해선 다음 코드로 의존성 추가가 필요하다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
BeanValidation 적용
@Data
public class Car {
@NotBlank
private String manufacturer;
@NotNull
@Size(min = 2, max = 14)
private String licensePlate;
@Min(2)
@NotNull
private int seatCount;
public Car(String manufacturer, String licensePlate, int seatCount) {
this.manufacturer = manufacturer;
this.licensePlate = licensePlate;
this.seatCount = seatCount;
}
}
- manufacturer : 값은 null이 될 수 없고, 반드시 공백이 아닌 1자 이상의 문자가 포함되어야 한다.
- licenseplate : null이 될 수 없고, 반드시 2와 14자 사이여야 한다.
- seatCount : 최소 2여야 한다.
BeanValidation 유효성 검사
public class BeanValidationTest {
private static Validator validator;
@BeforeEach
void setUpValidator() {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
validator = factory.getValidator();
}
@Test
void manufacturerIsNull() {
Car car = new Car(null, "가1234", 4);
Set<ConstraintViolation<Car>> validate = validator.validate(car);
assertThat(validate.size()).isEqualTo(1);
assertThat(validate.iterator().next().getMessage())
.isEqualTo("must not be blank");
}
@Test
void licensePlateRangeTest() {
Car car = new Car("Test", "가", 4);
Set<ConstraintViolation<Car>> validate = validator.validate(car);
assertThat(validate.size()).isEqualTo(1);
assertThat(validate.iterator().next().getMessage())
.isEqualTo("size must be between 2 and 14");
}
@Test
void seatCountMinTest() {
Car car = new Car("Test", "가1234", 1);
Set<ConstraintViolation<Car>> validate = validator.validate(car);
assertThat(validate.size()).isEqualTo(1);
assertThat(validate.iterator().next().getMessage())
.isEqualTo("must be greater than or equal to 2");
}
}
스프링과 통합하지 않고, 직접 Bean Validation을 사용하여 검증하였다.
애너테이션으로 필드에 맞는 검증 애너테이션을 적용해 놓으면, 유효성 검사를 대신해 주니 매우 편리하다고 느껴질 것이다.
Bean Validation 스프링 통합
@PostMapping("/add")
public String addCar(@Valid @ModelAttribute Car car,
BindingResult bindingResult,
RedirectAttributes redirectAttributes)
{
if (bindingResult.hasErrors()) {
log.info("errors:{}", bindingResult);
return "/validation/addForm";
}
Car saveCar = carRepository.save(car);
redirectAttributes.addAttribute("carId", saveCar.getId());
return "redirect:/validation/cars/{carId}";
}
이젠 컨트롤러를 이렇게만 해도, 올바르게 유효성 검사를 할 수 있게 된다. Bean Validation을 사용하기 이전에는, Validator로 분리하여도 car의 값에서 하나하나씩 꺼내와서 직접 유효성 검사를 해야 했지만, 이제는 Bean Validation이 모두 검증을 진행 후, bindingResult에 에러정보를 추가해 주니 너무 편리해졌다.
스프링 MVC는 어떻게 Bean Validation을 사용?
스프링 부트가 spring-boot-starter-validation 라이브러리를 추가해 주면 자동으로 Bean Validator를 인지하고 스프링에 통합.
스프링 부트는 자동으로 글로벌 Validator로 등록한다.
LocalValidatorFactoryBean을 글로벌 Validator로 등록한다. 이 Validator은 @NotNull 같은 애너테이션을 보고 검증을 수행한다. 이렇게 글로벌 Validator가 적용되어 있기 때문에, 검증 객체에 @Valid 또는 @Validated만 적용하면 된다. 여기에서 검증 오류가 발생한 경우, FieldError, ObjectError를 생성하여 BindingResult에 추가한다. 생성 방법은 Validation 포스팅에서 올린 내용과 동일하다. 주의해야 할 점은 직접 글로벌 Validator를 등록하면 이를 글로벌 Validator로 등록하지 않기때문에, 애너테이션 기반의 빈 검증기가 동작하지 않는다.
참고로, 타입 바인딩에 실패한 값에 대해선 당연히 Bean Validation으로 검사하지 않는다.
Error Message 설정
그럼 결국, Bean Validation은 관련 애너테이션이 적용되어있고, 타입 바인딩에 통과한 필드에 대해서만 동작하는 것이다.
만약 검증에 실패하면, BindingResult에 FieldError 객체를 생성하여 추가해주는데, 이전에 언급한 messageCodesResolver는 일정 패턴을 이용해서 생성하기 때문에 그에 맞게 메세지 코드를 등록해주면 된다. 이해가 되지 않는다면 이 포스팅을 참고해보자.
예시
NotBlank={0}, must not be blank
Range={0}, must between {2} ~ {1}
Max={0}, maximum {1}
글로벌 에러 검증
Bean Validation은 글로벌 에러를 검증하기 위하여 @ScriptAssert를 제공한다.
@ScriptAssert(lang = "javascript",
script = "_this.price * _this.quantity >= 10000", message = "The total price must be greater than or equal to 10000")
상황에 따라 검증이 다른 경우?
예를 들어, 회원 가입을 할 시엔 비밀번호가 패턴에 맞는지 검사를 하겠지만, 비밀번호를 수정하는 경우엔 보통 수정 전 비밀번호와 수정 비밀번호가 같은지를 검사하는 사이트도 본 기억이 있다.
예시 :
@Getter @Setter
public class User {
// 이전 비밀번호 필드
private String previousPassword;
// 새로운 비밀번호 필드
@NotBlank(groups = {MemberSaveValidation.class, MemberUpdateValidation})
@Size(min = 8, max = 20, groups = {MemberSaveValidation.class, MemberUpdateValidation})
private String newPassword;
// 수정의 경우엔, MemberUpdateValidation만
@AssertTrue(groups = MemberUpdateValidation.class)
public boolean isPreviousPasswordCorrect() {
// 이전 비밀번호와 실제로 일치하는지 검증하는 로직
}
}
// 가입 검증 그룹
public interface MemberSaveValidation {}
// 수정 검증 그룹
public interface MemberUpdateValidation {}
그리고, 이것을 적용시키는 방법은 @Validated의 속성을 다음과 같이 적용하면 된다.
// 회원가입 핸들러 메서드
@PostMapping("/add")
public String addMember(@Validated(MemberSaveCheck.class) @ModelAttribute User user,
BindingResult bindingResult,
RedirectAttributes redirectAttributes)
{
....
}
// 회원 정보 수정 핸들러 메서드
@PostMapping("/{userId}/edit")
public String edit(@PathVariable Long userId,
@Validated(MemberUpdateCheck.class) @ModelAttribute Item item,
BindingResult bindingResult)
{
....
}
이렇게 검증 애너테이션에 그룹을 지어 놓으면, Bean Validation이 유효성 검사를 진행할 때 그룹에 맞는 검증만 진행하기 때문에 상황에 따라 구분하여 검증을 진행할 수 있다. 하지만 이 방식은 일일히 interface를 등록하고, 지정해줘야 하기 때문에 실무에서 잘 쓰이지 않는 방식이다. 그리고 애초에 실무에선 도메인 객체로 전달받고 하는 것이 아니고, DTO 클래스를 사용하기 때문에 굳이 이러한 방식을 사용할 필요가 없다.
'스프링 > 스프링 MVC' 카테고리의 다른 글
검증 validation (0) | 2023.05.19 |
---|---|
스프링 MVC 기본 기능 (0) | 2023.05.16 |
스프링 MVC 구조 (0) | 2023.05.15 |
MVC 프레임워크 (0) | 2023.05.14 |
Servlet의 한계, 템플릿 엔진, MVC 패턴 (0) | 2023.05.13 |