사용하는 Dto Class에 Enum Type이 존재하고, 타입 별로 필드를 검증하고 싶을 때 Annotation과 ConstraintValidator 인터페이스를 활용하여 Validaton을 구현할 수 있다.
1. ConditinalValidator Class
Conditional Class는 2번에서 만들 Annotation을 의미한다.
isValid 메소드에서 Required Value가 null이거나 ""일 때 에러를 반환해준다.
@Slf4j
public class ConditionalValidator implements ConstraintValidator<Conditional, Object> {
private String selected;
private String[] required;
private String message;
private String[] values;
@Override
public void initialize(Conditional requiredIfChecked) {
selected = requiredIfChecked.selected();
required = requiredIfChecked.required();
message = requiredIfChecked.message();
values = requiredIfChecked.values();
}
@Override
public boolean isValid(Object objectToValidate, ConstraintValidatorContext context) {
Boolean valid = true;
try {
Object actualValue = BeanUtils.getProperty(objectToValidate, selected);
if (Arrays.asList(values).contains(actualValue)) {
for (String propName : required) {
Object requiredValue = BeanUtils.getProperty(objectToValidate, propName);
// value가 null이거나 ""인지 판별한다.
valid = requiredValue != null && !isEmpty(requiredValue);
if (!valid) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(message).addPropertyNode(propName).addConstraintViolation();
}
}
}
} catch (IllegalAccessException e) {
log.error("Accessor method is not available for class : {}, exception : {}", objectToValidate.getClass().getName(), e);
e.printStackTrace();
return false;
} catch (NoSuchMethodException e) {
log.error("Field or method is not present on class : {}, exception : {}", objectToValidate.getClass().getName(), e);
e.printStackTrace();
return false;
} catch (InvocationTargetException e) {
log.error("An exception occurred while accessing class : {}, exception : {}", objectToValidate.getClass().getName(), e);
e.printStackTrace();
return false;
}
return valid;
}
}
2. Conditional Annotaton
Dto에 쓰이며 위에서 만든 ConditionalValidator Class를 활용하는 Annotaton이다.
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {ConditionalValidator.class})
public @interface Conditional {
String message() default "This field is required.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String selected();
String[] required();
String[] values();
}
여기서 아래와 같이 @Repeatable 어노테이션으로 여러 개의 Conditianl Annotation을 같이 선언할 수도 있다.
@Repeatable(Conditionals.class)
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {ConditionalValidator.class})
public @interface Conditional {
String message() default "This field is required.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String selected();
String[] required();
String[] values();
}
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
public @interface Conditionals {
Conditional[] value();
}
3. DTO
사용이 필요한 DTO에 어노테이션을 추가해준다.
@Builder
@Getter
@Setter
@Conditional(selected = "locationOption", values = {"SFTP", "FTP"}, required = {"host", "user", "password", "port"})
@Conditional(selected = "locationOption", values = {"EMAIL"}, required = {"emailAddress"})
public class ExportLocationDTO {
@NotEmpty
private String name;
@NotNull
private ExportLocationOption locationOption;
private String emailAddress;
private String host;
@Min(1)
private Integer port;
@NotBlank
@Length(min = 1,max = 255)
private String user;
@NotBlank
@Length(min = 1,max = 100)
private String password;
}
2번에서 알려준 @Conditionals를 활용하면 다음과 같이 쓸 수 있다.
@Builder
@Getter
@Setter
@Conditionals(
{
@Conditional(selected = "locationOption", values = {"SFTP", "FTP"}, required = {"host", "user", "password", "port"}),
@Conditional(selected = "locationOption", values = {"EMAIL"}, required = {"emailAddress"})
}
)
public class ExportLocationDTO {
@NotEmpty
private String name;
@NotNull
private ExportLocationOption locationOption;
private String emailAddress;
private String host;
private Integer port;
private String user;
private String password;
}
4. @Valid 적용
마지막으로 해당 DTO가 사용되는 곳에 @Valid 어노테이션을 추가해준다.
@PutMapping
public CommonResponse<Boolean> exportLocation(
@Valid @RequestBody ExportLocationDTO dto
) {
service.exportLocation(dto);
return new CommonResponse<>(true);
}
5. Test Code
@Valid 유닛 테스트 코드는 다음과 같이 작성할 수 있다.
Assertions.assertFalse(violations.isEmpty()); 메소드를 사용하면 @Valid 검증이 실패할 경우 테스트가 통과된다.
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;
public class ExportLocationDTOTest {
private Validator validator;
private ExportLocationDTO dto;
@Before
public void setUp() throws Exception {
ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
validator = validatorFactory.getValidator();
dto = ExportLocationDTO.builder()
.name("validName")
.build();
}
@Test
public void ShouldFailOnMissingEmailAddressField() {
dto.setLocationOption(ExportLocationOption.EMAIL);
Set<ConstraintViolation<ExportLocationDTO>> violations = validator.validate(dto);
assertEquals(1, violations.size());
}
@Test
public void ShouldFailOnMissingFTPCredentials() {
dto.setLocationOption(ExportLocationOption.FTP);
Set<ConstraintViolation<ExportLocationDTO>> violations = validator.validate(dto);
assertEquals(4, violations.size());
}
@Test
public void ShouldFailOnMissingSFTPCredentials() {
dto.setLocationOption(ExportLocationOption.SFTP);
Set<ConstraintViolation<ExportLocationDTO>> violations = validator.validate(dto);
assertEquals(4, violations.size());
}
}
Assertions.assertFalse(violations.isEmpty());
아래는 Conditional 어노테이션 및 ConditionalValidator 클래스의 동작을 테스트한다.
LocaltionOption이 EMAIL일 경우 emailAddress 필드 값이 null이거나 빈 값이면 안되기 때문에 False를 검증하는 테스트이다.
public class ExportLocationDTOTest {
private final ConstraintValidatorContext constraintValidatorContext = mock(
ConstraintValidatorContext.class);
private ExportLocationDTO dto;
private conditionalValidator = new ConditionalValidator();
@BeforeEach
public void setUp(){
dto = ExportLocationDTO.builder()
.name("validName")
.build();
ConstraintAnnotationDescriptor.Builder<Conditional> descriptorBuilder = new ConstraintAnnotationDescriptor.Builder<>(
Conditional.class);
descriptorBuilder.setAttribute("selected", "locationOption");
descriptorBuilder.setAttribute("values", new String[]{"EMAIL"});
descriptorBuilder.setAttribute("required", new String[]{"emailAddress"});
Conditional conditional = descriptorBuilder.build()
.getAnnotation();
conditionalValidator.initialize(conditional);
}
@Test
public void ShouldFailOnMissingEmailAddressField() {
dto.setLocationOption(ExportLocationOption.EMAIL);
boolean valid = conditionalValidator.isValid(dto,
constraintValidatorContext);
assertFalse(valid);
}
}