우리는 사용자에게 정해진 절차와 양식에 맞춰 프로그램을 사용해주길 원합니다.
의도치 않는 조작에는 적절히 Error를 다룰 수 있어야하며, 정상적인 타입 input값들에 대해서도 '숫자가 맞는지?', '금액이 적절한지?', '수량은 충분한지?' 등... 유효성(Validation)을 확인 할 필요가 있습니다.
Spring은 @Vaild
라는 유용한 기능을 제공하고 있습니다. @Vaild
에 오기 까지 어떤식으로 유효성검사를 진행해 왔는지 밑바닥부터 알아봅시다.
가장 원초적인 방법입니다. 직접 인자 값을 검증하고 Map에 담아 Model로 보내면 됩니다.
field값을 key값으로 사용하며, field가 아닌 에러는 globalError와 같이 특정하게 지정한값을 사용하며 만들어주시면 됩니다.
//예제는 상품을 등록하는 예제의 컨트롤단입니다.
//error가 존재하면 내용을 가지고, 등록페이지로 다시 이동하고, 없으면 등록을 합니다.
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {
Map<String, String> errors = new HashMap<>();
if(!StringUtils.hasText(item.getItemName())){
errors.put("itemName", "상품 이름은 필수입니다.");
}
if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 100000){
errors.put("price", "가격은 1,000 ~ 100,000 까지 허용합니다.");
}
if(item.getQuantity() == null || item.getQuantity() >999){
errors.put("quantity", "수량은 1 ~ 999 까지 허용합니다.");
}
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 " + resultPrice);
}
}
if(!errors.isEmpty()){
model.addAttribute("errors", errors);
return "validation/v1/addForm";
}
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v1/items/{itemId}";
}
해당 errors
객체는 view(jsp, Thymeleaf)에 뿌려주시면 됩니다.
자 여기서 더 개선된것은 BindingResult
를 이용하는 것입니다. BindingResult
는 직접적으로 View에서 넘어온 field값을 참조하여 error를 지정해 줄 수 있습니다.
이때 사용하는 것이 FieldError
와 ObjectError
입니다.
public FieldError(String objectName, String field, String defaultMessage) {}
objectName : @ModelAttribute 이름
field : 오류가 발생한 필드 이름
defaultMessage : 오류 기본 메시지
마찬가지로 ObjectError
는 field가 아닌 global 적인 검증을 요구할 때 사용하시면 됩니다.
public ObjectError(String objectName, String defaultMessage) {}
그 밖에 다양한 생성자를 갖는 구현체를 가지고 있습니다. 아래는 소스에 반영한 생성자의 내용입니다.
public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure, @Nullable String[] codes,
@Nullable Object[] arguments, @Nullable String defaultMessage)
objectName : 오류가 발생한 객체 이름
field : 오류 필드
rejectedValue : 사용자가 입력한 값(거절된 값)
bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
codes : 메시지 코드
arguments : 메시지에서 사용하는 인자
defaultMessage : 기본 오류 메시지
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//BindingResult는 ModelAttribute 뒤(오른쪽)에 선언해야 사용가능하다
if(!StringUtils.hasText(item.getItemName())){
//bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null, "상품 이름은 필수입니다."));
}
if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 100000){
bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, null, null,"가격은 1,000 ~ 100,000 까지 허용합니다."));
}
if(item.getQuantity() == null || item.getQuantity() >999){
bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, null, null,"수량은 1 ~ 999 까지 허용합니다."));
}
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 " + resultPrice));
}
}
if(bindingResult.hasErrors()){
return "validation/v1/addForm";
}
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v1/items/{itemId}";
}
MessageSource
사용하는 것이 가능합니다.
우선 properties 파일에 해당내용을 작성합니다.
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
item.default=기본 오류입니다..
그리고 적용만 해주시면 됩니다. 메시지 국제화를 공부하셨다면, 이해하는것은 어렵지 않을것입니다.
// 검증문만 작성했습니다.
if(!StringUtils.hasText(item.getItemName())){
//bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null, "상품 이름은 필수입니다."));
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, new String[]{"required.item.itemName", "item.default"}, null, null));
}
if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 100000){
bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1000,100000},null));
}
if(item.getQuantity() == null || item.getQuantity() >999){
bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[]{999},null));
}
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item", new String[]{"totalPriceMin"}, new Object[]{10000,resultPrice}, null));
}
}
생각해보면 new FieldError
를 선언해주는건 귀찮은 일입니다. 당연히 bindingResult는 어떤 field에서 error가 나는지도 알고 있고요.
log.info("objectName={}", bindingResult.getObjectName());
log.info("target={}", bindingResult.getTarget());
다음과 같이 작성 하면 됩니다.
if(!StringUtils.hasText(item.getItemName())){
//bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, new String[]{"required.item.itemName", "item.default"}, null, null));
bindingResult.rejectValue("itemName", "required");
}
if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 100000){
bindingResult.rejectValue("price", "range", new Object[]{1000,100000}, null);
}
if(item.getQuantity() == null || item.getQuantity() >999){
bindingResult.rejectValue("quantity", "max", new Object[]{999}, null);
}
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000,resultPrice}, null);
}
}
원리는 간단합니다.
public String addItem(@ModelAttribute Item item, ...)
bindingResult.rejectValue("itemName", "required"); //(field, errorCode)
required.item.itemName=상품 이름은 필수입니다.
ModelAttribute
가 Item
으로 들어가있고 해당 필드 또한 itemName
으로 지정 되어있습니다. 이를 통해 MessageSource
의 key
를 찾게 됩니다. 이렇게 만듦으로써 세세한 메시지를 추가할 수 있습니다. 패턴은 아래 예시를 보시면 이해가실겁니다.
MessageSource의 key값과 일치하지 않더라도 global하게 설정을해두시면 우선순위에 따라 메시지가 지정이 됩니다.
#==ObjectError==
#Level1
totalPriceMin.item=상품의 가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
#Level2 - 생략
totalPriceMin=전체 가격은 {0}원 이상이어야 합니다. 현재 값 = {1}
#==FieldError==
#Level1_1 순위입니다.
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
#Level2 - 생략
#Level3
required.java.lang.String = 필수 문자입니다.
required.java.lang.Integer = 필수 숫자입니다.
min.java.lang.String = {0} 이상의 문자를 입력해주세요.
min.java.lang.Integer = {0} 이상의 숫자를 입력해주세요.
range.java.lang.String = {0} ~ {1} 까지의 문자를 입력해주세요.
range.java.lang.Integer = {0} ~ {1} 까지의 숫자를 입력해주세요.
max.java.lang.String = {0} 까지의 문자를 허용합니다.
max.java.lang.Integer = {0} 까지의 숫자를 허용합니다.
#Level4_모두 없다면 errorCode를 이걸로 설정하게 됩니다.
required = 필수 값 입니다.
min= {0} 이상이어야 합니다.
range= {0} ~ {1} 범위를 허용합니다.
max= {0} 까지 허용합니다.
유효성 검사에 대해서 알아 봤습니다. 하지만 원초적인 변수 타입에 대한 오류코드는 지정하지 못했네요. BindingResult
는 위에서 우리가 설정한 field Error들을 찾기전 가장먼저 이 점을 검사해줍니다.
따라서 해당 에러에 대한 메시지 값만 적절히 작성해주시면 메시지를 띄워줄겁니다.
// 다음의 코드를 Controller 시작하자마자 작성해주시면 타입에 대한 결과물을 출력합니다.
if(bindingResult.hasErrors()){
return "validation/v1/addForm";
}
#추가
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다
완성
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//typeMismatch
if(bindingResult.hasErrors()){
return "validation/v1/addForm";
}
if(!StringUtils.hasText(item.getItemName())){
bindingResult.rejectValue("itemName", "required");
}
if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 100000){
bindingResult.rejectValue("price", "range", new Object[]{1000,100000}, null);
}
if(item.getQuantity() == null || item.getQuantity() >999){
bindingResult.rejectValue("quantity", "max", new Object[]{999}, null);
}
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()){
return "validation/v1/addForm";
}
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v1/items/{itemId}";
}
추가사항 1. 조건문 제거
- 다음의 코드는 한 줄로 수정이 가능합니다. 다만,
null
이나 공백만 가능합니다.// 전 if(!StringUtils.hasText(item.getItemName())){ bindingResult.rejectValue("itemName", "required"); } // 후 ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
추가사항 2. ItemValidater와 @initBinding
ItemValidater.class
을 상속받아 검사파일로 묶어놓을 수 있습니다.- @initBinding을 이용하여 Controller에 전체 적용가능합니다.
- 이 또한 이어서 배울내용 때문에 굳이 설명 안하겠습니다.
private final ItemValidater itemValidater; // 요런게 있습니다.. 검색 ㄱ @InitBinder public void init(WebDataBinder dataBinder){ dataBinder.addValidators(itemValidater); }
객체에 Bean Vaildation을 이용하여 위의 과정들을 간단하게 작성이 가능합니다.
우선 depandency를 추가합니다.
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(999)
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
마찬가지로 Contorller단에도 작성해줍니다.
이때 @Vaildation
도 있는데, import org.springframework.validation.annotation.Validated
를 사용하는 @Vaild
를 사용해야 group
이던, 구체적인 범위든 더 많은 기능을 이용가능합니다.
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
if(bindingResult.hasErrors()){
return "validation/v1/addForm";
}
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v1/items/{itemId}";
}
@Validated @ModelAttribute
맨앞에다가 선언해주세요
마찬가지로 messageSource
로 이용가능합니다.
#Bean Validation 추가
NotBlank={0} 공백안됩니다.
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
ObjectError의 경우 객체단위에서 어노테이션을 선언 할 수 없습니다.
방법이 있기는한데 지저분해져요...(@ScriptAssert()
) 그냥 이전 코드 처럼 작성해주시는게 편합니다.
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
// Object의 경우에는 @Valid를 사용 못하니, 그냥 이전 버전처럼 사용하는 것도 나쁘지 않음
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()){
return "validation/v1/addForm";
}
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v1/items/{itemId}";
}
좀더 깔끔하게 할려면 ItemValidater
로 구현해서 만들면 될거 같아요. 검증코드만 따로 빼서 관리하시면 됩니다.
만약 Item
객체를 다양한 Service단에서 사용을 하고 각각 다른 조건을 제시해야한다면 어떻게 할까요? 아래 두가지 방법을 확인해 봅시다.
@Vaild
에서 제공하는groups
속성입니다.
우선 구분할 수 있는 구분자를 정해야하는데 여기서는 추상객체를 이용합니다. 그냥 텅텅비어있는 인터페이스를 하나 만들어 주시면 됩니다.
//파일을 두개만들면 됩니다.
//하나는 물건 등록시, 하나는 물건 수정시에 사용할 Validation을 할겁니다. 대상은 Item입니다.
public interface UpdateCheck {
}
public interface SaveCheck {
}
그리고 Item
에 다음과 같이 수정합니다.
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;
}
}
Controller에도 @Vaild
의 어떤조건을 사용할 건지 선택해주시면 됩니다.
@PostMapping("/add")
public String addItem(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//생략...
}
//수정페이지를 위한 매서드를 구현합니다.
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {
if(bindingResult.hasErrors()){
return "validation/v1/editForm";
}
itemRepository.update(itemId, item);
return "redirect:/validation/v1/items/{itemId}";
}
네 이건 그냥 따로따로 만드시면 됩니다. Item
객체를 수정용, 등록용 따로 만드는 것이죠.
사실 이 방법을 더 선호할 것입니다. 직관적이잖아요.
@Data
public class UpdateItemForm {
@NotNull
private Long id;
@NotBlank()
private String itemName;
@NotNull()
@Range(min = 1000, max = 1000000)
private Integer price;
private Integer quantity;
}
@Data
public class SaveItemForm {
@NotBlank()
private String itemName;
@NotNull()
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull()
@Max(value = 999)
private Integer quantity;
}
네 그럼 API에서 사용하는 예제를 확인해 보겠습니다.
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {
@PostMapping("/add")
public Object addItem(@RequestBody @Validated SaveItemForm form,
BindingResult bindingResult) {
log.info("API 컨트롤러 호출");
if (bindingResult.hasErrors()) {
log.info("검증 오류 발생 errors={}", bindingResult);
return bindingResult.getAllErrors();
}
log.info("성공 로직 실행");
return form;
}
}
뭐, 별거 없습니다. bindingResult.getAllErrors()
를 이용하여 에러나면 반환해주면 됩니다. 어차피 @RestController
의 @ResponseBody
가 알아서 해줍니다.
결과 :
{
"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"
}
]
실제 웹 서비스에서는 전체 출력보다는 데이터를 담을 Body객체를 하나 만들어서 필요한것만 빼서 쓰시면 됩니다.
서버단에서 field를 검사하는 방법을 알아 봤습니다. 추가로 Exception이 발생했을때를 대비한 검증도 필요하실 겁니다. 여기서 확인 하시면 됩니다.
즐겁게 읽었습니다. 유용한 정보 감사합니다.