프로젝트에서는 클라이언트의 요청 값을 검증해야 하는 많은 경우가 있습니다.
이때 Java에서는 'Bean Validation'을 통해 유효성 검증을 실시하는데요.
'Bean Validation'은 빈 유효성 검사를 위한 Java API 사양으로 @NotNull @NotBlank @Email @Positive 등과 같은 어노테이션을 사용해서 빈의 속성이 유효성을 충족하는지 확인하게 됩니다.
(JSR-303 또는 JSR-380 이라고도 불리며, Bean Validation 1.0 => JSR-303이고 Bean Validation 2.0 => JSR-380입니다.)
JSR-303, JSR-380이 제공해주는 Validation의 종류는 다양하지만, 서비스에 따라서 기본적으로 제공되는 검증 어노테이션 외에 유효성 검증이 필요한 경우가 있는데요. 이런 경우 필요에 맞는 'Custom Validator'를 만들어서 사용할 수 있습니다.
Custom Validator를 만들어서 적용하는 과정은 크게 Annotation을 만드는 과정과 해당 어노테이션에 적용될 Validator Class를 만들고 적용시키는 간단한 방법으로 진행됩니다.
하지만 커스텀 어노테이션을 사용하는 것에 있어서는 아래와 같이 생각해봐야 하는 부분도 있는데요.
***
우선 커스텀 어노테이션을 사용함으로써 얻을 수 있는 장점은 간결함입니다.
프로세스 로직에 한 부분을 어노테이션을 통해 작동시키는 것이기 때문에 적재적소에 사용된다면 불필요한 코드가 줄어들고, 개발자는 비즈니스 로직에 더 집중할 수 있게 됩니다.
단점으로는 어노테이션이 내부적으로 어떤 동작을 하는지 명확하지 않다면 프로세스 로직을 이해하기가 어려워지며, 마찬가지 이유로 여러 가지 기능을 한 번에 담는 경우도 어노테이션의 용도와 목적을 이해하기가 어려워지게 됩니다.
이러한 내용을 생각해보면서 'Custom Validator'를 적용하는 방법에 대해서 살펴보겠습니다.
단일 필드
먼저 단일 필드에 대한 적용 방법입니다.
예시로 휴대폰 번호 형식에 대한 유효성 검사를 적용해 볼 텐데요. 이런 간단한 패턴 같은 경우는 실제로 @Pattern 어노테이션을 사용해서 처리하면 되지만 구현에 대한 예시라는 점 참고 부탁드리겠습니다.
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PhoneNumCheckValidator.class)
public @interface PhoneNumCheck {
String message() default "잘못된 휴대폰 번호입니다.";
Class[] groups() default {};
Class[] payload() default {};
}
'PhoneNumCheck Annotation'
@Target
어노테이션을 적용할 수 있는 위치를 설정하는 것입니다.
해당 객체 전체를 대상으로 하려는 경우 ElementType을 TYPE(Class)로 정의하면 됩니다.
여기서는 필드가 대상이기 때문에 'ElementType.FIELD'를 정의합니다.
@Retention
어노테이션의 유지 범위를 설정하는 것입니다.
여기서는 실행하는 동안으로 설정하기 위해 'RetentionPolicy.RUNTIME'으로 설정해줍니다.
@Constratin
필드 값을 검증할 검증 클래스를 지정할 수 있습니다.
validateBy를 통해 지정하며, 지정되는 검증 클래스로는 ConstraintValidator 인터페이스를 구현한 클래스가 지정되어야 합니다.
public class PhoneNumCheckValidator implements ConstraintValidator<PhoneNumCheck, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) {
return false;
}
return value.matches("(01[016789])(\\d{3,4})(\\d{4})");
}
}
'PhoneNumCheckValidator Class'
ConstraintValidator 인터페이스를 구현한 클래스입니다.
구현할 때 ConstraintValidator의 제네릭 값으로는 첫 번째에 해당 Validator를 적용할 어노테이션 객체가 들어갑니다. 여기서는 위에서 만든 PhoneNumCheck 어노테이션이 들어가게 됩니다.
두 번째 들어가는 값으로는 해당 어노테이션을 적용하여 검증 작업을 진행할 필드의 데이터 유형을 넣습니다.
예시에서는 @PhoneNumCheck 어노테이션을 적용할 필드를 String으로 지정할 것이기 때문에 String을 넣었습니다.
예를 들어서 필드가 아닌 객체에 어노테이션을 적용하는 경우 해당 객체 타입을 입력할 수도 있지만, 하나의 객체가 아닌 여러 개의 다른 객체에서 범용적으로 사용하고 싶은 경우 Object를 넣어서 사용할 수 있습니다.
(해당 예시는 아래 다중 필드 적용 부분에서 확인할 수 있습니다.)
ConstraintValidator 인터페이스를 구현하게 될 경우 예외를 검증하는 메서드인 'isValid()'를 필수로 오버라이딩 해야 합니다.
@Getter
public static class ValidTestRequest {
@PhoneNumCheck
private String phoneNum;
...
}
(Request 객체에 @PhoneNumCheck Annotation 적용)
@PostMapping("/valid")
public void validTest(@Validated ValidTestRequest validTestRequest) {
...
}
(Controller Request 객체 앞에 @Valid 또는 @Validated 적용)
다중 필드
이어서 다중 필드에 대한 적용 방법입니다.
해당 예시에서는 비밀번호와 비밀번호 확인 값이 일치하는지를 확인하는 어노테이션을 만들어서 적용해보겠습니다.
@PasswordConfirmCheck(text1 = "password", text2 = "passwordConfirm")
@Getter
public static class PasswordValidRequest {
private String password;
private String passwordConfirm;
}
(Request 객체)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordConfirmCheckValidator.class)
public @interface PasswordConfirmCheck {
String message() default "비밀번호와 비밀번호 확인이 일치하지 않습니다.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String text1();
String text2();
}
'PasswordConfirmCheck Annotation'
여기서 @Target은 위에서 설명한 것처럼 단일 필드나 메서드가 아닌 Request 객체에 적용했기 때문에 'ElementType.TYPE'을 적용해줍니다.
여기서 주목해야 하는 부분은 text1(), test2() 부분인데요. 해당 부분은 어노테이션이 적용될 객체에서 가지고 올 값에 대한 그릇의 역할이 됩니다.
(text1, tes2라는 네이밍은 임의적으로 한 것으로 사용자가 원하는 대로 변경해서 사용할 수 있습니다.)
@Slf4j
public class PasswordConfirmCheckValidator implements ConstraintValidator<PasswordConfirmCheck, Object> {
private String message;
private String text1;
private String text2;
@Override
public void initialize(PasswordConfirmCheck constraintAnnotation) {
message = constraintAnnotation.message();
text1 = constraintAnnotation.text1();
text2 = constraintAnnotation.text2();
}
@Override
public boolean isValid(Object object, ConstraintValidatorContext context) {
boolean flag = true;
String password = getFieldValue(object, text1);
String confirm = getFieldValue(object, text2);
if (!password.equals(confirm)) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(message)
.addPropertyNode(text1)
.addConstraintViolation();
flag = false;
}
return flag;
}
// 리플렉션을 이용하여 필드를 가져오는 부분
private String getFieldValue(Object object, String fieldName) {
Class<?> clazz = object.getClass();
Field dateField;
try {
dateField = clazz.getDeclaredField(fieldName);
dateField.setAccessible(true);
Object target = dateField.get(object);
if (!(target instanceof String)) {
throw new ClassCastException("casting exception");
}
return (String) target;
} catch (NoSuchFieldException e) {
log.error("NoSuchFieldException", e);
} catch (IllegalAccessException e) {
log.error("IllegalAccessException", e);
}
throw new ServerErrorException("Not Found Field");
}
}
'PasswordConfirmCheckValidator Class'
여기서는 ConstraintValidator 인터페이스의 제네릭 부분 두 번째 값으로 Object를 받습니다.
이유는 위에서 말한 것처럼 해당 어노테이션을 하나가 아닌 여러 개의 Request에서 범용적으로 사용할 경우(회원가입 Request 객체, 비밀번호 변경 Request 객체 등)라서 Object로 설정한 것입니다.
해당 클래스에는 필수로 구현해야 하는 'isValid()' 메서드 외에 'initialize()' 메서드가 있는데요. 해당 메서드는 어노테이션이 부착된 객체로부터 필드명을 가지고 와서 초기화하기 위한 용도로 사용됩니다.
'isValid()' 메서드의 경우 위에서와 마찬가지로 유효성을 검증하는 용도로 사용되는데요. 내용만 보면 password와 passwordConfirm 값을 가지고 와서 비교하는 간단한 로직입니다.
하지만 중요한 부분은 password와 passwordConfirm 값을 가지고 오는 부분인데요. 내부적으로 사용되는 메서드인 'getFieldValue()' 메서드에 주목해봐야 합니다. 해당 메서드는 리플렉션(Reflection)을 통해 어노테이션이 적용된 객체에서 값을 가지고 오는 역할을 하는데요.
* 리플렉션(Reflection)은 메서드, 클래스, 인터페이스의 행위를 런타임에 검사하거나 수정하기 위해 사용되는 API입니다.
이때 사용에 있어서 꼭 고려되어야 하는 부분이 있습니다.
Reflection의 성능 부분에 대한 이슈인데요.
Java Reflection API는 동적으로 클래스를 불러와서 사용하기 때문에 속도가 느리고 높은 비용이 발생하게 된다는 점입니다.
초기 호출 이후로는 캐싱을 통해 최적화된다는 내용도 있지만 해당 부분은 'setAccessible(true)' 설정으로 인해 작용되지 않습니다.
Reflection을 사용하여 동적으로 가져온 클래스의 메타 데이터 정보는 JVM의 Perm 영역에 저장되는데, 만약 리플렉션을 사용하여 엄청나게 많은 클래스를 동적으로 사용하게 되면 'OutOfMemoryError'가 발생할 수도 있습니다.
(성능적인 부분에서의 이슈가 발행하지 않는 방법도 찾고 있는데 해당 방법을 찾으면 추가 포스팅으로 내용 남기도록 하겠습니다.)
< 참고 자료 >
'Programming > Spring Boot' 카테고리의 다른 글
스프링 시큐리티 SecurityContextHolder에 Authentication(인증) 정보가 저장되는 과정 (0) | 2022.07.13 |
---|---|
@Valid @Validated 동작 원리 파헤치기 (4) | 2022.07.01 |
Spring swagger 3 사용방법(springdoc-openapi-ui) (2) | 2022.06.25 |
@RequestBody @ResponseBody 어노테이션 이해하고 사용하기 (2) | 2022.05.13 |
@Value 어노테이션 null이 나오는 문제 해결 방법 (5) | 2022.05.07 |