sol 개발 블로그 로고
Published on

Spring 유효성 검사와 실습

Authors
  • avatar
    Name
    Chan Sol OH
    Twitter

목차

개요

쿠팡 프로젝트를 하는 도중 SellerUser의 전화번호를 문자열로 받는 경우가 있었다. 클라이언트가 잘 전달하더라도 문제가 생길 수 있기 때문에 이런 문자열을 원하는 형태로 검증하는 방법이 필요했다.

위 이미지를 참고하면 Spring은 데이터가 각 계층을 이동할 때 유효성 검사를 실시한다. 보통 DTO를 통해 비즈니스 로직을 처리하고 Entity를 통해 DB에 값을 삽입하기 때문에 이러한 도메인 모델을 생성할 때 유효성 검사를 실시하면된다.

유효성 검사를 하면 반드시 MethodArgumentNotValidException에 대한 예외처리를 해줘야한다. 하지만 이를 매 어노테이션마다 정의하는 것 보다는 예외처리를 중앙화하여 관리할 수 있다.

검증을 위한 어노테이션

검증을 위한 어노테이션은 다음과 같다. 모두 Class의 필드 위에 작성하는 것이다.

string-annotation.java
// 문자열 검증
@Null : null 값만 허용
@NotNull :null 허용 안하지만, """ "는 허용
@NoEmpty : null""을 허용하지 않지만, " "는 허용
@NoBlank : null, "", " " 모두 허용 안 함
@Email : 이메일 형식 검사, ""는 허용
@Size(min = $numb1, max=$numb2) : numb1 이상, numb2 이하의 범위를 허용
@Pattern(regexp = "$expression") : 정규식 검사. 자바의 java.util.regex.Pattern 패키지의 컨벤션을 따름
number-annotation.java
// 숫자형 검증
@Min(value = $number) : number 이상 값 허용
@Max(value = $number) : number 이하 값 허용
@Positive // 양수만 허용
@PositiveOrZero : 양수와 0을 허용
@Negative : 음수만 허용
@Digits(integer = $numb1, fraction=$numb2) : numb1 자리 수와 numb2의 소수 자리수를 허용
date-annotation.java
// Date, LocalDateTime, LocalDate형 검증
@Future : 현재보다 미래의 날짜 허용
@FutureOrpresent : 현재와 미래 날짜 허용
@Past : 현재보다 과거의 날짜를 허용

@Valid로 DTO를 검사해보기

Post 요청으로 아래 Dto에 값을 입력해보는 실험

ProductRequestDto.java
@NoArgsConstructor
@Getter
@Builder
@AllArgsConstructor
public class ProductRequestDto {
    @NotBlank // null, "", " " 모두 허용 X
    private String productName;
    @Min(value = 10) // 10 이상
    private Long weight;
    @Pattern(regexp = "01(?:0|1|[6-9])[.-]?(\\d{3,4})[-]?(\\d{4})$") //010-6604-3466 형태를 맞추도록
    private String phoneNumber;
    @Size(min = 7) // 7글자 이상
    private String kcCertificationInformation;
}

재미있는 점은 Validation 어노테이션을 DTO에 적용하기 위해 값을 입력하는 부분인 @RequestBody 앞에 @Valid를 적어야하는 점이다. 그렇지 않으면 값을 바인딩할 때 유효성 검사를 하지 않는다.

ValidException.java
@RestController
@RequestMapping(value = "/valid")
@Slf4j
public class ValidException {
    @PostMapping("/product")
    public ResponseEntity<ProductRequestDto> postProduct(@Valid @RequestBody ProductRequestDto productRequestDto) {
        log.info("입력 dto 정보 : "+productRequestDto.getProductName()+" 첫 이미지 : "+productRequestDto.getProductContents().get(0));
        return ResponseEntity.status(HttpStatus.OK).body(productRequestDto);
    }
}

만약 입력 http body에 "phoneNumber":"010-5555-4444"를 입력하면 정규표현식과 맞지 않다고 Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0]... 에러를 발생시키고 400 코드를 출력한다.

@Validated로 그룹화해서 검사하기

@Valid는 DTO를 바인딩할 때 모든 필드에 대해서 검사하기 때문에 모든 유효성 검사를 통과해야한다. 하지만, 그러고싶지 않고 필드마다 그룹을 지어서 그룹 별로 유효성 검사를 할 때는 어떻게 할까? @Validated를 사용할 수 있다. 실험은 안하고 사용법만 나열하면 아래와 같다.

  1. ValidateGroup 인터페이스 만들기
  2. DTO 객체에 유효성 검사할 때 groups = ValidationGrop1.class를 아래처럼 붙이기
    @Min(value = 10, groups = ValidationGrop1.class) // 10 이상
    @Min(value = 50, groups = ValidationGrop2.class) // 10 이상
    private Long weight;
    
  3. Controller에서 그룹 설정
    @PostMapping("/product")
    public ResponseEntity<ProductRequestDto> postProduct(@Validated(ValidationGroup2.class)
                                                         @RequestBody
                                                         ProductRequestDto productRequestDto) {
        log.info("입력 dto 정보 : "+productRequestDto.getProductName()+" 첫 이미지 : "+productRequestDto.getProductContents().get(0));
        return ResponseEntity.status(HttpStatus.OK).body(productRequestDto);
    }
    

위 Controller에서 설정한 Validated 안에 ValidateGroup을 작성하면 그 그룹이 포함된 유효성 검사를 진행한다. 위 Controller에서는 weight50 이상인 값만 받을 것이다.

커스텀 Validation

java나 spring이 제공하지 않는 특이한 유효성 검사는 ConstraintValidator와 커스텀 어노테이션을 조합해서 커스텀 Validation을 생성할 수 있다. 방법은 아래 순서와 같다.

  1. 커스텀 어노테이션 생성

    PhoneNumber.java
    @Target(ElementType.FIELD)
    @Retention(RetentionPolicy.RUNTIME)
    @Constraint(validatedBy = PhoneNumberValidator.class) // 전화번호 유효성 검사
    public @interface PhoneNumber {
        String message() default "전화번호 형식이 일치하지 않습니다.";
        Class[] groups() default {};
        Class[] payload() default {};
    }
    
  2. ConstraintValidator implements한 유효성 검사기 구현

    public class PhoneNumberValidator implements ConstraintValidator<PhoneNumber, String> {
        @Override
        public void initialize(PhoneNumber constraintAnnotation) {
            ConstraintValidator.super.initialize(constraintAnnotation);
        }
        @Override
        public boolean isValid(String value, ConstraintValidatorContext context) {
            if(value==null){
                return false;
            }
            return value.matches("01(?:0|1|[6-9])[.-]?(\\d{3,4})[-]?(\\d{4})$");
        }
    }
    
    
  3. 필드에서 어노테이션 사용

    //@Pattern(regexp = "01(?:0|1|[6-9])[.-]?(\\d{3,4})[-]?(\\d{4})$") //010-6604-3466 형태를 맞추도록
    @PhoneNumber
    private String phoneNumber;
    

위처럼 커스텀 어노테이션을 작성한 필드에 "phoneNumber":"010-5555-333"를 입력하면 동일하게 아래 같은 에러가 발생한다. @PhoneNumber에서 정의한 message가 출력된 것을 확인할 수 있다.

Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0]
...
default message [전화번호 형식이 일치하지 않습니다.]] ]

커스텀 어노테이션 생성법

  1. @Target 어노테이션은 어디서 커스텀 어노테이션을 선언할 수 있는지 정의. 매우 다양한 위치에 사용할 수 있도록 설정할 수 있고, 중복해서 사용할 수 있다.

    ElementType.PACKAGE
    ElementType.TYPE
    ElementType.FIELD
    ElementType.METHOD
    ElementType.PARAMETER ...
    
  2. @Retention 어노테이션은 어노테이션이 실제로 적용되고 유지되는 범위

    RetentionPolicy.RUNTIME : 컴파일 이후에도 JVM에 의해 계속 참조된다. 리플렉션이나 로깅에 사용
    RetentionPolicy.CLASS : 컴파일러가 클래스를 참조할 때까지 유지
    RetentionPolicy.SOURCE : 컴파일 전까지 유지되고 컴파일 이후에는 사라진다
    
  3. 인터페이스 내부 요소

     message() : 유효성 검사가 실패할 경우 출력하는 메시지
     groupps() : 유효성 검사를 사용하는 그룹으로 설정
     payload() : 사용자가 추가 정보를 위해 전달하는 값
    

커스텀 Validator 생성법

커스텀 Validator는 ConstraintValidator를 구현하고 어떤 어노테이션 인터페이스인지 타입을 지정해야한다. 그리고 구현할 때는 isValid 메소드를 정의해야한다. 여기에는 실제 어노테이션의 유효성 검사 로직이 들어간다.

커스텀 Validator를 보면 null에 대한 검사와 지정한 정규식과 비료해서 입력 값이 알맞은 형식을 가지고 있는지 검사한다. 이 로직에서 false가 출력되면 MethodArgumentNotValidException 예외가 발생한다.