Validation

YH·2023년 4월 23일
0

✅ 목표

  • 이번 포스트에서는 각 유효성 검증에 사용되는 방법들을 정리해본다.

✅ 1. HashMap에 오류 결과를 보관

@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}";
    }
  • HashMap을 사용하여 오류가 발생한 필드 명과 오류 메시지를 저장해서 model에 넘겨준다.

✔️ 위 방법의 문제점

  • 뷰 템플릿에서 중복 처리가 많다.
  • 타입 오류 처리가 안된다. 숫자 필드의 경우 문자 타입을 받을 수 없으므로 문자로 들어왔을 경우 컨트롤러에 진입하기 전에 이미 예외가 발생한다.
  • 클라이언트가 입력한 값을 별도로 관리할 필요가 있다.

✅ 2. BindingResult 사용 1

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

주의 : BidingResult 파라미터 위치는 반드시 @ModelAttribute 다음에 와야한다.

  • 필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult에 담아둔다.
    • FieldError 생성자
      • objectName : @ModelAttribute 이름
      • field : 오류가 발생한 필드 이름
      • defaultMessage : 오류 기본 메시지
	public FieldError(String objectName, String field, String defaultMessage) {}
  • 특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult에 담아둔다.
    • ObjectError 생성자
      • objectName : @ModelAttribute의 이름
      • defaultName : 오류 기본 메시지
	public ObjectError(String objectName, String defaultMessage) {}

✔️ Thymeleaf 템플릿은 BindingResult를 활용해서 편리하게 검증 오류를 표현하도록 기능을 제공한다.

<!-- 글로벌 오류 처리 -->
<div th:if="${#fields.hasGlobalErrors()}">
	<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
</div>
<!-- 필드 오류 처리 -->
<input type="text" id="itemName" th:field="*{itemName}"
	th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:errors="*{itemName}">
	상품명 오류
</div>

✅ BindingResult 특징

  • 스프링이 제공하는 검증 오류를 보관하는 객체이다.
  • BindingResult가 있으면 @ModelAttribute에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다.
    • @ModelAttribute 바인딩 시 실패하면 오류 정보를 BindingResult에 담아서 컨트롤러를 호출한다.
  • 컨트롤러 파라미터에서 BindingResult는 반드시 검증할 대상 바로 다음에 와야한다.
  • BindingResult는 Model에 자동으로 포함된다.

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

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

✔️ BindingResult 와 Errors

org.springframework.validation.Errors
org.springframework.validation.BindingResult

  • BindingResult는 인터페이스이고 Errors 인터페이스를 상속한다.
  • 실제 넘어오는 구현체는 BeanPropertyBindingResult인데 BindingResult 대신에 Errors를 사용해도 된다.
  • BindingResult는 Errors의 오류 저장 및 조회 기능에 더해 추가적인 기능을 제공한다.
  • 주로 관례상 BidingResult를 많이 사용한다.

✅ BindingResult 사용 2

✔️ FieldError는 아래 생성자를 추가로 제공한다.

public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure,
@Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage)
  • objectName : 오류가 발생한 객체 이름
  • field : 오류 필드
  • rejectedValue : 사용자가 입력한 값(거절된 값)
  • bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
  • codes : 메시지 코드
  • arguments : 메시지에서 사용하는 인자
  • defaultMessage : 기본 오류 메시지

📝 FieldError 는 오류 발생 시 rejectedValue 필드에 사용자의 입력 값을 보관하는 기능을 제공한다.
📝 Thymeleaf에서 th:field는 오류가 없는 경우는 model 객체의 값을 사용하고, 오류가 발생하면 FieldError 에서 보관한 값을 사용하여 값을 출력한다.

💡 ObjectError도 FieldError와 유사하게 추가 생성자를 제공한다.

✅ 오류 코드와 메시지 처리 1

✔️ FieldError의 codes, arguments 필드는 오류 발생 시 코드로 메시지를 찾기 위해 사용된다.
✔️ application.yamlerrors.yaml 파일에 아래 내용을 추가하여 에러 메시지를 설정 파일에서 가져와 공통적으로 사용한다.

#application.yaml
spring:
  messages:
    basename: messages,errors
#errors.yaml
required:
  item:
    itemName: 상품 이름은 필수입니다.
range:
  item:
    price: 가격은 {0} ~ {1} 까지 허용합니다.
max:
  item:
    quantity: 수량은 최대 {0} 까지 허용합니다.
totalPriceMin: 가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

✔️ 위 설정 파일 사용 예제 코드

//range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
bindingResult.addError(new FieldError("item", "price", item.getPrice(),
                    false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null));

✅ 오류 코드와 메시지 처리 2

✔️ 다루기 번거로운 FieldError 와 ObjectError를 사용하지 않고 BindingResult에서 제공하는 rejectValue(), reject()를 사용하면 더 깔끔하게 검증 오류를 처리할 수 있다.

if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
	bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
}

✔️ rejectValue()

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

✔️ reject()

void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
  • 메시지 코드에서 range.item.price와 같이 모두 입력해서 사용했는데 rejectValue()는 앞에 range만 사용해도 오류 코드를 잘 찾아서 출력한다.
  • 이 것은 MessageCodesResolver가 제공하는 기능이다.

✅ 오류 코드와 메시지 처리 3

✔️ 스프링은 아래와 같이 코드가 있을 때 코드의 세밀함으로 우선순위를 정해서 더 세밀한 코드의 메시지를 우선적으로 먼저 사용한다.

#Level1
required.item.itemName: 상품 이름은 필수 입니다.

#Level2
required: 필수 값 입니다.
  • 스프링은 이러한 기능을 MessageCodesResolver 라는 것을 통해 지원한다.

✅ 오류 코드와 메시지 처리 4

✔️ MessageCodesResolver

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

✔️ DefaultMessageCodesResolver의 기본 메시지 생성 규칙

  • 객체 오류

    • 객체 오류의 경우 다음 순서로 2가지 생성
      1. code + "." + object name
      2. code
    • 예) 오류 코드: required, object name: item
      1. required.item
      2. required
  • 필드 오류

    • 필드 오류의 경우 다음 순서로 4가지 메시지 코드 생성
      1. code + "." + object name + "." + field
      2. code + "." + field
      3. code + "." + field type
      4. code
    • 예) 오류 코드: typeMismatch, object name "user", field "age", field type: int
      1. "typeMismatch.user.age"
      2. "typeMismatch.age"
      3. "typeMismatch.int"
      4. "typeMismatch"

✔️ 동작 방식

  • rejectValue(), reject()는 내부에서 DefaultMessageCodesResolver 를 사용하여 메시지 코드들을 생성한다.
  • 여러 개의 오류 코드를 가질 수 있고, 생성된 순서대로 오류 코드를 보관한다.

✅ 오류 코드와 메시지 처리 5

✔️ 위에서 학습한 내용들을 가지고 실제 오류 코드 설정을 작성한다.

#==ObjectError==
#Level1
totalPriceMin.item: 상품의 가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

#Level2 - 생략
totalPriceMin: 전체 가격은 {0}원 이상이어야 합니다. 현재 값 = {1}

#==FieldError==
#Level1
required.item.itemName: 상품 이름은 필수입니다.
range.item.price: 가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity: 수량은 최대 {0} 까지 허용합니다.

#Level2 - 생략

#Level3
required.java.lang.String: 필수 문자입니다.
required.java.lang.Integer:  필수 숫자입니다.
min.java.lang.String:  {0} 이상의 문자를 입력해주세요.
min.java.lang.Integer:  {0} 이상의 숫자를 입력해주세요.
range.java.lang.String:  {0} ~ {1} 까지의 문자를 입력해주세요.
range.java.lang.Integer:  {0} ~ {1} 까지의 숫자를 입력해주세요.
max.java.lang.String:  {0} 까지의 문자를 허용합니다.
max.java.lang.Integer:  {0} 까지의 숫자를 허용합니다.

#Level4
required:  필수 값 입니다.
min: {0} 이상이어야 합니다.
range: {0} ~ {1} 범위를 허용합니다.
max: {0} 까지 허용합니다.

✔️ ValidationUtils

  • ValidationUtils 사용 전
if (!StringUtils.hasText(item.getItemName())) {
	bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은 필수입니다.");
}
  • ValidationUtils 사용 후
    다음과 같이 한줄로 가능, 제공하는 기능은 Empty , 공백 같은 단순한 기능만 제공
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");

✅ 오류 코드와 메시지 처리 6

✔️ 위에서는 직접 개발자가 설정하여 작성한 검증 오류 코드(rejectValue() 직접 호출)였다면, 이번에는 스프링이 직접 만드는 오류 메시지를 처리해본다.
✔️ 스프링이 직접 검증 오류에 추가한 경우는 주로 타입 정보가 맞지 않을 때가 대표적이다.

  • 만약 필드에 타입을 잘못 입력하면 스프링에서는 아래와 같은 메시지 코드를 생성한다.

    • typeMismatch.item.price
    • typeMismatch.price
    • typeMismatch.java.lang.Integer
    • typeMismatch
  • 위 메시지 코드에 대해 설정해두지 않았다면 기본 메시지가 출력된다.
    Failed to convert property value of type java.lang.String to required type java.lang.Integer for property price; For input string: "a"

  • 아래와 같이 error.properties에 추가하면 된다.

typeMismatch.java.lang.Integer: 숫자를 입력해주세요.
typeMismatch: 타입 오류입니다.

✅ Validator 분리 1

✔️ 복잡한 검증 로직을 별도로 분리해본다.

  • 스프링에서는 검증을 체계적으로 제공하기 위해 다음 인터페이스를 제공한다.
    • supports() {} : 해당 검증기를 지원하는 여부 확인
    • validate(Object target, Errors errors) : 검증 대상 객체와 BindingResult
public interface Validator {
    boolean supports(Class<?> clazz);
    void validate(Object target, Errors errors);
}
  • 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 분리 2

✔️ 만들었던 Validator 구현체를 직접 불러서 사용하지 않고 스프링 기능을 통해 자동으로 Validator를 사용하도록 설정해본다.

  • Validator를 추가하기 위해서는 아래와 같이 WebDataBinder를 사용한다.
  • 아래와 같이 WebDataBinder에 Validator를 추가하면 해당 컨트롤러에서 Validator가 자동으로 적용된다.
@InitBinder
    public void init(WebDataBinder webDataBinder) {
        log.info("init binder {}", webDataBinder);
        webDataBinder.addValidators(itemValidator);
    }
  • 아래와 같이 @Validated 어노테이션을 사용하면 WebDataBinder에 등록한 Validator를 찾아서 실행한다.
  • 여기서 여러 Validator가 등록되어 있다면, 어떤 것을 사용할지 Validator의 supports() 메소드를 통해 구분된다.
@PostMapping("/add")
    public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

        if (bindingResult.hasErrors()) {
            log.info("errors={}", bindingResult);
            return "validation/v2/addForm";
        }
        ...
    }

✔️ 글로벌 설정 - 모든 컨트롤러에 다 적용

  • 글로벌 설정을 하면 BeanValidator가 자동 등록되지 않는다.
  • 글로벌 설정을 직접 사용하는 경우는 드물다.
@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
    public static void main(String[] args) {
    	SpringApplication.run(ItemServiceApplication.class, args);
    }
    
    @Override
    public Validator getValidator() {
    	return new ItemValidator();
    }
}

💡 참고

  • 검증 시 @Validated, @Valid 둘 다 사용 가능하다.
  • @Valid를 사용하려면 의존관계를 추가해야 한다.
    • implementation 'org.springframework.boot:spring-boot-starter-validation'
  • @Valid는 자바 표준 검증 어노테이션이고 @Validated는 스프링 전용 검증 어노테이션이다.
profile
하루하루 꾸준히 포기하지 말고

0개의 댓글