JVM/Spring

[Spring] @Valid 와 @Validated 활용한 유효성 검증 (Feat. Custom Valid Annotation 생성)

헹창 2023. 10. 4.
반응형

부제 : 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 시도

부모 객체 검증 시

  1. 부모 객체 name 검증 : isNotNull = true 로 인해 검증 

자식 객체 검증 시

  1. 자식 객체 name 검증 : isNotNull = false 로 인해 검증 SKIP
  2. 부모 객체 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 실행을 못하는 것 같다!

 

728x90
반응형

댓글

추천 글