본문 바로가기

Programming/스프링

[김영한 스프링 MVC 2] 검증1 - Validation

검증 요구사항

 

  • 요구사항  - 검증 로직 추가

1. 타입 검증
가격, 수량에 문자가 들어가면 검증 오류 처리

 

2. 필드 검증
상품명: 필수, 공백X
가격: 1000원 이상, 1백만원 이하
수량: 최대 9999

 

3. 특정 필드의 범위를 넘어서는 검증
가격 * 수량의 합은 10,000원 이상

 

 

 

  • 서버 검증

폼 입력시 오류 발생할 때, 고객 데이터 유지한 채로 어떤 오류가 발생했는지 친절하게 알려줘야 함

컨트롤러 => HTTP 요청이 정상인지 검증해야 함

 

클라이언트 검증은 조작할 수 있어 보안에 취약
서버만으로 검증하면 즉각적인 고객 사용성이 부족
∴ 클라이언트 & 서버 검증 모두 사용하되, 최종적으로 서버 검증은 필수
  API 방식 => API 스펙을 잘 정의해 API 응답 결과에 검증 오류사항을 잘 남겨야 함

 

 

 

 

검증 직접 처리 

 

  • 검증 실패 로직

 

서버 검증 로직 실패시 => 상품 등록 폼 다시 표시 & 어떤 값을 잘못 입력했는지 친절하게 알려줌

 

 

  • ValidationItemControllerV1 수정
    @PostMapping("/add")
    public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) { //// model 추가
        //// 검증 오류 보관
        HashMap<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", "수량은 0 ~ 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); //// 현 메소드 파라미터의 item도 함께 전달됨
            return "validation/v1/addForm";
        }

        // 성공 로직
        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v1/items/{itemId}";
    }

 

 

  • addForm.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}"
          href="../css/bootstrap.min.css" rel="stylesheet">
    <style>
        .container {
            max-width: 560px;
        }

        .field-error {
            border-color: #dc3545;
            color : #dc3545;
        }
    </style>
</head>
<body>

<div class="container">

    <div class="py-5 text-center">
        <h2 th:text="#{page.addItem}">상품 등록</h2>
    </div>

    <form action="item.html" th:action th:object="${item}" method="post">
        <div th:if="${errors?.containsKey('globalError')}"> <!-- ?. : NPE 방지, null 그대로 반환 -> 태그 표시 X-->
            <p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
        </div>

        <div>
            <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
            <input type="text" id="itemName" th:field="*{itemName}"
                   th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"
                   class="form-control" placeholder="이름을 입력하세요"> <!--오류 있으면 field-error 클래스도 추가-->
            <!--th:classappend="${errors?.containsKey('itemName')} ? 'field-error" : _
                오류 있으면 해당 클래스 추가, 없으면 아무것도 하지 않음-->
            <div th:if="${errors?.containsKey('itemName')}" class="field-error" th:text="${errors['itemName']}">
                상품명 오류
            </div>
        </div>
        <div>
            <label for="price" th:text="#{label.item.price}">가격</label>
            <input type="text" id="price" th:field="*{price}" class="form-control" placeholder="가격을 입력하세요">
            <div th:if="${errors?.containsKey('price')}" class="field-error" th:text="${errors['price']}">
                가격 오류
            </div>
        </div>
        <div>
            <label for="quantity" th:text="#{label.item.quantity}">수량</label>
            <input type="text" id="quantity" th:field="*{quantity}" class="form-control" placeholder="수량을 입력하세요">
            <div th:if="${errors?.containsKey('quantity')}" class="field-error" th:text="${errors['quantity']}">
                수량 오류
            </div>
        </div>

        <hr class="my-4">

        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">상품 등록</button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg"
                        onclick="location.href='items.html'"
                        th:onclick="|location.href='@{/validation/v1/items}'|"
                        type="button" th:text="#{button.cancel}">취소</button>
            </div>
        </div>

    </form>

</div> <!-- /container -->
</body>
</html>

 

※ Safe Navigation Operator

errors?.containsKey('xxx')

 

등록 폼 첫 진입 시 errors 객체 존재 X -> NPE 발생함

?. => errors 가 null 일 때 NPE 막고 null 반환

th:if 에서 null은 실패로 처리 -> 오류 메시지 출력 X

 

 

 

  • 남은 문제점

1. 뷰 템플릿에 중복 처리할 코드가 많음

2. 타입 오류 처리가 안 됨

  ex) 숫자 타입에 문자 들어올 떄, 컨트롤러 진입도 전에 예외 발생되어 400 에러 발생

3. 타입 오류 발생 시 입력한 문자를 화면에 남겨야 함

  ex) Item의 price 필드는 Integer이므로 문자 보관 불가

 

 

 

 

BindingResult

 

  • BindingResult

: 스프링이 제공하는 검증 오류를 보관하는 객체

 

BindingResult 활용 시 @ModelAttribute 데이터 바인딩 오류 발생해도 컨트롤러가 호출됨

cf) BindingResult 없으면 => 400 오류 발생해 오류 페이지 이동, 컨트롤러 호출 X

 

BindingResult => Errors 인터페이스( 단순한 오류 저장, 조회 ) 상속받는 인터페이스

 

 

 

  • 오류 객체

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)

 

FieldError(@ModelAttribute 이름, 오류 발생 필드 이름, 오류 메시지)

FieldError(@ModelAttribute 이름, String 오류 필드 이름, @Nullable 입력값, boolean 바인딩실패 구분값, @Nullable 메시지 코드들, @Nullable 메시지에서 사용하는 파라미터들, @Nullable 오류 메시지)

ObjectError(@ModelAttribute 이름, 오류 메시지) => 특정 필드 넘어서는 오류 있을 때

 

rejectedValue : 오류 발생 시 사용자 입력값 저장

bindingFailure : 타입 오류 등 바인딩이 실패했는지 여부 확인

 

 

 

  • ValidationItemControllerV2
  • addItemV1
    @PostMapping("/add")
    public String addItemV1(@ModelAttribute Item item,
                          BindingResult bindingResult, // 순서 중요 (item에 binding된 결과 담음)
                          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", "수량은 0 ~ 9,999개까지 허용합니다."));
        }

        // 복합 룰
        if (item.getPrice() != null && item.getQuantity() != null){
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000){
                //Field 아닌 Object Error
                bindingResult.addError(new ObjectError("item", "가격 총합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
            }
        }

        // 검증 실패 시 다시 입력 폼으로
        if (bindingResult.hasErrors()){
            log.info("errors = {}", bindingResult);
            //model.addAttribute("errors", bindingResult); //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}";
    }

 

 

 

  • addForm.html 수정
    <form action="item.html" th:action th:object="${item}" method="post">
        <div th:if="${#fields.hasGlobalErrors()}">
            <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">글로벌 오류 메시지</p>
        </div>

        <div>
            <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
            <input type="text" id="itemName" th:field="*{itemName}"
                   th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
            <div class="field-error" th:errors="*{itemName}"> <!--itemName에 오류 있으면 출력 X-->
                상품명 오류
            </div>
        </div>
        <div>
            <label for="price" th:text="#{label.item.price}">가격</label>
            <input type="text" id="price" th:field="*{price}" th:errorclass="field-error" class="form-control" placeholder="가격을 입력하세요">
            <div class="field-error" th:errors="*{price}">
                가격 오류
            </div>
        </div>
        <div>
            <label for="quantity" th:text="#{label.item.quantity}">수량</label>
            <input type="text" id="quantity" th:field="*{quantity}" th:errorclass="field-error" class="form-control" placeholder="수량을 입력하세요">
            <div class="field-error" th:errors="*{quantity}">
                수량 오류
            </div>
        </div>

        <hr class="my-4">

        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">상품 등록</button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg"
                        onclick="location.href='items.html'"
                        th:onclick="|location.href='@{/validation/v2/items}'|"
                        type="button" th:text="#{button.cancel}">취소</button>
            </div>
        </div>

    </form>

 

※ 사용자 입력값 유지

th:field => 오류 발생 시 FieldError에서 보관한 값 사용해 출력

 

 

 

  • ValidationItemControllerV2
  • addItemV2
        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, "수량은 0 ~ 9,999개까지 허용합니다."));
        }

        // 복합 룰
        if (item.getPrice() != null && item.getQuantity() != null){
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000){
                //Field 아닌 Object Error
                bindingResult.addError(new ObjectError("item", null, null, "가격 총합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
            }
        }

 

생성자 활용

 

 

 

오류 코드와 메시지 처리

 

※ FieldError 생성자

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)

 

 

 

  • errors.properties 생성

※ erros_en.properties => 국제화 처리 가능

 

  • application.properties
spring.messages.basename=messages,errors

 

 

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

 

 

  • ValidationItemControllerV2 
@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, 
RedirectAttributes redirectAttributes) {
 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() > 10000) {
 bindingResult.addError(new FieldError("item", "quantity", 
item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[]
{9999}, null));
 }
 //특정 필드 예외가 아닌 전체 예외
 if (item.getPrice() != null && item.getQuantity() != null) {
 int resultPrice = item.getPrice() * item.getQuantity();
 if (resultPrice < 10000) {
 bindingResult.addError(new ObjectError("item", new String[]
{"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}";
}

 

codes : 메시지 코드 지정 ex) new String[] {"range.item.price"}

arguments : 코드의 파라미터에 대입 ex) new Object[]{1000, 100000}

 

 

 

  • BindingResult.rejectValue() 사용

컨트롤러에서 BindingResult는 검증해야 하는 객체인 target 바로 다음에 옴

=> BindingResult는 이미 검증할 객체 target을 인식하고 있음

=> FieldError, ObjectError 직접 생성하는 대신 BindingResult가 제공하는 rejectValue(), reject() 사용 가능

 

void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage);
// 오류 필드, 에러코드 (messageResolver), {0} 치환 값, 기본 오류 메시지

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

 

 

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

        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, 10000000}, null); /////
        }
        if (item.getQuantity() == null || item.getQuantity() >= 9999) {
            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}";
    }

 

 

 

  • MessageCodesResolver 기반 우선순위 활용
#Level1
required.item.itemName: 상품 이름은 필수 입니다.

#Level2
required: 필수 값 입니다

상세히 입력된 코드일수록 우선순위높음

 

크게 중요하지 않으면 required 같은 범용성 높은 메시지 사용,

중요한 메시지는 구체적으로 적어 사용하는 방식이 효과적

 

 

  • 필드 오류
1.: code + "." + object name + "." + field
2.: code + "." + field
3.: code + "." + field type
4.: code

 

4개 메시지코드 생성 (생성 순서대로 보관)

 

 

  • 객체 오류
1.: code + "." + object name
2.: code

 

2개 메시지 코드 생성

 

 

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

#==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.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");

 

 

 

  • 스프링이 직접 만든 오류 메시지 처리

1. 개발자가 직접 설정한 오류 코드 => rejectValue() 를 직접 호출
2. 스프링이 직접 검증 오류에 추가한 경우 => 주로 타입 정보가 맞지 않을 때 (typeMismatch)

 

ex. price 필드에 문자 입력 시 =>

typeMismatch.item.price
typeMismatch.price
typeMismatch.java.lang.Integer
typeMismatch

 

 

  • error.properties
#추가
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.

 

 

 

 

Validator 분리

 

복작한 검증 로직은 별도 클래스에 분리

 

 

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

 

 

  • ValidationItemControllerV2
    private final ItemValidator itemValidator; ////

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

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

 

 

 

  • 스프링 기능 추가 활용 (WebDataBinder)

 

  • ValidationItemControllerV2
    @InitBinder
    public void init(WebDataBinder dataBinder) { // 현 컨트롤러에 databinder 추가
        dataBinder.addValidators(itemValidator); // databinder 에 검증기 추가
    }
    
    @PostMapping("/add")
    // validator 호출 코드 작성하는 대신 어노테이션 통해 검증기 실행 (@Validated)
    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}";
    }

 

 

※ 모든 컨트롤러에 적용 (글로벌)

@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 : 자바 표준 검증 어노테이션 -> build.gradle 의존관계 추가 필요