우선 validation에대해서 짚고 넘어가야 할것이 있다.
우선 validation은 뒷단보다는 프론트에서 넘기는것이 일단 맞다.
왜냐하면, 서버측에서 검증이 불가능한게 아니라, 서버측에서 검증을 받기위해서는 일단 클라이언트가 요청을 서버측으로 날리고, 그다음에 서버측에서 검증을 다 한 다음에, 그 후에 BindingResult에 오류값을 view에 넘겨서 뿌려주는 형태이다.
딱 봐도, 일단 클라이언트가 서버측으로 요청을 해야하고, 검증도 해야되고, 그 다음에 view까지 가야하니까 복잡하고 + 시간도 걸린다.
다만 우리가 프로젝트를 할때 앞단 개발자와 협의를 하고 애초에 지정된 양식의 data를 보내는게 아니면, 애초에 서버측으로 요청을 못보내게 하는것이 훨씬 깔끔하고, 효율도 좋다.
그러므로 오류처리는 프론트단에서 하는게 맞다. 그러나 서버측에서도 어떤식으로 오류를 처리하면 좋을 지 남겨두면 좋을꺼 같아서 쓰는글이니, 도중에 아마 글을 읽다가 굳이 이걸 서버측에서 검증을해야하나 싶은 분들을 위해서 미리 말씀드리는 글이다.
다만, 오해하면 안되는게 만약에 get방식으로 id에 해당하는 개체를 가져올때 id가 없으면 어떻게해요? 이런걸 검증하는게 아니다. 만약 없으면 이제 프론트단과 협의된 오류 상태코드를 내보내는것이지.
여기서는 가격은 100~1000000이하, 이런조건을 걸고 사용자가 10원을 입력하는 이런 경우를 말하는것이다.
특정 필드에 대한 검증 로직은 대부분 빈값인지 아닌지, 특정 크기를 넘는지 아닌지와 같은 매우 일반적인 로직이다.
이러한 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화 한것이 바로 Bean Validation이다. BeanValidation을 잘 활용하면, 애노테이션 하나로 검증로직을 매우 편리하게 적용가능하다.
참고
BeanValidation은 특정한 구현체가 아니라 BeanValidation 2.0이라는 기술 표준이다. 쉽게 말해서 검증 애노테이션(@NotNull,@Size)과 여러 인터페이스의 모음이다. 마치 JPA가 표준 기술이고 그 구현체로 하이버네이트가 있는것과 같다.=> 즉 BeanValidation자체가 실제로 작업을 수행하는 코드는 아니고, 검증을 위해 따라야할 규칙과 구조를 정의한것이라는것이다.
BeanValidation을 구현한 기술중에 일반적으로 사용하는 구현체는 하이버네이트 Validatior이다. 이름이 하이버네이트가 붙어서 그렇지 ORM과는 관련이 없다. 즉 하이버네이트 Validator는 BeanValidation 규격을 따르는 검증로직을 제공하는 별도의 라이브러리이다.
결론적으로, BeanValidation은 자바 애플리케이션에서 객체의 데이터를 검증하는데 사용되는 규격이고, 이를 구현한 하이버네이트 Validator와 같은 라이브러리를 통해 실제 검증 로직을 적용할 수 있다.
@Slf4j
@Controller
@RequestMapping("/validation/v3/items")
@RequiredArgsConstructor
public class ValidationItemControllerV3 {
private final ItemRepository itemRepository;
@GetMapping
public String items(Model model) {
List<Item> items = itemRepository.findAll();
model.addAttribute("items", items);
return "validation/v3/items";
}
@GetMapping("/{itemId}")
public String item(@PathVariable long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "validation/v3/item";
}
@GetMapping("/add")
public String addForm(Model model) {
model.addAttribute("item", new Item());
return "validation/v3/addForm";
}
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult
bindingResult, RedirectAttributes redirectAttributes) {
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/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v3/items/{itemId}";
}
@GetMapping("/{itemId}/edit")
public String editForm(@PathVariable Long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "validation/v3/editForm";
}
@PostMapping("/{itemId}/edit")
public String edit(@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}";
}
}
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult
bindingResult, RedirectAttributes redirectAttributes) {...}
유심히 봐야하는것은 @Validated 부분이다. 아무것도 검증 오류에 대한 코드를 작성하지 않아도 @Validated 애노테이션으로 검증이 가능하다.
스프링 MVC는 어떻게 Bean Validator를 사용하는것일까?
스프링 부트가 spring - boot - starter - validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.
스프링 부트는 자동으로 글로벌 Validator로 등록한다.
LocalValidatorFactoryBean을 글로벌 Validator로 등록한다. 이 Validator가 @NotNull같은 애노테이션을 보고 검증을 수행한다. 이렇게 글로벌 validator가 적용 되어있어서, 우리는 @Valid, @Validated 만 적용하면된다.
만약 검증 오류가 발생하면, FieldError, ObjectError를 생성해서 BindingResult에 담아준다.
참고
@Validated vs @Valid
일단 둘다 사용이 가능하다. 그런데 javax.validation.@Valid를 사용하려면 build.gradle 의존관계 추가가 필요하다.(왜냐하면 이것도 Bean Validation API의 구현체가 필요하니까)
implementation 'org.springframework.boot::spring-boot-starter-validation' @Validated는 스프링 전용 검증 애노테이션이고, @Valid는 자바 표준 검증 애노테이션이다.
@Valid는 자바 표준이므로 Java프레임워크및 애플리케이션에서 널리 사용될수 있다.
예를 들어 다른 자바프레임워크와 스프링과 협업하는경우 @Valid를 사용하면 자바 프레임워크에서 일관되 방식으로 검증로직을 적용할 수 있게 해준다.
예시를 찾아보니까 스프링과 바티카노 가 있다는데 바티카노는 비동기 프로그래밍을 위한 자바 프레임워크라는데 스프링 웹플럭스와 같은 반응형 모델과 함께 바티카노를 사용하여 비동기 웹 애플리케이션을 개발할 수 있다고한다...
그러나 뭐 우리는 대부분은 스프링 프레임워크를 사용하여서 개발을 하니까 둘중 아무거나 사용해도 동일하게 동작한다.
검증순서
1. @Model Attribute 각각의 필드에 타입변환 시도
성고하면 다음으로
실패하면 typeMistmatch로 필드에러 추가
2. Validator 적용
즉 바인딩에 성공한 필드만 BeanValidation을 적용한다. 어찌보면 당연한 말이다. 만약 price에 문자 'A'를 입력하면 이걸 10부터 1000까지 정해진 범위에 검증할 이유가 없을 것이다.
그러므로, BeanValidator는 바인딩에 실패한 필드는 BeanValidation을 적용하지 않고, 성공한 필드만 BeanValidation을 적용한다.
그러면 에러코드가, 필드에러와 BeanValidation검증코드 두개가 나올수 있다.
그러면 에러코드를 출력할때는 어떻게 해야할까?
Bean Validation을 적용하고 bindingResult에 등록된 검증 오류 코드를 보자.
오류코드가 애노테이션 이름으로 등록된다.
@NotBlank
NotBlank.item.itemName
NotBlank.itemName
NotBlank.java.lang.String
NotBlank
@Range
Range.item.price
Range.price
Range.java.lang.Integer
Range
만약 상품 이름을 적지 않고 포스트 형식으로 부르면
이런식으로 에러가 난다.
이러면 에러코드를 수정하고 싶을때,
errors.properties에다가
설정을 해준다.
여기서 {0}에는 필드 명이 보통 온다.
그래서, 상품명을 적지 않은면
이런식으로 나온다. 즉 {0}에 해당하는 itemName이 들어가고 공백 x이런식으로 나오는것이다.
다른 필드도 적용해보면
이런식으로 필드명이 {0} 부분에 대체되고 그다음 내가 나타내고 싶은 에러코드가 나온다.
그런데 문제는 NotBlank이므로 NotBlank인 모든 필드에 저 공백 x라는게 적용이 된다.
만약에 itemName과 그냥 username이라는 필드가 있다면
@NotBlank
String itemName;
@NotBlank
String userName;
둘다 적지 않으면, itemName 공백 x userName 공백 x 이런식으로 나올것이다.
그래서 itemName,userName에 세부적으로 적고싶다면,
이런식으로 세부적인 내용을 적어주면 대체가 된다.
BeanValidation 메시지 찾는순서
1. 생성된 메시지 코드 순서대로 messageSource에서 메시지 찾기
2. 애노테이션의 message속성사용 -> @NotBlank(message = "공백! {0}")
3. 라이브러리가 제공하는 기본 값 사용 -> 공백일 수 없습니다.
애노테이션의 message 사용예시
@NotBlank(message = "공백은 입력할 수 없습니다.")
private String itemName;
즉 Validation은 별게 없다.
우리는 필드에다가 애노테이션을 붙여서 오류메시지를 출력하였다. 그러면 단일 필드가아닌 오브젝트오류는 어떻게 해결할까?
예를들어, 가격필드와 수량 필드가 있는데 가격 * 수량이 항상 10000원 이상이고 싶은것이다.
이럴때는 그냥 자바 코드로 처리를 해주느것이 명료하다.
price와 Quntity를 가져오고 계산해서 10000보다 작으면 totalPriceMin이라는 메시지에, 넘어야하는 10000원과 현재 가격을 넘겨준다.
erros.properties에 설정
그러면 {0}에 10000원이 들어가고 현재 값이 resultPrice로 들어가게 된다.
Bean Validation에는 한계가 있다.
예를 들어 데이터 등록할때와 수정 할때의 요구사항이 다르다고 가정해 보자.
데이터를 등록할 때는 quantity 수량을 최대 9999개까지 등록 할 수 있지만, 수정시에는 수량을 무제한으로 변경할 수 있다고 하자.
등록시에는 id에 값이 없어도 되지만, 수정시에는 id값이 필수적이다.
수정 요구사항 적용
수정시에는 Item에서 id값이 필수이고, quantity도 무제한으로 적용할 수 있다.
그러나 등록에서 작동을 제대로 하지 않는다. 왜냐하면 우리는 데이터를 처음 등록할때 id값을 넣지 않았고, quantity 수량 제한인 9999도 적용되지 않는다.
결과적으로 item은 등록과 수정에서 검증 조건의 충돌이 발생하고, 등록과 수정은 같은 BeanValidation을 적용할 수 없다.
해결방법
groups 적용
우선 save할때와 update할때를 구분지을 인터페이스를 생성한다.
Item - groups 적용
이렇게 각 필드에다가 save할때 검증할 필드인지 update할때 검증할 필드일지 작성을 해둔다.
적용
edit할때는 @Validated에 updateCheck만 검증하고
add할때는 SaveCheck를 통해 SaveCheck를 넣은 애노테이션만 검증한다.
참고: @Valid에는 groups기능이 없다. 따라서 groups를 사용하려면 @Validated를 사용해야한다.
참고: groups기능은 잘 사용되지 않는다. 그 이유는 두번째 방법인 ItemSaveForm과 ItemUpdateForm처럼 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다.
★★★★★결국 앞에있던 모든 것들을 지나, 결국 validation은 이 전송객체를 분리해서 사용하는 방법을 이용해야한다.
별도의 모델 객체 만들어서 적용
그래서 보통 Item을 직접 전달받는것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달해야한다. 예를들어,
ItemSaveForm이라는 폼을 전달받는 전용객체를 만들어서 @ModelAttribute로 사용한다.
그리고 컨트롤러에서 필요한 데이터를 사용해서 Item을 생성한다.
그러면 동일하게 수정의 경우 등록과는 완전히 다른 데이터가 넘어오므로, ItemUpdateForm이라는 별도의 객체로 데이터를 전달받는것이 좋다.
Item은 이제 더이상 검증에 사용되지 않으므로 검증 코드를 제거한다.
각 수정과 등록에 다른 요구사항들을 필드에 넣어준다.
등록 컨트롤러
일단 파라미터에서 더이상 Item을 받지 않는다. @ModelAttribute로 ItemSaveForm을 받고 이제 클라이언트 측에서 보내는 data의 형식이 ItemSaveForm과 비교를 한다.
ModelAttribute의 기능중 이름을 지정하지않으면 class명의 소문자로 바꿔서 model.addAttribute(itemSaveForm,form);이런식으로 보내므로,
기존의 html을 사용하기위해서 이름을 item으로 지정한다.
그다음에, 로직이 올바드라면, Item을 생성해서 set으로 설정한 뒤에 save메서드를 호출해서 저장한다.
수정 컨트롤러
수정도 마찬가지다 ModelAttribute를 Item이 아닌 ItemUpdateForm으로 받는다. 그다음에 Item을 생성하여 update메서드를 호출해서 수정한다.
결론: 백엔드에서 검증은 클라이언트가 넘기는 Data에 맞는 DTO를 만들어서 이 DTO로 받아서 검증해야한다.
만약에 클라이언트에서 data를 전달할때 HTML Form 형태나 쿼리 파라미터 형식이 아닌 JSON 형식으로 HTTP 메시지 바디에서 data를 전달할때는 어떻게 해야할까?
뭐 동일하게 @RequestBody를 쓰면 된다. -> 근데 Validated를 써도 우리가 원하는데로 오류가 나오지 않을 수 있다. 이게 무슨말일까?
컨트롤러
동일하게 @RequestBody와 @Validated를 적용하였다. 만약 필드에 문제가 있다면, 동일하게 @Validated가 동작하여 BindingResult에다가 넣어줄 것이다.
API의 경우 3가지 경우를 나눠서 생각해야한다.
만약에 애초에 price에 "A"라는 문자를 보내면, BindingResult에 오류 메시지가 담기는게아니라.
이런식으로 Bad Request가 뜬다.
그이유가 뭐냐면,
일단 API형식으로 보내는건 문자열 통이다. 그러니까 이걸 파싱해서 일단 먼저 JSON형식을 객체로 만들어야한다. 고로, 우선 파싱을해서 ItemSaveForm을 만들고 Validated를 실행해야하는데 애초에 파싱한 A가 바인딩 자체가 안되서 오류가 발생하는것이다.
객체를 만들고 그 객체를 검증해야하는데, 객체 자체가 만들어 지지 않은것이다.
두번째로 검증 오류 요청으로 quantity를 10000으로 보내면, 일단 객체가 만들어지고 Validated에서 @Max(9999)에서 걸려서 정상적인 오류 메시지가 뜬다.
[
"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"
}
]
어? 근데 우리 ModelAttribute로 사용할때는 만약에 @NotNull인 필드에다가 값을 넣지 않아도 공백 x로 오류를 제대로 반환했던것이 기억난다.
그러면 애초에 ModelAttribute도 NotNull 때문에 객체가 안만들어 지는데 이러면 어떻게 400에러가아니고, 오류메시지 검증이 된걸까?
@Model Attribute vs @RequestBody
Http요청 파라미터를 처리하는 @ModelAttribute는 각각의 필드 단위로 세밀하게 적용된다. 필드를 자바 프러퍼티 접근법으로 하나하나 접근하기 때문에 특정 필드에 타입이 맞지않는 오류가 발생해도 나머지 필드는 정상 처리가 가능하다.
다만, HttpMessageConverter는 @ModelAttribute와는 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용되므로, 따라서 메시지 컨버터의 작동이 성공하여서 ItemSaveForm 객체를 만들어야 @Validated 가 적용된다.