728x90

모든 코드는 깃허브 에 있다.

Validation

Validation의 사전적 정의는 확인 이다.

프로그래밍을 하면서 Validation을 한다고 하면 유효성 검증,

즉, 수없이 싸우는 null과 빈 값에 대해 검증을 한다.

뭐 때에 따라서는 조건에 맞는 값이 들어와야 한다는 것도 포함이다.

유효성 검증

애초에 검증을 한다는 것을 단순하게 생각하면

스프링에서 어떤 요청이 하나 들어왔다고 치자.

@RestController
public class Hello {

    @GetMapping("/hello")
    public String hello(@RequestParam String name) {
        return "hello " + name;
    }
}

/hello?name=이름이라는 주소로 호출을 한다면 파라미터에 name값이 들어갈 것이다.

우리는 저 부분에서 name이 null이거나 빈문자열인 경우를 검증해줄 수 있다.

그래서

@GetMapping("/hello")
public String hello(@RequestParam String name) {
    if (Objects.isNull(name) || name.equals("") {
        throw new RuntimeException("이름은 빈 문자열이거나 Null일 수 없습니다.");
    }
    return "hello " + name;
}

이런식으로 RuntimeException 을 던져줄 수도 있다.

이런 하나하나를 다 해주게 된다면 코드 로직이 좀 더 뚱뚱해지고 가독성을 헤칠 수 있고

빈 문자열이나 null을 검증한다. 라는 공통적 의미가 겹치는 로직이 많이 발생하게 될 것이다.

그래서 이 동작을 해줄 수 있는 편리한 라이브러리 javax.validation 을 사용하는 것이다.

의존성

검증은 javax.validation:validation-api 이 의존성을 추가하고 (버전은 명시하지 않음)

검증 API 참조는 org.hibernate.validator:hibernate-validator를 이용했다.

그러나 내가 해본 것은 Spring Boot에서 해보았기 때문에

그레이들에는 아래와 같이 설정해주었다.

dependencies {
    implementation (
            'org.springframework.boot:spring-boot-starter-web',
            'org.springframework.boot:spring-boot-starter-validation',
            'org.projectlombok:lombok'
    )
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation (
            'org.springframework.boot:spring-boot-starter-test',
            'org.assertj:assertj-core:3.21.0'
    )
}

어노테이션 종류

일단 검증을 할 수 있게 해주는 어노테이션을 알아보자

스크린샷 2021-12-15 오후 10 23 46

 어노테이션

예제 

 설명

 @DecimalMax

 @DecimalMax(value = "5.5")

- 소수 최대값 지정

- 같은값 허용

- null 허용

 @DecimalMin

 @DecimalMin(value = "5.5")

- 소수 최소값 지정

- 같은값 허용

- null 허용

 @Max

 @Max(value = 10)

- 정수 최대값 지정

- 같은값 허용

- null 허용

 @Min

 @Min(value = 10)

- 정수 최소값 지정 

- 같은값 허용

- null 허용

 @Digits

 @Digits(integer = 3, fraction = 2)

- interger 정수 허용 자리수 ex) 3이니까 100자리 까지허용

- fraction은 소수점 허용 자리수 ex)2니까 소수점 둘째짜리 까지 허용

- null 허용

 @Size

 @Size(min = 2, max = 4)

- 길이를 검증
- null 허용

- ex) "a" 비허용, "abcd"허용

 @NotNull

 @NotNull

 설명생략

 @Pattern

 @Pattern(regexp = "")

- 정규식에 해당하는 것만 통과

 @NotEmpty

 @NotEmpty

- null 비허용

- 길이가 0 비허용 ex) ""

 @Positive

 @Positive

- 양수만 허용

- 0 비허용 
- null 비허용

 @PositiveOrZero  @PositiveOrZero

- 양수 허용

- 0 허용
- null 비허용

 @Negative @Negative

- 음수만 허용

- 0 비허용 
- null 비허용

 @NegativeOrZero  @NegativeOrZero

- 음수만 허용

- 0 허용 
- null 비허용

 @Email  @Email(regexp = "정규식")

- 정규식따로 작성 권장 , "abc@def" 같은 케이스도 통과해버림

 @Future @Future

- 미래날짜만 허용

- 시간까지 체크는 안됨

- 멤버필드 타입 String일 경우 에러발생

- LocalDate권장 

 @FutureOrPresent @FutureOrPresent

- 미래, 현재날짜만 허용

- 시간까지 체크는 안됨

- 멤버필드 타입 String일 경우 에러.

- LocalDate권장

 @Past  @Past

 - 과거날짜만 허용

- 시간까지 체크는 안됨

- 멤버필드 타입 String일 경우 에러.

- LocalDate권장 

 @PastOrPresent

 @FutureOrPresent

 - 과거, 현재 날짜만 허용

- 시간까지 체크는 안됨

- 멤버필드 타입 String일 경우 에러.

- LocalDate권장 

이런 어노테이션들이 있다.

기본 Hibernate Test

유저 라는 클래스를 구현해놓고 null과 길이에 대한 검증만 진행하는 어노테이션을 구현해놓았다.

@Getter
@NoArgsConstructor
public class User {
    @NotNull(message = "email is not null")
    private String email;

    @NotNull(message = "name is not null")
    @Size(min = 2, max = 4, message = "name must be between 2 and 4")
    private String name;

    @Min(value = 1, message = "age is more than 0")
    private int age;

    public User(final String email, final String name, final int age) {
        this.email = email;
        this.name = name;
        this.age = age;
    }

}

UserTest.java

import static org.assertj.core.api.Assertions.assertThat;
import java.util.Set;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class UserTest {

    private Validator validator;

    @BeforeEach
    void setUp() {
        ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
        validator = validatorFactory.getValidator();
    }

    @Test
    void 이메일이_null일_때() {
        final User user = new User(null, "홍길동", 12);
        final Set<ConstraintViolation<User>> validate = validator.validate(user);

        assertThat(validate.stream().findFirst()
            .get().getMessage()).isEqualTo("email is not null");
    }

    @Test
    void 이름_null() {
        final User user = new User("test@email.com", null, 12);
        final Set<ConstraintViolation<User>> validate = validator.validate(user);

        assertThat(validate.stream().findFirst()
            .get().getMessage()).isEqualTo("name is not null");
    }

    @Test
    void 이름_2글자에서_4글자_사이가_아닐_때() {
        final User min = new User("test@email.com", "홍", 22);
        final User max = new User("test@email.com", "홍홍홍홍홍", 22);
        final Set<ConstraintViolation<User>> minValidate = validator.validate(min);
        final Set<ConstraintViolation<User>> maxValidate = validator.validate(max);

        assertThat(minValidate.stream().findFirst().get().getMessage()).isEqualTo("name must be between 2 and 4");

        assertThat(maxValidate.stream().findFirst()
            .get().getMessage()).isEqualTo("name must be between 2 and 4");
    }

    @Test
    void 나이_1보다_작을_때() {
        final User user = new User("test@abc.com", "lsj", 0);
        final Set<ConstraintViolation<User>> validUser = validator.validate(user);

        assertThat(validUser.stream().findFirst().get().getMessage()).isEqualTo("age is more than 0");
    }
}

이 테스트를 진행하면 hibernate가 돌아가면서 검증을 수행해주게 된다.
이 부분에서는 어떠한 예외가 던져져서 Exception 객체로 나오지는 않는다.

RestController Test

아래는 RestController로 테스트 했을 때 나오는 경우이다.

UserController.java

@Valid 어노테이션을 사용하여 검증을 시켜주었다.

MethodArgumentNotValidException 클래스는 @Valid 어노테이션이 달린 인자의

검증이 실패하면 던지게 된다.

스크린샷 2021-12-15 오후 11 00 55
@RestController
public class UserController {

    @PostMapping("/user")
    public ResponseEntity<User> getUser(@Valid @RequestBody User user) {
        return ResponseEntity.ok(user);
    }
}

일단 다른 값들의 검증은 위에서 살펴보았으니 단순하게 에러를 던지는 과정만 테스트에서 보도록 하겠다.

UserControllerTest.java

@WebMvcTest(UserController.class)
@AutoConfigureMockMvc
class UserControllerTest {

    ObjectMapper objectMapper = new ObjectMapper();

    @Autowired
    private MockMvc mockMvc;

    @Test
    void 유저_검증_이메일_null() throws Exception {
        final User user = new User(null, "테스트", 34);
        mockMvc.perform(post("/user")
                .contentType(APPLICATION_JSON_VALUE)
                .content(objectMapper.writeValueAsString(user)))
            .andExpect(status().isBadRequest())
            .andDo(print());
    }

}

스크린샷 2021-12-15 오후 11 05 38

보면 email값이 null로 요청이 들어왔고 그래서 예외에는 MethodArgumentNotValidException이 던져졌다.

이럴때 이제 @ExceptionHandler를 통해 커스텀으로 예외를 구현해주면 되겠다.

커스텀으로 구현해주는 이유는 통째로 값을 내려줄 수는 있지만,

불필요한 정보와 시스템 내부 정보가 포함되어 있기 때문에 별도로 정의하여 내려주는 것이 좋다.

12-29 추가

커스텀으로 validator를 구현하여 검증을 진행해 줄 수 있다.

리스트의 경우에는 @Valid가 붙어있음에도 불구하고 유효성 검사를 못하는 경우가 발생한다.

이렇게 해서 안에서 예외를 처리해주는 방법도 있다.

@Component
public class CustomValidator implements Validator {

    private SpringValidatorAdapter validatorAdapter;

    public CustomValidator() {
        this.validatorAdapter = new SpringValidatorAdapter(Validation.buildDefaultValidatorFactory()
            .getValidator());
    }

    @Override
    public boolean supports(final Class<?> clazz) {
        return Collection.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(final Object target, final Errors errors) {
        if (target instanceof Collection) {
            Collection collection = (Collection) target;

            for (Object object : collection) {
                validatorAdapter.validate(object, errors);
            }
        } else {
            validatorAdapter.validate(target, errors);
        }
    }
}

방법2

내가 사용한 방법인데

Controller로 넘어오는 RequestBody 객체에 리스트가 있는 경우에는 그 객체안에 List에 @Valid를 붙여준다.

@Valid
List<FormClass> formClassList;

이런식으로 붙여주고 객체 안에서 여러 변수에 유효성 체크하는 어노테이션을 넣어두면 잘 작동하게 되는데,

Controller에서는 이것을 처리해줄 것이 필요하다.

그게 바로 BindingResult 객체이다.

@PutMapping("/")
public ResponseEntity<String> update(
    @Valid @RequestBody List<FormClass> formClassList, BindingResult bindingResult) throws BindException {
    if (bindingResult.hasErrors()) {
        throw new BindException(bindingResult);
    }
}

이렇게 되게 되면 유효성 검사하고 발생된 에러들이 BindingResult 객체에 담겨있는것을 디버그모드로 확인할 수 있다.

그래서 그 객체가 에러를 갖고 있다면 BindException을 던져주고

나는 ExceptionHandler에서 처리를 진행해주었다.

참고

Baeldung - SpringBoot Bean Validation
Baeldung - javax Validation

728x90

'Spring' 카테고리의 다른 글

Spring Rest Docs 테스트로 문서화를 해보자!  (0) 2022.08.10
Service Layer에 대한 생각  (0) 2022.08.10
Filter, Interceptor 정리  (0) 2022.08.07
Jasypt  (0) 2022.08.07

+ Recent posts