우리는 이전까지의 코드에서 정말 많은 것을 했지만
검증 기능을 매번 코드로 작성하는 것은 번거로웠다. 특히나 특정 필드에 대한 검증 로직은
대부분 빈 값인지 아닌지, 특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다.
여기서
👀Bean Validation 이란?
먼저 Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준이다.
쉽게 이야기해서 검증 애노테이션과 여러 인터페이스의 모음이다. 마치 JPA가 표준 기술이고 그 구현체로 하이버네이트가 있는 것과 같다.
Bean Validation을 구현한 기술중에 일반적으로 사용하는 구현체는 하이버네이트 Validator이다.
이름이 하이버네이트가 붙어서 그렇지 ORM과는 관련이 없다.
바로 스프링과 통합하지 않고, 순수한 Bean Validation 사용법부터 테스트 코드로 알아볼 것이다.
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'
@Data
public class Item {
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(9999)
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
검증 애노테이션
@NotBlank
: 빈값 + 공백만 있는 경우를 허용하지 않는다.
@NotNull
: null 을 허용하지 않는다.
@Range(min = 1000, max = 1000000)
: 범위 안의 값이어야 한다.
@Max(9999)
: 최대 9999까지만 허용한다
+) 참고
javax.validation.constraints.NotNull
org.hibernate.validator.constraints.Range
javax.validation
으로 시작하면 특정 구현에 관계없이 제공되는 표준 인터페이스이고,
org.hibernate.validator
로 시작하면 하이버네이트validator
구현체를 사용할 때만 제공되는 검증 기능이다.
실무에서 대부분 하이버네이트 validator를 사용하므로 자유롭게 사용해도 된다.
검증기 생성
다음 코드와 같이 검증기를 생성한다.
이후 스프링과 통합하면 우리가 직접 이런 코드를 작성하지는 않으므로, 이렇게 사용하는구나 정도만 참고하자
검증 실행
검증 대상( item )을 직접 검증기에 넣고 그 결과를 받는다. Set 에는 ConstraintViolation 이라는 검증 오류가 담긴다.
-> 따라서 결과가 비어있으면 검증 오류가 없는 것이다
정리
이렇게 빈 검증기(Bean Validation)를 직접 사용하는 방법을 알아보았다.
아마 지금까지 배웠던 스프링MVC 검증 방법에 빈 검증기를 어떻게 적용하면 좋을지 여러가지 생각이 들 것이다.
스프링은 이미 개발자를 위해 빈 검증기를 스프링에 완전히 통합해두었다.😎👍👍
주의
이제 스프링 MVC에서 제공해주는 검증방법을 쓸 것인데 넘어가기 전에 꼭 ItemValidator부분 을 주석처리 해주자! @InitBinder의 init 메서드도!
스프링 부트가 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.
LocalValidatorFactoryBean
을 글로벌 Validator
로 등록한다.
이 Validator
는 @NotNull
같은 애노테이션을 보고 검증을 수행한다.
=> 이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid
,@Validated
만 적용하면 된다.
=> 검증 오류가 발생하면, FieldError , ObjectError 를 생성해서 BindingResult 에 담아준다.
@ModelAttribute
각각의 필드에 타입 변환 시도FieldError
추가Validator
적용 (성공했으니까! 😎)간단하다 생각해보면 바인딩 성공이 되어야지 실패한 애도 굳이 적용해 줄 필요가 있나?
BeanValidator는 바인딩에 실패한 필드는 BeanValidation을 적용하지 않는다.
생각해보면 타입 변환에 성공해서 바인딩에 성공한 필드여야 BeanValidation 적용이 의미 있다.
(일단 모델 객체에 바인딩 받는 값이 정상으로 들어와야 검증도 의미가 있다.)
Bean Validation을 적용하고 bindingResult
에 등록된 검증 오류 코드를 보자.
오류 코드가 애노테이션 이름으로 등록된다. 마치 typeMismatch
와 유사하다.
NotBlank
라는 오류 코드를 기반으로 MessageCodesResolver
를 통해 다양한 메시지 코드가 순서대로 생성된다.
.
.
[@NotBlank
]
[@Range
]
Range.item.price
Range.price
Range.java.lang.Integer
Range
messageSource
에서 메시지 찾기@NotBlank(message = "공백! {0}")
Bean Validation에서 특정 필드( FieldError )가 아닌 해당 오브젝트 관련 오류( ObjectError )는 어떻게 처리할 수 있을까?
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >=
10000")
public class Item {
//...
}
다음과 같은 메시지 코드 생성
- 실제 사용해보면 제약이 많고 복잡하다.
-> 실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우들도 종종 등장하는데, 그런 경우 대응이 어렵다.- 권장하는 것은? 🤷♂️
* 오브젝트 오류(글로벌 오류)의 경우 @ScriptAssert 을 억지로 사용하는 것 보다는 다음과 같이 '오브젝트 오류 관련' 부분만 직접 자바 코드로 작성하는 것을 권장한다.
물론 앞에 썼던 @ScriptAssert
부분은 제거해줘야 한다.👍
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000,
resultPrice}, null);
}
}
크게 달라질 것은 없다 컨트롤러에서도 Item 모델 객체에 @Validated를 추가 해주고 BindingResult를 매개변수에 넣는 등 등록과 비슷하다...
우리가 등록을 할 때와 수정을 할 때는 요구사항이 다를 수 있다.
< 등록시 기존 요구사항 >
<수정시 요구사항>
@Data
public class Item {
@NotNull //수정 요구사항 추가
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
//@Max(9999) //수정 요구사항 추가
private Integer quantity;
//...
}
-> 같은 item 도메인을 사용
=> 등록 시, id에 값이 없음, quantity 수량 제한 적용 (x)
'id': rejected value [null];
이전에 등록 시 id 값이 없다고 했다.
그럼 여기서 발생할 문제는 등록과 수정용의 도메인을 같이 공용으로 쓰고 있는데.
수정 때 적용한 @NotNull id 로 인해 검증이 실패한다는 것이다.
=> 곧 등록도 불가, 수량 제한도 안된다. 💦💦
BeanValidation
의 groups
기능을 사용한다.Item
을 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체를 만들어서 사용한다먼저 groups부터 사용해보자!! 👀
수정용/ 저장용 groups를 인터페이스를 생성한다.
그리고 그 인터페이스를 Item 모델 객체에 필드마다 적용해준다.
@Data
public class Item {
@NotNull(groups = UpdateCheck.class) //수정시에만 적용
private Long id;
@NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
private String itemName;
@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
@Range(min = 1000, max = 1000000, groups = {SaveCheck.class,
UpdateCheck.class})
private Integer price;
@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
@Max(value = 9999, groups = SaveCheck.class) //등록시에만 적용
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
참고
@Valid
에는groups
를 적용할 수 있는 기능이 없다.
=> 따라서 groups를 사용하려면@Validated
를 사용해야 한다
groups 기능을 사용해서 등록과 수정시에 각각 다르게 검증을 할 수 있었다.
그런데 groups 기능을 사용하니 Item 은 물론이고, 전반적으로 복잡도가 올라갔다.
사실 groups 기능은 실제 잘 사용되지는 않는데, 그 이유는 실무에서는 주로 다음에 등장하는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다.
- 참고: 클라이언트 검증, 서버 검증
- 클라이언트 검증은 조작할 수 있으므로 보안에 취약하다.
- 서버만으로 검증하면, 즉각적인 고객 사용성이 부족해진다.
- 둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수
- API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 함 -> API 명세서 등
실무에서는 groups 를 잘 사용하지 않는데, 그 이유가 다른 곳에 있다.
=> 바로 등록시 폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문이다.
소위 "Hello World" 예제에서는 폼에서 전달하는 데이터와 Item 도메인 객체가 딱 맞는다. 하지만 실무에서는 회원 등록시 회원과 관련된 데이터만 전달받는 것이 아니라, 약관 정보도 추가로 받는 등 Item과 관계없는 수 많은 부가 데이터가 넘어온다.
그래서 보통 Item 을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다.
예를 들면 ItemSaveForm 이라는 폼을 전달받는 전용 객체를 만들어서
@ModelAttribute 로 사용한다. 이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item 을 생성한다.
HTML Form -> Item -> Controller -> Item -> Repository
HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository
이름은 의미있게 지으면 된다. ItemSave 라고 해도 되고, ItemSaveForm , ItemSaveRequest , ItemSaveDto 등으로 사용해도 된다. 중요한 것은 일관성이다.
한 페이지에 그러니까 뷰 템플릿 파일을 등록과 수정을 합치는게 좋을지 고민이 될 수 있다. 각각 장단점이 있으므로 고민하는게 좋지만, 어설프게 합치면 수 많은 분기문(등록일 때, 수정일 때) 때문에 나중에 유지보수에서 고통을 맛본다.
=> 이런 어설픈 분기문들이 보이기 시작하면 분리해야 할 신호이다.
@Data
public class Item {
private Long id;
private String itemName;
private Integer price;
private Integer quantity;
}
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form,
BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//...
}
ItemSaveform
을 전달 받는다. 그리고 @Validated
로 검증도 수행하고, BindingResult
로 검증 결과도 받는다주의
@ModelAttribute("item")
에 item 이름을 넣어준 부분을 주의하자.
->"item"
을 넣지 않는다면... 규칙에 의해itemSaveForm"
이라는 이름으로 MVC Model에 담기게 된다.
=> 이렇게 되면 뷰 템플릿에서 접근하는 th:object 이름도 함께 변경해주어야 한다.
=>*{item}
들 전부 바꾸기...
매개변수에서 Form으로 받아온 우리의 객체들을 수정,저장 모두에서 Item으로 변환해주어야 한다!!
물론 setter 보다는 생성자를 만들어서 주입해주는 것이 더 좋다.
우리는 지금까지 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form) 에 대해서만 해봤다. 이런 @Valid
, @Validated
는 @ModelAttribute
에서 뿐 아니라 @RequestBody
에서도 사용 가능하다!!!
@ModelAttribute
는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때 사용한다.@RequestBody
는 HTTP Body의 데이터를 객체로 변환할 때 사용한다.
-> 주로 API JSON 요청을 다룰 때
@Slf4j
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {
//HTTP 메시지 컨버터 바탕으로 _ json 형식으로 주고 받음
// 문제는 얘네는 이전의 form형식의 애들은 필드 타입으로 하나하나 매핑했으나
//얘네는 객체 단위로 받기 때문에 하나라도 타입오류 등
// 잘못 들어오면 ItemSaveForm에 들어가지가 않음..
@PostMapping("/add")
public Object addItem(@RequestBody @Validated ItemSaveForm form,
BindingResult bindingResult) {
log.info("API 컨트롤러 호출");
if (bindingResult.hasErrors()) {
log.info("검증 오류 발생 errors={}", bindingResult);
return bindingResult.getAllErrors();
}
log.info("성공 로직 실행");
return form;
}
}
여기선 일부러 json 타입으로 보낼 때 문자타입으로 살짝 바꿔서 보내버리자.(price를)
여기선 일부러 타입은 잘 맞춰 보내고 , 검증오류가 생기게 price를 좀 더 넘치게 보내자.
POST http://localhost:8080/validation/api/items/add
{"itemName":"hello", "price":1000, "quantity": 10000}
json 결과
우리가 아까 컨트롤러 단에서 쓴 return bindingResult.getAllErrors();
는 ObjectError
와 FieldError
를 반환한다.
스프링이 이 객체를 JSON으로 변환해서 클라이언트에 전달했다.
여기서는 예시로 보여주기 위해서 검증 오류 객체들을 그대로 반환했다. 실제 개발할 때는 이 객체들을 그대로 사용하지 말고, 필요한 데이터만 뽑아서 별도의 API 스펙을 정의하고 그에 맞는 객체를 만들어서 반환해야 한다
@ModelAttribute
는 각각의 필드 단위로 세밀하게 적용된다.HttpMessageConverter
는 @ModelAttribute
와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다.@Valid
, @Validated
가 적용된다.@ModelAttribute
는 필드 단위로 정교하게 바인딩이 적용된다.Validator
를 사용한 검증도 적용할 수 있다.@RequestBody
는 HttpMessageConverter
단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진행되지 않고 예외가 발생한다.😥예외처리에 관해서는 뒷 부분에 다시 설명할 예정이다.
예외 발생 시, 저렇게 긴 문장을 다 내보내지 않고 이쁘게 처리하는 방법이 있다 😎