1. 검증 직접 처리
사용자가 잘못된 값을 입력했는지, 입력하지 않은 값은 없는지를 체크하기 위해서는 검증을 수행해야한다. 검증을 위해 전달된 값들이 우리가 원하는 의도된 값인지 아래와 같이 각각 직접 확인해줄 수 있다.
@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) {
errors.put("price", "가격은 최소 1000원 이상이어야 합니다.");
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
errors.put("quantity", "수량은 최대 9999개까지 허용합니다.");
}
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
errors.put("globalError", "가격*수량은 10000이상이어야 합니다.");
}
}
// 검증 실패
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}";
}
이후 에러 발생시 화면에 어떤 내용을 띄워주고 싶다면 아래와 같이 타임리프 코드를 추가할 수 있다.
<!--글로벌 오류-->
<div th:if="${errors?.containsKey('globalError')}">
<p class = "field-error" th:text="${errors['globalError']}">전체 메시지 오류</p>
</div>
<!--필드 오류-->
<div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">
상품명 오류
</div>
- errors는 에러가 없는 경우 null이다. 만약 errors.containsKey(...)을 사용하면 errors가 null인 경우 NullPointerException문제가 발생한다. 이를 위해 ?를 붙여주면, errors가 null인 경우 Exception을 발생시키지 않고 null을 반환한다.
하지만 위 방식은 각 값들마다 위와 같은 작업을 해줘야 하므로, 타임리프 코드든 서버 코드든 중복이 많고 너무 힘들다. 또한 아직 비용이나 수량이 들어올 때 숫자가 아닌 다른 타입이 들어오는 경우의 에러 처리는 하지 못한다.
2. BindingResult
2.1. BindingResult 사용법
스프링은 검증 오류 처리를 위해 BindingResult라는 인터페이스를 제공한다. 기존에는 errors라는 Map 객체에 field 이름을 키로, message를 value로 사용했다. 그리고 만약 Map 객체에 원소가 하나라도 있으면 에러가 발생한 것이므로 errors를 Model에 저장하고 기존의 addForm.html을 다시 전달했다. 이제는 Map객체를 사용하지 않고, BindingResult를 아래와 같이 사용할 수 있다.
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
if (!StringUtils.hasText(item.getItemName())) {
// FieldError(Object Name, Field Name, Message)
bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
}
if (item.getPrice() == null || item.getPrice() < 1000) {
bindingResult.addError(new FieldError("item", "price", "가격은 최소 1000원 이상이어야 합니다."));
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9999개까지 허용합니다."));
}
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item", "가격*수량은 10000이상이어야 합니다."));
}
}
// 검증 실패
if (bindingResult.hasErrors()) {
return "validation/v2/addForm";
}
...
}
- 반드시 @ModelAttribute Item item 다음에 @BindingResult bindingResult가 와야한다. 왜냐하면, BidingResult가 Item 객체에 바인딩 결과를 담고 있기 때문이다.
- 필드 오류는 FieldError 인스턴스를 생성해 Error로 추가하면 된다. objectName은 @ModelAttribute의 이름, field name은 오류가 발생한 필드 이름, message는 오류 발생 메시지를 의미한다.
- 글로벌 오류는 ObjectError 인스턴스를 생성해 Error로 추가하면 된다. objectName과 message만 넣어주면 된다.
그럼 이전에 추가한 타임리프 코드들은 아래와 같이 간편해진다.
<!--글로벌 오류-->
<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="*{itemName}">
상품명 오류
</div>
- #fields로 BindingResult가 제공하는 검증 오류에 접근할 수 있다.
- th:errors는 해당 필드에 오류가 있는 경우 태그를 출력한다. 이전에 작성한 th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}"를 하나로 축약해준 것이라 생각하면 된다.
2.2. BindingResult 설명
BindingResult는 스프링이 제공하는 검증 오류를 보관하는 객체로 다음과 같은 기능들을 제공한다.
- 이전 코드에서는 비용이나 수량에 숫자가 아닌 문자를 넣으면 (바인딩 시 문제가 생기면) 그냥 에러 페이지로 이동했다. 하지만 BindingResult를 사용하면 이러한 문제가 해결된다. BindingResult는 @ModelAttribute에 바인딩 시 오류가 발생하면 해당 오류를 FieldError로 생성해서 BindingResult에 넣는다.
- 기존에 @ModelAttribute에 바인딩 오류가 생기면 컨트롤러 호출 자체가 안된다. 하지만 BindingResult가 있으면 FieldError을 BindingResult에 넣고 컨트롤러가 호출된다. 이를 위해 BindingResult는 검증할 대상 바로 다음에 와야한다는 점을 주의하자.
즉, BindingResult는 두 가지 오류를 담는다.
- @ModelAttribute의 바인딩 에러
- 비지니스 로직에 작성한 에러
추가적으로, BindingResult는 Errors 인터페이스를 상속받는데, Errors는 BindingResult에 비해 단순한 오류 저장과 조회 기능만을 제공한다.
2.3. FieldError, ObjectError
BindingResult를 사용하고 나면, 가격과 수량을 입력하고 에러가 발생할 때 기존의 값이 유지된 상태로 addForm.html이 전달되지 않는다. 즉, 정보를 유지하지 못한다. 이때 FieldError의 다른 생성자를 사용하여 코드를 작성하면 문제가 해결된다.
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
if (!StringUtils.hasText(item.getItemName())) {
// FieldError(Object Name, Field Name, Rejected Value, Codes, Arguments, Message)
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null, "상품 이름은 필수입니다."));
}
if (item.getPrice() == null || item.getPrice() < 1000) {
bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 최소 1000원 이상이어야 합니다."));
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, null, null, "수량은 최대 9999개까지 허용합니다."));
}
if (item.getPrice() != null && item.getQuantity() != null) {
// ObjectError(Object Name, Codes, Arguments, Message)
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item", null, null, "가격*수량은 10000이상이어야 합니다."));
}
}
...
}
- rejectedValue는 거절된 값으로 사용자가 입력한 값을 의미한다. codes는 메시지 코드, arguments는 메시지에 사용하는 인자를 의미한다.
3. 오류 코드와 메시지 처리
그런데 이전 오류 메시지들도 message.properties처럼 따로 관리할 수 있으면 좋을 것이다. 따라서 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}까지 허용합니다.
mulPriceQuantityMin = 가격 * 수량은 {0}이상이어야 합니다. 현재 값은 {1}입니다.
그럼 이전 코드에서 defaultMessage는 null로 변경하고, codes와 arguments 인자를 전달하여 아래와 같이 코드를 변경할 수 있다.
@GetMapping("/add")
public String addItem(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
if (!StringUtils.hasText(item.getItemName())) {
// FieldError(Object Name, Field Name, Rejected Value, Codes, Arguments, Message)
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, new String[]{"required.item.itemName"}, null, null));
}
if (item.getPrice() == null || item.getPrice() < 1000) {
bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1000, 10000}, null));
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
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) {
// ObjectError(Object Name, Codes, Arguments, Message)
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item", new String[]{"mulPriceQuantityMin"}, new Object[]{10000, resultPrice}, null));
}
}
...
}
만약 명시된 코드에 해당하는 메시지(range.item.price 등)를 찾을 수 없으면 defaultMessage가 출력된다. 만약 둘 다 없다면 에러가 발생한다.
3.1. FieldError, ObjectError의 번거로움 (rejectValue, reject)
FieldError와 ObjectError를 사용하는 방법은 번거롭다. 이전에 말한 것처럼 BindingResult는 검증해야할 객체 바로 옆(@ModelAttribute)에 명시해야 한다. 이 말은 BindingResult가 현재 본인이 검증해야할 객체를 알고있다는 말이 된다. 이때 BindingResult는 rejectValue()와 reject() 메서드를 제공하는데 이를 활용하면 좀 더 쉽게 검증 오류를 다룰 수 있다.
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required");
}
if (item.getPrice() == null || item.getPrice() < 1000) {
// rejectValue(field, errorCode, errorArgs, defaultMessage)
bindingResult.rejectValue("price", "range", new Object[]{1000, 10000}, null);
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
}
if (item.getPrice() != null && item.getQuantity() != null) {
// ObjectError(Object Name, Codes, Arguments, Message)
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("mulPriceQuantityMin", new Object[]{10000, resultPrice}, null);
}
}
...
}
그런데 위 코드를 보면 기존에 에러 메시지인 required.item.itemName을 required만 가져와서 Code를 넘겨줬다. 이는 messageResolver를 위한 오류 코드이기 때문이다.
3.2. messageResolver
오류 코드를 만들 때는 이전과 같이 required.item.itemName과 같이 디테일하게 만들 수도 있고, required와 같이 단순하게 만들 수도 있다. 단순한 경우는 아무 곳이나 사용할 수 있기 때문에 범용성이 좋지만 메시지를 세밀하게 작성할 수는 없다. 반면 디테일하게 만드는 경우는 세밀하지만 범용성이 떨어진다. 따라서 가장 좋은 방법은 범용적으로 사용하다가 세밀해야하는 경우는 세밀하게 적용되도록 메시지에 단계를 두는 방법이다. 다음 예시를 살펴보자.
// Level1
required.item.itemName: 상품 이름은 필수입니다.
// Level2
required: 필수입니다.
- required.item.itemName과 같은 디테일한 메시지가 있으면 이를 1순위로 가져오고, 이런 디테일한 메시지가 없다면 required와 같은 범용적인 메시지를 가져온다.
이때 이렇게 메시지에 단계를 둘 수 있도록 해주는 것인 스프링의 MessageCodesResolver이다. 그래서 이전 코드에서 "required"라고만 명시해도 올바른 메시지를 가져올 수 있었던 것이다. 이때 메시지 생성의 기본 규칙은 다음과 같다.
- 객체 오류: code.(object_name) [1순위], code [2순위] / Ex) required.item, required
- 필드 오류: code.(object_name).(field) [1순위], code.(field) [2순위], code.(field type) [3순위], code [4순위]
/ Ex) required.item.itemName, required.itemName, required.java.lang.String, required
rejectValue()와 reject() 메서드는 내부에서 FieldError, ObjecError의 생성자에 MesageCodesResolver에서 생성된 오류 코드들을 전달한다. 이때 rejectValue는 필드 오류, reject는 객체 오류로 처리한다.
3.3. ValidationUtils
스프링은 기본적인 validation을 위해 몇 가지 유틸들을 제공한다. 예를 들어 아래와 같이 공백을 검증할 수 있다.
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
하지만 단순한 기능들만 제공하고 복잡한 것들은 우리가 직접 만들어야 한다.
3.4. 스프링 오류 처리
지금까지 작성한 코드를 실행하여 price에 숫자가 아닌 문자를 넣으면 다음과 같은 화면이 뜬다.
우리가 처리한 에러 외에도 스프링에서 처리하는 에러가 표시된다. 이때 타입 에러의 경우 BindingResult에 FieldError가 담겨 있고 다음과 같은 메시지 코드가 생성된다.
- typeMismatch.item.price, typeMismatch.price, typeMismatch.java.lang.Integer, typeMismatch
해당 메시지 코드들 중 하나가 우리의 errors.properties에 없기 때문에 default 메시지가 출력된다. 따라서 이 대신 다른 메시지가 출력되게 하고 싶다면 errors.properties에 해당 코드들 중 하나를 추가해주면 된다.
4. Validation 분리
이전 코드들은 컨트롤러에 검증 코드들이 있어서 컨트롤러에 너무 많은 기능이 포함되어 있다. 따라서 컨트롤러의 역할과 검증의 역할을 분리할 것이다. 이를 위해 입력 받은 Item 객체의 검증을 위한 ItemValidator 클래스를 새로 만들었다.
@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;
if (!StringUtils.hasText(item.getItemName())) {
errors.rejectValue("itemName", "required");
}
if (item.getPrice() == null || item.getPrice() < 1000) {
errors.rejectValue("price", "range", new Object[]{1000, 10000}, null);
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
errors.rejectValue("quantity", "max", new Object[]{9999}, null);
}
if (item.getPrice() != null && item.getQuantity() != null) {
// ObjectError(Object Name, Codes, Arguments, Message)
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
errors.reject("mulPriceQuantityMin", new Object[]{10000, resultPrice}, null);
}
}
}
}
- ItemValidator는 spring이 제공하는 Validator 인터페이스의 구현체다.
- Class.isAssignableFrom()은 Class가 어떤 클래스/인터페이스를 상속/구현했는지 체크한다. 특정 Object를 대상으로 체크하는 instanceof와 차이가 있다.
그리고 컨트롤러는 아래와 같이 위에서 구현한 메서드를 호출해주면 된다.
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
if (itemValidator.supports(item.getClass())) {
itemValidator.validate(item, bindingResult);
}
// 검증 실패
if (bindingResult.hasErrors()) {
return "validation/v2/addForm";
}
// 검증 성공
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
4.1. WebDataBinder
WebDataBinder는 (Item 객체를 바인딩 해주는 것처럼) 파라미터 바인딩 역할을 해주고 검증기를 가지고 검증도 해주는 Spring MVC가 내부적으로 사용하는 기능이다. 이를 밖으로 꺼내서 검증기를 넣어주면 위에서 작성한 코드처럼 직접 검증기를 호출하지 않아도 된다. 이를 위해 컨트롤러에 WebDataBinder를 가져와야 한다.
@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);
}
...
}
- 컨트롤러가 호출될 때(요청이 될 때)마다 WebDataBinder가 새로 만들어지고 init이 불러진다.
- init이 호출되면 컨트롤러의 어떤 메서드가 실행되던 해당 검증기가 적용된다.
그럼 기존 로직은 다음과 같이 직접 itemValidator의 메서드를 실행하지 않아도 된다.
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
// 검증 실패
if (bindingResult.hasErrors()) {
return "validation/v2/addForm";
}
// 검증 성공
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
- @Validated: 검증기를 실행하라는 어노테이션 (스프링 전용 어노테이션)
- @Valid는 javax.validation.@Valid로 자바에서 제공하는 표준 어노테이션이다. 이때 @Validated와 @Valid 둘 다 사용 가능한데 @Valid를 사용하려면 build.gradle에 의존관계를 추가해야 한다.
(추가: implementation 'org.springframework.boot:spring-boot-starter-validation') - 만약 검증기가 여러 개라면 어떤 검증기를 사용해야하는지 알아야한다. 이때 기존에 작성한 validator 클래스의 support 메서드가 사용된다.
출처: https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard
'BackEnd > Spring' 카테고리의 다른 글
[Spring MVC-2] 로그인 - 쿠키, 세션 (0) | 2024.05.27 |
---|---|
[Spring MVC-2] Bean Validation (0) | 2024.05.24 |
[Spring DB-1] 문제해결 - 예외 처리, 반복 (0) | 2024.05.08 |
[Spring DB-1] 문제 해결 - 트랜잭션 (0) | 2024.05.07 |
[Spring DB-1] 트랜잭션 이해 (0) | 2024.05.05 |