😎서론
이전에는 @Valid 가 어떻게 흘러가는지 알아봤다면
이번에는 사용하는 방법에 대해서 간략하게 적어보려 한다.
이전 글 - [SPRING] @Valid 어떻게 동작할까 - java bean validation
목차
- @Valid 사용법
- Custom Aonntation
- @Valid 예외처리
- @Valid 에선 안되는 것들
- @Validated 동작 원리
- @Validated 사용법
- @Validated 예외처리
🙄본론
어노테이션 종류가 어떤 것들이 있는지는 API 공식 문서를 참고해보면 좋을 것 같습니다.
API 공식문서 - 링크
@Valid 사용법
의존성 추가
// Gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'
// Maven
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Annotation 적용
public class ItemDTO {
@NotNull
private Long id;
@NotBlank
private String itemName;
@NotNull
@Min(1)
@Max(100)
private Integer quantity;
}
@RestController
@RequiredArgsConstructor
@RequestMapping(value = "/item")
public class ItemController {
private final ItemService itemService;
@PostMapping(value = "/save")
public ResponseEntity<String> save(@Valid @RequestBody ItemDTO itemDTO) {
itemService.save(itemDTO);
return new ResponseEntity<>(HttpStatus.CREATED);
}
}
끝 입니다.
- 검증할 객체에 검증할 어노테이션을 붙여준다.
- 컨트롤러에 인수 옆에 @Valid 를 붙여준다.
Custom Annotation
기본으로 제공되는 검증 외에 별도로 검증로직을 추가하고 싶을 수 있습니다.
새로 한 번 만들어 보겠습니다.
만들 어노테이션 정보
Phone
- 기본 Phone 번호의 규칙에 알맞는지 검증
Phone Annotation
@Target(FIELD)
@Retention(RUNTIME)
@Constraint(validatedBy = {PhoneValidator.class})
@Documented
public @interface Phone {
String message() default "휴대폰 번호가 올바르지 않습니다.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
PhoneValidator
public class PhoneValidator implements ConstraintValidator<Phone, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
Pattern pattern = Pattern.compile("\\d{3}-\\d{4}-\\d{4}");
Matcher matcher = pattern.matcher(value);
return matcher.matches();
}
}
UserDTO
public class UserDTO {
private Long id;
@NotEmpty
private String name;
@Phone
private String phone;
}
끝 입니다.
- ConstraintValidator 를 구현하는 Validator를 만들어 isValid 메소드를 구현한다.
- Custom Annotation을 만들어 validatedBy에 클래스 정보를 넣어 연결해준다.
- 사용한다.
테스트
.w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public org.springframework.http.ResponseEntity<java.lang.String> com.verification.controller.item.ItemController.save(com.verification.domain.item.ItemDTO) with 2 errors: [Field error in object 'itemDTO' on field 'id': rejected value [null]; codes [NotNull.itemDTO.id,NotNull.id,NotNull.java.lang.Long,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [itemDTO.id,id]; arguments []; default message [id]]; default message [널이어서는 안됩니다]] [Field error in object 'itemDTO' on field 'quantity': rejected value [101]; codes [Max.itemDTO.quantity,Max.quantity,Max.java.lang.Integer,Max]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [itemDTO.quantity,quantity]; arguments []; default message [quantity],100]; default message [100 이하여야 합니다]] ]
어후.. 보기 불편합니다.
조금 더 깔끔하게 @RestControllerAdvice 를 사용하여 해당 MethodArgumentNotValidException 을 꾸며봅시다.
@Valid 예외처리
@Slf4j
@RestControllerAdvice
public class CustomMethodArgumentNotValidHandler {
@ExceptionHandler(value = MethodArgumentNotValidException.class) // 유효성 검사 실패 시 발생하는 예외를 처리
protected ResponseEntity<ResponseErrorDTO> handleException(MethodArgumentNotValidException e) {
List<ObjectError> allErrors = e.getBindingResult().getAllErrors();
String message = getMessage(allErrors.iterator());
ResponseErrorDTO result = ResponseErrorDTO.builder()
.code("MethodArgumentNotValidException.class")
.message(message)
.build();
return ResponseEntity.badRequest().body(result);
}
private String getMessage(Iterator<ObjectError> errorIterator) {
final StringBuilder resultMessageBuilder = new StringBuilder();
while (errorIterator.hasNext()) {
ObjectError error = errorIterator.next();
resultMessageBuilder
.append("['")
.append(((FieldError) error).getField()) // 유효성 검사가 실패한 속성
.append("' is '")
.append(((FieldError) error).getRejectedValue()) // 유효하지 않은 값
.append("' :: ")
.append(error.getDefaultMessage()) // 유효성 검사 실패 시 메시지
.append("]");
if (errorIterator.hasNext()) {
resultMessageBuilder.append(", ");
}
}
log.error(resultMessageBuilder.toString());
return resultMessageBuilder.toString();
}
}
.v.h.CustomMethodArgumentNotValidHandler : ['id' is 'null' :: 널이어서는 안됩니다], ['quantity' is '101' :: 100 이하여야 합니다]
AOP를 통해 Controller의 Exception 처리를 직접 변경해서 해보았습니다.
@Valid 에선 안 되는 것들
하다보니, 문제점들이 보이기 시작합니다.
- Controller 말고 다른 곳에서도 검증을 하고 싶습니다.
- 객체 내에 있는 컴포넌트 객체들도 검증하고 싶습니다.
- 검증 메소드 별로 상황에 따라서 DTO를 계속 만들지 않고, 그룹을 사용하고 싶습니다.
위 3가지를 @Validated 를 통해서 해결할 수 있고, 이제 알아봅시다.
@Validated 동작 원리
@Validated는 JSP 표준 기술이 아닌, Spring 프레임워크에서 제공하는 어노테이션입니다.
@Validated 를 붙이면, AOP 기반으로 처리할 수 있게 됩니다.
해당 AOP 는 MethodValidationInterceptor 클래스입니다.
@Nullable
public Object invoke(MethodInvocation invocation) throws Throwable {
if (this.isFactoryBeanMetadataMethod(invocation.getMethod())) {
return invocation.proceed();
} else {
Class<?>[] groups = this.determineValidationGroups(invocation);
ExecutableValidator execVal = this.validator.forExecutables();
Method methodToValidate = invocation.getMethod();
Object target = invocation.getThis();
Assert.state(target != null, "Target must not be null");
Set result;
try {
result = execVal.validateParameters(target, methodToValidate, invocation.getArguments(), groups);
} catch (IllegalArgumentException var8) {
methodToValidate = BridgeMethodResolver.findBridgedMethod(ClassUtils.getMostSpecificMethod(invocation.getMethod(), target.getClass()));
result = execVal.validateParameters(target, methodToValidate, invocation.getArguments(), groups);
}
if (!result.isEmpty()) {
throw new ConstraintViolationException(result);
} else {
Object returnValue = invocation.proceed();
result = execVal.validateReturnValue(target, methodToValidate, returnValue, groups);
if (!result.isEmpty()) {
throw new ConstraintViolationException(result);
} else {
return returnValue;
}
}
}
}
간단하게 살펴보면, @Valid와 다르게 ConstraintViolationException이 발생하는 것을 보실 수 있습니다.
중요한건,
@Validated 를 붙이면, AOP에 등록되고,
MethodValidationInterceptor 클래스에서 가로채서 실행되기 때문에
검증이 가능하다는 것으로 알면 될 것 같습니다.
@Validated 사용법
public interface CreateUser {
}
@Slf4j
@Service
@Validated
public class UserService {
@Validated(CreateUser.class)
public void groupValid(@Valid UserDTO userDTO) {
log.info(userDTO.toString());
}
@Validated
public void validated(@Valid UserDTO userDTO) {
log.info(userDTO.toString());
}
}
public class UserDTO {
@NotNull
private Long id;
@NotEmpty(groups = CreateUser.class)
private String name;
@Phone(groups = CreateUser.class)
private String phone;
@Valid
@NotNull
private ItemDTO itemDTO;
}
끝입니다.
- 객체 내 객체속성에 @Valid를 붙이게 되면, ItemDTO 검증도 진행할 수 있습니다.
- 검증을 진행할 클래스에 @Validated 를 붙여줍니다.
- Group을 지정한 메소드가 있다면, @Validated(CreateUser.class) 처럼 메소드에 추가로 붙여줍니다.
- 검증할 인수에 @Valid 를 붙여줍니다.
이번에는 Group 도 볼 수 있습니다.
빈 인터페이스를 만들어두고 group으로 지정하게 되면,
@Validated 옵션을 통해 동일한 그룹만을 검사할 수 있게 됩니다.
테스트
이번에도 간단하게 예외처리를 변경해봅시다.
@Validated 예외처리
@Slf4j
@RestControllerAdvice
public class CustomConstraintViolationHandler {
@ExceptionHandler(value = ConstraintViolationException.class) // 유효성 검사 실패 시 발생하는 예외를 처리
protected ResponseEntity<ResponseErrorDTO> handleException(ConstraintViolationException e) {
String errorMessage = getResultMessage(e.getConstraintViolations().iterator());
ResponseErrorDTO result = ResponseErrorDTO.builder()
.code("ConstraintViolationException.class")
.message(errorMessage)
.build();
log.error(errorMessage);
return ResponseEntity.badRequest().body(result);
}
private String getResultMessage(final Iterator<ConstraintViolation<?>> violationIterator) {
final StringBuilder resultMessageBuilder = new StringBuilder();
while (violationIterator.hasNext()) {
final ConstraintViolation<?> constraintViolation = violationIterator.next();
resultMessageBuilder
.append("['")
.append(getPropertyName(constraintViolation.getPropertyPath().toString())) // 유효성 검사가 실패한 속성
.append("' is '")
.append(constraintViolation.getInvalidValue()) // 유효하지 않은 값
.append("' :: ")
.append(constraintViolation.getMessage()) // 유효성 검사 실패 시 메시지
.append("]");
if (violationIterator.hasNext()) {
resultMessageBuilder.append(", ");
}
}
return resultMessageBuilder.toString();
}
private String getPropertyName(String propertyPath) {
return propertyPath.substring(propertyPath.lastIndexOf('.') + 1); // 전체 속성 경로에서 속성 이름만 가져온다.
}
}
c.v.h.CustomConstraintViolationHandler : ['id' is 'null' :: 널이어서는 안됩니다], ['itemDTO' is 'null' :: 널이어서는 안됩니다]
사용자에게 깔끔하게 알려줄 수 있게 되었습니다.
😀결론
간단하게 @Valid / @Validated 두 가지 사용 방법과 Custom 그리고, 예외처리에 대해서 알아보았습니다.
아무래도 검증은 컨트롤러 단계에서만 사용하는 것이 가장 좋은 방법인 것 같고,
어쩔 수 없는 경우에는 @Validated 를 사용해서 검증을 진행하면 될 것 같습니다.
소스 코드는 github 에서 확인해보실 수 있습니다.
참고
https://jyami.tistory.com/55
https://kapentaz.github.io/spring/Spring-Boo-Bean-Validation-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EC%95%8C%EA%B3%A0-%EC%93%B0%EC%9E%90/#
https://meetup.toast.com/posts/223
https://stackoverflow.com/questions/36477544/javax-validation-implementation
https://gompangs.tistory.com/entry/Spring-Valid-Collection-Validation-%EA%B4%80%EB%A0%A8
https://mangkyu.tistory.com/174
https://en.wikipedia.org/wiki/Bean_Validation
'JVM > Spring' 카테고리의 다른 글
[Spring] @PropertySource yaml 파일 Load 하는 방법 (0) | 2022.06.16 |
---|---|
[SPRING] 스프링 MVC 구조? (DispatcherServlet?) (0) | 2022.04.22 |
[SPRING] @Valid 어떻게 동작할까 - java bean validation (0) | 2022.03.26 |
[SPRING] 스프링의 컨트롤러는 어떻게 여러 작업을 처리할까? (0) | 2021.11.15 |
[SPRING] @Transactional readOnly 성능향상 이유 (0) | 2021.09.05 |