모든 코드는 깃허브 에 있다.
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'
)
}
일단 검증을 할 수 있게 해주는 어노테이션을 알아보자

어노테이션 |
예제 |
설명 |
@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) | - 길이를 검증 - ex) "a" 비허용, "abcd"허용 |
@NotNull |
@NotNull | 설명생략 |
@Pattern |
@Pattern(regexp = "") | - 정규식에 해당하는 것만 통과 |
@NotEmpty |
@NotEmpty | - null 비허용 - 길이가 0 비허용 ex) "" |
| @Positive | @Positive |
- 양수만 허용 - 0 비허용 |
| @PositiveOrZero | @PositiveOrZero | - 양수 허용 - 0 허용 |
| @Negative | @Negative | - 음수만 허용 - 0 비허용 |
| @NegativeOrZero | @NegativeOrZero | - 음수만 허용 - 0 허용 |
| @Email(regexp = "정규식") | - 정규식따로 작성 권장 , "abc@def" 같은 케이스도 통과해버림 | |
| @Future | @Future | - 미래날짜만 허용 - 시간까지 체크는 안됨 - 멤버필드 타입 String일 경우 에러발생 - LocalDate권장 |
| @FutureOrPresent | @FutureOrPresent | - 미래, 현재날짜만 허용 - 시간까지 체크는 안됨 - 멤버필드 타입 String일 경우 에러. - LocalDate권장 |
| @Past | @Past | - 과거날짜만 허용 - 시간까지 체크는 안됨 - 멤버필드 타입 String일 경우 에러. - LocalDate권장 |
| @PastOrPresent | @FutureOrPresent |
- 과거, 현재 날짜만 허용 - 시간까지 체크는 안됨 - 멤버필드 타입 String일 경우 에러. - LocalDate권장 |
이런 어노테이션들이 있다.
유저 라는 클래스를 구현해놓고 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로 테스트 했을 때 나오는 경우이다.
UserController.java
@Valid 어노테이션을 사용하여 검증을 시켜주었다.
MethodArgumentNotValidException 클래스는 @Valid 어노테이션이 달린 인자의
검증이 실패하면 던지게 된다.
@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());
}
}

보면 email값이 null로 요청이 들어왔고 그래서 예외에는 MethodArgumentNotValidException이 던져졌다.
이럴때 이제 @ExceptionHandler를 통해 커스텀으로 예외를 구현해주면 되겠다.
커스텀으로 구현해주는 이유는 통째로 값을 내려줄 수는 있지만,
불필요한 정보와 시스템 내부 정보가 포함되어 있기 때문에 별도로 정의하여 내려주는 것이 좋다.
커스텀으로 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);
}
}
}
내가 사용한 방법인데
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