03.Validation

Validation

Spring Verification

컨트둀러의 μ€‘μš”ν•œ 역할쀑 ν•˜λ‚˜λŠ” HTTP μš”μ²­μ΄ 정상인지 κ²€μ¦ν•˜λŠ” 것

  • ν΄λΌμ΄μ–ΈνŠΈ 검증은 μ‘°μž‘ν•  수 μžˆμœΌλ―€λ‘œ λ³΄μ•ˆμ— μ·¨μ•½

  • κ·Έλ ‡λ‹€κ³  μ„œλ²„λ§ŒμœΌλ‘œ κ²€μ¦ν•˜λ©΄ 즉각적인 고객 μ‚¬μš©μ„±μ΄ λΆ€μ‘±

  • μ„œλ²„, ν΄λΌμ΄μ–ΈνŠΈ 검증을 적절히 μ„žμ–΄μ„œ μ‚¬μš©ν•˜λ˜ μ„œλ²„ 검증은 ν•„μˆ˜

  • API 방식 μ‚¬μš© μ‹œ, API μŠ€νŽ™μ„ 잘 μ •μ˜ν•΄μ„œ 검증 였λ₯˜λ₯Ό API 응닡 결과에 잘 남겨야 함

BindingResult

μŠ€ν”„λ§μ΄ μ œκ³΅ν•˜λŠ” 검증 였λ₯˜λ₯Ό λ³΄κ΄€ν•˜λŠ” 객체

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

    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 class
 * ν•„λ“œμ— 였λ₯˜κ°€ 있으면 FieldError 객체λ₯Ό μƒμ„±ν•΄μ„œ bindingResult 에 λ‹΄μ•„λ‘μž.
 */
public FieldError(
  String objectName, // @ModelAttribute 이름
  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 객체λ₯Ό μƒμ„±ν•΄μ„œ bindingResult 에 λ‹΄μ•„λ‘μž.
 */
public ObjectError(
  String objectName, // @ModelAttribute 의 이름
  String defaultMessage // 였λ₯˜ κΈ°λ³Έ λ©”μ‹œμ§€
) {}

BindingResult

  • @ModelAttribute 객체 λ‹€μŒ μœ„μΉ˜

  • BindingResult κ°€ 있으면 @ModelAttribute 에 데이터 바인딩 μ‹œ 였λ₯˜κ°€ λ°œμƒν•΄λ„ 컨트둀러 호좜

    • BindingResult κ°€ μ—†μœΌλ©΄ 400 였λ₯˜ λ°œμƒ ν›„, 였λ₯˜ νŽ˜μ΄μ§€λ‘œ 이동

    • BindingResult κ°€ 있으면 였λ₯˜ 정보(FieldError)λ₯Ό BindingResult 에 λ‹΄μ•„μ„œ 컨트둀러 정상 호좜

  • Model 에 μžλ™μœΌλ‘œ 포함

  • μ–΄λ–€ 객체λ₯Ό λŒ€μƒμœΌλ‘œ κ²€μ¦ν•˜λŠ”μ§€ νƒ€κ²Ÿμ„ μ•Œκ³  있음

.

BindingResult 에 검증 였λ₯˜λ₯Ό μ μš©ν•˜λŠ” μ„Έ 가지 방법

  • @ModelAttribute 객체의 νƒ€μž… 였λ₯˜ λ“±, 바인딩 μ‹€νŒ¨ μ‹œ μŠ€ν”„λ§μ΄ FieldError 생성 ν›„ BindingResult 에 μ‚½μž…

  • κ°œλ°œμžκ°€ 직접 μž…λ ₯

  • Validator μ‚¬μš©

Error Message

였λ₯˜ λ©”μ‹œμ§€ νŒŒμΌμ„ 인식할 수 μžˆλ„λ‘ μ„€μ • μΆ”κ°€

spring.messages.basename=messages,errors

errors.properties

required.item.itemName=μƒν’ˆ 이름은 ν•„μˆ˜μž…λ‹ˆλ‹€.
range.item.price=가격은 {0} ~ {1} κΉŒμ§€ ν—ˆμš©ν•©λ‹ˆλ‹€.
max.item.quantity=μˆ˜λŸ‰μ€ μ΅œλŒ€ {0} κΉŒμ§€ ν—ˆμš©ν•©λ‹ˆλ‹€.
totalPriceMin=가격 * μˆ˜λŸ‰μ˜ 합은 {0}원 이상이어야 ν•©λ‹ˆλ‹€. ν˜„μž¬ κ°’ = {1}
required.default=κΈ°λ³Έ 였λ₯˜ λ©”μ‹œμ§€
required=ν•„μˆ˜ κ°’ μž…λ‹ˆλ‹€.
bindingResult.addError(
  new FieldError("item", "itemName", item.getItemName(), false, 
  new String[]{"required.item.itemName"}, null, null)
);

bindingResult.addError(
    new FieldError("item", "price", item.getPrice(), false, 
    new String[]{"range.item.price"}, // codes : λ©”μ‹œμ§€ μ½”λ“œ
    new Object[]{1000, 1000000}, // arguments : λ©”μ‹œμ§€μ—μ„œ μ‚¬μš©ν•˜λŠ” 인자
    null)
);

rejectValue() , reject()

BindingResult λŠ” 검증해야 target 객체λ₯Ό μ•Œκ³  있음

  • BindingResult 의 rejectValue(), reject() λ₯Ό μ‚¬μš©ν•˜λ©΄ FieldError, ObjectError λ₯Ό 직접 μƒμ„±ν•˜μ§€ μ•Šκ³ , κΉ”λ”ν•˜κ²Œ 검증 였λ₯˜λ₯Ό λ‹€λ£° 수 있음

bindingResult.rejectValue("itemName", "required");

bindingResult.rejectValue("price", "range", new Object[]{1000, 10000000}, "μƒν’ˆ 가격 였λ₯˜");

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

void rejectValue(
  @Nullable String field, // 였λ₯˜ ν•„λ“œλͺ…
  String errorCode, // 였λ₯˜ μ½”λ“œ(λ©”μ‹œμ§€μ— λ“±λ‘λœ μ½”λ“œκ°€ μ•„λ‹Œ messageResolver λ₯Ό μœ„ν•œ 였λ₯˜ μ½”λ“œ)
  @Nullable Object[] errorArgs, // λ©”μ‹œμ§€μ—μ„œ μ‚¬μš©ν•˜λŠ” 인자
  @Nullable String defaultMessage // κΈ°λ³Έ λ©”μ‹œμ§€(였λ₯˜ λ©”μ‹œμ§€λ₯Ό 찾을 수 없을 λ•Œ μ‚¬μš©)
);

void reject(
  String errorCode, 
  @Nullable Object[] errorArgs,
  @Nullable String defaultMessage
);

Apply Thymeleaf

ν•„λ“œ 였λ₯˜ 처리

  • rejectedValue(): 였λ₯˜ λ°œμƒ μ‹œ μ‚¬μš©μž μž…λ ₯ 값을 μ €μž₯

bindingResult.rejectValue("price", "range", new Object[]{1000, 10000000}, "μƒν’ˆ 가격 였λ₯˜");
<input
  type="text"
  id="price"
  th:field="*{price}"
  th:errorclass="field-error"
  class="form-control"
  placeholder="가격을 μž…λ ₯ν•˜μ„Έμš”"
/>
<div class="field-error" th:errors="*{price}">가격 였λ₯˜</div>
  • th:fieldλŠ” ν‰μ†Œμ—λŠ” λͺ¨λΈ 객체의 값을 μ‚¬μš©ν•˜μ§€λ§Œ, 였λ₯˜κ°€ λ°œμƒν•˜λ©΄ FieldError μ—μ„œ λ³΄κ΄€ν•œ 값을 μ‚¬μš©ν•΄μ„œ 값을 좜λ ₯

κΈ€λ‘œλ²Œ 였λ₯˜ 처리

bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
<div th:if="${#fields.hasGlobalErrors()}">
  <p
    class="field-error"
    th:each="err : ${#fields.globalErrors()}"
    th:text="${err}"
  >
    전체 였λ₯˜ λ©”μ‹œμ§€
  </p>
</div>

Validation and Error Messages

MessageCodesResolver

  • 검증 였λ₯˜ μ½”λ“œλ‘œ λ©”μ‹œμ§€ μ½”λ“œλ“€μ„ 생성

  • MessageCodesResolver λŠ” μΈν„°νŽ˜μ΄μŠ€, DefaultMessageCodesResolver λŠ” κΈ°λ³Έ κ΅¬ν˜„μ²΄

  • ObjectError, FieldError 와 주둜 ν•¨κ»˜ μ‚¬μš©

DefaultMessageCodesResolver κΈ°λ³Έ λ©”μ‹œμ§€ 생성 κ·œμΉ™

ν•„λ“œ 였λ₯˜

ν•„λ“œ 였λ₯˜μ˜ 경우 λ‹€μŒ μˆœμ„œλ‘œ 4가지 λ©”μ‹œμ§€ μ½”λ“œ 생성

1. code + "." + ObjectName + "." + field
2. code + "." + field
3. code + "." + field type
4. code

---

(example)
error code: required
ObjectName: item
field: itemName
field type: String

1. "required.item.itemName"
2. "required.itemName"
3. "required.java.lang.String"
4. "required"

객체 였λ₯˜

객체 였λ₯˜μ˜ 경우 λ‹€μŒ μˆœμ„œλ‘œ 2가지 생성

1. code + "." + objectName
2. code

---

(example)
error code: totalPriceMin
ObjectName: item

1. "totalPriceMin.item"
2. "totalPriceMin"

λ™μž‘ 방식

  • rejectValue() , reject() λŠ” λ‚΄λΆ€μ—μ„œ MessageCodesResolver μ‚¬μš© ➜ μ—¬κΈ°μ„œ λ©”μ‹œμ§€ μ½”λ“œ 생성

    • FieldError, ObjectError μƒμ„±μžλ₯Ό 보면 μ•Œ 수 μžˆλ“―μ΄, μ—¬λŸ¬ 였λ₯˜ μ½”λ“œλ₯Ό κ°€μ§ˆ 수 있음

  • MessageCodesResolver λ₯Ό 톡해 μƒμ„±λœ μˆœμ„œλŒ€λ‘œ 였λ₯˜ μ½”λ“œ 보관

1. rejectValue() 호좜
2. MessageCodesResolver λ₯Ό μ‚¬μš©ν•΄μ„œ 검증 였λ₯˜ μ½”λ“œλ‘œ λ©”μ‹œμ§€ μ½”λ“œλ“€μ„ 생성
3. new FieldError() λ₯Ό μƒμ„±ν•˜λ©΄μ„œ λ©”μ‹œμ§€ μ½”λ“œλ“€μ„ 보관
4. th:erros μ—μ„œ λ©”μ‹œμ§€ μ½”λ“œλ“€λ‘œ λ©”μ‹œμ§€λ₯Ό μˆœμ„œλŒ€λ‘œ λ©”μ‹œμ§€μ—μ„œ μ°Ύκ³  좜λ ₯

였λ₯˜ μ½”λ“œ 관리 μ „λž΅

ꡬ체적인 것 ➜ 덜 ꡬ체적인 κ²ƒμœΌλ‘œκ°€ 핡심

  • MessageCodesResolver λŠ” ꡬ체적인 것(required.item.itemName)을 λ¨Όμ € μƒμ„±ν•˜κ³ , 덜 ꡬ체적인 것(required)을 λ‚˜μ€‘μ•  생성

  • μ€‘μš”ν•˜μ§€ μ•Šμ€ λ©”μ‹œμ§€λŠ” λ²”μš©μ„± μžˆλŠ” κ°„λ‹¨ν•œ λ©”μ‹œμ§€(requried)둜, μ€‘μš”ν•œ λ©”μ‹œμ§€λŠ” ν•„μš”ν•  λ•Œ ꡬ체적으둜 μ‚¬μš©ν•˜λŠ” 방식이 효과적

errors.properties

#==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 μ‚¬μš© ν›„

ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");

Spring 검증 였λ₯˜ λ©”μ‹œμ§€

  • Spring 은 νƒ€μž… 였λ₯˜κ°€ λ°œμƒν•˜λ©΄ typeMismatch 였λ₯˜ μ½”λ“œλ₯Ό μ‚¬μš©

    • 주둜 νƒ€μž… 정보가 λ§žμ§€ μ•Šμ„ 경우 Spring 직접 검증

  • typeMismatch 였λ₯˜ μ½”λ“œκ°€ MessageCodesResolver λ₯Ό 톡해 4가지 λ©”μ‹œμ§€ μ½”λ“œλ‘œ 생성

    • typeMismatch.item.price

    • typeMismatch.price

    • typeMismatch.java.lang.Integer

    • typeMismatch

errors.properties

typeMismatch.java.lang.Integer=숫자λ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”.
typeMismatch=νƒ€μž… 였λ₯˜μž…λ‹ˆλ‹€.

Validator

μŠ€ν”„λ§μ€ μ²΄κ³„μ μœΌλ‘œ 검증 κΈ°λŠ₯을 μ œκ³΅ν•˜κΈ° μœ„ν•΄ Validator μΈν„°νŽ˜μ΄μŠ€ 제곡

public interface Validator {
    boolean supports(Class<?> clazz); // ν•΄λ‹Ή 검증기λ₯Ό μ§€μ›ν•˜λŠ” μ—¬λΆ€ 확인
    void validate(Object target, Errors errors); // 검증 λŒ€μƒ 객체와 BindingResult
}

κ²€μ¦λ‘œμ§ 뢄리와 호좜

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

...

@PostMapping("/add")
public String addItem(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    itemValidator.validate(item, bindingResult);

    ...
}

WebDataBinder

WebDataBinder λŠ” μŠ€ν”„λ§μ˜ νŒŒλΌλ―Έν„° 바인딩 역할을 ν•΄μ£Όκ³ , 검증 κΈ°λŠ₯도 내뢀에 포함

/**
 * @InitBinder
 * νŠΉμ • μ»¨νŠΈλ‘€λŸ¬μ—λ§Œ 적용
 */
@InitBinder
public void init(WebDataBinder dataBinder) {
    dataBinder.addValidators(itemValidator);
}

...

/**
 * Application.class
 * κΈ€λ‘œλ²Œ μ„€μ •(λͺ¨λ“  μ»¨νŠΈλ‘€λŸ¬μ— λ‹€ 적용)
 * 직접 μ‚¬μš©ν•˜λŠ” κ²½μš°λŠ” λ“œλ¬Ύ
 */
@Override
public Validator getValidator() {
    return new ItemValidator();
}
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    ...
}
  • WebDataBinder 에 검증기 μΆ”κ°€ μ‹œ ν•΄λ‹Ή μ»¨νŠΈλ‘€λŸ¬μ—μ„œλŠ” 검증기λ₯Ό μžλ™ 적용

  • WebDataBinder λ₯Ό 톡해 ItemValidator 호좜

@Validated

  • 검증기λ₯Ό μ‹€ν–‰ν•˜λΌλŠ” μ• λ…Έν…Œμ΄μ…˜

  • WebDataBinder 에 λ“±λ‘ν•œ 검증기λ₯Ό μ°Ύμ•„μ„œ μ‹€ν–‰

    • μ—¬λŸ¬ 검증기가 λ“±λ‘λœλ‹€λ©΄ Validator.supports() λ₯Ό 톡해 ꡬ뢄

    • μ—¬κΈ°μ„œλŠ” Validator.supports(Item.class) 호좜 ν›„, κ²°κ³Όκ°€ true μ΄λ―€λ‘œ ItemValidator 의 validate() 호좜

  • bindingResult 에 검증 κ²°κ³Όκ°€ λ‹΄κΉ€

Bean Validation

  • 검증 λ‘œμ§μ„ λͺ¨λ“  ν”„λ‘œμ νŠΈμ— μ μš©ν•  수 있게 κ³΅ν†΅ν™”ν•˜κ³ , ν‘œμ€€ν™”

    • μ• λ…Έν…Œμ΄μ…˜ ν•˜λ‚˜λ‘œ 검증 λ‘œμ§μ„ 맀우 νŽΈλ¦¬ν•˜κ²Œ 적용

  • νŠΉμ •ν•œ κ΅¬ν˜„μ²΄κ°€ μ•„λ‹ˆλΌ Bean Validation 2.0(JSR-380)μ΄λΌλŠ” 기술 ν‘œμ€€

    • 검증 μ• λ…Έν…Œμ΄μ…˜κ³Ό μ—¬λŸ¬ μΈν„°νŽ˜μ΄μŠ€μ˜ λͺ¨μŒ

    • 마치 JPA κ°€ ν‘œμ€€ 기술이고 κ·Έ κ΅¬ν˜„μ²΄λ‘œ ν•˜μ΄λ²„λ„€μ΄νŠΈκ°€ μžˆλŠ” 것과 μœ μ‚¬

  • 일반적으둜 μ‚¬μš©ν•˜λŠ” κ΅¬ν˜„μ²΄λŠ” Hibernate Validator

    • ORM κ³ΌλŠ” 무관..

Hibernate Validator Reference

Hibernate Validator

Hibernate Validator Guide

Jakarta Bean Validation constraints


dependence

implementation 'org.springframework.boot:spring-boot-starter-validation'

Jakarta Bean Validation

  • jakarta.validation-api: Bean Validation μΈν„°νŽ˜μ΄μŠ€

  • hibernate-validator: κ΅¬ν˜„μ²΄

검증 μ• λ…Έν…Œμ΄μ…˜ 적용

@Data
@NoArgsConstructor
public class Item {

    private Long id;

    @NotBlank // λΉˆκ°’ + 곡백 ν—ˆμš© X
    private String itemName;

    @NotNull // null ν—ˆμš© X
    @Range(min = 1000, max = 1000000) // λ²”μœ„ μ•ˆμ˜ 값이어야 ν—ˆμš©
    private Integer price;

    @NotNull
    @Max(9999) // μ΅œλŒ€ 9999κΉŒμ§€λ§Œ ν—ˆμš©
    private Integer quantity;

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

...

@Test
void beanValidation() {
    /**
     * 검증기 생성
     */
    ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    Validator validator = factory.getValidator();

    Item item = new Item();
    item.setItemName(" ");
    item.setPrice(0);
    item.setQuantity(10000);

    /**
     * 검증 μ‹€ν–‰
     *
     * 검증 λŒ€μƒ(item)을 검증기에 μ‚½μž…
     * Set μ—λŠ” ConstraintViolation μ΄λΌλŠ” 검증 였λ₯˜κ°€ λ‹΄κΉ€
     * κ²°κ³Όκ°€ λΉ„μ–΄μžˆμœΌλ©΄ 검증 였λ₯˜κ°€ μ—†λŠ” 것
     */
    Set<ConstraintViolation<Item>> violations = validator.validate(item);
    for (ConstraintViolation<Item> violation : violations) {
        System.out.println("violation=" + violation);
        System.out.println("violation.message=" + violation.getMessage() + "\n");
    }
}
violation=ConstraintViolationImpl{interpolatedMessage='곡백일 수 μ—†μŠ΅λ‹ˆλ‹€', propertyPath=itemName, rootBeanClass=class com.conquest.spring.validation.domain.Item, messageTemplate='{jakarta.validation.constraints.NotBlank.message}'}
violation.message=곡백일 수 μ—†μŠ΅λ‹ˆλ‹€

violation=ConstraintViolationImpl{interpolatedMessage='9999 μ΄ν•˜μ—¬μ•Ό ν•©λ‹ˆλ‹€', propertyPath=quantity, rootBeanClass=class com.conquest.spring.validation.domain.Item, messageTemplate='{jakarta.validation.constraints.Max.message}'}
violation.message=9999 μ΄ν•˜μ—¬μ•Ό ν•©λ‹ˆλ‹€

violation=ConstraintViolationImpl{interpolatedMessage='1000μ—μ„œ 1000000 사이여야 ν•©λ‹ˆλ‹€', propertyPath=price, rootBeanClass=class com.conquest.spring.validation.domain.Item, messageTemplate='{org.hibernate.validator.constraints.Range.message}'}
violation.message=1000μ—μ„œ 1000000 사이여야 ν•©λ‹ˆλ‹€

Apply Bean Validation in Spring

μŠ€ν”„λ§ λΆ€νŠΈλŠ” spring-boot-starter-validation λΌμ΄λΈŒλŸ¬λ¦¬κ°€ μΆ”κ°€λ˜λ©΄ μžλ™μœΌλ‘œ Bean Validator λ₯Ό μΈμ§€ν•˜κ³  μŠ€ν”„λ§μ— 톡합

  • Spring Boot λŠ” μžλ™μœΌλ‘œ LocalValidatorFactoryBean 을 Global Validator 둜 등둝

    • LocalValidatorFactoryBean 은 Annotation 기반 검증

  • @Valid(μžλ°” ν‘œμ€€), @Validated(μŠ€ν”„λ§ μ „μš©) 만 μ μš©ν•˜μ—¬ 검증 μ‚¬μš© κ°€λŠ₯

  • 검증 였λ₯˜ λ°œμƒ μ‹œ FieldError, ObjectError λ₯Ό μƒμ„±ν•΄μ„œ BindingResult 에 λ‹΄μ•„ μ€€λ‹€.

  • ItemValidator κ°€ λ“±λ‘λ˜μ–΄ μžˆλ‹€λ©΄ 였λ₯˜ 검증기 쀑볡을 막기 μœ„ν•΄ μ œκ±°κ°€ ν•„μš”

@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    // ..
}

검증 μˆœμ„œ

\1. @ModelAttribute 각 ν•„λ“œμ— νƒ€μž… λ³€ν™˜ μ‹œλ„

  • λ³€ν™˜μ— μ„±κ³΅ν•œ ν•„λ“œλ§Œ BeanValidation 적용

  • μ‹€νŒ¨ν•˜λ©΄ typeMismatch 둜 FieldError μΆ”κ°€

\2. BeanValidation

  • 바인딩에 μ„±κ³΅ν•œ ν•„λ“œλ§Œ BeanValidation 적용

  • 바인딩에 μ‹€νŒ¨ν•œ ν•„λ“œλŠ” μ μš©ν•˜μ§€ μ•ŠμŒ

μ—λŸ¬ μ½”λ“œ

μ• λ…Έν…Œμ΄μ…˜ 였λ₯˜ μ½”λ“œλ₯Ό 기반으둜 MessageCodesResolver λ₯Ό 톡해 λ‹€μ–‘ν•œ λ©”μ‹œμ§€ μ½”λ“œκ°€ μˆœμ„œλŒ€λ‘œ 생성

@NotBlank

  • NotBlank.item.itemName

  • NotBlank.itemName

  • NotBlank.java.lang.String

  • NotBlank

@Range

  • Range.item.price

  • Range.price

  • Range.java.lang.Integer

  • Range

NotBlank.item.itemName=μƒν’ˆ 이름을 μž…λ ₯ν•΄μ£Όμ„Έμš”.
NotBlank={0} 곡백X
Range={0}, {2} ~ {1} ν—ˆμš©
Max={0}, μ΅œλŒ€ {1}

BeanValidation λ©”μ‹œμ§€ μ°ΎλŠ” μˆœμ„œ

  1. μƒμ„±λœ λ©”μ‹œμ§€ μ½”λ“œ μˆœμ„œλŒ€λ‘œ messageSource μ—μ„œ λ©”μ‹œμ§€ μ°ΎκΈ°

  2. μ• λ…Έν…Œμ΄μ…˜μ˜ message 속성 μ‚¬μš© ➜ @NotBlank(message = "곡백은 μž…λ ₯ν•  수 μ—†μŠ΅λ‹ˆλ‹€.")

  3. λΌμ΄λΈŒλŸ¬λ¦¬κ°€ μ œκ³΅ν•˜λŠ” κΈ°λ³Έ κ°’ μ‚¬μš© ➜ "곡백일 수 μ—†μŠ΅λ‹ˆλ‹€"

κΈ€λ‘œλ²Œ 였λ₯˜

@ScriptAssert() μ‚¬μš©μ€ μ œμ•½μ΄ 많고 검증 κΈ°λŠ₯이 ν•΄λ‹Ή 객체 λ²”μœ„λ₯Ό λ„˜μ–΄μ„€ 경우 λŒ€μ‘μ΄ μ–΄λ ΅λ‹€.

  • κΈ€λ‘œλ²Œ 였λ₯˜ κ΄€λ ¨ λΆ€λΆ„λ§Œ μžλ°” μ½”λ“œλ‘œ μž‘μ„±ν•˜λŠ” 것을 ꢌμž₯

if (item.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();
    if (resultPrice < 10000) {
        bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
    }
}

groups

λ“±λ‘μ‹œμ— 검증할 κΈ°λŠ₯κ³Ό μˆ˜μ •μ‹œμ— 검증할 κΈ°λŠ₯을 각각 그룹으둜 λ‚˜λˆ„μ–΄ 적용

  • groups κΈ°λŠ₯은 μ‹€μ œ 잘 μ‚¬μš©λ˜μ§€ μ•ŠμŒ

  • λŒ€μ‹  μ‹€λ¬΄μ—μ„œλŠ” 주둜 λ“±λ‘μš© 폼 객체(ItemSaveForm)와 μˆ˜μ •μš© 폼 객체(ItemUpdateForm)λ₯Ό λΆ„λ¦¬ν•΄μ„œ μ‚¬μš©

μ°Έκ³ . @Valid μ—λŠ” groups 적용 κΈ°λŠ₯을 μ œκ³΅ν•˜μ§€ μ•ŠμœΌλ―€λ‘œ, groups μ‚¬μš© μ‹œ @Validated λ₯Ό μ‚¬μš©ν•˜μž.

groups 생성

/**
 * μ €μž₯용 groups
 */
public interface SaveCheck {
}

...

/**
 * μˆ˜μ •μš© groups
 */
public interface UpdateCheck {
}

...

@Data
@NoArgsConstructor
public class Item {

    @NotNull(groups = UpdateCheck.class) // μˆ˜μ • μ‹œμ—λ§Œ 적용
    private Long id;

    @NotBlank(message = "{0} 곡백은 μž…λ ₯ν•  수 μ—†μŠ΅λ‹ˆλ‹€.", groups = {SaveCheck.class, UpdateCheck.class})
    private String itemName;

    @NotNull(groups = {SaveCheck.class, UpdateCheck.class})
    @Range(min = 1000, max = 1000000, groups = {SaveCheck.class, UpdateCheck.class})
    private Integer price;

    @NotNull(groups = {SaveCheck.class, UpdateCheck.class})
    @Max(value = 9999, groups = SaveCheck.class) // 등둝 μ‹œμ—λ§Œ 적용
    private Integer quantity;

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

validation interface λͺ…μ‹œ

@PostMapping("/add")
public String addItem(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
  //...
}

...

@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {
  //...
}

Form 전솑 객체 뢄리 🌞

groups κΈ°λŠ₯을 μ‚¬μš©ν•˜λ©΄ μ „λ°˜μ μΈ λ³΅μž‘λ„κ°€ μƒμŠΉν•΄μ„œ μ‹€λ¬΄μ—μ„œλŠ” 주둜 폼 객체λ₯Ό λΆ„λ¦¬ν•΄μ„œ μ‚¬μš©

  • 등둝과 μˆ˜μ •μ€ λ‹€λ£¨λŠ” 데이터 λ²”μœ„μ— 차이가 μžˆλ‹€λ³΄λ‹ˆ μ™„μ „νžˆ λ‹€λ₯Έ 데이터가 λ„˜μ–΄μ˜¨λ‹€.

  • λ”°λΌμ„œ, Save/Update λ³„λ„μ˜ 객체둜 데이터λ₯Ό μ „λ‹¬λ°›λŠ” 것이 μ’‹λ‹€.

  • 폼 데이터 전달을 μœ„ν•œ λ³„λ„μ˜ 객체λ₯Ό μ‚¬μš©ν•˜λ©΄ 등둝, μˆ˜μ •μ΄ μ™„μ „νžˆ λΆ„λ¦¬λ˜κΈ° λ•Œλ¬Έμ— groups 적용이 λΆˆν•„μš”

Form 전솑 객체 뢄리

@Data
public class Item {
    private Long id;
    private String itemName;
    private Integer price;
    private Integer quantity;
}

...

@Data
public class ItemSaveForm {

    @NotBlank(message = "{0} 곡백은 μž…λ ₯ν•  수 μ—†μŠ΅λ‹ˆλ‹€.")
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    @NotNull
    @Max(value = 9999)
    private Integer quantity;
}

...

@Data
public class ItemUpdateForm {

    @NotNull
    private Long id;

    @NotBlank(message = "{0} 곡백은 μž…λ ₯ν•  수 μ—†μŠ΅λ‹ˆλ‹€.")
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    private Integer quantity;
}

λΆ„λ¦¬λœ 전솑 객체 적용

  • MVC Model 에 item 으둜 담기도둝 ν•˜κΈ° μœ„ν•΄ @ModelAttribute("item") 적용

@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

    ...

    Item item = new Item();
    item.setItemName(form.getItemName());
    item.setPrice(form.getPrice());
    item.setQuantity(form.getQuantity());

    ...
}

@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {

    ...

    Item itemParam = new Item();
    itemParam.setItemName(form.getItemName());
    itemParam.setPrice(form.getPrice());
    itemParam.setQuantity(form.getQuantity());

    ...
}

HTTP Message Converter

@Valid, @Validated λŠ” HttpMessageConverter(@RequestBody)에도 적용 κ°€λŠ₯

@ModelAttribute λŠ” HTTP μš”μ²­ νŒŒλΌλ―Έν„°(URL 쿼리 슀트링, POST Form)λ₯Ό λ‹€λ£° λ•Œ μ‚¬μš©

@RequestBody λŠ” HTTP Body의 데이터λ₯Ό 객체둜 λ³€ν™˜ν•  λ•Œ μ‚¬μš©(주둜 API JSON μš”μ²­)

@Slf4j
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {

    @PostMapping("/add")
    public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult) {
        // ...

        if (bindingResult.hasErrors()) {
            return bindingResult.getAllErrors();
        }

        // ...
    }
}

API μš”μ²­ μΌ€μ΄μŠ€

  • 성곡 μš”μ²­: 성곡

  • μ‹€νŒ¨ μš”μ²­: JSON 을 객체둜 μƒμ„±ν•˜λŠ” 것 μžμ²΄κ°€ μ‹€νŒ¨

    JSON parse error: Cannot deserialize value of type `java.lang.Integer` from String "A": not a valid `java.lang.Integer` value]
    {
      "timestamp": "2023-07-07T00:00:00.000+00:00",
      "status": 400,
      "error": "Bad Request",
      "message": "",
      "path": "/validation/api/items/add"
    }
  • 검증 였λ₯˜ μš”μ²­: JSON을 객체둜 μƒμ„±ν•˜λŠ” 것은 μ„±κ³΅ν–ˆμ§€λ§Œ, κ²€μ¦μ—μ„œ μ‹€νŒ¨

    • μ‹€λ¬΄μ—μ„œλŠ” ν•„μš”ν•œ λ°μ΄ν„°λ§Œ 뽑아 별도 API μŠ€νŽ™μ„ μ •μ˜ν•˜κ³  객체λ₯Ό λ§Œλ“€μ–΄μ„œ 였λ₯˜ 정보 λ°˜ν™˜

    Field error in object 'itemSaveForm' on field 'quantity': rejected value [10000]; codes [Max.itemSaveForm.quantity,Max.quantity,Max.java.lang.Integer,Max]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [itemSaveForm.quantity,quantity]; arguments []; default message [quantity],9999]; default message [9999 μ΄ν•˜μ—¬μ•Ό ν•©λ‹ˆλ‹€]
    [
      {
        "codes": [
          "Max.itemSaveForm.quantity",
          "Max.quantity",
          "Max.java.lang.Integer",
          "Max"
        ],
        "arguments": [
          {
            "codes": [
              "itemSaveForm.quantity",
              "quantity"
            ],
            "arguments": null,
            "defaultMessage": "quantity",
            "code": "quantity"
          },
          9999
        ],
        "defaultMessage": "9999 μ΄ν•˜μ—¬μ•Ό ν•©λ‹ˆλ‹€",
        "objectName": "itemSaveForm",
        "field": "quantity",
        "rejectedValue": 10000,
        "bindingFailure": false,
        "code": "Max"
      }
    ]

@ModelAttribute VS @RequestBody

  • HttpMessageConverter λŠ” 각 ν•„λ“œ λ‹¨μœ„λ‘œ μ μš©λ˜λŠ” 것이 μ•„λ‹ˆλΌ, 전체 객체 λ‹¨μœ„λ‘œ 적용

  • λ”°λΌμ„œ, λ©”μ‹œμ§€ 컨버터 μž‘λ™μ΄ μ„±κ³΅ν•΄μ„œ 객체λ₯Ό 생성해야 @Valid, @Validated κ°€ 적용

@ModelAttribute

  • HTTP μš”μ²­ νŒŒλ¦¬λ―Έν„°λ₯Ό 처리

    • ModelAttribute λŒ€μƒ κ°μ²΄λŠ” Setter λ©”μ„œλ“œ ν•„μš”

  • ν•„λ“œ λ‹¨μœ„λ‘œ μ •κ΅ν•˜κ²Œ 바인딩이 적용

  • νŠΉμ • ν•„λ“œκ°€ 바인딩 λ˜μ§€ μ•Šλ”λΌλ„ λ‚˜λ¨Έμ§€ ν•„λ“œλŠ” 정상 바인딩 되고, @Validator λ₯Ό μ‚¬μš©ν•œ 검증도 적용 κ°€λŠ₯

@RequestBody

  • HttpMessageConverter λ‹¨κ³„μ—μ„œ JSON 데이터λ₯Ό 객체둜 λ³€κ²½ μ‹€νŒ¨νŒŒλ©΄ 이후 단계가 μ§„ν–‰λ˜μ§€ μ•Šκ³  400 Bad Request μ˜ˆμ™Έ λ°œμƒ

  • νƒ€μž… 였λ₯˜κ°€ λ°œμƒν•  경우 μ»¨νŠΈλ‘€λŸ¬λ„ ν˜ΈμΆœλ˜μ§€ μ•Šκ³ , Validator 도 적용 λΆˆκ°€

    {
        "timestamp": "2023-07-07T04:36:11.489+00:00",
        "status": 400,
        "error": "Bad Request",
        "path": "/validation/api/items/add"
    }

Ideal result code..

Last updated