Spring Validation VS Java Bean Validation

박건우·2023년 1월 20일
1
post-thumbnail

Spring Validation

유효성 검사, 데이터 바인딩

Spring은 validationdata binding을 웹 티어와 분리하여 다양한 validatior를 쉽게 통합할 수 있는 디자인을 제공한다. validation을 비즈니스 로직과 분리한다는 점에서 큰 이점이 발생한다.

Data binding은 사용자가 입력한 데이터를 어플리케이션 도메인 모델에 동적으로 바인딩하는 것을 가능하게 해준다. Spring은 DataBinder를 제공하여 이를 처리한다.

웹 레이어에서 DataBinder마다 컨트롤러에 Spring Validator 인스턴스를 등록할 수 있으며, 이는 커스텀 validation 로직을 적용하는데 유용하게 쓸 수 있다.

Spring의 Validator 인터페이스 구현

Spring은 객체의 유효성을 검사하는 데 사용할 수 있는 Validator 인터페이스를 제공한다. Validator 인터페이스는 Errors 객체를 사용하여 작동하므로 유효성을 검사하는 동안 Validator가 Errors 객체에 유효성 검사 실패를 보고할 수 있다.

public class Person {
    
    private String name;
    private int age;
    
    // the usual getters and setters...
}

Validator 인터페이스의 다음 두 메서드를 구현하여 Person 클래스에 대한 유효성 검사 동작을 제공한다.

  • supports(Class) : 제공된 클래스의 인스턴스를 검증할 수 있는지를 정의. (true를 반환하면 validate()가 동작)

  • validate(Object, Errors) : 주어진 개체의 유효성을 검사하고 오류의 경우 지정된 Errors 개체에 등록

ValidationUtils 클래스를 이용하여 Validtor 구현을 간단하게 할 수 있다.

public class PersonValidator implements Validator {
    
    @Override
    public boolean supports(Class claszz) {
        return Person.class.equals(claszz);
    }

    @Override
    public void validate(Object obj, Errors e) {
        ValidationUtils.rejectIfEmpty(e, "name", "name.empty");
        Person p = (Person) obj;
        if (p.getAge < 0) {
            e.rejectValue("age", "nagativevalue");
        } else if (p.getAge() > 110) {
            e.rejectValue("age", "too.darn.old");
        }
    }
}

ValidationUtils 클래스의 rejectIfEmpty() 는 null 또는 빈 문자열인 경우 특정 속성을 거부하는데 사용된다.

중첩된 객체를 검증하는 데 하나의 Validator 클래스를 구현하는 것은 가능하지만, 각 중첩된 클래스의 객체에 대한 검증 로직을 각각의 Validator 구현에 캡슐화하는 것이 더 좋을 수 있다.

간단한 예로 Customer 객체가 두 개의 문자열 속성 , 이름 과 복잡한 Address 객체로 구성된 고객이 있다고 가정해보자.

이 때, Address 객체는 Customer 객체와 독립적으로 사용할 수 있으므로, 별도의 AddressValidator가 구현되어 있다.

CustomerValidator에서 AddressValidator 에 포함된 검증 로직을 재사용하고 싶다면, AddressValidator의 로직을 끌어와 사용하지 않고 아래와 같이 CustomValidator 내에서 AddressValidtor를 의존주입하거나 인스턴스화 할 수 있다.

아래 예제를 보면 이해하는데 도움이 될 것이다.

public class CustomValidator implements Validator {

    private final Validator addressValidator;

    public CustomValidator(Validator addressValidator) {
        if (addressValidator == null) {
            throw new IllegalArgumentException("제공된 Validtor는 필수이며 null이 아니어야 합니다.");
        }
        if (!addressValidator.supports(Address.class)) {
            throw new IllegalArgumentException("제공된 Validator는 주소 인스턴스의 유효성 검사를 지원합니다.");
        }
        this.addressValidator = addressValidator;
    }

    @Override
    public boolean supports(Class clazz) {
        return Customer.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        ValidationUtils.rejectIfEmptyOrWhitespace(errors, "firstName", "field.required");
        ValidationUtils.rejectIfEmptyOrWhitespace(errors, "surname", "field.required");
        Customer customer = (Customer) target;
        try {
            errors.pushNestedPath("address");
            ValidationUtils.invokeValidator(this.addressValidator, customer.getAddress(), errors);
        } finally {
            errors.popNestedPath();
        }
    }
}
  • pushNestedPath() : 중첩된 경로 스택에 지정된 하위경로를 push
    • 현재 경로 : parent
      pushNestedPath("child") 실행 후 결과 경로 : parent.child
  • popNestedPath() : 중첩된 경로 스택에서 이전의 중첩경로로 재지정

Java Bean Validation

Bean Validation 개요

Bean Validation은 Java 애플리케이션에 대한 제약 조건 선언메타데이터를 통한 일반적인 유효성검사 방법을 제공한다. 이를 사용하려면 도메인 모델 속성에 선언적 검증 제약조건을 어노테이션으로 붙이면, 런타임에서 이를 준수하도록 강제한다.

Bean Validation을 사용하면 다음 예제와 같이 제약조건을 선언할 수 있다.

public class PersonForm {

  @NotNull
  @Size(max = 64)
  private String name;

  @Min(0)
  private int age;
}

그런 다음 Bean Validation은 선언된 제약조건을 기반으로 이 클래스의 인스턴스를 유효성 검사한다.

Custom 제약조건 구성

Bean Validation 제약조건은 두 부분으로 구성된다.

  • 제약조건 및 구성 가능한 속성을 선언하는 @Constraint 어노테이션
  • 제약조건의 동작을 구현하는 jakarta.validation.Validator 인터페이스의 구현

선언을 구현과 연결하기 위해 각 @Constraint 어노테이션은 해당 ConstraintValidator 구현 클래스를 참조한다. 런타임 시에 ConstraintValidatorFactory가 제약조건 어노테이션을 도메인 모델에서 발견하면 참조된 Validator를 인스턴스화한다.

다음 예제는 사용자 정의 @Constraint 선언이다.

@Target({ElementType.METHOD, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = MyConstraintValidator.class)
public @interface MyConstraint {
}

ContraintValidator 인터페이스 구현

우리가 구현할 Validator는 JSR에서 제공하는 javax.validation의 ConstraintValidator 인터페이스를 구현해주어야 한다. 해당 인터페이스는 다음과 같이 생성되어 있다.

public interface ConstraintValidator<A extends Annotation, T> {

    default void initialize(A constraintAnnotation) {
    }

    boolean isValid(T value, ConstraintValidatorContext context);
}

위의 인터페이스는 2가지 제네릭 타입을 받고 있는데, 순서대로 적용될 어노테이션과 적용될 타입에 해당된다. 또한 ConstraintValidator가 갖는 메소드들은 각각 다음의 역할을 한다.

  • initialize() : Validator를 초기화하기 위한 메서드
  • isValid() : 유효성 검증을 하는 메서드

initialize()는 기본적으로 default 메서드로 구현되어 있으므로 초기화할 작업이 없다면 따로 구현하지 않아도 된다. initialize()는 isValid()가 처음 호출될 때 1회 호출된다. isValid() 에는 우리가 검증할 로직을 구현하면 된다.

@Valid와 @Validated

@Valid를 이용한 유효성 검증

@Valid는 JSR-303 표준 스펙으로써 Bean Validation을 이용해 객체의 제약조건을 검증하도록 지시하는 어노테이션이다. JSR 표준의 빈 검증 기술의 특징은 객체의 필드에 달린 어노테이션으로 편리하게 검증을 한다는 것이다.

아래와 같이 도메인 모델 속성에 필요한 제약조건 어노테이션을 붙여줄 수 있다.

@Getter @Setter
public class AddUserRequest {
	
    @Email
    private final String email;
    
    @NotBlank
    private final String pw;
    
    @NotNull
    private final UserRole userRole;
    
    @Min(12)
    private final int age;
}
  • @Email : 올바른 형식의 이메일 주소여야 함
  • @NotBlank : null이 아니어야 하고, 하나 이상의 공백이 아닌 문자를 포함해야 함
  • @NotNull : null이 아니어야 함
  • @Min(value=) : value보다 크거나 같아야 함

이외에도 많은 제약조건 어노테이션이 존재한다. 더 궁금하다면 Spring Validation Annotation 총정리 링크에서 확인하길 바란다.

그리고 다음과 같이 컨트롤러의 메서드에 @Valid를 붙여주면 유효성 검증이 진행된다.

@RequestMapping("/user/add")
public ResponseEntitiy<Void> addUser(@Valid @RequestBody AddUserRequest addUserRequest) {
	//...
}

@Valid 동작 원리

@Valid는 ArgumentResolver에 의해 처리된다. 대표적으로 @RequestBody는 JSON 메시지를 객체로 변환해주는 작업을 ArgumentResolver의 구현체인 RequestResponseBodyMethodProcessor가 처리하며, 이 내부에서 @Valid로 시작하는 어노테이션이 있을 경우에 유효성 검사를 진행한다. 만약 @ModelAttribute를 사용중이라면 ModelAttributeMethodProcessor에 의해 @Valid가 처리된다.

그리고 검증에 오류가 있다면 MethodArgumentNotValidException 예외가 발생하고 404 BadRequest 에러가 발생한다.

이러한 이유로 @Valid는 기본적으로 컨트롤러에서만 동작하며 다른 계층에서는 검증이 되지 않는다. 다른 계층에서 파라미터를 검증하기 위해서는 @Validated와 결합되어야 하는데, 아래에서 @Validated와 함께 자세히 살펴보자.


@Validated을 이용한 유효성 검증

입력 파라미터의 유효성 검증은 컨트롤러에서 최대한 처리하고 넘겨주는 것이 좋다. 하지만 불가피하게 다른 곳에서 파라미터를 검증해야 할 수 있다. Spring에서는 이를 위해 AOP 기반으로 메서드의 요청을 가로채서 유효성 검증을 진행해주는 @Validated를 제공하고 있다. @Validated는 JSR 표준 기술이 아니며 Spring 프레임워크에서 제공하는 어노테이션 및 기능이다.

다음과 같이 클래스에 @Validated를 붙여주고, 유효성 검증할 메서드의 파라미터에 @Valid를 붙여주면 유효성 검증이 진행된다.

@Service
@Validated
public class UserService {

	public void addUser(@Valid AddUserRequest addUserRequest) {
    	//...
    }
}

유효성 검증에 실패하면 @Valid에서 발생한 MethodArgumentNotValidException 예외가 아닌 ConstraintViolationException 예외가 발생한다. 이는 앞에서 잠깐 설명한대로 둘의 동작 원리가 다르기 때문이다.

@Validated 동작 원리

특정 ArgumnetResolver에 의해 유효성 검사가 진행되었던 @Valid와 달리, @Validated는 AOP 기반으로 메서드 요청을 인터셉터하여 처리된다. @Validated를 클래스 레벨에 선언하면 해당 클래스에 유효성 검증을 위한 AOP의 어드바이스 또는 인터셉터(MethodValidationInterceptor)가 등록된다.

그리고 해당 클래스의 메서드들이 호출될 때 AOP의 포인트 컷으로써 요청을 가로채서 유효성 검증을 진행한다. 이러한 이유로 @Validated를 사용하면 컨트롤러, 서비스, 레포지토리 등 계층에 무관하게 스프링 빈이라면 유효성 검증을 진행할 수 있다.

대신 클래스에는 유효성 검증 AOP가 적용되도록 @Validated를, 검증을 진행할 메서드에는 @Valid를 선언해주어야 한다. 그렇기 때문에 @Valid에 의한 예외는 MethodArgumentNotValidException이며, @Validated에 의한 예외는 ConstraintViolationException 이다.


@Validated 유효성 검증 그룹 지정

동일한 클래스에 대해 제약조건이 요청에 따라 달라질 수 있다. 예를 들어 일반 사용자의 요청과 관리자의 요청이 1개의 클래스로 처리될 때, 다른 제약 조건이 적용되어야 할 수 있다.

이때는 검증될 제약 조건이 2가지로 나누어져야 하는데, Spring은 이를 위해 제약 조건이 적용될 검증 그룹을 지정할 수 있는 기능 역시 @Validated를 통해 제공하고 있다.

검증 그룹을 지정하기 위해서는 마커 인터페이스(요소가 하나도 없는)를 간단히 정의해야 한다. 위의 예시의 경우에는 사용자인 경우와 관리자인 경우를 분리해야 하므로 다음과 같은 2개의 마커 인터페이스를 만들 수 있다.

public interface UserValidationGroup {}  //일반 사용자
public interface AdminValidationGroup {} //관리자

그리고 해당 제약 조건이 적용될 그룹을 groups로 지정해줄 수 있다. 제약 조건이 적용될 그룹이 여러 개라면 {}를 이용해 그룹의 이름을 모두 넣어주면 된다. 다음과 같이 도메인 모델 속성에 그룹 속성을 지정할 수 있다.

@NotEmpty(groups = {UserValidationGroup.class, AdminValidationGroup.class} ) 
private String name; 

@NotEmpty(groups = UserValidationGroup.class) 
private String userId; 

@NotEmpty(groups = AdminValidationGroup.class) 
private String adminId;

그리고 컨트롤러에서도 다음과 같이 제약조건 검증을 적용할 클래스를 지정해주면 된다.

@RequestMapping("/users") 
public ResponseEntity<Void> addUser(@RequestBody @Validated(UserValidationGroup.class) AddUserRequest addUserRequest) { 
	//...
}

만약 위와 같이 UserValidationGroup를 @Validated의 파라미터로 넣어주었다면 UserValidationGroup에 해당하는 제약 조건만 검증이 된다. 만약 @Validated에 특정 마커를 지정해주지 않았다면, groups가 없는 속성들만 처리된다.

Custom Annotation을 사용한 Validator 구현 (Java Bean Validation)

javax.validation dependency 추가

<dependency>
	<groupId>javax.validation</groupId>
	<artifactId>validation-api</artifactId>
	<version>2.0.1.Final</version>
</dependency>

검증을 위한 어노테이션 생성

@Target(FIELD)
@Retention(RUNTIME)
@Constraint(validatedBy = PhoneValidator.class)
@Documented
public @interface Phone {

    String message() default " 은(는) 휴대폰번호 형식에 맞지 않습니다.";

    Class<?>[] groups() default { };

    Class<? extends Payload>[] payload() default { };
}

아래의 3가지 속성들은 JSR-303 표준 어노테이션들이 갖는 공통 속성들이다. 해당 속성들은 각각 다음의 역할을 한다.

  • message() : 유효하지 않을 경우 반환될 메시지
  • groups() : 유효성 검증이 진행될 그룹
  • payload() : 유효성 검증 시에 전달할 메타 정보

검증을 위한 Validator 구현

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class PhoneValidator implements ConstraintValidator<Phone, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        Pattern pattern = Pattern.compile("\\d{3}-\\d{4}-\\d{4}");
        Matcher matcher = pattern.matcher(value);
        return matcher.matches();
    }
}

검증할 클래스 생성 및 제약조건 생성

@Getter @Setter
public class ValidateDto {

    @NotEmpty
    @Size(min = 4, max = 20)
    private String loginId;

    @NotEmpty
    @Min(value = 8)
    private String password;

    @NotEmpty
    private String userNm;

    @Email
    private String email;

    @Phone //PhoneValidator가 동작할 커스텀 어노테이션
    private String moblphone;
}

검증 시 발생할 Exception Handler 생성

위에 적용한 것 처럼 에러를 처리하는 객체를 따로 생성해 가공하는 것 외에도, @ExceptionHandler 어노테이션을 이용해 예외를 처리할 수 있다.

아래는 @Valid를 이용한 검증 과정에서 발생할 수 있는 MethodArgumentNotValidException 예외에 대한 핸들러를 구현한 것이다.

@ControllerAdvice
@RestController
public class ExceptionAdvisor {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<List<FieldErrorDto>> processValidationError(MethodArgumentNotValidException exception) {
        BindingResult bindingResult = exception.getBindingResult();
        List<FieldErrorDto> errorDtoList = bindingResult.getFieldErrors().stream()
                .map(fieldError -> new FieldErrorDto(
                        fieldError.getField(),
                        fieldError.getRejectedValue(),
                        fieldError.getDefaultMessage()))
                .collect(Collectors.toList());

        return ResponseEntity.badRequest().body(errorDtoList);
    }

}

테스트 및 검증

이제 작성한 유효성 검증이 올바르게 동작하는지 확인하기 위해 컨트롤러를 추가해줄 차례이다.

아래와 같이 @Valid를 사용해 @RequestBody를 이용해 받아온 validateDto 타입의 파라미터를 검증하도록 작성한다.

@Controller
public class PhoneController {

    @RequestMapping("/validatePhone.lims")
    @ResponseBody
    public ValidateDto validatePhone(@Valid @RequestBody ValidateDto validateDto) {
        return validateDto;
    }
}

결과

프론트엔드에서 잘못된 응답을 넘겨받았을 때, 사용자가 잘못 입력한 rejectValue와 검증 에러 시 반환된 메시지인 defualtMessage를 조합하여 사용자에게 적합한 경고메시지를 띄워주게끔 구현하였다.


또한, 개발자가 검증 에러 객체에 포함된 전체 내용을 확인하도록 따로 설정도 가능하다.

0개의 댓글