Spring/SpringMVC

Custom Bean Validation (Custom Validation)

ysk(0soo) 2022. 12. 29. 14:05

Spring MVC Custom Validation

사용 가능한 constraint 어노테이션이 제공하는 제약 조건 외에 필요한 경우, 직접 만들어서 사용할 수 있다.

임의의 제약(Constraint)과 검증자(Validator)를 구현하여 사용하면 된다.

CustomValidation을 위한 Annotation을 생성한다.

@Documented
@Constraint(validatedBy = ContactNumberValidator.class)
@Target( { ElementType.METHOD, ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
public @interface ContactNumberConstraint {
    String message() default "Invalid phone number";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

@Constraint 주석을 사용하여 필드의 유효성을 검사할 클래스를 정의.

message() 는 사용자 인터페이스에 표시되는 오류 메시지 .

마지막으로 추가 코드는 대부분 Spring 표준을 준수하기 위한 상용구 코드이다.

Validator 만들기

public class ContactNumberValidator implements 
  ConstraintValidator<ContactNumberConstraint, String> {

    @Override
    public void initialize(ContactNumberConstraint contactNumber) {
    }

    @Override
    public boolean isValid(String contactField,
      ConstraintValidatorContext cxt) {
        return contactField != null && contactField.matches("[0-9]+")
          && (contactField.length() > 8) && (contactField.length() < 14);
    }

}

유효성 검사 클래스는 ConstraintValidator 인터페이스를 구현하고 isValid 메서드도 구현해야 한다.

isValid 메서드에서 유효성 검사 규칙을 정의하면 된다 .

ConstraintValidator의 구현은 다음 제한 사항을 준수해야 한다.

  • 객체는 매개변수화되지 않은 유형으로 확인되어야 합니다.
  • 개체의 일반 매개변수는 제한되지 않은 와일드카드 유형이어야 합니다.

이 어노테이션을 사용하고자 하는 FIELD에 정의하면 된다

커스텀 클래스 수준 유효성 검사 Custom Class Validation

클래스에 적용할 수 있는 FieldsValueMatch 라는 새 annotaion을 추가한다 .

주석에는 비교할 필드의 이름을 나타내는 두 개의 매개변수( field 및 fieldMatch) 가 있다.

Custom Annotation 만들기

@Constraint(validatedBy = FieldsValueMatchValidator.class)
@Target({ ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface FieldsValueMatch {

    String message() default "Fields values don't match!";

    String field();

    String fieldMatch();

    @Target({ ElementType.TYPE })
    @Retention(RetentionPolicy.RUNTIME)
    @interface List {
        FieldsValueMatch[] value();
    }
}

사용자 지정 주석에는 클래스에서 여러 FieldsValueMatch 주석을 정의하기 위한 List 하위 인터페이스 도 포함되어 있음을 볼 수 있다.

Custom Validator 만들기

public class FieldsValueMatchValidator 
  implements ConstraintValidator<FieldsValueMatch, Object> {

    private String field;
    private String fieldMatch;

    public void initialize(FieldsValueMatch constraintAnnotation) {
        this.field = constraintAnnotation.field();
        this.fieldMatch = constraintAnnotation.fieldMatch();
    }

    public boolean isValid(Object value, 
      ConstraintValidatorContext context) {

        Object fieldValue = new BeanWrapperImpl(value)
          .getPropertyValue(field);
        Object fieldMatchValue = new BeanWrapperImpl(value)
          .getPropertyValue(fieldMatch);

        if (fieldValue != null) {
            return fieldValue.equals(fieldMatchValue);
        } else {
            return fieldMatchValue == null;
        }
    }
}

어노테이션 적용

@FieldsValueMatch.List({ 
    @FieldsValueMatch(
      field = "password", 
      fieldMatch = "verifyPassword", 
      message = "Passwords do not match!"
    ), 
    @FieldsValueMatch(
      field = "email", 
      fieldMatch = "verifyEmail", 
      message = "Email addresses do not match!"
    )
})
public class NewUserForm {
    private String email;
    private String verifyEmail;
    private String password;
    private String verifyPassword;

    // standard constructor, getters, setters
}

사용자 등록에 필요한 데이터를 위한 NewUserForm 모델 클래스이다 .

두 개의 값을 다시 입력하기 위한 두 개의 verifyEmailverifyPassword 속성 과 함께 두 개의 이메일 및 비밀번호 속성이 있다.

일치하는 해당 필드에 대해 확인할 두 개의 필드가 있으므로 NewUserForm 클래스 에 두 개의 @FieldsValueMatch 어노테이션을 추가한다. 하나는 이메일 값용 이고 다른 하나는 비밀번호 이다 .

Enum Validation 예시

public enum MyColor {
    RED,
    GREEN,
    BLUE;
}

public class DTO {
  //...
      @EnumValid(enumClass = MyColor.class, message = "GREEN은 안됩니다.")
          MyColor favoriteColor;
  //...
}

커스텀 validation annotation

@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {EnumValidator.class}) 
public @interface EnumValid {
  // message는 어노테이션에서 지정해주지 않으면 default로 나가거나, properties로 설정해줄 수도 있다.
    String message() default "Invalid value. This is not permitted.";

  // 유효성 검사가 어떤 상황에서 실행되는지 정의할 수 있는 매개변수 그룹
    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    Class<? extends java.lang.Enum<?>> enumClass();
}

커스텀 Validator

public class EnumValidator implements ConstraintValidator<EnumValid, Enum<?>> {
    private EnumValid enumValid;

  // 어노테이션에서 설정한 값을 가져오려면 초기화해주어야 한다. 만약 isValid함수에서 그냥 해결할 수 있으면 안해도됨
    @Override
    public void initialize(EnumValid constraintAnnotation) {
        enumValid = constraintAnnotation;
    }

  // isValid를 오버라이드해서 작성하면 된다.
    @Override
    public boolean isValid(Enum<?> value, ConstraintValidatorContext context) {
        boolean result = true;
        Object[] enumValues = enumValid.enumClass().getEnumConstants();

        if (String.valueOf(value).equals(MyColor.GREEN.toString())) return false;

        return result;
    }
}

예외 처리 Hanlder (ControllerAdvice)

@RestControllerAdvice
public class ExceptionHandler {
  @ExceptionHandler(value = MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationException(MethodArgumentNotValidException e) {
        ErrorCode errorCode = ErrorCode.INVALID_INPUT_VALUE;
        ErrorResponse er = getErrorResponse(e, errorCode);
        log.error("handleValidationException[{}]", er);
        return ResponseEntity
                .status(errorCode.getStatus())
                .body(er);
    }

    @ExceptionHandler(value = ConstraintViolationException.class)
    public ResponseEntity<ErrorResponse> handleConstraintViolationException(ConstraintViolationException e) {
        ErrorCode errorCode = ErrorCode.INVALID_INPUT_VALUE;
        ErrorResponse er = getErrorResponse(e, errorCode);
        log.error("ConstraintViolationException[{}]", er);
        return ResponseEntity
                .status(errorCode.getStatus())
                .body(er);
    }

    public static ErrorResponse getErrorResponse(BindException e, ErrorCode code) {

        List<ErrorResponse.ValidationError> validationErrorList = e.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(ErrorResponse.ValidationError::of)
                .collect(Collectors.toList());

        return ErrorResponse.builder()
                .code(code.getCode())
                .message(code.getMessage())
                .errors(validationErrorList)
                .status(code.getStatus())
                .build();
    }
}

예외 공통 응답 Response Dto

@Getter
@ToString
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ErrorResponse {
    private int status;
    private String code;
    private String message;

    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    private List<ValidationError> errors;

    @Getter
    @Builder
    @RequiredArgsConstructor
    public static class ValidationError {
        private final String field;
        private final String value;
        private final String message;

        public static ValidationError of(FieldError fieldError) {
            return ValidationError.builder()
                    .field(fieldError.getField())
                    .value(String.valueOf(fieldError.getRejectedValue()))
                    .message(fieldError.getDefaultMessage())
                    .build();
        }

        public static ValidationError of(ConstraintViolation violation) {
            return ValidationError.builder()
                    .field(String.valueOf(violation.getPropertyPath()))
                    .value(String.valueOf(violation.getInvalidValue()))
                    .message(violation.getMessageTemplate())
                    .build();
        }
    }

    @Builder
    public ErrorResponse(int status, String code, String message, List<ValidationError> errors) {
        this.status = status;
        this.code = code;
        this.message = message;
        this.errors = errors;
    }
}

결론

어노테이션의 의도는 숨어있기 때문에 내부적으로 어떤 동작을 하게 되는지 명확하지 않다면 로직 플로우를 이해하기 어렵다.

하물며 ‘커스텀’ 어노테이션은 그 부담을 가중시킬 수 있다.

어노테이션 추가가 당장의 작업 속도를 끌어올릴 순 있지만, 장기적 관점에서 시의적절한 것인지를 공감할 수 있어야 한다.

코드가 간결해진다는 장점 하나만 보고 커스텀 어노테이션을 남용하지 않게 주의해야 한다.

반복적으로 사용하지도 않고, 특정 요청 외에는 사용할 일이 없어 보이는 유효성 검사라면 단순 메서드로 만들어 처리하는 것이 더 좋을 것이다.

커스텀 어노테이션을 잘 이용하면 불필요한 반복코드가 줄어들고, 비즈니스 로직에 더 집중할 수 있다는 장점이 있다.

다만, 커스텀 어노테이션은 의도와 목적을 명확히 하여 구성원간 공감대를 이룬 후 추가하는 것이 좋다.