모든 코드는 깃허브 에 있다.
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