본문 바로가기
Spring Study/MVC 패턴

[MVC 패턴] 검증1 - Validation

by 정재인 2023. 8. 24.

컨트롤러의 중요한 역할 중 하나는 HTTP 요청이 정상인지 검증하는 것이다.

* 클라이언트 검증, 서버검증

  • 클라이언트 검증은 조작할 수 있으므로 보안에 취약하다.
  • 서버만으로 검증하면, 즉각적인 고객 사용성이 부족해진다.
  • 둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수이다.
  • API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 한다.

 

상품 등록 검증 코드

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

 

검증 로직

if (!StringUtils.hasText(item.getItemName())){
    errors.put("itemName", "상품 이름은 필수입니다.");
}

 

검증시 오류가 발생하면 errors에 담아둔다. 이때 어떤 필드에서 오류가 발생했는지 구분하기 위해 오류가 발생한 필드명을 key로 사용한다. 이후 뷰에서 이 데이터를 사용해서 고객에게 친절한 오류 메시지를 출력할 수 있다.

 

검증에 실패하면 다시 입력 폼으로

if (!errors.isEmpty()) {
    model.addAttribute("errors", errors);
    return "validation/v1/addForm";
}

만약 검증에서 오류 메시지가 하나라도 있으면 오류 메시지를 출력하기 위해 modelerrors를 담고, 입력 폼이 있는 뷰 템플릿으로 보낸다.

 


BindingResult

- 스프링이 제공하는 검증 오류를 보관하는 객체이다. 검증 오류가 발생하면 여기에 보관한다.

- BindingResult가 있으면 @ModelAttribute에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다.

ex) @ModelAttribute에 바인딩 시 타입 오류가 발생하면?

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

 

위 상품 등록 코드에 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는 @ModelAttibute Item item 다음에 와야 한다.

 

BindingResult vs Errors

- BindingResult는 인터페이스이고, Errors 인터페이스를 상속받고 있다.

- Errors 인터페이스는 단순한 오류 저장과 조회 기능을 제공하는데, BindingResult는 여기에 추가적인 기능을 제공한다.

- 주로 관례상 BindingResult많이 사용한다.

 

 

 

필드 오류 - FieldError

if (!StringUtils.hasText(item.getItemName())){
    bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
}

 

FieldError 생성자 

public FieldError(String objectName, String field, String defaultMessage){}

필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult에 담아두면 된다.

  • objectName: @ModelAttribute 이름
  • field: 오류가 발생한 필드 이름
  • defaultMessage: 오류 기본 메시지

 

ObjectError 생성자

public ObjectError(String objectName, String defaultMessage){}

특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult에 담아두면 된다.

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

 


rejectValue(), reject()

BindingResult가 제공하는 rejectValue(), reject()를 사용하면 FieldError, ObjectError를 직접 생성하지 않고 깔끔하게 검증 오류를 다룰 수 있다.

 

@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult,RedirectAttributes redirectAttributes) {
    log.info("objectName={}", bindingResult.getObjectName());
    log.info("target={}", bindingResult.getTarget());
      
    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,1000000}, null);
    }
      
    if (item.getQuantity() == null || item.getQuantity() > 10000) {
        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("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}";
}

 

rejectValue()

void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
  • field: 오류 필드명
  • errorCode: 오류 코드
  • errorArgs: 오류 메시지에서 {0}을 치환하기 위한 값
  • defaultMessage: 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

 


MessageCodesResolverTest

package hello.itemservice.validation;
  
import org.junit.jupiter.api.Test;
import org.springframework.validation.DefaultMessageCodesResolver;
import org.springframework.validation.MessageCodesResolver;

import static org.assertj.core.api.Assertions.assertThat;
  
public class MessageCodesResolverTest {
    MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();
    
    @Test
    void messageCodesResolverObject() {
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
        assertThat(messageCodes).containsExactly("required.item", "required");
    }
    
    @Test
    void messageCodesResolverField() {
        String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
        assertThat(messageCodes).containsExactly(
                "required.item.itemName",
                "required.itemName",
                "required.java.lang.String",
                "required"
			); 
	}
}

 

MessageCodesResolver

  • 검증 오류 코드로 메시지 코드들을 생성한다.
  • MessageCodesResolver 인터페이스이고 DefaultMessageCodesResolver는 기본 구현체이다.
  • 주로 ObjectError, FieldError와 함께 사용한다.

 

ValidationUtils

//ValidationUtils 사용 전
if (!StringUtils.hasText(item.getItemName())){
    bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은 필수입니다.");
}

//ValidationUtils 사용 후
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");

다음과 같이 한줄로 가능, 제공하는 기능은 Empty, 공백 같은 단순한 기능만 제공

 


 

Validator 분리1

 

ItemValidator

package hello.itemservice.web.validation;
  
import hello.itemservice.domain.item.Item;
import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
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);
    }
      
    @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);
			} 
        }
	}  
}

 

Validator 인터페이스

public interface Validator {
    boolean supports(Class<?> clazz);
    void validate(Object target, Errors errors);
}
  • supports() {}: 해당 검증기를 지원하는 여부 확인
  • validate(Object target, Errors errors): 검증 대상 객체와 BindingResult

 

ValidationItemControllerV2

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

 


Validator 분리2

 

ValidationItemControllerV2

@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는 검증기를 실행하는 애노테이션이다.

 

댓글