부제 : Java Bean Validation 과 Spring Framework Validation
Validation의 정확한 동작 방식을 모른채, Validation Annotation 생성하여 해결하려다 원하는 대로 동작하지 않아 결국 유효성 검증 동작 방식을 찾아보게 되었다.
관련해서 동일한 난관에 부딪혔을 때 참고하면 좋을 것 같아 남긴다.
문제 상황
동일한 클래스에 대해 제약조건이 다른 경우
필자의 경우, 하나의 클래스에서 제약조건이 다른 경우 보다는 상속 관계를 가진 부모-자식 객체에서 동일한 필드에 대한 제약조건이 다른 경우였다. 결론에 언급하겠지만, 현 게시글의 해결책은 하나의 클래스에 대해 제약조건 설정하는 경우에 더 적합하긴 하다.
요구 사항
- 부모 객체 name 필드는 NotNull 이다
- 자식 객체 name 필드는 Nullable 이다
부모 객체
@Getter
@Setter
@NoArgsConstructor
public class Parent {
// .. 유효성 검증 필요 없는 필드 생략
@Size(max = 60)
private String id;
@ApiModelProperty(required = true)
@NotNull
private String name;
@Min(0)
@Max(100)
private int size;
}
자식 객체
- 여기서 name 을 정의한 뒤 @Nullable 등의 설정을 하면, 유효성 검증 시 Override (?) 가 될 거란 착각
@Getter
@Setter
@NoArgsConstructor
public class Child extends Parent {
// .. 유효성 검증 필요 없는 필드 생략
@NotNull
private String subField1;
@ApiModelProperty(required = false)
// @Nullable
private String name;
@Size(max = 200)
private int subField2;
}
물론 부모, 자식 객체 필드를 상속받지 않고 분리하는 방법도 있지만 분리할 수 없다는 가정하에 진행하겠다!
시도1. Custom Valid Annotation 생성
이때까지만 해도, Validation 동작 방식을 제대로 파악하려고 하지 않았다.. (ㅎ)
현 상황에 적합하진 않았지만, 이런 방법으로 유효성 검증을 할 수 있구나 정도만 알자..!
새로운 Valid 어노테이션을 생성하고 파라미터 값의 여부에 따라 NotNull 검사를 진행하도록..
지금 보면, 바보 같은 생각이지만.. 의도는 그렇다..
Custom Annotation
- @NotNull 과 동일한 구조로 생성
- validateBy 부분 설정
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = NotNullCustomValidator.class)
public @interface NotNullCustom {
boolean isNotNull() default true;
String message() default "{javax.validation.constraints.NotNull.message}";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
}
Custom Validator
- isNotNull = false 인 경우 validation 검사 Skip
- isNotNull = true 인 경우 validation NotNull 검사
public class NotNullCustomValidator implements ConstraintValidator<NotNullCustom, Object> {
private boolean isNotNull;
@Override
public void initialize(NotNullCustom constraintAnnotation) {
isNotNull = constraintAnnotation.isNotNull();
ConstraintValidator.super.initialize(constraintAnnotation);
}
@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
// Nullable, null check X
if (!isNotNull) return true;
// NotNull, null check, !isNull
if (!Objects.isNull(value)) return true;
return false;
}
}
Annotation 설정
- 부모 객체
public class Parent {
// .. 생략
@ApiModelProperty(required = true)
@NotNullCustom // default true 이기 때문에 설정 X
private String name;
}
- 자식 객체
public class Child extends Parent {
// .. 생략
@ApiModelProperty(required = false)
@NotNullCustom(isNotNull = false)
private String name;
}
시도1 결과
- 런타임 시점에 @NotNullCustom 설정한 필드 모두 Validation 시도
부모 객체 검증 시
- 부모 객체 name 검증 : isNotNull = true 로 인해 검증
자식 객체 검증 시
- 자식 객체 name 검증 : isNotNull = false 로 인해 검증 SKIP
- 부모 객체 name 검증 : isNotNull = true 로 인해 검증
예상치 못한 검증
여기서, 자식 → 부모 객체 순으로 검증할 것이라는 것을 생각 못했다.
검증 시 동일한 필드명의 동일한 어노테이션은 Override 될 거란 착각을 했다.
검증하려는 (동일한 필드명) 필드의 순서가 랜덤인지, 자식 → 부모 순서인지도 정확히 모르겠다..
관련해서 순서 설정 가능한 지 여러 시도해보았지만 실패 ..
이 쯤 부터 동작 원리을 찾아보게 됨..
@Valid 유효성 검증
@Valid는 JSR-303 표준 스펙으로써 Bean Validation을 이용해 객체의 제약조건을 검증하도록 지시하는 어노테이션이다.
- 도메인 모델 속성에 필요한 제약조건 어노테이션을 붙일 수 있음
- @Email : 올바른 형식의 이메일 주소여야 함
- @NotBlank : null이 아니어야 하고, 하나 이상의 공백이 아닌 문자를 포함해야 함
- @NotNull : null이 아니어야 함
- @Min(value=) : value보다 크거나 같아야 함
이 외에도 많은 Annotation이 있는데 여기에 정리가 잘되어있다
@Getter
@Setter
public class Request {
@Email
private String email;
@NotBlank
private String pw;
@NotNull
private UserRole userRole;
@Min(12)
private int age;
}
위와 같이 도메인 필드에 제약조건을 설정하고. 컨트롤러 메서드에서 @Valid 를 붙여주면 유효성 검증이 진행된다
@RequestMapping("/test")
public ResponseEntitiy<Void> addUser(@Valid @RequestBody Request request) {
//...
}
@Valid 동작 원리
- @Valid는 ArgumentResolver에 의해 처리됨.
- 대표적으로 @RequestBody는 JSON 메시지를 객체로 변환해주는 작업을 ArgumentResolver의 구현체인 RequestResponseBodyMethodProcessor가 처리하며,
이 내부에서 @Valid로 시작하는 어노테이션이 있을 경우에 유효성 검사를 진행 - 만약 @ModelAttribute를 사용중이라면 ModelAttributeMethodProcessor에 의해 @Valid가 처리된다.
- 검증에 오류가 있을 경우, MethodArgumentNotValidException 예외가 발생하고 404 BadRequest 에러가 발생
- 이러한 이유로 @Valid는 기본적으로 컨트롤러에서만 동작하며 다른 계층에서는 검증이 되지 않음
다른 계층에서 파라미터 검증하기 위해서는 @Validated와 결합해야 함
@Validated 유효성 검증
- Spring에서 AOP 기반으로 메서드의 요청을 가로채 유효성 검증을 진행해주는 @Validated를 제공
- @Validated는 JSR 표준 기술이 아니며 Spring 프레임워크에서 제공하는 어노테이션 및 기능
유효성 검증에 실패할 경우 @Valid에서 발생한 MethodArgumentNotValidException 예외가 아닌 ConstraintViolationException 예외가 발생
@Validated 동작 원리
- 특정 ArgumnetResolver에 의해 유효성 검사가 진행되었던 @Valid와 달리, @Validated는 AOP 기반으로 메서드 요청을 인터셉터하여 처리됨
- @Validated를 클래스 레벨에 선언하면 해당 클래스에 유효성 검증을 위한 AOP의 어드바이스 또는 인터셉터(MethodValidationInterceptor)가 등록됨
- 해당 클래스의 메서드들이 호출될 때 AOP의 포인트 컷으로써 요청을 가로채서 유효성 검증을 진행
- @Validated를 사용하면 컨트롤러, 서비스, 레포지토리 등 계층에 무관하게 스프링 빈이라면 유효성 검증을 진행가능
- 클래스에는 유효성 검증 AOP가 적용되도록 @Validated를, 검증을 진행할 메서드에는 @Valid를 선언해주어야 함
- @Valid에 의한 예외는 MethodArgumentNotValidException이며, @Validated에 의한 예외는 ConstraintViolationException
시도2. @Validated 유효성 검증 그룹 지정 (해결)
다시 돌아가서, @Validated는 제약 조건이 적용될 검증 그룹을 지정할 수 있는 기능을 제공한다.
이를 이용해 동일한 클래스에 대해 제약조건을 다르게 처리할 수 있다.
스크롤 올리기 귀찮을 것 같아 다시 언급하자면, 제약사항은 다음과 같다
- 부모 객체 필드 : NotNull
- 자식 객체 필드 : Nullable
마커 인터페이스 정의
검증 그룹을 지정하기 위해서는 마커 인터페이스 (요소가 없는) 를 정의해야 한다.
위의 경우, 부모/자식 생성 객체를 분리해야하므로 2개의 마커 인터페이스를 만들 수 있다.
public interface ChildValidationGroup {} // 자식 객체 생성 시 검증할 그룹
public interface ParentValidationGroup {} // 부모 객체 생성 시 검증할 그룹
그리고 위의 제약 조건이 적용될 그룹을 필드 제약조건 groups 속성에 적용해 준다.
부모 객체
@Getter
@Setter
@NoArgsConstructor
public class Parent {
// .. 유효성 검증 필요 없는 필드 생략
@Size(max = 60, groups = {ParentValidationGroup.class, ChildValidationGroup.class})
private String id;
@ApiModelProperty(required = true)
@NotNull(groups = {ParentValidationGroup.class})
private String name;
@Min(value = 0, groups = {ParentValidationGroup.class, ChildValidationGroup.class})
@Max(value = 100, groups = {ParentValidationGroup.class, ChildValidationGroup.class})
private int size;
}
자식 객체
@Getter
@Setter
@NoArgsConstructor
public class Child extends Parent {
// .. 유효성 검증 필요 없는 필드 생략
@NotNull(groups = {ChildValidationGroup.class})
private String subField1;
@ApiModelProperty(required = false)
private String name;
@Size(max = 200, groups = {ChildValidationGroup.class})
private int subField2;
}
컨트롤러
@RequestMapping("/parent")
public ResponseEntitiy<Void> createParent(@RequestBody @Validated(ParentValidationGroup.class) Parent request) {
//...
}
@RequestMapping("/child")
public ResponseEntitiy<Void> createChild(@RequestBody @Validated(ChildValidationGroup.class) Child request) {
//...
}
이렇게 해결하였다...
문제 상황에 적합하진 않았지만, 검증을 위한 어노테이션 생성과 Validator 구현 그리고 검증 시 발생할 Exception Handler 처리 생성 부분도 커스텀하여 잘 활용하면 좋을 것 같다
번외로 컨트롤러에서 @PathVariable @NotBlank 와 같은 경우, @Valid만 설정했을 시 동작이 안되기 때문에 @Validated 를 같이 설정해야한다.
아마, @PathVariable 은 ArgumentResolver 의 구현체를가 없기 때문에 내부적으로 @Valid 실행을 못하는 것 같다!
'JVM > Spring' 카테고리의 다른 글
[Spring] 스프링 5.0 WebFlux 에 대해 (0) | 2022.05.11 |
---|---|
[Spring] Request Parameter (QueryString, Json) 데이터와 Annotation (@RequestParam, @RequestBody) 알고 쓰자 (0) | 2022.04.05 |
[MyBatis] Dynamic Query Map(or Value Object) 파라메터 foreach 문법에 key, value 사용하기 (0) | 2022.03.15 |
[Spring] Spring Scheduler 간단하게 설정하기 (1) | 2022.03.07 |
[Web] 415 (Unsupported Media Type) 오류 원인 살펴보기 (1) | 2022.02.18 |
댓글