ABOUT ME

-

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

    댓글

Designed by Tistory.