주제 : 스프링 MVC의 기본 기능을 공부하면서 부족했던 기능 학습한다.
내용 : 스프링 MVC2를 통해서 검증과 예외 처리에 대해 학습한다.
요구사항 : 검증 로직 추가
- 타입 검증
- 가격, 수량에 문자가 들어가면 검증 오류 처리
- 필드 검증
- 상품명 : 필수, 검백X
- 가격 : 1000원 이상, 1백만원 이하
- 수량 : 최대 9999
- 특정 필드의 범위를 넘어서는 검증
- 가격*수량의 합은 10000원 이상
지금 웹 애플리케이션은 폼 입력시 숫자를 문자로 작성하거나해서 검증 오류가 발생하면 오류화면으로 바로 이동한다. 이렇게 되면 사용자는 처음부터 해당 폼으로 다시 이동해서 입력을 해야 한다. 웹 서비스는 폼 입력시 오류가 발생하면, 고객이 입력한 데이터를 유지한 상태로 어떤 오류가 발생했는지 친절하게 알려주어야 한다. 이러한 검증 로직을 작성해보자.
고객이 상품 등록 폼에서 상품명을 입력하지 않거나, 가격, 수량 등이 너무 작거나 커서 검증 범위를 넘어서면, 서버 검증 로직이 실패해야 한다. 이렇게 검증이 실패한 경우 고객에게 다시 상품 등록 폼을 보여주고, 어떤 값을 잘못 입력했는지 알려주어야 한다.
상품 등록 검증
먼저 상품 등록 검증 코드를 작성해보자.ValidationItemControllerV1 - addItem() 수정
@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}"; } }
- 성공 로직 제외 엄청 많은 검증 로직을 추가해주어야 한다.
- 검증 오류 보관
Map<String, String> errors = new HashMap<>();- 만약 검증시 오류가 발생하면 어떤 검증에서 오류가 발생했는지 정보를 담아둔다.
- 검증 로직
import org.springframework.util.StringUtils;추가 필요- 검증시 오류가 발생하면 errors 에 담아둔다. 이때 어떤 필드에서 오류가 발생했는지 구분하기 위해 오류가 발생한 필드명을 key 로 사용한다. 이후 뷰에서 이 데이터를 사용해서 고객에게 친절한 오류 메시지를 출력할 수 있다.
- 특정 필드의 범위를 넘어서는 검증 로직
- 특정 필드를 넘어서는 오류를 처리해야 할 수도 있다. 이때는 필드 이름을 넣을 수 없으므로
globalError라는key를 사용한다.- 검증에 실패하면 다시 입력 폼으로
- 만약 검증에서 오류 메시지가 하나라도 있으면 오류 메시지를 출력하기 위해
model에error를 담고, 입력 폼이 있는 뷰 템플릿으로 보낸다.
정리
- 만약 검증 오류가 발생하면 입력 폼을 다시 보여준다.
- 검증 오류들을 고객에게 친절하게 안내해서 다시 입력할 수 있게 한다.
- 검증 오류가 발생해도 고객이 입력한 데이터가 유지된다.
남은 문제점
- 뷰 템플렛에서 중복 처리가 많다.
- 타입 오류 처리가 안된다.
- 고객이 입력한 값도 어딘가에 별도로 관리가 되어야 한다.
지금부터 스프링이 제공하는 검증 방법을 하나씩 알아보자.
스프링이 제공하는 검증 오류 처리 방법 BindingResult
@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다음에 와야 한다.
- 필드 오류 - FiedError
if (!StringUtils.hasText(item.getItemName())) { bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다.")); }
- FieldError 생성자 요약
public FieldError(String objectName, String field, String defaultMessage) {}필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult 에 담아두면 된다.
- objectName : @ModelAttribute 이름
- field : 오류가 발생한 필드 이름
- defaultMessage : 오류 기본 메시지
글로벌 오류 - ObjectError
bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));ObjectError 생성자 요약
public ObjectError(String objectName, String defaultMessage) {}특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult 에 담아두면 된다.
- objectName : @ModelAttribute 의 이름
- defaultMessage : 오류 기본 메시지
정리
BindingResult , FieldError , ObjectError 를 사용해서 오류 메시지를 처리하는 방법을 알아보았다. 그런데 오류가 발생하는 경우 고객이 입력한 내용이 모두 사라진다. 이 문제를 해결해보자.
목표
- 사용자 입력 오류 메시지가 화면에 남도록 하자.
- 예) 가격을 1000원 미만으로 설정시 입력한 값이 남아있어야 한다.
FieldError 생성자
- addItemV1에 있던
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: 오류가 발생한 객체 이름field: 오류 필드rejectedValue: 사용자가 입력한 값(거절된 값)bindingFailure: 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값codes: 메시지 코드arguments: 메시지에서 사용하는 인자defaultMessage: 기본 오류 메시지
ObjectError도 유사하게 두 가지 생성자를 제공한다.
- 오류 발생시 사용자 입력 값 유지
new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다.")사용자의 입력 데이터가 컨트롤러의 @ModelAttribute 에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력 값을 유지하기 어렵다. 예를 들어서 가격에 숫자가 아닌 문자가 입력된다면 가격은
Integer타입이므로 문자를 보관할 수 있는 방법이 없다.그래서 오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요하다.
그리고 이렇게 보관한 사용자 입력 값을 검증 오류 발생시 화면에 다시 출력하면 된다.FieldError 는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공한다.
여기서 rejectedValue 가 바로 오류 발생시 사용자 입력 값을 저장하는 필드다.
bindingFailure 는 타입 오류 같은 바인딩이 실패했는지 여부를 적어주면 된다.
여기서는 바인딩이 실패한 것은 아니기 때문에 false를 사용한다.
오류 메시지를 체계적으로 다루어보자.
errors 메시지 파일 생성
messages.properties를 사용해도 되지만, 오류 메시지를 구분하기 쉽게errors.properties라는 별도의 파일로 관리해보자.1. 스프링 부트 메시지 설정 추가 -
application.propertiesspring.messages.basename=messages,errors2. errors.properties 추가 -
src/main/resources/errors.propertiesrequired.item.itemName=상품 이름은 필수입니다. range.item.price=가격은 {0} ~ {1} 까지 허용합니다. max.item.quantity=수량은 최대 {0} 까지 허용합니다. totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}ValidationItemControllerV2 - addItemV3() 추가
@PostMapping("/add") public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) { 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.getQuantity() == null || item.getQuantity() > 10000) { bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[] {9999}, 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)); } } 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}"; }코드 변경
//range.item.price=가격은 {0} ~ {1} 까지 허용합니다. new FieldError("item", "price", item.getPrice(), false, new String[] {"range.item.price"}, new Object[]{1000, 1000000}
- codes : required.item.itemName 를 사용해서 메시지 코드를 지정한다. 메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.
- arguments : Object[]{1000, 1000000} 를 사용해서 코드의 {0} , {1} 로 치환할 값을 전달한다.
목표
FieldError,ObjectError는 다루기 너무 번거롭다.- 오류 코드도 좀 더 자동화 할 수 있지 않을까?
BindingResult 가 제공하는rejectValue(),reject()를 사용하면 FieldError , ObjectError 를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다.
rejectValue(),reject()를 사용해서 기존 코드를 단순화해보자.ValidationItemControllerV2 - addItemV4() 추가
@PostMapping("/add") public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) { log.info("objectName={}", bindingResult.getObjectName()); log.info("target={}", bindingResult.getTarget()); 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.getQuantity() == null || item.getQuantity() > 10000) { bindingResult.rejectValue("quantity", "max", new Object[]{9999}, 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()) { 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.rejectValue를 사용함으로써 코드가 굉장히 많이 줄어들었다.
오류 코드를 만들 때 자세히 만들 수도 있고 단순하게 만들 수도 있다.
스프링은MessageCodesResolver라는 기능을 지원함으로써 매우 편리하게 오류 메시지를 관리할 수 있다.
우선 테스트 코드로 MessageCodesResolver를 알아보자.
public class MessageCodesResolverTest { MessageCodesResolver codesResolver = new DefaultMessageCodesResolver(); @Test void messageCodesResolverObject() { String[] messageCodes = codesResolver.resolveMessageCodes("required", "item"); assertThat(messageCodes).containsExactly("required.item", "required"); } @Test void messageCodesResolverField() { String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class); assertThat(messageCodes).containsExactly( "required.item.itemName", "required.itemName", "required.java.lang.String", "required" ); } }MessageCodesResolver
- 검증 오류 코드로 메시지 코드들을 생성한다.
MessageCodesResolver인터페이스이고DefaultMessageCodesResolver는 기본 구현체이다.- 주로 다음과 함께 사용
ObjectError,FieldError동작 방식
rejectValue(),reject()는 내부에서MessageCodesResolver를 사용한다. 여기에서 메시지 코드들을 생성한다.FieldError,ObjectError의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다.
MessageCodesResolver를 통해서 생성된 순서대로 오류 코드를 보관한다.- 이 부분을
BindingResult의 로그를 통해서 확인해보자.
codes [range.item.price, range.price, range.java.lang.Integer, range]
FieldError
rejectValue("itemName", "required")
다음 4가지 오류 코드를 자동으로 생성
- required.item.itemName
- required.itemName
- required.java.lang.String
- required
ObjectError
reject("totalPriceMin")
다음 2가지 오류 코드를 자동으로 생성
- totalPriceMin.item
- totalPriceMin
오류 코드 관리 전략
핵심은 구체적인 것에서! 덜 구체적인 것으로!
MessageCodesResolver는required.item.itemName처럼 구체적인 것을 먼저 만들어주고,required처럼 덜 구체적인 것을 가장 나중에 만든다.
이렇게 하면 앞서 말한 것 처럼 메시지와 관련된 공통 전략을 편리하게 도입할 수 있다.ValidationUtils
ValidationUtils사용 전if (!StringUtils.hasText(item.getItemName())) { bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은 필수입니다."); }
ValidationUtils사용 후
다음과 같이 한줄로 가능, 제공하는 기능은 Empty , 공백 같은 단순한 기능만 제공ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName","required");정리
1.rejectValue()호출
2.MessageCodesResolver를 사용해서 검증 오류 코드로 메시지 코드들을 생성
3.new FieldError()를 생성하면서 메시지 코드들을 보관
4.th:erros에서 메시지 코드들로 메시지를 순서대로 메시지에서 찾고, 노출
스프링이 직접 만든 오류 메시지 처리
검증 오류 코드는 다음과 같이 2가지로 나눌 수 있다.
- 개발자가 직접 설정한 오류 코드 ->
rejectValue()를 직접 호출- 스프링이 직접 검증 오류에 추가한 경우(주로 타입 정보가 맞지 않음)
스프링은 타입 오류가 발생하면
typeMismatch라는 오류 코드를 사용한다. 이 오류 코드가MessageCodesResolver를 통하면서 4가지 메시지 코드가 생성된 것이다.
errors.properties에 메시지 코드가 없으면 스프링이 생성한 기본 메시지가 출력된다.Failed to convert property value of type java.lang.String to required type java.lang.Integer for property price; nested exception is java.lang.NumberFormatException: For input string: "A"
error.properties에 다음 내용을 추가하자typeMismatch.java.lang.Integer=숫자를 입력해주세요. typeMismatch=타입 오류입니다.이렇게 하면 소스코드를 하나도 건들지 않고, 원하는 메시지를 단계별로 설정할 수 있다.
- 복잡한 검증 로직을 별도로 분리하자
컨트롤러에서 검증 로직이 차지하는 부분은 매우 크다. 이런 경우 별도의 클래스로 역할을 분리하는 것이 좋다. 그리고 이렇게 분리한 검증 로직을 재사용 할 수도 있다.
ItemValidator를 만들자.
- 검증 로직을 처리하는 Validator라는 클래스를 만들고 거기에 다 처리해주자.
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); } } } }스프링은 검증을 체계적으로 제공하기 위해 다음 인터페이스를 제공한다
public interface Validator { boolean supports(Class<?> clazz); void validate(Object target, Errors errors); }
- 애플리케이션을 실행해보면 기존과 완전히 동일하게 동작한다. 검증과 관련된 부분이 분리되었다.
스프링이 Validator 인터페이스를 별도로 제공하는 이유는 체계적으로 검증 기능을 도입하기 위해서다. 그런데 앞에서는 검증기를 직접 불러서 사용했고, 이렇게 사용해도 된다. 그런데 Validator 인터페이스를 사용해서 검증기를 만들면 스프링의 추가적인 도움을 받을 수 있다.
WebDataBinder를 통해서 사용하기
WebDataBinder 는 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함한다.@InitBinder public void init(WebDataBinder dataBinder) { log.info("init binder {}", dataBinder); dataBinder.addValidators(itemValidator); }
- 이렇게 WebDataBinder 에 검증기를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있다.(
@Validated를 사용할 수 있다!)- build.gradle에
implementation 'org.springframework.boot:spring-boot-starter-validation'추가해서 사용해도 된다.(보편적)
@Validated 적용
ValidationItemControllerV2 - addItemV6()@PostMapping("/add") public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) { 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}"; }
- validator를 직접 호출하는 부분이 사라지고, 대신에 검증 대상 앞에
@Validated가 붙었다.