스프링부트 검증

이동건 (불꽃냥펀치)·2025년 1월 17일
0

검증 처리

클라이언트/서버 검증

  • 클라이언트 검증은 조작할 수 있어 보안에 취약하다
  • 서버만으로 검증하면 즉각적인 고객 사용성이 부족해진다
  • 둘을 섞어서 사용하되 최종적으로 서버 검증은 필수이다

V1 개발

@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() >
 1000000) {
errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다."); }
	if (item.getQuantity() == null || item.getQuantity() > 9999) { 
    	errors.put("quantity", "수량은 최대 9,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}";
}
  redirectAttributes.addAttribute("status", true);
  • redirect 될때 url에 /validation/v1/items/{itemId}?status=true식으로 전달된다

검증 로직 살피기

if (!StringUtils.hasText(item.getItemName())) { 
	errors.put("itemName", "상품 이름은 필수입니다.");
}
  • 상품의 이름이 비어있으면 오류를 추가함
if (item.getPrice() != null && item.getQuantity() != null) {
     int resultPrice = item.getPrice() * item.getQuantity();
     if (resultPrice < 10000) {
		errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 
        현재 값 = " + resultPrice);
	} 
}
  • 상품 가격의 총 합이 10,000원 미만이면 오류가 발생하도록 함
 if (!errors.isEmpty()) {
     model.addAttribute("errors", errors);
     return "validation/v1/addForm";
}
  • 오류가 한개라도 있으면 다시 상품을 입력하는 폼으로 이동시킴

Html 상의 오류 처리

<input type="text" id="quantity"  th:field="*{quantity}" 
th:class="${errors?.containsKey('quantity')} ? 'form-control field-error' : 'form-control'"
class="form-control" placeholder="수량을 입력하세요">
...
<div class="field-error" th:if="${errors?.containsKey('quantity')}" th:text=
"${errors['quantity']}"> 수량 오류 </div>
  • errors?.errorsnull 일때 NullPointerException 이 발생하는 대신, null 을 반환하는 문법 이다.th:if 에서 null 은 실패로 처리되므로 오류 메시지가 출력되지 않는다.
 <input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'field-
 error' : _" class="form-control">
  • 값이 없으면 _(No-Operation)을 사용해서 아무것도 하지 않는다



V2 개발

 @PostMapping("/add")
 public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult,
 RedirectAttributes redirectAttributes) {
 		if (!StringUtils.hasText(item.getItemName())) {
			bindingResult.addError(new FieldError("item", "itemName", 
            "상품 이름은 필수입니다.")); 
         }
     	if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() >1000000) {
		bindingResult.addError(new FieldError("item", "price", 
        "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
     }
     	if (item.getQuantity() == null || item.getQuantity() >= 10000) {
			bindingResult.addError(new FieldError("item", "quantity", 
            "수량은 최대 9,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()) {
         log.info("errors={}", bindingResult);
         return "validation/v2/addForm";
}
//성공 로직
	Item savedItem = itemRepository.save(item); 
	redirectAttributes.addAttribute("itemId", savedItem.getId()); 
	redirectAttributes.addAttribute("status", true);
	return "redirect:/validation/v2/items/{itemId}";
}

주의
BindingResult bindingResult의 파라미터 위치는 @ModelAttribute Item item 다음에 와야한다.

검증 로직

if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
}
public FieldError(String objectName, String field, String defaultMessage) {}
  • 필드에 오류가 있으면 FieldError객체를 생성해서 bindingResult에 담아두면 된다.
  • objectName: @ModelAttribite 이름
  • field: 오류가 발생한 필드 이름
  • defaultMessage: 오류 기본 메시지
bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원
이상이어야 합니다. 현재 값 = " + resultPrice));
 public ObjectError(String objectName, String defaultMessage) {}
  • 특정 필드를 넘어서는 오류가 있으면 ObjectEror객체를 생성해서 bindingResult에 담아두면된다.
  • objectName: @ModelAttribute의 이름
  • defaultMessage: 오류 기본 메시지
 <div th:if="${#fields.hasGlobalErrors()}">
         <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">
         글로벌 오류 메시지
         </p>
 </div>
 
 <div class="field-error" th:errors="*{price}">
	가격 오류
 </div>

타임리프 스프링 검증 오류 통합 가능

  • #fields: #fieldsBindingResult가 제공하는 검증 오류에 접근할 수 있다
  • th:errors: 해당 필드에 오류가 있는 경우에 태그를 출력한다
  • th:errorclass : th:field 에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.

BindingResult

  • 스프링이 제공하는 검증 오류를 보관하는 객체이다.
  • BindingResult가 있으면 @ModelAttribute에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다.
  • BindingResult가 없으면 400오류가 발생하면서 컨트롤러가 호출되지 않고 오류페이지로 이동한다
  • BindingResult가 있으면 오류정보를 BindingResult에 담아서 컨트롤러를 정상 호출한다

주의

  • BindingResult는 검증할 대상 바로 다음에 와야한다. @ModelAttribute Item item 바로 다음에 BindingResult가 와야한다.
  • BindingResultModel에 자동으로 포함된다.
  • 오류가 발생하는 경우 고객이 입력했던 정보들이 모두 지워진다.

FieldError/ObjectError

bindingResult.addError(new FieldError("item", "itemName",
item.getItemName(), false, null, null, "상품 이름은 필수입니다."));

bindingResult.addError(new FieldError("item", "price", item.getPrice(), 
false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다."));

bindingResult.addError(new ObjectError("item", null, null, 
"가격 * 수량 의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));

FieldError 생성자

 public FieldError(String objectName, String field, String defaultMessage);
 public FieldError(String objectName, String field, @Nullable Object
 rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable
 Object[] arguments, @Nullable String defaultMessage)
  • objectName: 오류가 발생한 객체 이름
  • rejectedValue: 오류 필드
  • bindingFailure: 사용자가 입력한 값(거절된 값)
  • codes: 메시지 코드
  • arguments: 메시지에서 사용하는 인자
  • defaultMessage: 기본 오류 메시지

오류 코드와 메시지 처리

FieldError 생성자

  • FieldError는 두가지 생성자를 제공한다.
 public FieldError(String objectName, String field, String defaultMessage);
 public FieldError(String objectName, String field, @Nullable Object
 rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable
 Object[] arguments, @Nullable String defaultMessage)
  • objectName: 오류가 발생한 객체 이름
  • rejectedValue: 오류 필드
  • bindingFailure: 사용자가 입력한 값(거절된 값)
  • codes: 메시지 코드
  • arguments: 메시지에서 사용하는 인자
  • defaultMessage: 기본 오류 메시지

스프링부트 메시지 설정 추가

src/main/resources/errors.properties
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
 bindingResult.addError(new FieldError("item", "itemName",
 item.getItemName(), false, new String[]{"required.item.itemName"}, null, null));
 
 bindingResult.addError(new FieldError("item", "price", item.getPrice(),
 false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null));
     
bindingResult.addError(new FieldError("item", "quantity",
 item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[]
 {9999}, null));
 
  bindingResult.addError(new ObjectError("item", new String[]
 {"totalPriceMin"}, new Object[]{10000, resultPrice}, null));
  • codes: required.item.itemName를 사용해서 메시지 코드를 지정
  • arguments: Object[]{1000,1000000}를 사용해서 {0},{1}로 치환할 값을 전달

reject/rejectValue

BindingResult 가 제공하는 rejectValue() , reject() 를 사용하면 FieldError , ObjectError 를 직
접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다.

bindingResult.rejectValue("itemName", "required");
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000},null);
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);



검증의 분리

Validator1

@Component
public class ItemValidator implements Validator {
    @Override
    public boolean supports(Class<?> clazz) {
        return Item.class.isAssignableFrom(clazz);
    }
    @Override
    public void validate(Object target, Errors errors) {
        Item item = (Item) target;
        ValidationUtils.rejectIfEmptyOrWhitespace(errors, "itemName","required");
        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() > 10000) {
            errors.rejectValue("quantity", "max", new Object[]{9999}, null);
		}
//특정 필드 예외가 아닌 전체 예외
		if (item.getPrice() != null && item.getQuantity() != null) {
        	
			int resultPrice = item.getPrice() * item.getQuantity();
			if (resultPrice < 10000) {
    			errors.reject("totalPriceMin", new Object[]{10000, resultPrice},null);
               }
          }
      }
}
 private final ItemValidator itemValidator;
  @PostMapping("/add")
 public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult,
 RedirectAttributes redirectAttributes) {
     itemValidator.validate(item, bindingResult);
     if (bindingResult.hasErrors()) {
         log.info("errors={}", bindingResult);
         return "validation/v2/addForm";
		}
//성공 로직
	Item savedItem = itemRepository.save(item); 
    redirectAttributes.addAttribute("itemId", savedItem.getId()); 
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v2/items/{itemId}";
}
  • ItemValidator 를 스프링 빈으로 주입 받아서 직접 호출했다.

WebDataBinder

 @InitBinder
 public void init(WebDataBinder dataBinder) {
     log.info("init binder {}", dataBinder);
     dataBinder.addValidators(itemValidator);
 }
 @PostMapping("/add")
 public String addItemV6(@Validated @ModelAttribute Item item, BindingResult
 bindingResult, RedirectAttributes redirectAttributes) {
....

이렇게 WebDataBinder 에 검증기를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있다. @InitBinder 해당 컨트롤러에만 영향을 준다.

@Validated 는 검증기를 실행하라는 애노테이션이다.
이 애노테이션이 붙으면 앞서 WebDataBinder 에 등록한 검증기를 찾아서 실행한다. 그런데 여러 검증기를 등록한다 면 그 중에 어떤 검증기가 실행되어야 할지 구분이 필요하다. 이때 supports() 가 사용된다. 여기서는
supports(Item.class) 호출되고, 결과가 true 이므로 ItemValidatorvalidate() 가 호출된다.

 @SpringBootApplication
 public class ItemServiceApplication implements WebMvcConfigurer {
     public static void main(String[] args) {
         SpringApplication.run(ItemServiceApplication.class, args);
}
     @Override
     public Validator getValidator() {
         return new ItemValidator();
     }
}

이렇게 글로벌 설정을 추가할 수 있다. 기존 컨트롤러의 @InitBinder 를 제거해도 글로벌 설정으로 정상 동작하는
것을 확인할 수 있다.


Bean Validation

 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;
 }

검증 에노테이션

  • @NotBlank: 빈값이나 공백만 있는 경우를 허용하지 않는다
  • @NotNull: null을 허용하지 않는다
  • @Range(min=1000,max=100000): 범위안의 값이어야 한다
  • @Max(9999): 최대 9999까지만 허용한다

스프링이 적용된 Bean Validation

 @Slf4j
 @Controller
 @RequestMapping("/validation/v3/items")
 @RequiredArgsConstructor
 public class ValidationItemControllerV3 {
  @PostMapping("/add")
    public String addItem(@Validated @ModelAttribute Item item, BindingResult
bindingResult, RedirectAttributes redirectAttributes) {
        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}";
}
  • 스프링부트는 spring-boot-starter-validation라이브러리를 넣으면 Bean Validator를 인지하고 스프링에 통합한다
  • 이때 적용된 Validator@NotNull과 같은 에노테이션을 보고 검증을 수행한다
  • @Valid,@Validated만 적용해 주면 FieldError, ObjectError를 생성해서 BindingResult에 담아준다

검증 순서

  • BeanValidation에서 바인딩에 실패한 필드는 BeanValidation을 적용하지 않는다
  • @ModelAttribute에서 타입변환에 실패할 경우 BeanValidation을 적용하지 않는다

Bean Validation 에러코드

Bean Validation 오류 코드 사용 에시

@NotBlank(message = "공백은 입력할 수 없습니다.")
private String itemName;

Bean Validation Object Error 처리

 @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}";
}

Bean Validation - groups

동일한 모델 객체를 등록할 때와 수정할 때 각각 다르게 검증하는 방법을 알아보자
저장용 groups

package hello.itemservice.domain.item;
 public interface SaveCheck {
}

수정용 groups

 package hello.itemservice.domain.item;
 public interface UpdateCheck {
}

groups 적용

@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;
	} 
}

저장 로직에 Groups 적용 예시

 @PostMapping("/add")
 public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item,
 BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//...
}

Form 전송객체 분리

실무에서는 회원 등록시 회원과 관련된 데이터만 전달받는 것이 아니라, 약관 정보도 추가로 받는 등 Item과 관계없는 수많은 부가 데이터가 넘어온다. 그래서 보통 Item을 직접 전달 받는 것이 아닌 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다. 예를 들면 ItemSaveForm이라는 폼을 전달받는 전용객체를 만들어서 @ModelAttribute로 사용한다. 이로 인해 굳이 groups를 사용하지 않아도 된다.

 @Data
 public class Item {
     private Long id;
     private String itemName;
     private Integer price;
     private Integer quantity;
}

ItemSaveForm


 @Data
 public class ItemSaveForm {
     @NotBlank
     private String itemName;
     @NotNull
     @Range(min = 1000, max = 1000000)
     private Integer price;
     @NotNull
     @Max(value = 9999)
     private Integer quantity;
}

ItemUpdateForm

 @Data
 public class ItemUpdateForm {
     @NotNull
     private Long id;
     @NotBlank
     private String itemName;
     @NotNull
     @Range(min = 1000, max = 1000000)
     private Integer price;
//수정에서는 수량은 자유롭게 변경할 수 있다.
	 private Integer quantity;
  }

폼 객체 바인딩

 @PostMapping("/add")
 public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form,
 BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//...
}

출처: https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2

profile
자바를 사랑합니다

0개의 댓글

관련 채용 정보