Spring

@Validated vs @Valid

자바생 2023. 6. 14. 12:26
728x90

글을 쓰게 된 이유

 

레벨 2 장바구니 미션을 진행하면서 입력에 대한 유효성 검증을 할 때,

 

 

크루들마다 @Valid, @Validated 를 사용하고 있어서 둘은 어떤 차이가 있는지 학습해 보고자 글을 작성했습니다.

 

 

 

dependency

 

bean validation 을 사용하기 위해서는 아래와 같은 의존성을 추가해줘야 합니다.

 

implementation 'org.springframework.boot:spring-boot-starter-validation'

 

 

왜 사용할까?

 

 

두 어노테이션 모두 유효성 검사를 편하게 하기 위해서 사용합니다.

 

 

 

다만 @Valid는 Java Bean 유효성 검사 사양의 일부로 javax 패키지에 존재합니다.

 

 

@Validated는 스프링 자체에서 위 @Valid의 응용으로 스프링과 함께 사용할 수 있는 어노테이션입니다.

그래서 @Validated의 패키지는 springframework로 시작합니다.

 

 

테스트 코드를 통해서 각 어노테이션이 어떠한 특징이 있는지 알아보겠습니다.

 

 

 

 

 

공통점

 

 

데이터를 요청받을 때, @Valid와 @Validation을 사용하게 되면 공통적으로 argumentResolver에서 동작합니다.

 

 

만약에 컨트롤러에서 데이터 바인딩을 하기 위해 @RequestBody를 사용한다면

RequestResponseBodyMethodProcessor 에서 @Valid와 @Validation이 동작하게 됩니다.

 

 

아래 코드는 RequestResponseBodyMethodProcessor에서 데이터를 바인딩하고, validation 하는 부분입니다.

 

 

이 메서드가 시작점이기 때문에 알아두시면 좋습니다.

 

 

 

 

 

그러면 이제 어떻게 bean validation이 동작하는지 알아보도록 하겠습니다.

 

 

 

예시 코드

 

 

먼저 예시 코드를 설명드리자면 순서대로

 

@Validated 사용 및 그룹핑,

@Valid & @Validated를 사용하지 않을 때,

@Valid를 사용할 때입니다.

 

 

큰 틀의 예시 코드를 먼저 보여드리고, 각 테스트마다 디버깅하며 어떻게 동작하는지 설명드리겠습니다.

 

@RestController
@RequestMapping("/validated")
public class MyValidatedController {

    @PostMapping("/first")
    public String first(@Validated(FirstValidation.class) @RequestBody MyValidatedRequest myValidatedRequest) {
        return "first";
    }

    @PostMapping("/second")
    public String second(@RequestBody MyValidatedRequest myValidatedRequest) {
        return "second";
    }

    @PostMapping("/third")
    public String third(@Valid @RequestBody MyValidatedRequest myValidatedRequest) {
        return "third";
    }
}
public class MyValidatedRequest {

    @Size(min = 2)
    private String name;

    @Size(min = 2, groups = SecondValidation.class)
    private String email;

    @Positive(groups = FirstValidation.class)
    private int age;

    public interface FirstValidation {}
    public interface SecondValidation {}

    // getter, constructor ...

}

 

 

 

@Validated & @Validated groupping

 

 

@Validated는 @Valid와 다르게 그룹 유효성 검증 기능을 제공합니다.

 

 

같은 클래스끼리 그룹으로 명시해 줌으로써 식별할 수 있습니다.

그래서 FirstValidation, SecondValidation과 같이 marker interface를 사용합니다.

 

 

 

그러고 나서 검증할 인스턴스 필드에 groups를 통해 그룹을 명시해 줍니다.

 

그룹을 명시하게 된다면 @Validated를 해줄 때, 명시된 그룹만 유효성 검증을 하게 됩니다.

 

 

@PostMapping("/first")
public String first(@Validated(FirstValidation.class) @RequestBody MyValidatedRequest myValidatedRequest) {
    return "first";
}

///////////////////Test///////////////

@Test
@DisplayName("유효성 그룹 정의 FirstValidation")
void test_group_firstValidation() throws Exception {
    //given
    final MyValidatedRequest myValidatedRequest = 
					new MyValidatedRequest("유효성검사통과", "a", 1);

    final String data = objectMapper.writeValueAsString(myValidatedRequest);

    //when & then
    mockMvc.perform(post("/validated/first")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(data))
           .andDo(print())
           .andExpect(status().isOk());
}

 

MyValidatedRequest를 보시면 age가 FirstValidation 그룹으로 되어있습니다.

 

즉, "유효성 검증을 할 때 명시해 준 그룹이 아니면 유효성 검증을 하지 않겠다"라는 말로

데이터 유효성 검증을 할 때 "age만 보겠다"과 같은 말입니다.

 

그래서 "다른 그룹은 검증을 하지 않는다"라는 것을 보여드리기 위해 SecondValidation 그룹인 email의 길이를 1로 설정해서 요청을 보내보도록 하겠습니다.

 

 

 

 

 

 

 

 

 

이 부분이 DataBinder에서 validate 하는 부분입니다.

 

 

여기서 분기가 나눠지게 됩니다. 

 

이 분기는 중요하게 봐야 하는데, @Validated를 사용하게 되면 SmartValidator 가 validate를 하고,

@Valid를 사용하게 되면 Validator가 validate를 하게 됩니다.(두 개의 차이는 아래에서 언급)

 

 

 

아래 사진을 보면 @Valid를 사용하면 else-if 문에 들어가는 것을 알 수 있습니다.

 

 

현재 @Validated를 사용하고 있기 때문에 SmartValidator가 validate를 한다는 점입니다.

 

 

 

 

validateIfApplicable 메서드를 통해 validate를 했지만 error 가 발생하지 않아서 네모 박스의 if 문이 수행되지 않는 것을 알 수 있습니다.

 

 

 

다시 정리해 보면 @Validated는 그룹핑을 지원하는데, 이 그룹 유효성 검사는 명시된 그룹에 대해서만 유효성 검증을 하게 됩니다.

 

 

해당 기능은 @Validated에서만 지원하고 @Valid는 지원하지 않습니다.

 

 

 

 

@Valid, @Validated가 없을 때

 

 

@PostMapping("/second")
public String second(@RequestBody MyValidatedRequest myValidatedRequest) {
    return "second";
}

@Test
@DisplayName("Valid와 Validated가 없을 때 동작")
void test_no_valid_validated() throws Exception {
    //given
    final MyValidatedRequest myValidatedRequest = 
												new MyValidatedRequest("a", "a", 3);

    final String data = objectMapper.writeValueAsString(myValidatedRequest);

    //when & then
    mockMvc.perform(post("/validated/second")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(data))
           .andDo(print())
           .andExpect(status().isOk());
}

 

 

 

 

첫 번째 예시와 디버깅 과정이 거의 동일합니다.

 

 

 

validateIfApplicable을 보면 annotation 목록 중에 @Valid, @Validated가 없기 때문에 binder#validate를 호출하지 않아서 validate를 하지 않게 됩니다.

 

 

 

 

@Valid 사용

 

 

이번 테스트는 다른 테스트와 다르게 유효성 검증이 실패할 때를 예시로 보겠습니다.

 

 

앞서 글이 제대로 이해되셨다면 먼저 디버깅 과정을 보기 전에 어떻게 될지 한 번 생각해 보시는 것도 좋을 것 같습니다.

 

 

 

@Valid가 있기 때문에 유효성 검증을 할 것이고, 이전에 어느 메서드에서 유효성 검증을 하는데,

거기서 error를 통해 BindingResult가 발생하여 exception이 발생할 것입니다.

 

 

@PostMapping("/third")
public String third(@Valid @RequestBody MyValidatedRequest myValidatedRequest) {
    return "third";
}

@Test
@DisplayName("Valid가 있을 경우")
void test_valid() throws Exception {
    //given
    final MyValidatedRequest myValidatedRequest 
				= new MyValidatedRequest("a", "a", 3);

    final String data = objectMapper.writeValueAsString(myValidatedRequest);

    //when & then
    mockMvc.perform(post("/validated/third")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(data))
           .andDo(print())
           .andExpect(status().isBadRequest());
}

 

 

똑같이 validateIfApplicable에서 validation을 합니다.

 

 

 

@Valid가 존재하기 때문에 유효성 검증을 합니다.

 

 

 

 

 

 

 

 

 

이전에 @Validated 할 때는 if 분기에 들어갔습니다.

 

 

 

하지만 이번에는 @Valid 이기 때문에 if 분기문을 들어가지 않고, else if 문으로 들어가게 됩니다.

 

 

 

 

BingResult를 보면 name에 대한 유효성 검증이 실패하게 됩니다.

 

 

그래서 error 가 생기게 되고 argument Resolver에서는 MethodArgumentNotValidException을 던지게 됩니다.

 

 

 

 

 

 

의문점

 

 

 

그러면 argument Resolver에서만 동작하기 때문에 해당 어노테이션들은 handler method arguments에서만 동작할 수 있는 걸까?

 

라는 생각이 들 수 있습니다.

 

 

혹여나 유효성 검증을 handler method arguments를 사용하는 곳뿐만 아니라 일반적인 상황,

 

즉, handler method arguments를 사용하지 않는 곳에서도 검증을 하고 싶을 수도 있습니다.

 

 

예시로 Service에서 테스트를 해보도록 하겠습니다.

 

 

 

@Service
@Validated
public class ValidatedService {

    @Validated(MyValidatedRequest.FirstValidation.class)
    public void first(final MyValidatedRequest myValidatedRequest) {
        System.out.println("ok");
    }
}

@Test
@DisplayName("validation 실패 시 ConstraintViolationException 반환")
void test_constraintViolationException() throws Exception {

    final MyValidatedRequest myValidatedRequest = 
				new MyValidatedRequest("a", "", -1);

    assertThrows(ConstraintViolationException.class,
                 () -> validatedService.first(myValidatedRequest));
}

 

 

컨트롤러에서와 마찬가지로 @Validated를 통해서 그룹핑을 할 수 있습니다.

 

 

신기한 점이 컨트롤러에서의 exception 종류가 다릅니다.

 

 

컨트롤러에서는 MethodArgumentNotValidException,

서비스에서는 ConstraintViolationException이 발생하게 됩니다.

 

 

 

처음에 학습을 하면서 

 

@Validated -> ConstraintViolationException,

@Valid -> MethodArgumentNotValidException 이라는 글을 보았습니다.

 

 

저도 이러는 줄 알았는데 실제 테스트를 해보니 컨트롤러에서 @Validated를 사용해도 MethodArgumentNotValidException이 나왔습니다.

 

 

그 이유는 바로 어노테이션의 차이가 아닌 누구와 사용했느냐가 중요합니다.

 

 

Controller에서는 데이터 바인딩을 위해서 대부분 method argument들과 같이 사용하게 됩니다.

 

그래서 HandlerMethodArgumentResolver를 구현하고 있는 argument resovler들에서 유효성 검증을 하고 BindingResult를 반환합니다.

 

 

 

코드를 보시면 BindingResult에 error가 존재한다면 MethodArgumentNotValidException을 던집니다.

 

 

그와 반대로 Service에서는 handler method argument를 사용하지 않기 때문에 위와 같은 코드가 수행되지 않습니다.

즉, MethodArgumentNotValidException을 던질 수도, 던지지 않을 수도 있죠.

 

결과는 MethodArgumentNotValidException이 아닌 COnstraintViolationException을 던지게 됩니다.

 

 

 

그전에 "왜 Service에서는 Controller에서와 다르게 @Validated를 클래스 수준에 붙이지?"라는 의문을 가지시면 좋을 듯합니다.

 

 

@Validated 공식 문서를 보면 메서드 수준 유효성 검사와 함께 사용할 수 있다고 합니다.

클래스에 어노테이션을 통해 유효성 검사 그룹을 지정할 수 있습니다. 만약에 메서드 수준에서 어노테이션을 붙인다면 재정의는 할 수 있지만 포인트 컷 역할을 하지 못하게 됩니다.

즉, 클래스 레벨에서 어노테이션을 붙이지 않고 메서드에서만 붙인다면 유효성 검사를 하지 못하게 됩니다.

 

그래서 유효성 검사를 트리거하기 위해서는 클래스 수준 어노테이션이 필요하다고 합니다.

(해석이 맞는지 조금 애매합니다,,)

 

 

Can also be used with method level validation, indicating that a specific class is supposed to be validated at the method level (acting as a pointcut for the corresponding validation interceptor), but also optionally specifying the validation groups for method-level validation in the annotated class. Applying this annotation at the method level allows for overriding the validation groups for a specific method but does not serve as a pointcut; a class-level annotation is nevertheless necessary to trigger method validation for a specific bean to begin with. Can also be used as a meta-annotation on a custom stereotype annotation or a custom group-specific validated annotation

 

 

@Service
public class ValidatedService {

    @Validated(MyValidatedRequest.FirstValidation.class)
    public void first(final MyValidatedRequest myValidatedRequest) {
        System.out.println("ok");
    }
}

 

만약에 클래스 레벨에 어노테이션이 없고 메서드에만 있다면, service가 프록시 객체가 아닐뿐더러 유효성 검증이 되지 않습니다.

 

 

그래서 @Validated를 클래스 레벨에서 사용하게 되면 AOP를 사용하게 됩니다.

 

 

AOP를 사용하면 스프링은 기본적으로 CGLIB를 사용하여 프록시 객체를 만들기 때문에 클래스를 주입받아서 Proxy 객체인지 직접 확인해 보았습니다.

 

@Test
@DisplayName("AOP 사용 근거")
void test_aop() throws Exception {
    assertTrue(AopUtils.isCglibProxy(validatedService));
}

 

 

 

 

 

이 부분을 통해서 @Validated 는 클래스 수준에 있어야 유효성 검증이 되는 것을 알 수 있습니다.

 

 

이제 왜 ConstraintViolationException 가 발생하는지 알아보도록 하겠습니다.

 

 

AOP로 동작하기 때문에 MethodValidationInterceptor 가 해당 요청을 가로채서 유효성 검사를 한 뒤 결과를 반환합니다.

 

 

AOP를 공부 안 하신 분들이 계실 수 있기 때문에 간단히 말씀드리자면 invocation#proceed 가 실제 객체의 메서드를 호출하는 부분이라고 생각하시면 됩니다.

 

 

 

 

 

execVal.validateParameters에서 유효성 검증하고 나서, 유효성 검증이 실패한 결괏값을 result에 넣게 됩니다.

 

 

 

result에서 결과가 하나라도 존재하게 된다면 ConstraintViolationException이 발생하게 됩니다.

 

 

만약 유효성 검증이 실패하지 않았다면 전에 언급했던 invocation#proceed를 통해서 실제 객체의 메서드를 호출해서 반환값을 얻습니다.

 

 

 

이제 @Validated, @Valid를 사용할 때 왜 서로 다른 exception이 나오는지 알게 되셨을 거라 생각됩니다!

 

 

 

결론

 

 

정리하면 어노테이션에 따라 exception이 정해지는 것이 아닌, 누구와 사용하느냐에 따라 달라집니다.

 

 

@Validated, @Valid를 데이터 바인딩하는 argument resolver들과 함께 사용한다면 MethodArgumentNotValidException이 발생하고,

 

argument resolver가 아닌 스프링 빈에서 유효성 검사를 하기 위해서 @Validated를 사용한다면 ConstraintViolationException이 발생합니다.

 

 

 

REFERENCES

 

 

https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#validation-beanvalidation-spring-method

https://stackoverflow.com/questions/63102468/why-is-validated-required-for-validating-spring-controller-request-parameters

https://stackoverflow.com/questions/36173332/difference-between-valid-and-validated-in-spring

728x90