Spring은 validation
과 data binding
을 웹 티어와 분리하여 다양한 validatior를 쉽게 통합할 수 있는 디자인을 제공한다. validation을 비즈니스 로직과 분리한다는 점에서 큰 이점이 발생한다.
Data binding은 사용자가 입력한 데이터를 어플리케이션 도메인 모델에 동적으로 바인딩하는 것을 가능하게 해준다. Spring은 DataBinder
를 제공하여 이를 처리한다.
웹 레이어에서 DataBinder마다 컨트롤러에 Spring Validator 인스턴스를 등록할 수 있으며, 이는 커스텀 validation 로직을 적용하는데 유용하게 쓸 수 있다.
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()
: 중첩된 경로 스택에 지정된 하위경로를 pushpopNestedPath()
: 중첩된 경로 스택에서 이전의 중첩경로로 재지정Bean Validation은 Java 애플리케이션에 대한 제약 조건 선언
과 메타데이터
를 통한 일반적인 유효성검사 방법을 제공한다. 이를 사용하려면 도메인 모델 속성에 선언적 검증 제약조건을 어노테이션으로 붙이면, 런타임에서 이를 준수하도록 강제한다.
Bean Validation을 사용하면 다음 예제와 같이 제약조건을 선언할 수 있다.
public class PersonForm {
@NotNull
@Size(max = 64)
private String name;
@Min(0)
private int age;
}
그런 다음 Bean Validation은 선언된 제약조건을 기반으로 이 클래스의 인스턴스를 유효성 검사한다.
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 {
}
우리가 구현할 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는 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와 함께 자세히 살펴보자.
입력 파라미터의 유효성 검증은 컨트롤러에서 최대한 처리하고 넘겨주는 것이 좋다. 하지만 불가피하게 다른 곳에서 파라미터를 검증해야 할 수 있다. 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
이다.
동일한 클래스에 대해 제약조건이 요청에 따라 달라질 수 있다. 예를 들어 일반 사용자의 요청과 관리자의 요청이 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가 없는 속성들만 처리된다.
<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()
: 유효성 검증 시에 전달할 메타 정보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;
}
위에 적용한 것 처럼 에러를 처리하는 객체를 따로 생성해 가공하는 것 외에도, @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
를 조합하여 사용자에게 적합한 경고메시지를 띄워주게끔 구현하였다.
또한, 개발자가 검증 에러 객체에 포함된 전체 내용을 확인하도록 따로 설정도 가능하다.