개발
Java #validation api - custom constraints 추가, 조건부 검증
늘보김
2022. 10. 23. 21:20
서론
도메인에 기존에 없던 유형이 추가되면서 유형에 따라 validation을 분기처리해야 하는 경우가 생겼다.
보통 validation은 service layer에서 이루어지지만, 이 경우 사용자 입력에 대한 검증을 담당하므로 presentation layer에서 검증할 수 있도록 구현하면 어떨까 싶어 custom constraints를 추가하기로 결정하였다.
Java validation api를 활용해 조건부로 검증할 수 있는 제약조건을 추가했다.
요구사항은 아래와 같다.
- A type일 때, 해당 field는 null일 수 없다.
- B type일 때, 해당 field는 null일 수 있다.
아래와 같은 회원 도메인을 예시로 들어보고자 한다. (좀 더 적절한 예시가 있을 것 같은데.. 추천 부탁드려요🥲)
- 회원 도메인의 휴대폰 번호는 필수값이 아니었다.
- 회원에 유형이 추가되었다. VIP, STANDARD 유형이 존재한다.
- VIP 회원은 휴대폰 번호가 필수값이다.
- STANDARD 회원은 휴대폰 번호가 필수값이 아니다.
구현
@ConditionalNotNull
- field: 조건이 될 필드명
- fieldValue: 조건이 될 필드의 값
- notNullField: 값에 대해 NotNull 제약될 필드명
@Documented
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ConditionalNotNullValidator.class)
public @interface ConditionalNotNull {
String message() default "must be not null";
String field();
String fieldValue();
String notNullField();
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@interface List {
ConditionalNotNull[] value();
}
}
ConditionalNotNullValidator
public class ConditionalNotNullValidator implements
ConstraintValidator<ConditionalNotNull, Object> {
private String targetField;
private String targetFieldValue;
private String notNullField;
@Override
public void initialize(final ConditionalNotNull constraintAnnotation) {
ConstraintValidator.super.initialize(constraintAnnotation);
this.targetField = constraintAnnotation.field();
this.targetFieldValue = constraintAnnotation.fieldValue();
this.notNullField = constraintAnnotation.notNullField();
}
@Override
public boolean isValid(final Object value, final ConstraintValidatorContext context) {
final var conditionValue = new BeanWrapperImpl(value).getPropertyValue(targetField);
final var notNullValue = new BeanWrapperImpl(value).getPropertyValue(notNullField);
if (conditionValue != null && conditionValue.equals(targetFieldValue)) {
return notNullValue != null;
}
return true;
}
}
MemberInsertRequest
@Getter
@Setter
@Builder
@ToString
@NoArgsConstructor
@AllArgsConstructor
@ConditionalNotNull(field = "type", fieldValue = "VIP", notNullField = "phone")
public class MemberInsertRequest {
@NotBlank
private String type;
private String phone;
}
MemberController
@Slf4j
@RestController
@RequestMapping("/members")
public class MemberController {
@PostMapping
public ResponseEntity<String> insert(
@RequestBody @Valid final MemberInsertRequest request) throws URISyntaxException {
log.info("insert requested: {}", request);
return ResponseEntity.created(new URI("/members/1")).body("OK");
}
}
MemberControllerTest
@SpringBootTest
@ExtendWith(MockitoExtension.class)
class MemberControllerTest {
@Autowired
MemberController memberController;
MockMvc mockMvc;
@BeforeEach
void setup() {
mockMvc = MockMvcBuilders.standaloneSetup(memberController)
.alwaysDo(print())
.build();
}
@Test
void givenVIPMemberWithPhone_whenPostNewMember_then201Created() throws Exception {
final var request = MemberInsertRequest.builder()
.type("VIP")
.phone("01012345678")
.build();
final var objectMapper = new ObjectMapper();
mockMvc.perform(
post("/members")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated());
}
@Test
void givenVIPMemberWithPhoneNull_whenPostNewMember_then400Error() throws Exception {
final var request = MemberInsertRequest.builder()
.type("VIP")
.build();
final var objectMapper = new ObjectMapper();
mockMvc.perform(
post("/members")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void givenStandardMemberWithPhoneNull_whenPostNewMember_thenOk() throws Exception {
final var request = MemberInsertRequest.builder()
.type("STANDARD")
.build();
final var objectMapper = new ObjectMapper();
mockMvc.perform(
post("/members")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated());
}
}
결론
간단한 예제로 만들어본 것이지만, 사실 휴대폰 번호는 Phone이라는 value object를 통해 구현해보면 조금 더 좋았겠단 생각이 들었다.
아마 ConditionalValid와 같은 어노테이션으로 구현할 수 있었을 듯하다.
그리고 조건부 검증을 위해 ConditionalNotEmpty, CondtionalNotBlank 등과 같은 것들을 몽땅 구현해야 되나? 그 부분은 좀 손이 많이 간단 생각이 든다. 그리고 리터럴로 작성할 수밖에 없는데, 컴파일 단계에서 에러를 확인할 수 있는 방법은 없을까? 좀 더 좋은 방법이 없을지 확인해봐야 될 듯하다.