검증 기능을 매번 코드로 작성하는 것은 상당히 번거롭다. 특히 특정 필드에 대한 검증 로직은 대부분 빈 값인지 아닌지, 특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이며 통상적으로 많이 사용한다.
그렇다면 표준화해서 사용할 수 없는 걸까
아래의 코드들을 비교해보자.
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
errors.rejectValue("price", "range", new Object[]{1000,1000000},null);
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
errors.rejectValue("quantity", "max", new Object[]{9999}, null);
}
@NotNull
@Range(min=1000, max=1000000)
private Integer price;
@NotNull
@Max(9999)
private Integer quantity;
확연하게 아래 코드가 더 간결하고, 직관적이며 편리하다는 것을 알 수 있을 것이다.
여기서 사용한 것이 바로 BeanValidation인데, BeanValidation을 사용하려면 아래의 라이브러리를 추가해야한다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
어노테이션은 아래와 같이 사용한다.
@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를 사용하므로 자유롭게 사용해도 된다.
어노테이션을 테스트하기 위해선 검증기를 사용해야한다. 검증기는 이후 스프링과 통합하면 직접 이런 코드를 작성하지는 않으므로, 이렇게 사용하는구나 정도만 참고하자.
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Set<ConstraintViolation<Item>> violations = validator.validate(item);
검증 대상( item )을 직접 검증기에 넣고 그 결과를 받는다. Set 에는 ConstraintViolation 이라는 검증 오류가 담긴다. 따라서 결과가 비어있으면 검증 오류가 없는 것이다.
어노테이션의 작성은 확인했다. 그렇다면 이 어노테이션을 어떻게 사용할 수 있을까.
스프링 부트가 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.
LocalValidatorFactoryBean 을 글로벌 Validator로 등록한다. 이 Validator는 @NotNull 같은 애노테이션을 보고 검증을 수행한다. 이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid , @Validated 만 적용하면 된다.
검증 오류가 발생하면, FieldError , ObjectError 를 생성해서 BindingResult 에 담아준다.
//@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
/*
앞에 넣으면 예를 들어 price에 숫자가 들어갈 경우 price에 null값이 들어가 오류를 두개 내지 않고 하나만 낼 수 있다.
*/
//검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {//스프링이 뷰로 보내주기 때문에 따로 model.addAttribute할 필요가 없음
log.info("errors = {} ", bindingResult);
return "validation/v3/addForm"; //다시 addForm으로 보내도 입력했던 내용이 남아있다. 왜 ? ModelAttribute 때문에 !
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v3/items/{itemId}";
}
주의
직접 글로벌 Validator를 직접 등록하면 스프링 부트는 Bean Validator를 글로벌
Validator 로 등록하지 않는다. 따라서 애노테이션 기반의 빈 검증기가 동작하지 않는다. 있다면 제거하자.
참고
검증시 @Validated @Valid 둘다 사용가능하다.
javax.validation.@Valid 를 사용하려면 build.gradle 의존관계 추가가 필요하다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
@Validated 는 스프링 전용 검증 애노테이션이고, @Valid 는 자바 표준 검증 애노테이션이다. 둘 중 아무거나 사용해도 동일하게 작동하지만, @Validated 는 내부에 groups 라는 기능을 포함하고 있다. 따라서 groups를 쓴다면 무조건 @Validated를 사용하고 그게 아니라면 편한걸 사용하면된다.
검증 순서
1. @ModelAttribute 각각의 필드에 타입 변환 시도
2.Validator 적용
바인딩에 성공한 필드만 Bean Validation 적용한다. 따라서 바인딩에 실패한 필드는 BeanValidation을 적용하지 않는다. 모델 객체에 바인딩 받는 값이 정상으로 들어와야 검증도 의미가 있기 때문이다.
@ModelAttribute 각각의 필드 타입 변환시도 변환에 성공한 필드만 BeanValidation 적용
예)
itemName 에 문자 "A" 입력 -> 타입 변환 성공 -> itemName 필드에 BeanValidation 적용
price 에 문자 "A" 입력 -> "A"를 숫자 타입 변환 시도 -> 실패 -> typeMismatch FieldError 추가 => price 필드는 BeanValidation 적용 X 따라서 이 경우엔 typaMismatch 오류 메시지만 출력됨.
Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶으면 어떻게 하면 될까
typeMismatch에서 오류 메시지를 변경한 것과 동일하게 변경하면 된다.
일단 @NotNull 오류를 내보면 에러코드가 아래와 같은 것을 확인할 수 있다.
NotBlank.item.itemName
NotBlank.itemName
NotBlank.java.lang.String
NotBlank
그렇다면 errors.properties에 우리가 원하는 level에 오류메시지를 추가하면 된다.
예를 들어 아래와 같이 변경할 수 있다.
#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
{0} 은 필드명이고, {1} , {2} ...은 각 애노테이션 마다 다르다. 파라미터가 아님에 주의해야 한다.
BeanValidation 메시지 찾는 순서
1. 생성된 메시지 코드 순서대로 messageSource 에서 메시지 찾기
2. 애노테이션의 message 속성 사용 @NotBlank(message = "공백! {0}")
3. 라이브러리가 제공하는 기본 값 사용 공백일 수 없습니다.
Field Error는 위에서 확인한 것 처럼 필드위에 어노테이션을 붙여 사용했다. 그렇다면 필드를 여러개 사용하는 Object Error는 어떻게 해결할까.
@ScriptAsser를 사용하면 된다. 예제를 통해 확인해보자.
@ScriptAssert(lang="javascript",script = "_this.price * _this.quantity>=10000", message="총합이 10000원 넘게 입력해주세요.")
를 클래스 단위에 어노테이션 선언하면 된다.
오류코드도 아래와 같이 생성된다.
하지만 위의 코드는 실제 사용해보면 제약이 많고 복잡하다. 그리고 실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우들도 종종 등장하는데, 그런 경우 대응이 어렵다.
따라서 오브젝트 오류(글로벌 오류)의 경우 @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);
}
}
기획자는 등록할 때의 검증과 수정시의 검증 기준이 다르게 요구할 수 있다.
예를 들어 아래와 같다.
수정시 요구사항
이 요구사항을 보고 quantity에 있는 @Max(9999)를 삭제하면 등록에서도 수량을 무제한으로 변경할 수 있다.
또한 id에 @NotNull을 설정하면 등록 폼에선 id설정을 따로 하지 못하기 때문에 오류가 나 다시 등록 폼으로 돌아간다. 영원히 등록하지 못하는 것이다.
따라서 등록과 수정의 요구사항 즉 검증조건이 충돌할 때는 같은 BeanValidation
을 적용할 수 없다.
이를 어떻게 해결할지 아래에서 알아보자.
참고
현재 구조에서는 수정시 item 의 id 값은 항상 들어있도록 로직이 구성되어 있다. 그래서 검증하지 않아도 된다고 생각할 수 있다. 그런데 HTTP 요청은 언제든지 악의적으로 변경해서 요청할 수 있으므로 서버에서 항상 검증해야 한다. 예를 들어서 HTTP 요청을 변경해서 item 의 id 값을 삭제하고 요청할 수도 있다.
따라서 최종 검증은 서버에서 진행하는 것이 안전한다.
동일한 모델 객체를 등록할 때와 수정할 때 각각 다르게 검증하는 방법을 알아보자.
방법 2가지
BeanValidation groups 기능 사용
이런 문제를 해결하기 위해 Bean Validation은 groups라는 기능을 제공한다.
예를 들어서 등록시에 검증할 기능과 수정시에 검증할 기능을 각각 그룹으로 나누어 적용할 수 있다.
각각 등록과 수정에서 따로 사용할 것이기 때문에 이를 구분할 인터페이스를 만든다.
SaveCheck.class
, Update.class
그리고 검증조건을 나타내는 어노테이션에 group을 구분해서 넣어주면된다.
예를 들어 아래와 같다.
@NotNull(groups = UpdateCheck.class)
@NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
그리고 이러한 groups는 이전에 컨트롤러에서 붙여준 @Validated에 인터페이스이름을 붙여 사용하면 된다.
예를 들어 아래와 같다.
@PostMapping("/{itemId}/edit")
public String edit2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {
//특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v3/editForm"; //
}
itemRepository.update(itemId, item);
return "redirect:/validation/v3/items/{itemId}";
}
groups 기능을 사용해서 등록과 수정시에 각각 다르게 검증을 할 수 있었다. 그런데 groups 기능을 사용하니 Item 은 물론이고, 전반적으로 복잡도가 올라갔다.
사실 groups 기능은 실제 잘 사용되지는 않는데, 그 이유는 실무에서는 주로 다음에 등장하는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다.
참고로 위에서도 이야기 했지만 groups를 사용하려면 Validated를 사용해야하고 Valid는 사용하지 못한다.
우리는 간단한 프로젝트이기 때문에 등록시 폼에서 전달 받는 데이터가 Item 도메인 객체와 맞다. 하지만 실무에서는 등록시 여러 부가적인 정보를 받기 때문에 객체와 맞지 않는 경우가 많다. 그래서 보통 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의
객체를 만들어서 전달한다. 예를 들면 ItemSaveForm 이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute 로 사용한다. 이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item 을 생성한다.
데이터 전달에 Item 도메인 객체 사용
폼 데이터 전달을 위한 별도의 객체 사용
Item 도메인 객체를 폼 전달 데이터로 사용하고, 그대로 쭉 넘기면 편리하겠지만, 앞에서 설명한 것과 같이 실무에서는 Item 의 데이터만 넘어오는 것이 아니라 무수한 추가 데이터가 넘어온다. 그리고 더 나아가서 Item 을 생성하는데 필요한 추가 데이터를 데이터베이스나 다른 곳에서 찾아와야 할 수도 있다.
따라서 이렇게 폼 데이터 전달을 위한 별도의 객체를 사용하고, 등록, 수정용 폼 객체를 나누면 등록, 수정이 완전히 분리되기 때문에 groups 를 적용할 일은 드물다.
Q: 이름은 어떻게 지어야 하나요?
이름은 의미있게 지으면 된다. ItemSave 라고 해도 되고, ItemSaveForm , ItemSaveRequest,ItemSaveDto 등으로 사용해도 된다. 중요한 것은 일관성이다.
Q: 등록, 수정용 뷰 템플릿이 비슷한데 합치는게 좋을까요?
한 페이지에 그러니까 뷰 템플릿 파일을 등록과 수정을 합치는게 좋을지 고민이 될 수 있다. 각각 장단점이 있으므로 고민하는게 좋지만, 어설프게 합치면 수 많은 분기문(등록일 때, 수정일 때) 때문에 나중에 유지보수에서 고통을 맛본다.
이런 어설픈 분기문들이 보이기 시작하면 분리해야 할 신호이다.
등록과 수정에서 사용될 객체가 다르므로 각각 클래스를 만들어 주자.
등록에서는 id가 필요없다. 이에 근거해서 만들어보자.
public class ItemSaveForm {
//id를 쓸 필요가 없다. -> 사용할 필요가 없는 필드는 넣지 않아 깔끔하게 사용이 가능하다.
@NotNull
private String itemName;
@NotNull
@Range(min = 1000, max=1000000)
private Integer price;
@NotNull
@Max(9999)
private Integer quantity;
}
수정에서는 quantity를 자유롭게 수정할 수 있도록 했으므로 검증 조건을 삭제하면된다.
public class ItemUpdateForm {
@NotNull
private Long id;
@NotNull
private String itemName;
@NotNull
@Range(min = 1000, max=1000000)
private Integer price;
//수량은 수정시에 자유롭게 변경가능 -> null이 가능하고 9999의 한계가 없음
private Integer quantity;
}
위와 같이 각각의 폼에서 사용할 객체를 따로 생성했다. 이를 컨트롤러에서 각각 사용하면 된다. 등록을 예시로 확인해보자.
@PostMapping("/add")
public String addItem3(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//@ModelAttribute("item")의 "item"을 생략하면 model.addAttribute("itemSaveForm",form)이 된다.
// 이렇게 되면 htmlform들의 item들을 모두 수정해야 되므로 itemSaveForm이 아닌 item이 되도록 지정하자.
//특정 필드가 아닌 복합 룰 검증
if (form.getPrice() != null && form.getQuantity() != null) {
int resultPrice = form.getPrice() * form.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
/*
앞에 넣으면 예를 들어 price에 숫자가 들어갈 경우 price에 null값이 들어가 오류를 두개 내지 않고 하나만 낼 수 있다.
*/
//검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {//스프링이 뷰로 보내주기 때문에 따로 model.addAttribute할 필요가 없음
log.info("errors = {} ", bindingResult);
return "validation/v4/addForm"; //다시 addForm으로 보내도 입력했던 내용이 남아있다. 왜 ? ModelAttribute 때문에 !
}
//성공 로직
Item item = new Item();// setter가 아닌 생성자를 통해서 넣어주는 것이 더 좋다.
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());
Item savedItem = itemRepository.save(item);//save에 form이 들어가면 안됨 itemRepository가 원하는 것은 form이 아니라 item이기 때문이다.
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v4/items/{itemId}";
}
위의 코드를 보면 원래는 Item을 파라미터로 넣어줬지만 등록시에 사용할 객체인 ItemSaveForm을 만들어 줬으므로 해당 객체를 넣어줘서 사용한 것을 확인할 수 있다.
하지만 save를 할 때는 item객체를 넣어줘야 하므로 item객체를 생성해서 form의 정보를 넣어준것을 확인할 수 있다.
@ModelAttribute("item") 에 item 이름을 넣어준 부분을 주의하자. 이것을 넣지 않으면 ItemSaveForm 의 경우 규칙에 의해 itemSaveForm 이라는 이름으로 MVC Model에 담기게 된다.
이렇게 되면 뷰 템플릿에서 접근하는 th:object 이름도 함께 변경해주어야 한다.
더 많은 어노테이션들은 Hibernate 공식사이트의 스펙을 확인해보자.
@Valid , @Validated 는 HttpMessageConverter ( @RequestBody )에도 적용할 수 있다.
API의 경우 3가지 경우를 나누어 생각해야 한다.
@Slf4j
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {
@PostMapping("/add")
public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult) {
log.info("API 컨트롤러 호출");
if (bindingResult.hasErrors()) {
log.info(" 검증 오류 발생 errors={}", bindingResult);
return bindingResult.getAllErrors();
//bindingResult가 가지고 있는 모든 error 반환 (object error+field error) field error는 object error의 자식이기 때문
//@RestController이기 때문에 안에 @ResponseBody를 가지고 있다. 따라서 모든 error를 JSON형식으로 반환한다.
}
log.info("성공 로직 실행");
return form;
}
}
price 의 값에 숫자가 아닌 문자를 전달해서 실패하게 만들면 어떻게 될까
실패 요청 결과
{
"timestamp": "2021-04-20T00:00:00.000+00:00",
"status": 400,
"error": "Bad Request",
"message": "",
"path": "/validation/api/items/add"
}
실패 요청 로그
.w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `java.lang.Integer` from String "A": not a valid Integer value; nested exception is com.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `java.lang.Integer` from String "A": not a valid Integer value at [Source: (PushbackInputStream); line: 1, column: 30] (through reference chain: hello.itemservice.domain.item.Item["price"])]
위의 로그를 보면 컨트롤러가 호출 되지 않은 것을 볼 수 있다.
API는 JSON이 객체로 바뀌어야 Validation할 수 있는데, 객체를 못만들었기 때문에 오류가 난것이다. HttpMessageConverter 에서 요청 JSON을 ItemSaveForm 객체로 생성하는데 실패한다.
이 경우는 ItemSaveForm 객체를 만들지 못하기 때문에 컨트롤러 자체가 호출되지 않고 그 전에 예외가 발생한다. 물론 Validator도 실행되지 않는다.
이번에는 HttpMessageConverter 는 성공하지만 검증(Validator)에서 오류가 발생하는 경우를 확인해보자.
검증 오류 결과
{
"codes": [
"Max.itemSaveForm.quantity",
"Max.quantity",
"Max.java.lang.Integer",
"Max"
],
"arguments": [
{
"codes": [
"itemSaveForm.quantity",
"quantity"
],
"arguments": null,
"defaultMessage": "quantity",
"code": "quantity"
},
9999
],
"defaultMessage": "9999 이하여야 합니다",
"objectName": "itemSaveForm",
"field": "quantity",
"rejectedValue": 10000,
"bindingFailure": false,
"code": "Max"
}
]
return bindingResult.getAllErrors(); 는 ObjectError 와 FieldError 를 반환한다. 스프링이 이 객체를 JSON으로 변환해서 클라이언트에 전달했다. 여기서는 예시로 보여주기 위해서 검증 오류 객체들을 그대로 반환했다. 실제 개발할 때는 이 객체들을 그대로 사용하지 말고, 필요한 데이터만 뽑아서 별도의 API 스펙을 정의하고 그에 맞는 객체를 만들어서 반환해야 한다.
HTTP 요청 파리미터를 처리하는 @ModelAttribute 는 각각의 필드 단위로 하나씩 꺼내서 세밀하게 적용된다. 그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다.
HttpMessageConverter 는 @ModelAttribute 와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다. 따라서 메시지 컨버터의 작동이 성공해서 ItemSaveForm 객체를 만들어야 @Valid , @Validated 가 적용된다.