JVM/Spring

[SPRING] @Valid @Validated 사용하기 - java bean validation

Hyo Kim 2022. 3. 26. 20:05
728x90
반응형

😎서론

이전에는 @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
728x90
반응형