서론
스프링 컨트롤러에서 클라이언트로부터 받은 값을 검증하기 위해 Validation을 많이 사용하곤 합니다.
JSR 380이 제공해주는 Bean Validation 의 종류는 다양하지만 각자의 서비스에 따라
기본적으로 제공해주는 애노테이션만으로는 검증을 모두 다 지원해주지는 못합니다.
따라서 상황에 따라 각 서비스에 맞는 커스텀한 Validation을 만들어서 활용하기도 합니다.
그렇다면 이번 글에서는 왜 커스텀 Validation을 만들었고 만드는 과정을 소개하고자 합니다.
왜 필요한가?
기존 @NotNull, @NotEmpty, @NotBlank, @Email
등의 애노테이션으로 클라이언트 요청으로 값을
검증하는 것은 필드 자체의 형식이나 값을 체크하는 용도로만 사용됩니다.
즉 필드와 필드를 비교하여 검증하는 로직이 서비스 로직에 포함되는 경우가 발생하게 됩니다.
예시
-------------------------------------------------------------------------------------------------------------
예약시간
, 예약종료시간
이라는 필드가 존재할 때 기본적으로 예약시간이 예약 종료시간보다 늦을 수는 없습니다.
결국 예약시간
, 예약종료시간
이 알맞게 들어왔는지 확인하기 위해 다음과 같은 코드를 작성하게 됩니다.
if (reservationStart.isAfter(reservationEnd)){
//TODO Exception
}
과연 이러한 코드는 비즈니스 로직에 몇 번이나 포함이 될까요?
예약을 저장, 예약시간을 변경, 선택 시간 범위 내 존재하는 예약 리스트 반환 등등 클라이언트로 요청이 넘어온 예약시간
, 예약종료시간
필드들은 항상 검증을 해야 합니다.
이러한 방법은 어쩔 수 없이 중복 코드를 발생시키고 까먹고 검증과정을 누락하여 버그를 발생시킬 수 있습니다.
또한 이러한 검증 로직이 서비스 레이어까지 내려오는 것이 맞을까요?
단순히 입력값 자체를 비교하는 과정은 조금 더 외부 Layer에서 처리하는 것이 좋을 것 같습니다.
Custom Valid Annotation 만들기
클라이언트로 받을 DTO는 다음과 같습니다.
@StartAndEndTimeCheck(startDate = "reservationStart", endDate = "reservationEnd") //해당 애노테이션을 만들고자합니다.
public class RequestOrder {
@NotBlank
@ApiModelProperty(value = "메뉴이름", required = true, example = "다운펌")
private String menuName;
@NotBlank
@ApiModelProperty(value = "회원 전화번호", required = true, example = "01012345678")
private String memberPhoneNumber;
@NotBlank
@ApiModelProperty(value = "디자이너 이름", required = true, example = "디자이너")
private String designerName;
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
@NotNull
@ApiModelProperty(value = "예약 시작시간", required = true, example = "2021-06-16T11:40")
private LocalDateTime reservationStart; // 필드 비교 대상
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
@NotNull
@ApiModelProperty(value = "예약 끝나는시간", required = true, example = "2021-06-16T12:40")
private LocalDateTime reservationEnd; // // 필드 비교 대상
}
저희의 목적은 @Valid
를 이용하여 기존 서비스 로직에 존재하는 검증 로직을 제거하는 것이 목표입니다.
@StartAndEndTimeCheck 만들기
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = StartAndEndTimeCheckValidator.class)
public @interface StartAndEndTimeCheck {
String message() default "에약시작 시간이 예약종료 시간 보다 늦을수 없습니다."; // 예외가 발생하면 출력할 메세지
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String startDate(); // 대상 객체의 시작시간 필드 이름을 담을 그릇
String endDate(); // 대상 객체의 종료시간 필드 이름을 담을 그릇
}
사용할 커스텀한 애노테이션을 만듭니다.
이제 커스텀 애노테이션을 만들었으니 작동할 validator를 생성하면 끝입니다.
StartAndEndTimeCheckValidator
public class StartAndEndTimeCheckValidator implements ConstraintValidator<StartAndEndTimeCheck, Object> {
private String message;
private String startDate;
private String endDate;
@Override
public void initialize(StartAndEndTimeCheck constraintAnnotation) {
message = constraintAnnotation.message(); // 애노테이션에 저장된 메세지
startDate = constraintAnnotation.startDate(); // 애노테이션에 저장된 비교할 필드
endDate = constraintAnnotation.endDate(); // 애노테이션에 저장된 비교할 필드
}
@Override
public boolean isValid(Object o, ConstraintValidatorContext context) {
int invalidCount = 0;
LocalDateTime reservationStart = getFieldValue(o, startDate);
LocalDateTime reservationEnd = getFieldValue(o, endDate);
if (reservationStart.isAfter(reservationEnd)) { // 검증 후 오류가 있다면
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(message)//context에 오류메세지와
.addPropertyNode(startDate)//대상 필드를 넣어줍니다.
.addConstraintViolation();
invalidCount += 1;
}
return invalidCount == 0;
}
// 리플렉션을 이용하여 필드를 가져옴
private LocalDateTime getFieldValue(Object object, String fieldName) {
Class<?> clazz = object.getClass();
Field dateField;
try {
dateField = clazz.getDeclaredField(fieldName);
dateField.setAccessible(true);
Object target = dateField.get(object);
if (!(target instanceof LocalDateTime)) {
throw new ClassCastException("casting exception");
}
return (LocalDateTime) target;
} catch (NoSuchFieldException e) {
log.error("NoSuchFieldException", e);
} catch (IllegalAccessException e) {
log.error("IllegalAccessException", e);
}
throw new ServerErrorException("Not Found Field");
}
}
ConstraintValidator<StartAndEndTimeCheck, Object>
여기서 왜 Object 형태를 사용하는가?
해당 애노테이션을 어떠한 특정 DTO에 종속되게 사용하지 않고 범용적으로 재활용하기 위해서 Object 형태로 받았습니다.
따라서 Object 형태로는 필드의 값을 가져오지 못해
@StartAndEndTimeCheck(startDate = "reservationStart", endDate = "reservationEnd")
리플랙션을 이용하여 특정 필드의 값을 가져올 수 있도록 하기 위해서 위처럼 애노테이션으로 대상 필드 Name을 받았습니다.
사용
사용법은 간단합니다.
해당 커스텀 애노테이션의 값에 대상 필드를 작성합니다
(Type safe 하지 않다는 단점이 존재하네요 필드 철자가 틀리면 오류가 발생합니다. )
@StartAndEndTimeCheck(startDate = "reservationStart", endDate = "reservationEnd")
public class RequestOrder {
//생략
private LocalDateTime reservationStart;
private LocalDateTime reservationEnd;
}
@StartAndEndTimeCheck(startDate = "reservationStart", endDate = "reservationEnd")
public class RequestOrderTimeEdit {
private Long id;
private LocalDateTime reservationStart;
private LocalDateTime reservationEnd;
}
컨트롤러에 @Valid를 붙여 검증을 하시면 됩니다.
@PostMapping("/order")
@ApiOperation(value = "예약 추가", notes = "예약 일정을 생성합니다.")
//@Valid 애노테이션으로 검증
public ResponseEntity<ResponseOrder> createOrder(@RequestBody @Valid RequestOrder requestOrder) {
ResponseOrder order = orderService.saveOrder(requestOrder);
return ResponseEntity.ok(order);
}
오류 발생 시 응답 결과
요청
Request Post /order
{
"designerName": "디자이너",
"memberPhoneNumber": "01012345678",
"menuName": "다운펌",
"reservationEnd": "2021-06-16T12:40",
"reservationStart": "2021-06-16T13:40" // 현재 시작시간이 종료시간보다 늦습니다
}
응답
해당 오류 응답처럼 내보내기 위해 저는 GlobalExceptionController를 만들어서 처리하였습니다.
프로젝트를 확인해주세요!
결론
클라이언트가 요청한 내부의 단순 값과 값의 비교에 따라 비즈니스 로직 처리가 필요한 경우
Service Layer에서 일일이 검증하는 것보다는 Custom Valid Annotation을 이용해 보는 것도 좋을 것 같습니다.
긴 글 읽어주셔서 감사합니다.
본문에 존재하는 오류나 질문은 댓글로 남겨주세요!
'Spring > boot' 카테고리의 다른 글
[Spring-boot] Master - Slave 구조에 따른 Read, Write 분기 (2) | 2021.07.21 |
---|---|
[Spring-boot] ExceptionHandler (0) | 2021.01.22 |
[Spring-boot] Thymeleaf (0) | 2021.01.21 |
[Spring-boot] 웹MVC 2부 : 정적리소스 (0) | 2021.01.19 |
[Spring-boot] 웹MVC (1부) : 소개 , ViewResolver (0) | 2021.01.19 |