Spring/스프링 기본

오류 처리 / validation & bindingResult

공대키메라 2022. 3. 18. 17:20

Spring에서 에러를 어떻게 처리하는지 제대로 기억하고 사용하기 위해 이 글을 정리한다. 

 

BindingResult는 검증 오류가 발생할 경우 오류 내용을 보관하는 스프링 프레임워크에서 제공하는 객체다. 

 

1. BindingResult 사용하기 - addError 

 

//    @PostMapping("/add")
    public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

        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"}, null, null));

        }
        if (item.getPrice() == null || item.getPrice()< 1000 || item.getPrice() > 100000000){
            bindingResult.addError(new FieldError("item", "price", item.getPrice(),
                    false, new String[]{"range.item.price"}, new Object[]{1000,10000000}, 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){
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000){
//                errors.put("globalError", "가격 * 수량의 합은 10,000이상어야 합니다. 현재 값 = " + resultPrice);
                bindingResult.addError(new ObjectError("item", new String[]{"totalPriceMin"}, new Object[]{10000,resultPrice}, null));
            }
        }

        //검증에 실패하면 다시 입력 폼으로
        if (bindingResult.hasErrors()){
            log.info("bindingResult = {}", bindingResult);
//            model.addAttribute("errors", errors);
            return "validation/v2/addForm";
        }

        //성공 로직
        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v2/items/{itemId}";
    }

현재 개발자가 넘어오는 Item값에 대해서 일일이 확인을 해줘야 하는 상황이다.

 

@ModelAttribute를 이용하면 입력받은 form의 데이터들이 Item객체에 알맞게 parsing되어 들어온다. 

 

문제는 값이 있는지, 없는지를 확인해야 한다는 것이다. 

 

이러한 조건을 이제 값이 있는지, 없는지를 판단해서 BindingResult에 담아주는 것이다. 

 

bindingResult에 erorr를 FieldError를 이용해서 담을 수 도 있다.

 

같은 이름으로 선언된 메서드가 2개이다. (오버로딩)

위의 경우, 즉 파라미터가 3개인 경우의 FieldError를 선언하게 되면 값이 틀려서 기존에 담겨있는 데이터들이 날아가게된다.

 

잘못된 값을 입력한다 하더라도 사용자가 직접 수정할 수 있도록 유도해야지, 다 지워버리면 얼마나 짜증나겠는가?

 

그렇기에 두번째 메소드를 사용하는게 좋다. 

 

어떤 실패한 값을 담아줄 지, 실패시 출력할 메시지는 무엇인지, message에 들어갈 내용은 무엇인지, default 메시지는 무엇인지  설정이 가능하다. 

 

근데 무언가 너무 많은 값을 넘겨주어야 하기에 불편한 것 같다. 

 

2. BindingResult 사용하기 - rejectValue

 

addError 내에서 FieldError를 선언해서 사용하는 경우 너무 많은 값을 넣어줘야해서 개발자 입장에서 불편했다.

 

이를 다시 한번 더 편하게 사용할 수 있도록 이미 기능을 제공하고 있다. rejectValue를 사용하면 이러한 문제가 해결된다.

//    @PostMapping("/add")
    public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

        log.info("objectName={}", bindingResult.getObjectName());
        log.info("target={}", bindingResult.getTarget());

        //검증 로직
        if (!StringUtils.hasText(item.getItemName())){
            bindingResult.rejectValue("itemName", "required"); // => required.item.itemName 으로 생성
        }

        if (item.getPrice() == null || item.getPrice()< 1000 || item.getPrice() > 100000000){
            bindingResult.rejectValue("price", "range", new Object[]{1000,1000000}, null);
        }
        if (item.getQuantity() == null || item.getQuantity() >= 9999){
            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("bindingResult = {}", 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}";
    }

 

rejectValue 에 출력할 메시지를 선택할 수 있는데

 

우리가 입력한 것 + 우리가 확인중인 객체 이름 + 객체의 성분에 대해서 어떤 메시지를 선택할 건지 자동으로 생성해준다. 

 

예를 들어

 

bindingResult.rejectValue("itemName", "required"); 

 

로 입력을 한다면 new String[]{"required.item.itemName", "required"} 하듯이 FieldError 에 넣어줄 메시지 형식을 만들어준다. 

 

이것은 spring 내에서 MessageCodesResolver에서 만들어 주는 것이다. 

 

만들어 주는 방식도 ObjectE에서 Filed에서 만들어주는 방식이 있다.

 

순서대로 보면 된다. 

    @Test
    void messageCodesResolverObject() {
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
        for (String messageCode : messageCodes) {
            System.out.println("messageCode = " + messageCode);
        }

        Assertions.assertThat(messageCodes).containsExactly("required.item", "required");
    }

    @Test
    void messageCodesResolverField(){
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
        for (String messageCode : messageCodes) {
            System.out.println("messageCode = " + messageCode);
        }
//      bindingResult.rejectValue("itemName", "required");
//        new FieldError("item", "itemName", null, false, messageCodes, null, null);
        Assertions.assertThat(messageCodes).containsExactly("required.item.itemName", "required.itemName", "required.java.lang.String","required");
    }

 

위의 결과를 실행하면 통과하게 되는데  resolveMessageCodes에 적절한 값을 넣어주면 이에 해당되는 messageCodes를 만들어 준다. 

 

 

같은 것이긴 한데 bindingResult에서 지원하는 api중 .reject를 사용할 것이냐, .rejectValue를 사용할 것이냐 두개 중 적절한 것을 선택해주면 된다. 


이것의 장점은 결국 어떤 오류가 와도 message.properties에만 적절히 입력해 주면 코드를 수정할 필요 없이 원하는 오류 메시지를 출력할 수 있다는 것이다. 

 

하지만 현재 코드가 controller쪽에서 너무 많이 구현되어있다.

 

성공 로직은 고작 4줄 정도인데 그 앞의 검증 과정이 너무 험난하다. 

 

이것을 Validator를 따로 분리해서 좀 더 깔끔하게 할 수 가 있다.

 

ItemValidator.java

package hello.itemservice.web.validation;

import hello.itemservice.domain.item.Item;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.validation.Errors;
import org.springframework.validation.ObjectError;
import org.springframework.validation.ValidationUtils;
import org.springframework.validation.Validator;

@Component
public class ItemValidator implements Validator {

    @Override
    public boolean supports(Class<?> clazz) {
        return Item.class.isAssignableFrom(clazz);
        //item == clazz
        //item == subItem
    }

    @Override
    public void validate(Object target, Errors errors) {
        Item item = (Item) target;

        //검증 로직
        if (!StringUtils.hasText(item.getItemName())){
            errors.rejectValue("itemName", "required"); // => required.item.itemName 으로 생성
        }

        ValidationUtils.rejectIfEmptyOrWhitespace(errors,"itemName", "required");
//        if (!StringUtils.hasText(item.getItemName())){
//            bindingResult.rejectValue("itemName", "required"); // => required.item.itemName 으로 생성
//        }

        if (item.getPrice() == null || item.getPrice()< 1000 || item.getPrice() > 100000000){
            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);
            }
        }
    }
}

 

그리고 다음과 같이 사용하면 된다. 

 

    
@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {

    private final ItemRepository itemRepository;
    private final ItemValidator itemValidator;
    
    ...
    
    @PostMapping("/add")
    public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
		
        if(itemValidator.supports(item)){
        	itemValidator.validate(item, bindingResult);
        }

        //검증에 실패하면 다시 입력 폼으로
        if (bindingResult.hasErrors()){
            log.info("bindingResult = {}", 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 인터페이스를 사용해서 검증기를 만들면 스프링의 추가적인 도움을 받을 수 있다. 

 

바로 WebDataBilder를 사용하면 된다. 

 

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

 

이렇게 선언해 주면 이 컨트롤러가 호출이 될 때 마다 항상 dataBinder가 내부적으로 동작해 검증할 수 있다. 

 

그러면 최종적으로 다음과 같은 메소드로 구현이 가능하다. 

 

@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

    //검증에 실패하면 다시 입력 폼으로
    if (bindingResult.hasErrors()){
        log.info("bindingResult = {}", 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}";
}

 

이것을 하나의 controller에만 적용하는 것이 아니라 global로 적용을 하고 싶다면 다음과 같이 하면 된다. 

 

ItemServiceApplicaton.java

package hello.itemservice;

import hello.itemservice.web.validation.ItemValidator;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.validation.Validator;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {

   public static void main(String[] args) {
      SpringApplication.run(ItemServiceApplication.class, args);
   }

   @Override
   public Validator getValidator() {
      return new ItemValidator();
   }
}