Validation
- Validation
- @ModelAttribute vs @RequestBody
- Validator
- Message
- Bean Validation
- Container Validation
- Custom Constraint Validation
- Grouping
- MethodArgumentNotValidException
- ExceptionHandler
- ValidationErrorExtractor
- Links
Validation
검증은 Infrastructure, Domain, Application, Interfaces 계층 어느곳에서나 존재할 수 있다. 컨트롤러에서는 요청 파라미터가 적절한지 검증할 수 있으며, 도메인 레이어에서는 비지니스 업무 규칙에 적합한지 검증할 수 있고, 인프라스트럭쳐 레이어에서는 외부 통신의 결과가 적절한지 검증할 수 있다. 검증(Validation) 을 한 마디로 정의하자면 제약 조건에 위배되는지 확인하는 과정 이라고 할 수있다.
- 클라이언트 검증 vs 서버 검증
- 프론트에서의 검증은 조작할 수 있으므로 보안에 취약
- 서버에서만 검증을 하면, 즉각적인 고객 사용성이 부족해짐
- 둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수
- API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 함
컨트롤러의 중요한 역할 중 하나는 HTTP 요청이 정상인지를 검증 하는 것이다. 대표적으로 요청 파라미터가 올바른지 검사를 실시한다. 컨트롤러에서 검증이 필요한 이뉴는 HTTP 요청이 비정상적일때 잘못된 값이 DB 까지 전달되는 경우 SQL Injection 이 발생할 수도 있고, 앞단에서 발견되었어야 할 검증이 DB 조회까지 실행되어야 발견되기 때문이다. 또한 앞단에서 미리 검증을 한다면 QA(Quality Assurance) 에 드는 시간(cost)이 줄어들게 되는 이점도 있다.
스프링에서는 BindingResult
를 사용하여 검증 오류를 보관한다. BindingResult 는 검증할 객체 바로 뒤에 와야하며, 검증 객체 앞에 @ModelAttribute
가 있으면 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다.
@PostMappping
fun save(
@ModelAttribute request: SaveRequest,
bindingResult: BindingResult
) {
// ...
}
BindingResult 는 인터페이스이고, Errors 인터페이스를 상속받고 있다. 실제로 넘어오는 구현체는 BeanPropertyBindingResult
이다.
// 검증 로직
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null, "상품 이름은 필수 입니다."));
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, null ,null, "수량은 최대 9,999 까지 허용합니다."));
}
// 특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item",null ,null, "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
}
}
ValidationUtils 를 사용해서 한 줄로 처리할 수 있다.
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
컨트롤러에서 처리해야하는 검증 로직이 많은 경우에는 Validator
클래스를 만들어서 처리한다.
@ModelAttribute vs @RequestBody
HTTP 요청 파리미터를 처리하는 @ModelAttribute 는 각각의 필드 단위로 세밀하게 적용된다. 그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다.
HttpMessageConverter 는 @ModelAttribute 와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다. 따라서 메시지 컨버터의 작동이 성공해서 Item 객체를 만들어야 @Valid , @Validated 가 적용된다.
@ModelAttribute 는 필드 단위로 정교하게 바인딩이 적용된다. 특정 필드가 바인딩 되지 않아도 나머지 필드는 정상 바인딩 되고, Validator 를 사용한 검증도 적용할 수 있다.
@RequestBody 는 HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진행되지 않고 예외가 발생한다. 컨트롤러도 호출되지 않고, Validator 도 적용할 수 없다.
Validator
Validator 를 만드는 방식은 크게 두가지가 있다.
- 스프링에서 제공하는 Validator 인터페이스를 구현
- 별도 인터페이스 없이 Custom Validator 를 빈으로 등록해서 사용
- 컨트롤러에서 validator.validate 형식으로 호출하여 사용
Spring Validator Interface
스프링은 검증을 체계적으로 제공하기 위해 아래 인터페이스를 제공한다.
public interface Validator {
// 해당 검증기를 지원하는 지 확인
boolean supports(Class<?> clazz);
// target: 검증 대상 객체, errors: BindingResult
void validate(Object target, Errors errors);
}
Validator 인터페이스를 사용해서 검증기를 만들면 WebDataBinder
를 사용하여 스프링의 추가적인 도움을 받을 수 있다. 아래처럼 WebDataBinder 에 검증기를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있다.
@InitBinder
public void init(WebDataBinder dataBinder) {
log.info("init binder {}", dataBinder);
dataBinder.addValidators(itemValidator);
}
@InitBindder
는 해당 컨트롤러 안에서만 영향을 준다. WebDataBinder 에 검증기를 추가하면 각 핸들러 메서드마다 validator.validate 같은 코드를 제거할 수 있다.
PostMapping("/add")
public String addItem(
@Validated @ModelAttribute Item item,
BindingResult bindingResult
) {
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v2/addForm";
}
//...
}
@Validated 는 검증기를 실행하라는 애노테이션이다. 이 애노테이션이 붙으면 앞서 WebDataBinder 에 등록한 검증기를 찾아서 실행한다. 그런데 여러 검증기를 등록한다면 그 중에 어떤 검증기가 실행되어야 할지 구분이 필요하다. 이때 supports() 가 사용된다.
@Component
public class ItemValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return Item.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) { //... }
}
isAssignableFrom() 을 쓰는 이유는 자식 클래스 까지 검증이 가능하기 때문이다. 검증시 @Validated @Valid 둘다 사용가능하다.
@InitBinder 를 사용하여 여러 객체를 대상으로 검증기를 적용할 수 있다.
@InitBinder("targetObject")
public void initTargetObject(WebDataBinder webDataBinder) {
log.info("webDataBinder={}, target={}", webDataBinder, webDataBinder.getTarget());
webDataBinder.addValidators(/*TargetObject 관련 검증기*/);
}
@InitBinder("sameObject")
public void initSameObject(WebDataBinder webDataBinder) {
log.info("webDataBinder={}, target={}", webDataBinder, webDataBinder.getTarget());
webDataBinder.addValidators(/*SameObject 관련 검증기*/);
}
BeanValidator
Custom Validator 를 Bean 으로 등록하여 사용할 수 있다. 이 경우의 장점은 하나의 Validator 안에서 여러 validate 메서드를 가질 수 있다는 점이다.
@Component
public class CardValidator {
public void registerValidate(CardDto.RegisterRequest request, Errors e) {}
public void updateValidate(CardDto.RegisterRequest request, Errors e) {}
}
- 추천하는 전략
- 컨트롤러 내에서 Global 하게 적용되어야 하는 검증의 경우에는 Spring Validator Interface 를 구현하여 사용
- 그 외에는 직접 BeanValidator 를 만들어서 사용
Message
spring 의 message.properties 파일에 정의되어있는 값을 아래처럼 사용할 수 있다.
public class MemberDto {
@NotEmpty(message = "{email.notempty}")
private String email;
// standard getter and setters
}
@Constraint(validatedBy = EmailValidator.class)
@Target(ElementType.FIELD)
@Retention(value = RetentionPolicy.RUNTIME)
@Documented
public @interface AdvisorEmail {
String message() default "{com.dope.pro.validator.ValidEmail.message}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
- message file name: ValidationMessage_언어코드_국가코드
- e.g ValidationMessage_ko_KR
Bean Validation
Bean Validation 은 JSR-380 이라는 기술 표준이다. 마치 JPA 가 표준 기술이고 그 구현체로 하이버네이트가 있는 것과 같다.
Bean Validation 을 구현한 기술중에 일반적으로 사용하는 구현체는 하이버네이트 Validator 이다. javax.validation 으로 시작하면 특정 구현에 관계없이 제공되는 표준 인터페이스이고, org.hibernate.validator 로 시작하면 하이버네이트 validator 구현체를 사용할 때만 제공되는 검증 기능이다.
Bean Validation 을 사용하기 위해서는 의존 관계를 추가해야 한다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
스프링 부트가 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator 를 인지하고 스프링에 통합한다. 또한 스프링 부트는 자동으로 LocalValidatorFactoryBean 를 글로벌 Validator 로 등록하기 때문에 @Valid, @Validated 만 적용하면 @NotNull 같은 어노테이션을 보고 검증을 수행할 수 있다.
@Validated 는 스프링 전용 검증 애노테이션이고, @Valid 는 자바 표준 검증 애노테이션이다. 둘중 아무거나 사용해도 동일하게 작동하지만, @Validated 는 내부에 groups 라는 기능을 포함하고 있다.
Container Validation
Bean Validation 2.0 부터 가능하다.
public class DeleteContacts {
@Min(1)
private Collection<@Length(max = 64) @NotBlank String> uids;
}
Custom Constraint Validation
Annotation
- Java
@Constraint(validatedBy = EmailValidator.class)
@Target(ElementType.FIELD)
@Retention(value = RetentionPolicy.RUNTIME)
@Documented
public @interface AdvisorEmail {
String message() default "{com.dope.pro.validator.ValidEmail.message}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
- Kotlin
@Target(AnnotationTarget.FIELD)
@Retention
@Constraint(validatedBy = [PasswordValidator::class])
annotation class Password(
val message: String = "",
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<out Payload>> = []
)
Validator
- Java
@Component
public class AdvisorEmailValidator implements ConstraintValidator<AdvisorEmail, String> {
private final List<String> HOSTS = List.of("dope.com");
@Override
public void initialize(AdvisorEmail constraintAnnotation) {
ConstraintValidator.super.initialize(constraintAnnotation);
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
// Do Something
}
}
- Kotlin
@Component
class PasswordValidator: ConstraintValidator<Password, String> {
companion object {
private const val MIN_SIZE = 12
private const val MAX_SIZE = 20
private const val pattern = "^(?=.*[A-Za-z])(?=.*[0-9])(?=.*[$@!%*#?&])[A-Za-z0-9$@!%*#?&]{$MIN_SIZE,$MAX_SIZE}$"
}
override fun isValid(value: String, context: ConstraintValidatorContext): Boolean {
val isValidPassword = value.matches(Regex(pattern))
if (!isValidPassword) {
context.disableDefaultConstraintViolation()
context.buildConstraintViolationWithTemplate(
MessageFormat.format("{0}자 이상의 {1}자 이하의 숫자, 영문자, 특수문자를 포함하여야 합니다.", MIN_SIZE, MAX_SIZE)
).addConstraintViolation()
}
return isValidPassword
}
}
DTO
class AdvisorDto {
data class Request(@AdvisorEmail email: String)
}
Grouping
Single Group
- Validation Group
public interface ItemValidationGroup {
interface Create {}
interface Update {}
}
public interface UpdateCheck {
}
- DTO
@Data
@NoArgsConstructor
public class Item {
@NotNull(groups = ItemValidationGroup.Update.class)
private Long id;
@NotBlank(groups = {ItemValidationGroup.Create.class, ItemValidationGroup.Update.class})
private String itemName;
}
- Controller
@PostMapping
public String create(
@Validated(ItemValidationGroup.Create.class) @ModelAttribute Item item,
BindingResult bindingResult,
) {}
@Validated 는 클래스 바로 위에 선언할 수도 있다.
GroupSequence
- Validation Group
@GroupSequence({
CardValidationSequence.ExpireMonth.class,
CardValidationSequence.ExpireYear.class,
CardValidationSequence.CardNumber.class,
})
public interface CardValidationGroup {
interface ExpireMonth {
}
interface ExpireYear {
}
interface CardNumber {
}
}
MethodArgumentNotValidException
javax.validation.ConstraintValidator 를 구현한 구현체를 만들어 사용하는 경우 ConstraintViolationException 이 발생할 수 있다.
그러나 Spring 은 ConstraintViolationException 을 MethodArgumentNotValidException 으로 Wrapping 하여 최종적으로는 MethodArgumentNotValidException 가 발생하게 된다.
@Valid 를 쓰던 @Validated 를 쓰던 MethodArgumentNotValidException 이 발생한다.
You can use @RequestPart in combination with jakarta.validation.Valid or use Spring’s @Validated annotation, both of which cause Standard Bean Validation to be applied. By default, validation errors cause a MethodArgumentNotValidException, which is turned into a 400 (BAD_REQUEST) response. Alternatively, you can handle validation errors locally within the controller through an Errors or BindingResult argument, as the following example shows:
ExceptionHandler
@RestControllerAdvice
class ExceptionHandlerAdvice {
@ExceptionHandler(MethodArgumentNotValidException ::class)
fun methodArgumentNotValidException(e: MethodArgumentNotValidException ) {
// Do Something
}
@ExceptionHandler(ConstraintViolationException::class)
fun constraintViolationException(e: ConstraintViolationException) {
// Do Something
}
}
ValidationErrorExtractor
@UtilityClass
public class ValidationErrorExtractor {
public static String getResultMessage(MethodArgumentNotValidException e) {
BindingResult bindingResult = e.getBindingResult();
final StringBuilder resultMessageBuilder = new StringBuilder();
// Field Errors
final Iterator<FieldError> fieldErrorIterator = bindingResult.getFieldErrors().iterator();
while (fieldErrorIterator.hasNext()) {
final FieldError fieldError = fieldErrorIterator.next();
resultMessageBuilder
.append("[")
.append(fieldError.getField())
.append("' is '")
.append(fieldError.getRejectedValue())
.append("'. ")
.append(fieldError.getDefaultMessage())
.append("]");
if (fieldErrorIterator.hasNext()) {
resultMessageBuilder.append(", ");
}
}
// Other Errors
final Iterator<ObjectError> objectErrorIterator = bindingResult.getAllErrors().iterator();
while (objectErrorIterator.hasNext()) {
ObjectError objectError = objectErrorIterator.next();
resultMessageBuilder
.append("[")
.append(objectError.getObjectName())
.append("' is '")
.append(objectError.getCode())
.append("'. ")
.append(objectError.getDefaultMessage())
.append("]");
if (objectErrorIterator.hasNext()) {
resultMessageBuilder.append(", ");
}
}
return resultMessageBuilder.toString();
}
public String getResultMessage(ConstraintViolationException e) {
final Iterator<ConstraintViolation<?>> violationIterator = e.getConstraintViolations().iterator();
final StringBuilder resultMessageBuilder = new StringBuilder();
while (violationIterator.hasNext() == true) {
final ConstraintViolation<?> constraintViolation = violationIterator.next();
resultMessageBuilder
.append("['")
.append(getPopertyName(constraintViolation.getPropertyPath().toString()))
.append("' is '")
.append(constraintViolation.getInvalidValue())
.append("'. ")
.append(constraintViolation.getMessage())
.append("]");
if (violationIterator.hasNext() == true) {
resultMessageBuilder.append(", ");
}
}
return resultMessageBuilder.toString();
}
private String getPropertyName(String propertyPath) {
return propertyPath.substring(propertyPath.lastIndexOf('.') + 1);
}
}