Spring 이 Bean validation 을 수행하는 법 (+Jakarta Validator 와 Spring Validator 차이)

잼구·2023년 11월 20일
4
post-thumbnail

개요

애플리케이션 개발 과정에서 일반적으로 입력 데이터에 대한 최소 두 차례의 유효성 검사가 수행됩니다. 첫 번째는 클라이언트 측에서 진행되며, 두 번째는 백엔드에서 이루어집니다. 이렇게 두 단계로 검사를 진행하는 것은 데이터의 정확성과 보안을 강화하기 위한 최소한의 절차라고 생각합니다.

중점적으로 검사하는 부분은 다르겠지만 보통 백엔드에서는
1. 기본적인 데이터 유효성 검사 2. 서비스 특수적인 유효성 검사 로 나누어 검사를 진행하는 것 같습니다.
spring 을 사용해 개발을 하면 1번의 경우는 주로 Bean validation 을 통해 해결합니다.

해당 글에서는
1. Jakarta EE의 표준인 Jakarta Bean Validation (JSR 380, 349 , 303)에 대해 알아보고,
2. 이를 spring 에서 어떻게 수행하는지에 대해 알아보겠습니다.


Jakarta Bean Validation

목적

Java 애플리케이션에서 객체의 유효성을 검증하기 위한 규칙과 규격을 정의.
하지만 어떻게 동작해야 하는지에 대한 상세한 사양을 제공하는 "설계 명세서" 이기 때문에 구현체는 따로 존재한다.

기능 (제공하는 것)

  • 객체 수준의 유효성 검사: 객체의 각 필드에 대해 특정 규칙(예: 필드가 비어 있지 않아야 함, 숫자가 특정 범위 내에 있어야 함 등)을 설정하고, 이러한 규칙이 충족되는지 검사.
  • 제약 조건 메타데이터와 쿼리: 유효성 검사 규칙에 대한 정보를 저장하고, 필요할 때 이를 조회할 수 있는 기능을 제공.
  • 메소드 및 생성자 검증: 메소드나 생성자가 호출될 때, 그 매개변수와 반환 값이 정해진 규칙을 만족하는지 검증.

장점

문제 상황 및 등장 배경

유효성 검증 로직을 구현하다보면 해당 문제 상황들이 등장합니다.

  1. 검증 로직의 중복 문제 : 데이터 검증은 애플리케이션의 여러 부분(프레젠테이션 계층부터 지속성 계층까지)에서 발생하는 공통적인 작업이기에 같은 유효성 검증 로직이 애플리케이션의 각 계층에서 반복적으로 구현된다. 이는 유지보수를 어렵게하고 오류를 발생시키는 주범이다.

  2. 도메인 모델의 복잡성 : 재사용성과 중앙화를 위해 도메인 모델에 검증 로직을넣는 경우도 있다. 하지만 이 경우, 프레젠테이션 계층과의 결합도와 클래스의 복잡성을 늘릴 수 있다. 또한 메타데이터인 검증 로직이 도메인 모델이 합쳐지면 "단일 책임 원칙"을 위배한다.

  3. 표준화된 유효성 검사 접근 방법의 필요성 : 서로 다른 검증 로직과 접근 방법은 일관성 부족과 혼란을 초래한다.

Jakarta Bean Validation 해결방법

어노테이션과 메타데이터(검증로직) 기반 접근법
어노테이션을 사용하여 유효성 검증 규칙을 정의함으로써 (xml도 가능), 동일한 유효성 검사 규칙을 다른 클래스나 컴포넌트에서 재사용할 수 있으며, 검사할 객체에 선언하는 식으로 애플리케이션 전반에 걸쳐 일관된 유효성 검사를 수행할 수 있음.
또한 코드와 분리된 메타데이터 형태로 유효성 검사 규칙을 정의할 수 있어, 코드의 명확성과 가독성을 향상시킨다.


Spring Bean Validation

스프링 프레임워크에서 Bean Validation(의존성 spring-boot-starter-validation)을 사용하면 주로 Hibernate Validator(Bean Validation의 가장 대표적인 구현체)를 사용하게 됩니다.

실제 사용 방법

1. 제약 조건 설정 : 모델 클래스(dto, Entity)에 검증 애너테이션 추가

Hibernate가 제공하는 더 많은 제약 조건들

import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
...

public class CreateUserRequest {

    @NotNull
    private String name;

    @Size(min = 2, max = 30)
    private String username;
    
    @CheckRole // Custom validation
    private String role;

    // getters and setters
}

2. 유효성 검사 : 실제 제약 조건들이 검사 되는 구간

1) 컨트롤러에서 @Valid 사용

    import org.springframework.web.bind.annotation.*;
    import jakarta.validation.Valid;

    @RestController
    @RequestMapping("/users")
    public class UserController {

    @PostMapping
    public String createUser(@Valid @RequestBody CreateUserRequest user) {
        // 유저 생성 로직
        return "User created";
      }
    }

2) @Validated 사용

import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;

@Service
@Validated
public class UserService {

    public void registerUser(@Valid CreateUserRequest user) {
        // 유저 등록 로직
    }
}

3) Validator 직접 호출 (validator.validate())

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
import jakarta.validation.Validator;

@Service
public class CustomValidatorService {

    // jakarta.validation.Validator 임
    @Autowired
    private Validator validator;
    
    // org.springframework.validation.Validator 임
    @Autowired
    private Validator validator;
    

	// org.springframework.validation.Validator 를 사용하는 경우
    public void validateUser(CreateUserRequest user) {
        Errors errors = new BeanPropertyBindingResult(user, "user");
        validator.validate(user, errors);

        if (errors.hasErrors()) {
            // 에러 처리 로직
            throw new RuntimeException("Validation error: " + errors.toString());
        }
    }


	// jakarta.validation.Validator 를 사용하는 경우
    public <T> void validate(T object) {
        Set<ConstraintViolation<T>> violations = validator.validate(object);
        if (!violations.isEmpty()) {
            String validationErrors = violations.stream()
                    .map(violation -> violation.getPropertyPath() + " : " + violation.getInvalidValue() + " : " + violation.getMessage())
                    .collect(Collectors.joining(", "));
            throw new ValidationException(validationErrors);
        }
    }
}

3가지 경우 비교


Jakarta Validator 와 Spring Validator 차이

직접 Validator 를 호출해 검사를 하는 3번 경우를 보면 Validator 인터페이스가 2개인 것을 알 수 있습니다.

  • org.springframework.validation.Validator
  • jakarta.validation.Validator (Hibernate Validator)

각각의 Validator 는 무엇이고, 그렇다면 자동 유효성 검증을 해주는 1,2 번에서 사용하는 validator 역시 둘 중에 무엇일까요?

org.springframework.validation.Validator

목적
해당 인터페이스는 스프링 프레임워크 내에서 유효성 검사를 수행하는 표준 방법을 제공한다.
주로 스프링 MVC와 함께 사용되며, 스프링의 데이터 바인딩과 긴밀하게 통합된다.

사용 방식
validate(Object target, Errors errors) 메서드를 구현하여, 대상 객체에 대한 유효성 검사를 수행하고, 검사 결과를 Errors 객체에 저장.
org.springframework.validation.Validator 는 기본 검사기가 없기 때문에 반드시 해당 객체 타입을 처리할 수 있는 커스텀 Validator 구현체를 작성하고, 스프링 빈으로 등록해야 한다.
또한 직접적으로 Bean Validation을 지원하지 않는다.

  • supports(Class clazz): 이 메서드는 Validator가 주어진 클래스의 인스턴스를 검증할 수 있는지 여부를 확인한다.
  • validate(Object obj, Errors e): 이 메서드는 실제 객체를 검증하고 Errors 객체에 에러를 등록한다.
public class PersonValidator implements Validator {

	/**
	 * Person instances 에 대해서만 동작한다.
	 */
	public boolean supports(Class clazz) {
		return Person.class.equals(clazz);
	}

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

즉, 해당 org.springframework.validation.Validator 를 implement 한 스프링 프레임워크 내 맞춤형 유효성 검사 로직을 구현하는 데 사용.

jakarta.validation.Validator

목적
이 인터페이스는 객체의 필드에 정의된 제약 조건을 검증하는 표준 방식을 제공.(애너테이션 기반의 유효성 검사)
Jakarta EE의 Bean Validation 표준을 구현.
구현체로는 보통 Hibernate Validator 를 사용 한다. 하지만 스프링 부트는 spring-boot-starter-validation 의존성이 포함된 경우 자동으로 LocalValidatorFactoryBean을 해당 인터페이스의 구현체로 사용한다.

사용방식

public interface Validator {
	<T> Set<ConstraintViolation<T>> validate(T object, Class<?>... groups);
...
}

@Valid 와 @Validated 검사기는 뭘까?

정답은 둘 다 입니다! 무슨 소리인가 싶지만...
실제로 @Valid 와 @Validated 를 검사하는 유효성 검사기는 LocalValidatorFactoryBean 입니다.

LocalValidatorFactoryBean
스프링 프레임워크에서 Bean Validation API를 사용하기 위한 구성 요소로 스프링의 Validator 인터페이스를 구현하며, 내부적으로 Bean Validation API (예: Hibernate Validator)를 래핑(사용) 한다.
스프링 부트는 spring-boot-starter-validation 의존성이 포함된 경우 자동으로 LocalValidatorFactoryBean을 Bean Validation API의 구현체로 설정한다 (jakarta.validation.Validator)

즉, LocalValidatorFactoryBean 은 Hibernate Validator 를 spring 에서 통합해서 쓸 수 있게 spring validator 인터페이스를 통해 구현한 구현체라고 볼 수 있습니다.

  • spring validator 인터페이스를 구현중
  • 내부에서 Hibernate Validator 사용중

그렇다면 왜 굳이 Hibernate Validator 를 직접 쓰지 않고 spring validator 로 감싼걸까요?

LocalValidatorFactoryBean은 스프링 애플리케이션에서 사용되는 유효성 검사 메커니즘과 Bean Validation API 사이의 어댑터 역할을 합니다. 스프링과 Bean Validation API의 차이를 해소해 주는 역할을 하는 것 입니다.

  • 스프링의 데이터 바인딩과 통합: 스프링은 자체적인 데이터 바인딩 메커니즘을 가지고 있으며, org.springframework.validation.Validator 인터페이스는 이 데이터 바인딩 시스템과 긴밀하게 연동됩니다. Hibernate Validator와 같은 Bean Validation 구현체를 직접 사용하는 대신에 스프링의 Validator를 통해 유효성 검사를 수행하면, 데이터 바인딩과 유효성 검사를 일관된 방식으로 처리할 수 있습니다.

  • 스프링 생태계의 통합: 스프링 프레임워크는 다양한 모듈과 기능을 제공하며, 이들은 서로 긴밀하게 통합되어 작동합니다. LocalValidatorFactoryBean을 통해 Bean Validation API를 사용하면, 스프링 MVC, 스프링 데이터, 스프링 시큐리티 등 스프링 생태계의 다른 부분과의 일관성과 호환성을 유지할 수 있습니다.

🥹 3번의 경우 Spring boot에서 기본적으로 Validator 을 주입 받으면 LocalValidatorFactoryBean 이 주입되므로 별 다른 설정 없이 사용하시면 됩니다.

3번 경우에서 실수하기 쉬운 부분


결론

지금까지 Spring 이 Bean validation 을 하는 법에 대해 알아보았습니다. MVC 만 쓰다보면 제대로 모를 수도 있는데 웹이 아닌 애플리케이션을 만들다 보면 생각보다 직접 구현할 일이 있어서 총정리 해보았습니다. 다음 post 는 validation error handling 에 대해 적어보겠습니다!

profile
잼구입니다

1개의 댓글

comment-user-thumbnail
2024년 7월 4일

정리를 정말 잘 하셨네요

좋은 글 잘 보고 갑니다!!!

감사합니다

답글 달기