-
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 등과 같은 것들을 몽땅 구현해야 되나? 그 부분은 좀 손이 많이 간단 생각이 든다. 그리고 리터럴로 작성할 수밖에 없는데, 컴파일 단계에서 에러를 확인할 수 있는 방법은 없을까? 좀 더 좋은 방법이 없을지 확인해봐야 될 듯하다.'개발' 카테고리의 다른 글
[ChatGPT] 터미널 AI shell_gpt 소개 (0) 2023.03.29 SVN 사용하기 (CLI, svn branch) (0) 2023.03.24 개발 생산성을 높이는 원자적 커밋(Atomic commit) (0) 2023.03.22