Notice
Recent Posts
Recent Comments
Link
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
Archives
Today
Total
관리 메뉴

참새의 이야기

[MVC2] Validation 본문

Spring

[MVC2] Validation

참새짹짹! 2023. 8. 8. 12:59

HTTP request가 항상 정상이라는 보장은 없다.

요구하는 스펙에 맞지 않는 값이 들어오는 경우를 처리하기 위해 검증을 해야 한다.

클라이언트 검증은 조작이 가능하기 때문에 취약하다.

그렇다고 서버 검증만을 활용하면 고객 사용성이 부족하다.

따라서 둘을 적절히 섞어 사용하는 것이 중요하다.

검증 직접 처리

Map을 이용해서 검증 오류를 직접 처리하는 것부터 시작해 보자.

유효하지 않은 값이 들어오면, 서버는 어떤 값에 어떤 문제가 있는지에 대한 설명을 Map인 errors에 담아 반환한다.

@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()) {
        log.info("errors = {}", errors);
        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의 key로 하고, 여러 필드에 걸쳐있는 경우에는 globalError 를 key로 한다.

실패한 검증이 하나라도 있다면 model에 errors를 담아 폼으로 돌아간다.

Binding Result

만약 data type이 Integer인데 문자가 들어온다면 오류가 발생한다.

아래와 같이 400 에러가 발생한다.

컨트롤러에 도달하기 전에 오류가 발생하기 때문에 현재의 코드에서는 다룰 수 없다.

Form에도 중복이 많이 생긴다.

이런 문제들을 스프링이 제공하는 BindingResult 를 이용해서 해결해 보자.

@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() > 9999) {
        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

BindingResult 를 사용하면 스프링이 제공하는 FieldError와 ObjectError를 사용할 수 있다.

FieldError는 필드에 오류가 있을 때 사용한다.

아래와 같은 생성자를 이용하여 bindingResult에 추가해 주면 된다.

특히 두 번째 생성자는 rejectedValue를 통해 거절된 값을 다시 보낼 수 있다.

이를 이용해 사용자의 입력 값을 유지할 수 있다.

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);

ObjectError

글로벌 에러를 처리하기 위해 ObjectError를 지원한다.

public ObjectError(String objectName, String defaultMessage);

public ObjectError(String objectName, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage);

BindingResult는 검증할 객체를 알고 있다

위의 public String addItemV2(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) 를 보면 BindingResult 는 검증할 객체인 Item의 바로 뒤에 온다.

BindingResult 는 Item 뒤라는 위치만으로 자신이 검증해야 하는 객체가 무엇인지 알아낸다.

이를 활용하여 FieldError와 ObjectError를 제거할 수 있다.

rejectValue()와 reject()로 대체하면 아래와 같다.

// FieldError 대체
bindingResult.rejectValue("itemName", "required");
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);

// ObjectError 대체
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);

rejectValue()의 두 번째 parameter와 reject()의 첫 번째 파라미터는 errorCode다.

errorCode를 이용하여 MessageCodesResolver가 적절한 error message를 전달해 준다.

Validator

위의 코드에서는 모든 검증 로직이 컨트롤러에 그대로 들어가 있다.

public interface Validator {
    boolean supports(Class<?> clazz);

    void validate(Object target, Errors errors);
}

검증 로직을 위의 Validator interface를 사용하여 별도의 클래스로 분리한다.

@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 || item.getPrice() > 1000000) {
            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);
            }
        }
    }
}

@Validated

조금 더 세련된 방식으로 컨트롤러에 추가하려면 @InitBinder 를 사용하여 WebDataBinder에 추가하면 된다.

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

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

위의 코드로 해당 컨트롤러에서는 validator가 자동으로 작동한다.

public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes);

위와 같이 @Validated 를 사용하면 WebDataBinder에 등록해 둔 검증기가 실행된다.

등록한 검증기가 여러 개일 경우를 대비하여 Validator에 supports() 메서드가 있는 것이다.

reference

이 글은 김영한님의 '스프링 MVC 2편'을 듣고 작성했습니다.

'Spring' 카테고리의 다른 글

[MVC2] 로그인 처리 - Cookie, Session  (0) 2023.08.10
[MVC2] Bean Validation  (0) 2023.08.08
[MVC2] 메시지와 국제화  (0) 2023.08.06
[MVC2] Thymeleaf - 스프링 통합과 폼  (0) 2023.08.05
[MVC2] Thymeleaf - 기본  (0) 2023.08.05