Spring Boot validation
- Java에서는 null값에 대해서 접근 하려고 할 때 null pointer exception이 발생 함으로, 이러한 부분을 방지 하기 위해 미리 검증을 해야 하는데 이러한 과정을 Validation이라고 한다.
단순한 검증
1
2
3
4
5
6
7
8
9
public void run(String account, String pw, int age){
if(account == null || pw == null){
return ...
}
if(age == 0){
return ..
}
proceed
}
- 이러한 검증은 불필요하게 반복되는 코드가 생기게 된다.
- 구현에 따라서 달라 질 수 있지만 Service Logic과의 분리가 필요하다.
- 또한 흩어져 있는 경우 어디에서 검증을 하는지 알기 어려우며, 재사용의 한계가 있다.
- 구현에 따라 달라질 수 있지만 검증 Logic이 변경 되는 경우 테스트 코드 등 참조하는 클래스에서 logic이 변경되어야 하는 부분이 발생 할 수 있다.
실습1(Annotation 사용)
- dependency추가
1
implementation 'org.springframework.boot:spring-boot-starter-validation'
- Email 검증
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.example.validation.dto;
import com.fasterxml.jackson.databind.PropertyNamingStrategy;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import javax.validation.constraints.Email;
@Getter
@Setter
@JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class)
public class User {
private String name;
@Max(value=90)
private int age;
@Email
private String email;
private String phoneNumber;
@Override
public String toString() {
return "User{" +
"name='" + name + '\'' +
", age=" + age +
", email='" + email + '\'' +
", phoneNumber='" + phoneNumber + '\'' +
'}';
}
}
- User dto의 Email에 @Email Annotation을 작성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.example.validation.controller;
import com.example.validation.dto.User;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
@RestController
@RequestMapping("/api")
public class ApiController {
@PostMapping("/user")
public User user(@Valid @RequestBody User user){
System.out.println(user);
return user;
}
}
- controller에서는 기존 Post와 똑같이 작성하면 된다. 단, Validation할 항목이 있는 경우 @Valid Annotation을 작성해 줘야한다. spring이 해당 Annotation이 작성 되어 있는 경우 @Email과 같은 Validation용 Annotation을 찾아 검증을 수행한다.
실습2(정규식)
- phoneNumber에 대한 검증
1
2
@Pattern(regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$")
private String phoneNumber;
- message parameter를 추가해 Error msg를 설정할 수 있다.
1
2
@Pattern(regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$", message = "핸드폰 번호의 양식과 맞지 않습니다. xxx-xxx(x)-xxxx")
private String phoneNumber;
Validation 실패 후 처리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RestController
@RequestMapping("/api")
public class ApiController {
@PostMapping("/user")
public ResponseEntity user(@Valid @RequestBody User user, BindingResult bindingResult){
if(bindingResult.hasErrors()){
StringBuilder sb = new StringBuilder();
bindingResult.getAllErrors().forEach(objectError -> {
FieldError field = (FieldError)objectError;
String message = objectError.getDefaultMessage();
sb.append("field : " + field.getField());
sb.append("message : " + message);
});
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(sb.toString());
}
return ResponseEntity.ok(user);
}
- BindingResult를 사용해 Validation 결과를 알 수 있다.
![regex]](/assets/img/post/2021-06-28/regex.png)
실습3(Custom)
- AssertTure, AssertFalse와 같은 method 지정을 통해 Custom Logic적용이 가능하다.
- ConstraintValidator 를 적용해 재사용 가능한 Custom Logic적용이 가능하다.
- Custom validation이 필요한 경우의 예로는 날짜 형식을 String yyyyMM과 같은 형식을 받을 때 Spring의 validation annotation으로는 검증할 수 없기 때문에 Custom이 필요하다.
메서드 지정 Validation
- yyyyMM형식의 날짜를 받는다고 생각
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@JsonNaming(value = PropertyNamingStrategy.SnakeCaseStrategy.class)
public class User {
@Size(min = 6, max = 6)
private String reqYearMonth; // yyyyMM
@AssertTrue(message = "yyyyMM 의 형식에 맞지 않습니다.")
public boolean isReqYearMonthValidation(){
try{
LocalDate localDate = LocalDate.parse(getReqYearMonth() + "01", DateTimeFormatter.ofPattern("yyyyMMdd"));
}catch(Exception e){
return false;
}
return true;
}
public String getReqYearMonth() {
return reqYearMonth;
}
public void setReqYearMonth(String reqYearMonth) {
this.reqYearMonth = reqYearMonth;
}
}
- AssertTrue를 사용하기 위해선 boolean반환에 메서드 이름은 반드시 is변수명 이어야 한다.
- yyyyMM형식을 검증 해야 하기 때문에 localDate로 dummy date인 01을 추가해 파싱을 시도하고 예외가 발생되면 False, 제대로 파싱되면 True를 반환하게 하면 된다.
- 하지만 위와 같이 Validation을 진행하게 되면 User Dto에 직접 작성을 했기 때문에 재사용이 불가능하다. 재사용이 가능한 Validation을 하기 위해 직접 Annotation을 작성해 주면된다.
Annotation 작성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Documented
@Constraint(validatedBy = { })
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Repeatable(List.class)
public @interface Email {
String message() default "{javax.validation.constraints.Email.message}";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
/**
* @return an additional regular expression the annotated element must match. The default
* is any string ('.*')
*/
String regexp() default ".*";
/**
* @return used in combination with {@link #regexp()} in order to specify a regular
* expression option
*/
Pattern.Flag[] flags() default { };
/**
* Defines several {@code @Email} constraints on the same element.
*
* @see Email
*/
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
public @interface List {
Email[] value();
}
}
- 위의 코드는 Email Annotation코드이다. 이를 참고하여 Annotation을 작성하면된다.
- YearMonth
1
2
3
4
5
6
7
8
9
10
11
12
13
@Constraint(validatedBy = {YearMonthValidator.class}) // 검증을 수행할 Class
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
public @interface YearMonth {
String message() default "{yyyyMM형식에 맞지 않습니다.}";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
String pattern() default "yyyyMMdd"; //외부에서는 yyyyMM이 들어오지만 내부에서 dummy값을 붙혀 yyyyMMdd형식으로 만들어 검증하기 때문
}
- YearMonthValidator
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//ConstraintValidator <Annotation, ValueType>
public class YearMonthValidator implements ConstraintValidator<YearMonth, String> {
private String pattern;
@Override
public void initialize(YearMonth constraintAnnotation) {
//초기화 때 YearMonth의 pattern을 가져옴
this.pattern = constraintAnnotation.pattern();
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
//검증 수행은 이전 AssertTrue와 마찬가지로 진행
try{
LocalDate localDate = LocalDate.parse(value + "01", DateTimeFormatter.ofPattern(this.pattern));
}catch(Exception e){
return false;
}
return true;
}
}
추가
- User가 CarList를 가지고 있는 model을 생각해보자
- 이때 User객체 RequestBody에 Valid Annotation 을 작성해 준다고 해서 User가 가지고 있는 Car 객체 대해서도 Validation을 수행하는 것은 아니다. 항상 Valid를 명시해 주어야 하며 이럴 경우 아래와 같이 코드를 작성해 주어야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class User{
@NotBlank
private String name;
@Max(value = 90)
private int age;
@Valid
private List<Car> cars;
...
}
public class Car{
@NotBlank
private String name;
@NotBlank
private String carNumber;
}