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 모델 클래스이다 .
두 개의 값을 다시 입력하기 위한 두 개의 verifyEmail 및 verifyPassword 속성 과 함께 두 개의 이메일 및 비밀번호 속성이 있다.
일치하는 해당 필드에 대해 확인할 두 개의 필드가 있으므로 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;
}
}
결론
어노테이션의 의도는 숨어있기 때문에 내부적으로 어떤 동작을 하게 되는지 명확하지 않다면 로직 플로우를 이해하기 어렵다.
하물며 ‘커스텀’ 어노테이션은 그 부담을 가중시킬 수 있다.
어노테이션 추가가 당장의 작업 속도를 끌어올릴 순 있지만, 장기적 관점에서 시의적절한 것인지를 공감할 수 있어야 한다.
코드가 간결해진다는 장점 하나만 보고 커스텀 어노테이션을 남용하지 않게 주의해야 한다.
반복적으로 사용하지도 않고, 특정 요청 외에는 사용할 일이 없어 보이는 유효성 검사라면 단순 메서드로 만들어 처리하는 것이 더 좋을 것이다.
커스텀 어노테이션을 잘 이용하면 불필요한 반복코드가 줄어들고, 비즈니스 로직에 더 집중할 수 있다는 장점이 있다.
다만, 커스텀 어노테이션은 의도와 목적을 명확히 하여 구성원간 공감대를 이룬 후 추가하는 것이 좋다.
'Spring > SpringMVC' 카테고리의 다른 글
UriComponentsBuilder, ServletUriComponentsBuilder, 201 URI 생성 (0) | 2023.02.01 |
---|---|
Spring consumes와 produces (0) | 2023.01.25 |
Java Bean Validation + Validation Tip (0) | 2022.12.29 |
Bean Validation Annotation 종류 (0) | 2022.12.28 |
Spring MVC의 기본 요청 처리 방식 - Thread Per Request Model (0) | 2022.12.17 |