[Spring] 검증1 - Validation
사용자가 어떤 입력 폼에 적절하지 않은 값을 넣고 다음 버튼을 눌렀을 때 오류를 확인할 수 있도록 기능을 만들려고
한다. 즉, 검증 로직을 어떻게 만들어야 하는지에 대한 내용을 다룬다.
검증 로직
HashMap
을 이용해서 에러를 담고 처리하도록 짠다면 다음과 같이 변수를 선언해주고 검증 과정을 거친 뒤
에러가 발생했다면 기존 웹사이트 정보를 다시 불러오고, 발생하지 않았다면 다음 사이트로 넘어간다.
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {
//에러 담을 변수 선언
Map<String, String> errors = new HashMap<>();
/*
검증 로직
예) 가격/수량 범위 체크
*/
//검증 실패
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}";
}
하지만 이 방식은 스프링이 제공하는 검증 오류 처리 방법이 아니다. BindingResult를 사용해보자!
BindingResult 파라미터 위치는 반드시 @ModelAttribute Item item
다음에 와야한다.
검증 로직에서 FieldError로 에러를 생성하고 bindingResult에 넣어준다.
복합 조건 검증 로직에서는 FieldError
가 아니라 ObjectError로 만들고 넣어준다.
@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.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
}
}
if (!errors.isEmpty()) {
model.addAttribute("errors", errors);
return "validation/v1/addForm";
}
//검증 실패
if (bindingResult.hasErrors()) {
return "validation/v2/addForm";
}
//성공 로직 (위 코드와 같음)
}
FieldError
, ObjectError
생성자는 다음과 같다.
public FieldError(String objectName, String field, String defaultMessage) {}
public ObjectError(String objectName, String defaultMessage) {}
BindingResult
가 없었을 땐 타입 오류 발생 시 컨트롤러도 호출을 못하고 오류 페이지로 이동하는데,
BindingResult
사용 후에는 타입 오류가 발생해도 컨트롤러를 호출한다는 점이 중요하다!
@ModelAttribute
객체에 타입 오류로 바인딩 실패 시 스프링이 FieldError
를 생성해서 BindingResult
로
넣어주거나, 직접 넣어주는 방법이 있다. 뒤에서 설명할 Validator로도 가능하다!
참고로 BindingResult
는 Errors
인터페이스를 상속받아서 BindingResult
대신 Errors
를 사용해도 된다.
하지만 위 코드에선 오류 발생 시 고객이 입력했던 내용이 다 사라진다는 문제점이 존재한다.
그래서 기존값 유지를 위해 다른 FieldError
생성자를 이용하면 된다. 다음은 정의와 예시이다.
타입 오류로 바인딩 실패 시 스프링이 FieldError
생성하면서 기존값을 넣고 오류를 BindingResult
에 담아
컨트롤러를 호출한다. 즉, 바인딩 실패 시에도 오류 메시지를 출력할 수 있게된다.
/*
정의
- rejectedValue : 오류 발생 시 기존값 저장 필드
- bindingFailure : 타입 오류처럼 바인딩이 실패했는지 여부
- codes : 오류 메시지
- arguments : codes에 들어갈 인자 정보
- defaultMessage : 기본 오류 메시지(codes가 null이면 출력)
*/
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);
//예시
new FieldError("item", "price", item.getPrice(), false, null, null,
"가격은 1,000 ~ 1,000,000 까지 허용합니다.");
오류 코드와 메시지 처리
오류 메시지를 따로 관리하기위해 errors.properties라는 파일을 만들고 스프링 부트가 인식할 수 있게
다음 설정을 추가해주자. 그 아래는 errors.properties
내용이다.
//application.properties
spring.messages.basename=messages,errors
//errors.properties
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
위에서 등록한 내용을 사용하도록 코드를 수정한다면 다음과 같다.
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName",
item.getItemName(), false, new String[]{"required.item.itemName"}, null, null));
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.addError(new FieldError("item", "price", item.getPrice(),
false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, 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));
}
}
하지만 여기서 FieldError
, ObjectError
를 모두 다루면 코드가 복잡해지므로 BindingResult
의 함수
rejectValue(), reject()로 코드를 간단히 만들 수 있다. "item"
객체에 대해 처리한다는 정보를
넣지 않아도 되는 이유가 BindingResult
는 그 대상(target)을 이미 알고 있어서이다!
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required");
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, 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);
}
}
오류 메시지를 찾을 때 required.item.itemName
, required
가 있다면 전자가 더 자세하고 후자가 더 간단하다.
전자처럼 자세할 때 우선순위가 더욱 높아져서 세밀한 오류 메시지를 적을 때 쓰고, 후자는 더 범용적인 내용으로
구성한다. 그니까 구체적인 것에서 덜 구체적인 것으로 이동한다!.
스프링은 MessageCodesResolver라는 것으로 이 기능을 지원한다.
그리고 타입 오류같은건 스프링에서 기본으로 지원해서 오류 메시지를 지원하는데 직접 설정해줄 수도 있다.
typeMismatch.item.price
, typeMismatch.price
, typeMismatch.java.lang.Integer
, typeMismatch
에
대한 오류 메시지를 errors.properties에 적어주면 된다.
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
Validator
기존 코드에 검증 로직까지 포함하니 내용이 너무 많아지므로 분리해서 사용해보자.
기존 코드에서 ItemValidator
를 호출해서 사용할거니까 @Component
을 붙여서 스프링 컨테이너에서 관리될 수
있게 만들어주자.
@Component
public class ItemValidator implements Validator {
//해당 검증기 지원 여부 확인
@Override
public boolean supports(Class<?> clazz) {
//item == clazz OR item == subItem
return Item.class.isAssignableFrom(clazz);
}
//검증 로직에서 호출할 함수
@Override
public void validate(Object target, Errors errors) {
Item item = (Item) target;
// Errors는 BindingResult의 부모여서 캐스팅 가능
//검증 로직
if(!StringUtils.hasText(item.getItemName())) {
errors.rejectValue("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() > 9999) {
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);
}
}
}
}
기존 코드에서는 검증 로직을 ItemValidator.validate()
를 쓰면서 다음과 같이 수정할 수 있다.
private final ItemValidator itemValidator;
@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//검증 로직 <- 간단해짐!
itemValidator.validate(item, bindingResult);
//검증 실패, 성공 로직 생략
}
위에서 ItemValidator
정의를 보면 Validator
를 구현하고 있다.
이렇게 만든 검증기는 스프링의 추가적인 도움을 받아 코드를 더 간단히 만들 수 있다!
단, 이 코드는 해당 컨트롤러에서만 유효하다.
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {
private final ItemRepository itemRepository;
private final ItemValidator itemValidator;
//추가!
//이 컨트롤러에 속한 모든 함수에 대해 검증기 자동 적용
@InitBinder
public void init(WebDataBinder dataBinder) {
dataBinder.addValidators(itemValidator);
}
...
//@Validated 꼭 추가하기!
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//검증 로직 별도 작성 필요 없음!
//검증 실패, 성공 로직 생략
}
}
참고
검증 시 @Validated
, @Valid
상관없이 모두 사용 가능하다!
(@Validated
는 스프링 전용, @Valid
는 자바 표준 검증 어노테이션이다)
다만 @Valid
사용 시 build.gradle에 다음 내용을 추가해줘야 한다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
댓글남기기