Framework/Spring

[Spring] Validation은 어떻게 확인할까?

KOOCCI 2022. 8. 12. 02:29

목표 : 스프링에서 Validation을 확인할 수 있다.


Validator

Spring은 Validator라는 Interface를 통해, Validation을 제공하고 있다.

 

public class Person {

    private String name;
    private int age;

    // the usual getters and setters...
}

위와 같은 클래스가 존재한다고 하자.

그리고, 위 Person 클래스의 필드에 대해, validation을 제공하는 클래스를 만들어볼 것이다.

public class PersonValidator implements Validator {

    /**
     * This Validator validates only Person instances
     */
    public boolean supports(Class clazz) {
        return Person.class.equals(clazz);
    }

    public void validate(Object obj, Errors e) {
        ValidationUtils.rejectIfEmpty(e, "name", "name.empty");
        Person p = (Person) obj;
        if (p.getAge() < 0) {
            e.rejectValue("age", "negativevalue");
        } else if (p.getAge() > 110) {
            e.rejectValue("age", "too.darn.old");
        }
    }
}

먼저 Validator Interface를 알아보자.

두 가지 메서드를 지원하고 있으며, 다음과 같다.

  • supports(Class): Can this Validator validate instances of the supplied Class?
  • validate(Object, org.springframework.validation.Errors): Validates the given object and, in case of validation errors, registers those with the given Errors object.

supports는  해당 클래스가 Person 클래스인지 확인하며, validate는 실제 validation 로직이 작성된다.

이 때, 오류 상황에 대한 내용은 Errors Object로 확인할 수 있다.

validate Method

validate 메서드를 보면, Return Type이 조금 생각한 것과 다르다.

boolean이 아닌 void 타입인 것이다.

보통 Validation을 할 때는 boolean으로 처리하거나, Exception 으로 예외처리하는데, 현재 코드는 Argument로 Errors를 받고, Errors의 값을 변경하는 형태의 코드다.

 

이렇게 처리되는 것은 다음 문서를 보면 이해가 쉽다.

public void check() {
   if (date == null) throw new IllegalArgumentException("date is missing");
   LocalDate parsedDate;
   try {
     parsedDate = LocalDate.parse(date);
   }
   catch (DateTimeParseException e) {
     throw new IllegalArgumentException("Invalid format for date", e);
   }
   if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
   if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null");
   if (numberOfSeats < 1) throw new IllegalArgumentException("number of seats must be positive");
 }

위 코드를 보면, 모든 데이터의 Validation에 대해 Exception 처리를 하고 있다.

그러나, 위 문서의 제목처럼 Validation에서는 Exception을 Notification 으로 변경하라고 한다.

 

위와 같이 Exception 처리를 하면, 제일 처음 Exception 사항 외에는 처리 결과를 알지 못한다.

따라서, 아래와 같이 변경하는 것을 추천한다.

 

Replace Exception

 

Resolving Codes to Error Messages

위에서 ValidationUtils Errors를 통해 에러 상황을 처리중이다.

이 때, 에러 코드를 작성해 넣어주고 있는데 이에 대한 처리를 보도록 하자.

 

에러 코드는 국제화(i18n)에 따라, 사용자의 Locale에 따라, 적절한 언어로 응답을 만들어 줄 수 있다.

에러 코드와 에러 메세지는 보통 messages.properties와 같은 properties 파일에서 읽어오도록 구현한다.

이 에러 메세지는 Spring에서 MessageSource를 통해, 가져오도록 할 수 있다.

 

MessageSource

MessageSource의 구현은 2가지가 있다.

  • StaticMessageSource: 코드로 메시지를 등록한다.
  • ResourceBundleMessageSource: 리소스 파일로부터 메시지를 읽어와 등록한다.

예시를 통해 알아보도록 하자.

 

위에서 사용한 Person과 PersonValidator 소스는 동일하다.

@Getter
@Setter
@AllArgsConstructor
public class Person {

    private String name;
    private int age;
}
public class PersonValidator implements Validator {

    /**
     * This Validator validates only Person instances
     */
    public boolean supports(Class clazz) {
        return Person.class.equals(clazz);
    }

    public void validate(Object obj, Errors e) {
        ValidationUtils.rejectIfEmpty(e, "name", "name.empty");
        Person p = (Person) obj;
        if (p.getAge() < 0) {
            e.rejectValue("age", "negativevalue");
        } else if (p.getAge() > 110) {
            e.rejectValue("age", "too.darn.old");
        }
    }
}

메인 함수를 통해, Validation을 체크할텐데, 이 때 Error는 Interface이기 때문에, 이에 대한 구현체 중 하나인 BindException을 사용할 것이다.

public static void main(String[] args) {
    Person person = new Person("", 200);
    PersonValidator personValidator = new PersonValidator();
    if(personValidator.supports(person.getClass())) {
        BindException error = new BindException(person, "person");
        personValidator.validate(person, error);

        log.error(">>" + error.hasErrors());
        log.error(">>>>" + error.getAllErrors());
    } else {
        log.error("invalid Class");
    }
}
23:48:15.866 [main] ERROR com.example.demo.validation.Main - >>true
23:48:15.871 [main] ERROR co
m.example.demo.validation.Main - >>>>[Field error in object 'person' on field 'name': rejected value []; codes [name.empty.person.name,name.empty.name,name.empty.java.lang.String,name.empty]; arguments []; default message [null], Field error in object 'person' on field 'age': rejected value [200]; codes [too.darn.old.person.age,too.darn.old.age,too.darn.old.int,too.darn.old]; arguments []; default message [null]]

에러에 대한 로그를 보면, 설정했던 에러 코드들을 볼 수 있다.

 

Validator의 단점

위와 같이, 비즈니스 로직을 하나하나 구현해줘야 하는 단점이 있다.

해당 로직이 복잡하다면, 당연히 어울리는 방향이지만, 문자열이 없는 경우 ,특정값보다 큰 경우와 같은 건 Validator로 구현하는 것보다 더 간단한 방법이 필요해보인다.

그에 따라, Annotation으로 설정하는 방법들이 있다.

 

Java Bean Validation

해당 링크를 보면, Annotation에 대한 설정들이 나온다.

package com.example.demo.validation;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;

@Setter
@Getter
@AllArgsConstructor
public class PersonForm {
    @NotNull
    @Size(max=64)
    private String name;

    @Min(0)
    private int age;
}

위 과정에서 validation을 위한 Dependency를 추가했다는 것을 보기 위해, import도 추가해 두었다.

validation-api

많은 Validation을 제공해주며, 간단한 것은 Annotation으로 처리가 가능하다는 것을 볼 수 있다.

 

Configuring a Bean Validation Provider

위와 같이, Annotation으로 적용 가능했고, 이를 이제 어떻게 쓰는지 알아야 한다.

예시를 보며, 그 사용법을 먼저 보도록 하자.

 

우선, Dependency부터 확인하자.

implementation 'org.springframework.boot:spring-boot-starter-validation'

Springboot 에서는 위와 같이 등록되면 되며, Spring에서는 Hibernate Validator 와 같은 구현체가 필요하니, 유의하도록 하자.

spring-boot-starter-validation에는 Hibernate-validator가 이미 포함되어 있다.

더보기

조심해야 하는 부분은, validation-api 가 jakarta 로 3.0.2버전이길래, 해당 버전으로 적용하려 했더니, 패키지 구조가 달라 정상적이 처리가 불가능했다.

(springboot-starter에서 적용되는 버전은 2버전 대였으며, javax를 사용하고 있었다.)

AppConfig(Configuration)를 통해, validator를 Bean으로 등록하자.

@Configuration
public class AppConfig {
    @Bean
    public LocalValidatorFactoryBean validator() {
        return new LocalValidatorFactoryBean();
    }
}

위와 같이, Method를 Bean으로 등록하면, Autowired를 통해 Service에서 Validator를 가져와 쓸 수 있게 된다.

package com.example.demo.service;

import com.example.demo.validation.PersonForm;
import java.util.Set;
import javax.validation.ConstraintViolation;
import javax.validation.Validator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
@Slf4j
public class MyService {

    @Autowired
    private Validator validator;

    public void check() {
        PersonForm personForm = new PersonForm("KOO", 25);
        // PersonForm personForm = new PersonForm("KOO", -5);
        Set<ConstraintViolation<PersonForm>> results = validator.validate(personForm);

        if(!results.isEmpty()) {
            log.error("Validate Fail");
            results.forEach(v -> {
                log.error(">> error Msg : " + v.getMessage());
            });
        } else {
            log.info("Validate Success");
        }
    }
}

마지막으로 메인에서 실행해보며, 설정에 따라, Success 혹은 Fail를 확인하자.

@Slf4j
public class Main {
    public static void main(String[] args) {
        AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
        context.register(AppConfig.class);
        context.refresh();
        MyService service = context.getBean(MyService.class);
        service.check();
        context.close();
    }
}

에러 상황에서 로그를 확인하면 다음과 같이, 한글로 나오는 것을 볼 수 있다. (check method 내에 Locale 설정으로, 다른 언어도 가능하다)

01:55:46.733 [main] ERROR com.example.demo.service.MyService - Validate Fail
01:55:46.737 [main] ERROR com.example.demo.service.MyService - >> error Msg : 110 이하여야 합니다 01:55:46.737 [main] ERROR com.example.demo.service.MyService - >> error Msg : 크기가 0에서 3 사이여야 합니다

 

Custom Annotation for Validation (Configuring Custom Constraints)

Spring의 Validator를 사용하는 것보다, Annotation을 사용하는 것이 매우 간편하다.

그런데, 지원하지 않는 기능에 대한 Validation은 어떻게 적용할 수 있을까?

앞서, Validator Interface의 구현체가 또 필요한 것일까?

 

다행히, 그렇지 않았다.

 

Custom 하게 Annotation을 적용해 볼 수 있다.

그를 위해서는 2가지를 구현해야 한다.

Constraint(제약), 그리고 Validator(검증자)다.

  • @Constraint annotation that declares the constraint and its configurable properties.
  • An implementation of the javax.validation.ConstraintValidator interface that implements the constraint’s behavior.
@Target({ElementType.METHOD, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy=MyConstraintValidator.class)
public @interface MyConstraint {
}
import javax.validation.ConstraintValidator;

public class MyConstraintValidator implements ConstraintValidator {

    @Autowired;
    private Foo aDependency;

    // ...
}

예시를 봐야 좀더 이해가 쉬울 것 같아, 다른 분이 적용해 둔 것을 가져와보았다.

 

@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
@Retention(RUNTIME)
@Constraint(validatedBy = NoEmojiValidator.class)
@Documented
public @interface NoEmoji{
    String message() default "Emoji is not allowed";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    @Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
    @Retention(RUNTIME)
    @Documented
    @interface List{
        NoEmoji[] value();
    }
}
public class NoEmojiValidator implements ConstraintValidator<NoEmoji, String> {
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (StringUtils.isEmpty(value) == true) {
            return true;
        }

        return EmojiParser.parseToAliases(value).equals(value);
    }
}
public class CreateContact {
    @NoEmoji
    @Length(max = 64)
    @NotBlank
    private String uid;
    @NotNull
    private ContactType contactType;
    @Length(max = 1_600)
    private String contact;
}

 

Wrap up

꽤 길게 Validation에 대해 알아보았다.

Annotation을 사용하는게 바람직하면서도, Custom 하게 만들 수도 있어야 한다는 점을 유념하도록 하자.