데이터를 등록할 때와 수정할 때는 요구사항이 다를 수 있다.
타입 검증
필드 검증
등록시에는 quantity
수량을 최대 9999까지 등록할 수 있지만 수정시에는 수량을 무제한으로 변경할 수 있다.
등록시에는 id
에 값이 없어도 되지만, 수정시에는 id
값이 필수이다.
수정시에는 Item
에서 id
값이 필수이고, quantity
도 무제한으로 적용할 수 있다.
package hello.itemservice.domain.item;
@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;
//...
}
수정 요구사항을 적용하기 위해 다음을 적용했다.
id
: @NotNull
추가
quantity
: @Max(9999)
제거
🤞 참고
현재 구조에서는 수정시 item
의 id
값은 항상 들어있도록 로직이 구성되어 있다. 그래서 검증하지 않아도 된다고 생각할 수 있다. 그런데 HTTP 요청은 언제든지 악의적으로 변경해서 요청할 수 있으므로 서버에서 항상 검증해야 한다. 예를 들어서 HTTP 요청을 변경해서 item
의 id
값을 삭제하고 요청할 수도 있다. 따라서 최종 검증은 서버에서 진행하는 것이 안전한다.
수정을 실행해보자.
정상 동작을 확인할 수 있다.
그런데 수정은 잘 동작하지만 등록에서 문제가 발생한다.
등록시에는 id
에 값도 없고, quantity
수량 제한 최대 값인 9999도 적용되지 않는 문제가 발생한다.
등록시 화면이 넘어가지 않으면서 다음과 같은 오류를 볼 수 있다.
'id': rejected value [null];
왜냐하면 등록시에는 id 에 값이 없다. 따라서 @NotNull
id
를 적용한 것 때문에 검증에 실패하고 다시 폼 화면으로 넘어온다. 결국 등록 자체도 불가능하고, 수량 제한도 걸지 못한다.
결과적으로 item
은 등록과 수정에서 검증 조건의 충돌이 발생하고, 등록과 수정은 같은 BeanValidation
을 적용할 수 없다. 이 문제를 어떻게 해결할 수 있을까?
동일한 모델 객체를 등록할 때와 수정할 때 각각 다르게 검증하는 방법을 알아보자.
방법 2가지
1. BeanValidation
의 groups
기능을 사용한다.
2. Item을 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체를 만들어서 사용한다.
이런 문제를 해결하기 위해 Bean Validation
은 groups
라는 기능을 제공한다.
예를 들어서 등록시에 검증할 기능과 수정시에 검증할 기능을 각각 그룹으로 나누어 적용할 수 있다.
코드로 확인해보자.
package hello.itemservice.domain.item;
public interface SaveCheck {
}
package hello.itemservice.domain.item;
public interface UpdateCheck {
}
package hello.itemservice.domain.item;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@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;
}
}
@PostMapping("/add")
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//...
}
SaveCheck.class
적용
@PostMapping("/{itemId}/edit")
public String editV2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {
//...
}
UpdateCheck.class
적용
🤞 참고
@Valid
에는 groups
를 적용할 수 있는 기능이 없다.
따라서 groups
를 사용하려면 @Validated
를 사용해야 한다.
정리
groups
기능을 사용해서 등록과 수정시에 각각 다르게 검증을 할 수 있었다. 그런데 groups
기능을 사용하니 Item
은 물론이고, 전반적으로 복잡도가 올라갔다.
사실 groups
기능은 실제 잘 사용되지는 않는데,
그 이유는 실무에서는 주로 다음에 등장하는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다.
실무에서는 groups 를 잘 사용하지 않는데, 그 이유가 다른 곳에 있다.
바로 등록시 폼에서 전달하는 데이터가 Item
도메인 객체와 딱 맞지 않기 때문이다.
소위 "Hello World" 예제에서는 폼에서 전달하는 데이터와 Item
도메인 객체가 딱 맞는다. 하지만 실무에서는 회원 등록시 회원과 관련된 데이터만 전달받는 것이 아니라, 약관 정보도 추가로 받는 등 Item
과 관계없는 수 많은 부가 데이터가 넘어온다.
그래서 보통 Item
을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다. 예를 들면 ItemSaveForm
이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute
로 사용한다. 이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item
을 생성한다.
HTML Form -> Item -> Controller -> Item -> Repository
장점: Item
도메인 객체를 컨트롤러, 리포지토리 까지 직접 전달해서 중간에 Item
을 만드는 과정이 없어서 간단하다.
단점: 간단한 경우에만 적용할 수 있다. 수정시 검증이 중복될 수 있고, groups
를 사용해야 한다.
HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository
장점: 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있다. 보통 등록과, 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다.
단점: 폼 데이터를 기반으로 컨트롤러에서 Item
객체를 생성하는 변환 과정이 추가된다.
수정의 경우 등록과 수정은 완전히 다른 데이터가 넘어온다.
생각해보면 회원 가입시 다루는 데이터와 수정시 다루는 데이터는 범위에 차이가 있다.
예를 들면 등록시에는 로그인id, 주민번호 등등을 받을 수 있지만, 수정시에는 이런 부분이 빠진다. 그리고 검증 로직도 많이 달라진다.
그래서 ItemUpdateForm
이라는 별도의 객체로 데이터를 전달받는 것이 좋다.
Item
도메인 객체를 폼 전달 데이터로 사용하고, 그대로 쭉 넘기면 편리하겠지만, 앞에서 설명한 것과 같이 실무에서는 Item
의 데이터만 넘어오는 것이 아니라 무수한 추가 데이터가 넘어온다. 그리고 더 나아가서 Item
을 생성하는데 필요한 추가 데이터를 데이터베이스나 다른 곳에서 찾아와야 할 수도 있다.
따라서 이렇게 폼 데이터 전달을 위한 별도의 객체를 사용하고, 등록, 수정용 폼 객체를 나누면 등록, 수정이 완전히 분리되기 때문에 groups
를 적용할 일은 드물다.
Q: 이름은 어떻게 지어야 하나요?
이름은 의미있게 지으면 된다. ItemSave
라고 해도 되고, ItemSaveForm
, ItemSaveRequest
, ItemSaveDto
등으로 사용해도 된다. 중요한 것은 일관성이다.
Q: 등록, 수정용 뷰 템플릿이 비슷한데 합치는게 좋을까요?
한 페이지에 그러니까 뷰 템플릿 파일을 등록과 수정을 합치는게 좋을지 고민이 될 수 있다. 각각 장단점이 있으므로 고민하는게 좋지만, 어설프게 합치면 수 많은 분기문(등록일 때, 수정일 때) 때문에 나중에 유지보수에서 고통을 맛본다.
이런 어설픈 분기문들이 보이기 시작하면 분리해야 할 신호이다.
이제 Item
의 검증은 사용하지 않으므로 검증 코드를 제거해도 된다
@Data
public class Item {
private Long id;
private String itemName;
private Integer price;
private Integer quantity;
}
package hello.itemservice.web.validation.form;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class ItemSaveForm {
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(value = 9999)
private Integer quantity;
}
package hello.itemservice.web.validation.form;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class ItemUpdateForm {
@NotNull
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
//수정에서는 수량은 자유롭게 변경할 수 있다. private Integer quantity;
}
package hello.itemservice.web.validation;
import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import hello.itemservice.web.validation.form.ItemSaveForm;
import hello.itemservice.web.validation.form.ItemUpdateForm;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.util.List;
@Slf4j
@Controller
@RequestMapping("/validation/v4/items")
@RequiredArgsConstructor
public class ValidationItemControllerV4 {
private final ItemRepository itemRepository;
@GetMapping
public String items(Model model) {
List<Item> items = itemRepository.findAll();
model.addAttribute("items", items);
return "validation/v4/items";
}
@GetMapping("/{itemId}")
public String item(@PathVariable long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "validation/v4/item";
}
@GetMapping("/add")
public String addForm(Model model) {
model.addAttribute("item", new Item());
return "validation/v4/addForm";
}
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//특정 필드 예외가 아닌 전체 예외
if (form.getPrice() != null && form.getQuantity() != null) {
int resultPrice = form.getPrice() * form.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v4/addForm";
}
//성공 로직
Item item = new Item();
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v4/items/{itemId}";
}
@GetMapping("/{itemId}/edit")
public String editForm(@PathVariable Long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "validation/v4/editForm";
}
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {
//특정 필드 예외가 아닌 전체 예외
if (form.getPrice() != null && form.getQuantity() != null) {
int resultPrice = form.getPrice() * form.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v4/editForm";
}
Item itemParam = new Item();
itemParam.setItemName(form.getItemName());
itemParam.setPrice(form.getPrice());
itemParam.setQuantity(form.getQuantity());
itemRepository.update(itemId, itemParam);
return "redirect:/validation/v4/items/{itemId}";
}
}
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//...
}
Item
대신에 ItemSaveform
을 전달 받는다. 그리고 @Validated
로 검증도 수행하고,BindingResult
로 검증 결과도 받는다.
📍 주의
@ModelAttribute("item")
에 item
이름을 넣어준 부분을 주의하자. 이것을 넣지 않으면 ItemSaveForm
의 경우 규칙에 의해 itemSaveForm
이라는 이름으로 MVC Model
에 담기게 된다. 이렇게 되면 뷰 템플릿에서 접근하는 th:object
이름도 함께 변경해주어야 한다.
//성공 로직
Item item = new Item(); item.setItemName(form.getItemName()); item.setPrice(form.getPrice()); item.setQuantity(form.getQuantity());
Item savedItem = itemRepository.save(item);
폼 객체의 데이터를 기반으로 Item 객체를 생성한다. 이렇게 폼 객체 처럼 중간에 다른 객체가 추가되면 변환하는 과정이 추가된다.
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {
//...
}
수정의 경우도 등록과 같다. 그리고 폼 객체를 Item 객체로 변환하는 과정을 거친다.
Form 전송 객체 분리해서 등록과 수정에 딱 맞는 기능을 구성하고, 검증도 명확히 분리했다.
@Valid
, @Validated
는 HttpMessageConverter
( @RequestBody
)에도 적용할 수 있다.
🤞 참고
@ModelAttribute
는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때 사용한다.
@RequestBody
는 HTTP Body의 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때 사용한다.
package hello.itemservice.web.validation;
import hello.itemservice.web.validation.form.ItemSaveForm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@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();
}
log.info("성공 로직 실행");
return form;
}
}
Postman을 사용해서 테스트 해보자.
POST http://localhost:8080/validation/api/items/add
{"itemName":"hello", "price":1000, "quantity": 10}
Postman에서 Body -> raw -> JSON을 선택해야 한다.
성공 요청: 성공
실패 요청: JSON을 객체로 생성하는 것 자체가 실패함
검증 오류 요청: JSON을 객체로 생성하는 것은 성공했고, 검증에서 실패함
API 컨트롤러 호출
성공 로직 실행
POST http://localhost:8080/validation/api/items/add
{"itemName":"hello", "price":"A", "quantity": 10}
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"])]
HttpMessageConverter
에서 요청 JSON
을 ItemSaveForm
객체로 생성하는데 실패한다.
이 경우는 ItemSaveForm
객체를 만들지 못하기 때문에 컨트롤러 자체가 호출되지 않고 그 전에 예외가 발생한다. 물론 Validator
도 실행되지 않는다.
이번에는 HttpMessageConverter
는 성공하지만 검증(Validator
)에서 오류가 발생하는 경우를 확인해보자.
POST http://localhost:8080/validation/api/items/add
{"itemName":"hello", "price":1000, "quantity": 10000}
수량( quantity
)이 10000 이면 BeanValidation @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"
}
]
return bindingResult.getAllErrors();
는 ObjectError
와 FieldError
를 반환한다. 스프링이 이 객체를 JSON으로 변환해서 클라이언트에 전달했다. 여기서는 예시로 보여주기 위해서 검증 오류 객체들을 그대로 반환했다. 실제 개발할 때는 이 객체들을 그대로 사용하지 말고, 필요한 데이터만 뽑아서 별도의 API 스펙을 정의하고 그에 맞는 객체를 만들어서 반환해야 한다.
API 컨트롤러 호출
검증 오류 발생, errors=org.springframework.validation.BeanPropertyBindingResult: 1
errors
Field error in object 'itemSaveForm' on field 'quantity': rejected value [99999]; codes [Max.itemSaveForm.quantity,Max.quantity,Max.java.lang.Integer,Max]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [itemSaveForm.quantity,quantity]; arguments []; default message [quantity],9999]; default message [9999 이하여야 합니다]
로그를 보면 검증 오류가 정상 수행된 것을 확인할 수 있다.
@ModelAttribute
vs @RequestBody
HTTP 요청 파리미터를 처리하는 @ModelAttribute
는 각각의 필드 단위로 세밀하게 적용된다. 그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다. HttpMessageConverter
는 @ModelAttribute
와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다.
따라서 메시지 컨버터의 작동이 성공해서 ItemSaveForm
객체를 만들어야 @Valid
, @Validated
가 적용된다.
@ModelAttribute
는 필드 단위로 정교하게 바인딩이 적용된다. 특정 필드가 바인딩 되지 않아도 나머지 필드는 정상 바인딩 되고, Validator
를 사용한 검증도 적용할 수 있다.
@RequestBody
는 HttpMessageConverter
단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진행되지 않고 예외가 발생한다. 컨트롤러도 호출되지 않고, Validator
도 적용할 수 없다.
🤞 참고
HttpMessageConverter
단계에서 실패하면 예외가 발생한다. 예외 발생시 원하는 모양으로 예외를 처리하는 방법은 예외 처리 부분에서 다룬다.