Spring MVC(5) 기본 Validation (직접 검증하고 BindingResult에 보관)

오잉·2023년 5월 24일
0

SPRING

목록 보기
14/15

컨트롤러의 중요한 역할 중 하나는 HTTP 요청이 정상인지 검증하는 것이다.
어쩌면 정상로직 개발보다 검증로직 개발이 더 어려울 수도 있다. 하지만 그만큼 중요하다.

클라이언트 검증 vs 서버 검증

  • 클라이언트에서 검증
    • 고객이 입력하자마자 바로 반응할 수 있어 편하다
    • 조작이 가능해서 보안에 취약하다.
  • 서버에서 검증
    • 즉각적인 고객사용성이 부족해진다

따라서 둘을 적절히 사용해야 한다.
기본적으로는 클라이언트 검증을 하더라도, 최종적으로 서버 검증도 해봐야 한다.

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 || 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<>();
    • 만약 검증시 오류가 발생하면 어떤 검증에서 오류가 발생했는지 정보를 담아둔다.
    • 이때 어떤 필드에서 오류가 발생했는지 구분하기 위해 오류가 발생한 필드명을 key 로 사용한다.
    • 이후 뷰에서 이 데이터를 사용해서 고객에게 친절한 오류 메시지를 출력할 수 있다.
  • 만약 검증 오류가 발생하면 입력 폼을 다시 보여준다.
  • 검증 오류들을 고객에게 친절하게 안내해서 다시 입력할 수 있게 한다.
  • 문제점
    • 귀찮다. 뭔가 비슷하다.
    • 타입 오류 처리가 안된다. (타입 오류 발생시 스프링 MVC에서 컨트롤러에 진입하기도 전에 예외가 발생하기 때문에, 컨트롤러가 호출되지도 않고, 400에러가 발생하면서 오류 페이지를 띄워준다)

2. BindingResult

  • BindingResult

    • 스프링이 제공하는 검증 오류를 보관하는 객체이다. 검증 오류가 발생하면 여기에 보관하면 된다.
    • BindingResult가 있으면 @ModelAttribute 에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다
    • BindingResult 는 검증할 대상 바로 다음에 와야한다. 순서가 중요하다.
    • BindingResult 는 Model에 자동으로 포함된다.
  • @ModelAttribute에 바인딩 시 타입 오류가 발생하면?

    • BindingResult가 없으면 : 400에러가 발생하면서 컨트롤러가 호출되지 않고, 오류 페이지로 이동
    • BindingResult가 있으면 : 오류 정보를 BindingResult에 담아서 컨트롤러를 정상 호출

💫 BindingResult에 검증 오류를 적용하는 3가지 방법 💫

  1. 개발자가 직접 넣어준다
  2. @ModelAttribute의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError 생성해서 BindingResult에 넣어준다
  3. Validator 사용

1) 개발자가 직접 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 다음에 와야 한다.
  • 필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult 에 담아두면 된다.
    • objectName : @ModelAtrribute 이름
    • field : 오류가 발생할 필드 이름
    • defaultMessage : 오류 기본 메시지
  • 특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult에 담아두면 된다.
    • objectName : @ModelAttribute 이름
    • defaultMessage : 오류 기본 메시지

+) 만약 사용자가 입력한 데이터를 그대로 유지하고 싶다면

    @PostMapping("/add")
    public String addItemV2(@ModelAttribute Item item, BindingResult bindingResult,
                            RedirectAttributes redirectAttributes) {
        if (!StringUtils.hasText(item.getItemName())) {
            bindingResult.addError(new FieldError("item", "itemName",
                    item.getItemName(), false, null, null,
                    "상품 이름은 필수입니다.")); }
        if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() >
                1000000) {
            bindingResult.addError(new FieldError("item", "price",
                    item.getPrice(), false, null, null,
                    "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
        }
        if (item.getQuantity() == null || item.getQuantity() > 10000) {
            bindingResult.addError(new FieldError("item", "quantity",
                    item.getQuantity(), false, null, null,
                    "수량은 최대 9,999 까지 허용합니다."));
        }

        //특정 필드 예외가 아닌 전체 예외
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.addError(new ObjectError("item",
                        null, null,
                        "가격 * 수량의 합은 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}";
    }
  • FieldError는 두가지 생성자를 제공한다
    • rejectedValue : 오류 발생 시 사용자 입력 값 (사용자가 입력한 값, 거절된 값)
    • bindingFailure : 타입 오류 같은 바인딩 실패인지(T), 검증 실패인지(F) 구분 값

2) 타입 오류 등으로 바인딩 실패시 스프링이 알아서 BindingResult에 넣어준다

타입 오류로 바인딩에 실패하면 스프링은 FieldError를 생성하면서 사용자가 입력한 값을 넣어둔다. 그리고 해당 오류를 BindingResult 에 담아서 컨트롤러를 호출한다. 따라서 타입 오류 같은 바인딩 실패시에도 사용자의 오류 메시지를 정상 출력할 수 있다.

3) Validator 사용하기

스프링은 검증을 체계적으로 제공하기 위해 Validator라는 인터페이스를 제공한다.

  • supports() {} : 해당 검증기를 지원하는 여부 확인
  • validate(Object target, Errors errors) : 검증 대상 객체와 BindingResult

다음은 Validator를 구현한 예시코드이다.
해당 코드를 꼼꼼히 볼 필요는 없고,
그냥 대충 검증 로직을 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;
        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);
            }
        }
    }
}

참고1. Errors
BindingResult 는 인터페이스이고, Errors 인터페이스를 상속받고 있다.
BindingResult 대신에 Errors 를 사용해도 된다. Errors 인터페이스는 단순한 오류 저장과 조회 기능을 제공한다. BindingResult 는 여기에 더해서 추가적인 기능들을 제공한다.

참고2. rejectValue()
BindingResult와 Errors가 제공하는 rejectValue(), reject()를 사용하면 FieldError, ObjectError를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다.
(관련해서 조만간 글 쓸 예정!)

1) Validator 적용 1 : 직접 호출

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

2) Validator 적용 2 : WebDataBinder를 통해서 사용

WebDataBinder 는 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함한다.

    @InitBinder
    public void init(WebDataBinder dataBinder) {
        log.info("init binder {}", dataBinder);
        dataBinder.addValidators(itemValidator);
    }

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

    @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 가 붙었다.

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

3) Validator 적용 3 : 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();
      }
}
  • 이런 식으로 모든 컨트롤러에 다 적용하기 위해 글로벌 설정을 할 수 있다.
  • 글로벌 설정을 하면 BeanValidator가 자동 등록되지 않는다.
  • 참고로 글로벌 설정을 직접 사용하는 경우는 드물다.

앞에서 여러 방법을 알아봤지만,
사실 검증 기능을 이처럼 코드로 작성하는 것은 상당히 번거롭다.
특히 특정 필드에 대한 검증 로직은 대부분 빈 값인지 아닌지, 특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다.

그래서 스프링은 Bean Validation이라는 기능을 제공한다.
Bean Validation에 대해서는 다음에 정리해보도록 하겠다!


이 글은 인프런 김영한 님의 스프링 MVC2의 [섹션4. 검증1 - Validation]을 정리한 글이다.

profile
오잉이라네 오잉이라네 오잉이라네 ~

0개의 댓글