Spring에서의 Validation

hoyong.eom·2023년 7월 22일
0

스프링

목록 보기
18/59
post-thumbnail

Spring

BindingResult

Spring에서는 BindingResult 객체를 이용해서 검증 오류를 보관하는 기능을 제공한다.
즉, Controller에서 특정 객체에 대해서 검증 오류가 발생하면 BindingResult객체에 담기게 된다.

BindingResult의 간단한 사용 예시는 아래와 같다.

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

        //검증 로직
        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() >= 9999) {
            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는 위 코드에서 Item 객체에 대한 검증 오류 결과가 저장된다.
다만, 주의해야할점이 있는데, BindingResult는 ModelAttribute 바로 뒤에 위치해야한다고 한다.

그렇다면 굳이 BindingResult를 써야하는 이유가 뭘까? 별도의 Map 컬렉션을 사용해도되지 않을까?
BindingResult를 사용함으로써 아래와 같은 이점을 얻을 수 있다.

  • ModelAttribute 애노테이션을 이용해서 객체를 바인딩할때, 타입 오류에 대한 처리를 자동으로 해준다.
  • BindingResult를 사용하는 경우, Model에 자동으로 포함된다.(물론 ModelAttribute도 이런 기능이 있다.)

BindingResult를 사용하지 않은 경우, ModelAttribute 수행시에 타입 오류가 발생한다면 Controller가 호출되지 않고, 400 오류가 발생하지만 BidingResult를 사용하면 Controller 로직이 그대로 호출되고 타입 오류의 결과가 BindingResult에 담기게 된다.

위 코드에서 사용하는 FieldError와 ObjectError는 아래와 같은 생성자를 호출하므로 참고만 하자.

public FieldError(String objectName, String field, String defaultMessage) {
		this(objectName, field, null, false, null, null, defaultMessage);
	
objectName : @ModelAttribute 이름
field : @ModelAttribute 객체에서 오류가 발생한 필드 이름
defaultMessage : 오류 기본 메시지


public ObjectError(String objectName, String defaultMessage) {
		this(objectName, null, null, defaultMessage);
	}

objectName : @ModelAttribute 이름
defaultMessage : 오류 기본 메시지

FieldError, ObjectError

FieldError와 ObjectError는 앞서 사용한 기본적인 생성자 이외에도 특별한 생성자를 제공해서 더 다양한 기능을 사용할 수 있다.

	public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure,
			@Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage) {

		super(objectName, codes, arguments, defaultMessage);
		Assert.notNull(field, "Field must not be null");
		this.field = field;
		this.rejectedValue = rejectedValue;
		this.bindingFailure = bindingFailure;
	}

rejectedValue : 사용자가 입력한 값(거절된 값)
bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 하기 위한 값
codes : 메시지 코드(messages.properteis, errors.properties)
arguments : 메시지에서 사용하는 인자

따라서, 위 생성자를 이용해서 사용자가 입력했을때 검증에 통과하지 못한 값은 rejectecValue로 유지가 가능하다.

메시지 코드

스프링부트는 MessageCodeResolver의 구현체를 스프링빈에 자동으로 등록해주기 때문에 messages.properties와 errors.properties 리소스 파일에서 메시지 코드를 이용해서 메시지를 가져와서 처리할 수 있다.
이 기능은 FieldError와 ObjectError에서도 동일하게 사용이 가능하다.
하지만, 명시적으로 application.properties에 추가해주는게 좋다.

spring.messages.basename=messages,errors
        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() >= 9999) {
            bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, new String[]{"max.item.quantity"} ,new Object[]{9999}, null));
        }

위 예시코드에서 String 배열을 이용해서 메시지 코드 값을 전달한다.(배열이므로 여러개의 메시지 코드를 전달해서 일치하는 코드를 찾은 후 보여줄 수 있다

BindingResult의 코드 단순화

BindingResult를 사용하기 위해서는 1가지 제약이 있었다.
바로, ModelAttribute 매개 변수 바로 뒤에 존재해야한다는 점이다.
이 점을 이용해서 BindingResult를 사용할때 몇가지 생성자 매개 변수 값을 생략할 수 있다.

ModelAttribute 바로 뒤에 BindingResult가 오기 떄문에 BindingResult는 본인이 검증해야할 ModelAttribute 객체를 알 고 있다.
따라서 FieldError와 ObjectError를 사용하지 않고 rejectValue와 reject 함수를 통해서 좀 더 코드 단순화를 할 수 있다.

        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, 10000000}, null);
        }
        if (item.getQuantity() == null || item.getQuantity() >= 9999) {
            bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
        }

위 코드에서는 ModelAttribute 객체를 지정하지 않는다. 왜냐하면 BindingResult는 자신이 검증해야할 ModelAttribute를 알 고 있다. 그리고 여기에는 추가로 error.properties 또는 messages.properties에서 메시지를 가져오는 특별한 기능이 있다.

오류 코드를 조합해서 사용한다
ex)
bindingResult.rejectValue("price", "range", new Object[]{1000, 10000000}, null);
---> 메시지 오류 코드 : Item.price.range

Valdation 분리

Spring에서는 Validator 인터페이스를 이용해서 검증 로직을 분리시킬 수 있다.

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;

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

스프링과 Validator를 함께 사용하면 객체 검증을 좀 더 편리하게 사용할 수 있다.

1) Controller에 @InitBinder 애노테이션 호출 및 검증기 추가
2) 검증해야할 ModelAttribute객체에 @Validated or @Valid 애노테이션 추가

    @InitBinder
    public void init(WebDataBinder dataBinder) {
        dataBinder.addValidators(itemValidator);
    }


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

        //검증에 실패하면 다시 입력 폼으로
        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}";
    }

@Validated는 검증기를 실행하라는 의미가 된다.
이 애노테이션이 붙으면 WebDataBinder에 등록한 검증기를 찾아서 실행한다. 이떄 Validator에서 구현한 support를 호출해서 true이면 실행한다. 글로벌 설정도 할 수 있지만 권장하진 않는다.

참고

해당 포스팅은 아래의 강의를 공부하여 정리한 내용입니다.
김영한님의 SpringMVC2-Validation

1개의 댓글

comment-user-thumbnail
2023년 7월 22일

BindingResult를 통한 검증 오류 관리에 대한 설명이 상세하고 이해하기 쉽게 잘 정리되어 있네요. 특히, Validator를 분리하여 사용하는 부분이 인상적입니다. 좋은 정보 공유 감사드립니다.

답글 달기