개발

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 등과 같은 것들을 몽땅 구현해야 되나? 그 부분은 좀 손이 많이 간단 생각이 든다. 그리고 리터럴로 작성할 수밖에 없는데, 컴파일 단계에서 에러를 확인할 수 있는 방법은 없을까? 좀 더 좋은 방법이 없을지 확인해봐야 될 듯하다.