본문 바로가기

Development/Spring

[Spring] 유효성 검사와 Hibernate Validator

유효성 검사(validation)

  • 애플리케이션의 비즈니스 로직이 올바르게 동작하는지 검증하는 작업을 유효성 검사라고 한다.
  • 유효성 검사의 예로는 여러 계층에서 들어오는 데이터에 대해 의도한 형식대로 값이 들어오는지 체크하는 과정이 있다.
  • 특히 자바에서 가장 신경 써야 하는 것 중 하나로 NullPointException이 있다.

Bean Validation

  • 계층별로 진행하는 유효성 검사는 검증 로직이 각 클래스별로 분산돼 있어 관리하기가 어렵다.
  • 그리고 검증 로직에 의외로 중복이 많아 여러 곳에 유사한 기능의 코드가 존재할 수 있다.
  • 검증해야 할 값이 많다면 검증하는 코드가 길어진다.
  • 이러한 문제로 코드가 복잡해지고 가독성이 떨어지는 문제가 있다.
  • 이 같은 문제를 해결하기 위해 자바 진영에서는 2009년부터 Bean Validation 이라는 데이터 유효성 검사 프레임워크를 제공한다.
  • Bean Validation은 어노테이션을 통해 다양한 데이터를 검증하는 기능을 제공한다.
  • Bean Validation을 사용한다는 것은 유효성 검사를 위한 로직을 DTO같은 도메인 모델과 묶어서 각 계층에서 사용하면서 검증 자체를 도메인 모델에 얹는 방식으로 수행한다는 의미이다.
  • 또한 Bean Validation은 어노테이션을 사용한 검증 방식이기 때문에 코드의 간결함도 유지할 수 있다.

Hibernate Validator

  • Hibernate Validator는 Bean Validation 명세의 구현체이다.
  • 스프링 부트에서는 Hibernate Validator를 유효성 검사 표준으로 채택해서 사용하고 있다.
  • Hibernate Validator는 JSR-303명세의 구현체로서 도메인 모델에서 어노테이션을 통한 필드값 검증을 가능하게 도와준다.
  • JSR이란 Java Specification Request의 약자로, Java 애플리케이션에서 데이터를 검증하는 기준을 명시하고 있다.

스프링 부트의 유효성 검사

  • 유효성 검사는 각 계층으로 데이터가 넘어오는 시점에 해당 데이터에 대한 검사를 실시한다.
  • 보통 스프링 부트 프로젝트에서는 계층 간 데이터 전송에 DTO(Data Transfer Object)를 활용하고 있기 때문에 다음과 같은 구조로 유효성 검사를 수행하는 것이 일반적이다.

스프링 부트에서 유효성 검사 기능 사용하기

  • 원래 스프링 부트의 유효성 검사 기능은 spring-boot-starter-web에 포함돼 있었으나, 스프링 부트 2.3 버전 이후로 별도의 라이브러리로 제공하고 있다.
  • 따라서 다음과 같은 의존성을 추가해야 한다.

Maven

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Gradle

dependencies {
  implementation 'org.hibernate.validator:hibernate-validator:'
}

유효성 검사를 위한 대표적인 어노테이션은 다음과 같다.

문자열 검증

  • @Null: null 값만 허용합니다.
  • @NotNull: null을 허용하지 않습니다. "", " "는 허용합니다.
  • @NotEmpty: null, ""을 허용하지 않습니다. " "는 허용합니다.
  • @NotBlank: null, "", " "을 허용하지 않습니다.

최댓값/최솟값 검증

  • BigDecimal, BigInteger, int, long 등 타입을 지원합니다.
  • @DecimalMax(value = "$numberString") : $numberString보다 작은 값을 허용합니다.
  • @DecimalMin(value = "$numberString") : $numberString보다 큰 값을 허용합니다.
  • @Min(value = $number) : $number 이상의 값을 허용합니다.
  • @Max(value = $number) : $number 이하의 값을 허용합니다.

값의 범위 검증

  • BigDecimal, BigInteger, int, long 등 타입을 지원합니다.
  • @Positive : 양수를 허용합니다.
  • @PositiveOrZero : 0 포함 양수를 허용합니다.
  • @Negative : 음수를 허용합니다.
  • @NegativeOrZero : 0 포함 음수를 허용합니다.

시간에 대한 검증

  • Date, LocalDate, LocalDateTime 등의 타입을 지원합니다.
  • @Future : 현재보다 미래 날짜를 허용합니다.
  • @FutureOrPresent : 현재 포함 미래 날짜를 허용합니다.
  • @Past : 현재보다 과거 날짜를 허용합니다.
  • @PastOrPresent : 현재 포함 과거 날짜를 허용합니다.

이메일 검증

  • @Email : 이메일 형식을 검사합니다. ""을 허용합니다.

자릿수 범위 검증

  • BigDecimal, BigInteger, int, long 등 타입을 지원합니다.
  • @Digits(integer = $number1, fraction = $number2) : $number1의 정수 자릿수와 $number2의 소수 자릿수를 허용합니다.

Boolean 검증

  • @AssertTrue : true인지 체크합니다. null 값은 체크하지 않습니다.
  • @AssertFalse : false인지 체크합니다. null 값은 체크하지 않습니다.

문자열 길이 검증

  • @Size(min = $number1, max = $number2) : $number1 이상 $number2 이하 범위 허용

정규식 검증

  • @Pattern(regexp = "$expression") : 정규식을 검사합니다. 정규식은 자바의 java.util.regex.Pattern 패키지 컨벤션을 따릅니다.

사용 예시

@RestController
@RequestMapping("/validation")
public class ValidationController {

    private final Logger LOGGER = LoggerFactory.getLogger(ValidationController.class);

    @PostMapping("/valid")
    public ResponseEntity<String> checkValidationByValid(
            @Valid @RequestBody ValidRequestDto validRequestDto) {
        LOGGER.info(validRequestDto.toString());
        return ResponseEntity.status(HttpStatus.OK).body(validRequestDto.toString());
    }

위 코드에서 checkValidationByValid 메서드는 인자로 ValidRequestDto를 받고 RequestBody로 받고 있다.

앞에 @Valid 어노테이션을 지정해야 해당 클래스에 대해 유효성 검사를 수행한다.
아래는 ValidRequestDto 클래스이다.

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Builder
public class ValidRequestDto {

    @NotBlank
    private String name;

    @Email
    private String email;

    @Pattern(regexp = "01(?:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{4})$")
    private String phoneNumber;

    @Min(value = 20)
    @Max(value = 40)
    private int age;

    @Size(min = 0, max = 40)
    private String description;

    @Positive
    private int count;

    @AssertTrue
    private boolean booleanCheck;

}

위 DTO에서 만약 Validation이 어긋나는 필드가 포함된 요청이 들어온다면 400 Bad Request를 클라이언트에게 응답한다.

@Validated 활용하기

  • 컨트롤러 클래스에서 메서드에 인자 앞에 명시한 @Valid 어노테이션은 자바에서 지원하는 어노테이션이며, 스프링에서 지원하는 어노테이션은 @Validated라는 어노테이션이다.
  • 이 어노테이션은 @Valid의 기능을 포함하고 있기 때문에 @Validated로 변경할 수 있다.
  • @Valid와 다른 점은 @Validated는 유효성 검사를 그룹으로 묶어 대상을 특정할 수 있는 기능이 있다.

예를 들어, 두 인터페이스를 생성한다.

ValidationGroup1

public interface ValidationGroup1 {

}

ValidationGroup2

public interface ValidationGroup2 {

}

그리고 위에서 사용한 Dto 객체에서 agecount에 다음과 같이 수정한다.

    @Min(value = 20, groups = ValidationGroup1.class)
    @Max(value = 40, groups = ValidationGroup1.class)
    private int age;

    @Positive(groups = ValidationGroup2.class)
    private int count;

각 어노테이션에 groups 속성을 사용하여 그룹을 설정한다.

실제로 어느 구룹에 유효성 검사를 실시할지 결정하는 것은 @Validated 어노테이션에서 한다.

@RestController
@RequestMapping("/validation")
public class ValidationController {

    private final Logger LOGGER = LoggerFactory.getLogger(ValidationController.class);

    @PostMapping("/valid1")
    public ResponseEntity<String> checkValidation1(
            @Validated(ValidationGroup1.class) @RequestBody ValidRequestDto validRequestDto) {
        LOGGER.info(validRequestDto.toString());
        return ResponseEntity.status(HttpStatus.OK).body(validRequestDto.toString());
    }

    @PostMapping("/valid2")
    public ResponseEntity<String> checkValidation2(
            @Validated(ValidationGroup2.class) @RequestBody ValidRequestDto validRequestDto) {
        LOGGER.info(validRequestDto.toString());
        return ResponseEntity.status(HttpStatus.OK).body(validRequestDto.toString());
    }

위 컨트롤러 클래스의 checkValidation1 메서드는 ValidRequestDto에서 ValidationGroup1.class의 그룹으로 설정한 필드에 대해서만 유효성 검사를 실시한다.


마찬가지로 checkValidation2 메서드는 ValidRequestDto에서 ValidationGroup2.class의 그룹으로 설정한 필드에 대해서만 유효성 검사를 실시한다.


만약 그룹으로 지정하지 않고 @Validated으로만 사용한다면 그룹으로 지정하지 않은 필드에 대해서만 유효성 검사를 실시한다.

Custom Validation

  • 자바 또는 스프링의 유효성 검사 어노테이션에서 제공하지 않는 기능을 사용해야 할 경우, ConstrainValidator와 커스텀 어노테이션을 조합해서 별도의 유효성 검사 어노테이션을 생성할 수 있다.

전화번호 형식이 일치하는지 확인하는 간단한 유효성 검사 어노테이션을 생성

  • 먼저 ConstraintValidator 인터페이스를 구현하는 클래스를 생성한다.
  • 이 인터페이스는 inValid 메서드를 정의하고 있으며, 이 메서드를 구현해야한다.
    이 메서드에서 false가 리턴되면 MethodArgumentNotValidException 예외가 발생한다.
public class TelephoneValidator implements ConstraintValidator<Telephone, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if(value==null){
            return false;
        }
        return value.matches("01(?:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{4})$");
    }
}
  • 커스텀한 로직을 어노테이션으로 사용하기 위해 다음과 같이 어노테이션 인터페이스를 구현한다.
  • 참고로 어노테이션 인터페이스는 클래스의 메타데이터를 제공하기 위해 사용된다. 이는 컴파일러나 런타임 환경에서 추가적인 정보를 제공할 수 있다.
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = TelephoneValidator.class)
public @interface Telephone {
    String message() default "전화번호 형식이 일치하지 않습니다.";
    Class[] groups() default {};
    Class[] payload() default {};
}

@Target 어노테이션은 이 어노테이션이 어디서 선언할 수 있는지 정의하는 데 사용된다.
사용할 수 있는 ElementType은 대표적으로 다음과 같다.

  • ElementType.FIELD: 각 필드에 적용 가능합니다.
  • ElementType.METHOD: 메서드에서 사용하는 모든 파라미터를 검사하기 위해 사용됩니다.
  • ElementType.TYPE: 클래스 레벨에서 유효성을 검사하기 위해 사용되며, 같은 객체에서 다른 필드들의 관계를 확인하는 데 유용합니다.
  • ElementType.PARAMETER: 메서드의 특정 파라미터를 검사하기 위해 사용됩니다.

@Retention 어노테이션은 이 어노테이션이 실제로 적용되고 유지되는 범위를 의미한다.
@Retention의 적용 범위는 RetentionPolicy를 통해 지정하며, 지정 가능한 항목은 다음과 같다.

  • RetentionPolicy.RUNTIME: 컴파일 이후에도 JVM에 의해 계속 참조합니다. 리플렉션이나 로깅에 많이 사용되는 정책입니다.
  • RetentionPolicy.CLASS: 컴파일러가 클래스를 참조할 때까지 유지합니다.
  • RetentionPolicy.SOURCE: 컴파일 전까지만 유지됩니다. 컴파일 이후에는 사라집니다.

@Constraint 어노테이션은 앞에서ConstrainValidator를 구현한TelephoneValidator를 매핑한다.

또한message()메서드를 통해 유효성 검사를 위배했을 때 보여질 메세지를 구현한다.

groups()는 유효성 검사를 사용하는 그룹으로 설정하며,payload()는 사용자가 추가 정보를 위해 전달하는 값이다.

 

이와 관련된 내용은ConstraintHelper를 참조

 

이제 커스텀한 @Telephone 어노테이션을 사용할 수 있다.

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Builder
public class ValidRequestDto {

    @NotBlank
    private String name;

    @Email
    private String email;

    @Telephone // custom validation annotation
    private String phoneNumber;

    @Min(value = 20)
    @Max(value = 40)
    private int age;

    @Size(min = 0, max = 40)
    private String description;

    @Positive
    private int count;

    @AssertTrue
    private boolean booleanCheck;

}

참고