Validation

dongdong·2022년 4월 26일
0

mvc2

목록 보기
1/2

우리가 웹 어플리케이션을 개발할때 가격을 입력해야하는 부분에 문자가 들어온다던가 수량을 입력하는부분에 문자나 음수가 들어올 경우 등등 검증에 실패하면 오류 화면으로 이동한다.
스프링부트에서는 검증하기쉽게 많은 기능을 지원 해준다.
처음은 기능없이 직접 구현하고 뒤에선 기능을 사용해서 검증을 해보자


📌컨트롤러는 HTTP요청이 정상인지 검증하는 역할도 있다!


💡참고: 클라이언트 검증, 서버 검증

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

검증 실패

고객이 상품 등록 폼에서 상품명을 입력하지 않거나, 가격, 수량 등이 너무 작거나 커서 검증 범위를
넘어서면, 서버 검증 로직이 실패해야 한다. 이렇게 검증에 실패한 경우 고객에게 다시 상품 등록 폼을
보여주고, 어떤 값을 잘못 입력했는지 친절하게 알려주어야 한다.


ControllerV1

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

}

보면 addItem메서드 안에 검증하는 로직을 개발자가 직접 구현
error를 Map타입인 errors에 key값으로 field를 value값으로 message를 담아서 뷰로 전달한다.

Thymeleaf - 입력 폼V1

<!--css-->
.field-error {
 border-color: #dc3545; 
 color: #dc3545;
}
<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')}">
            <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="이름을 입력하세요">
            <div class="field-error" th:if="${errors?.containsKey('itemName')}"
                   th:text="${errors['itemName']}">상품명 오류
            </div>
        </div>
        <!--th:classappend="${errors?.containsKey('itemName')} ? 'fielderror' : _"
 				class="form-control"> -->
  //...
  1. 필드 오류 처리
    th:class, th:appendclass : containsKey값에 해당 에러가 있을경우 class에 field-error 클래스를 붙여준다.
    class="form-control" ➡ "form-control field-error"
    errors?.containsKey : ?의 의미는 errors가 null일 경우 nullpointException이 터지지않고 null을 반환

📋정리

  • controller 에서 map 담은 error정보를 출력한다.
  • 만약 검증 오류가 발생하면 입력 폼을 다시 보여준다.
  • 검증 오류들을 고객에게 친절하게 안내해서 다시 입력할 수 있게 한다.
  • 검증 오류가 발생해도 고객이 입력한 데이터가 유지된다

남은 문제

  • 여러항목일경우 뷰 템플릿에서 중복이 많다
  • 타입 오류 처리가 안돼고 400에러 페이지가 뜬다.
  • 타입 오류일시 입력한 데이터가 사라진다

BindingResult - 스프링이 제공하는 검증 오류 처리 방법

BindingResult 사용 방법

ControllerV2 - addItemV1

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

        //단일 항목 검증, FieldError첫번째 생성자 사용
        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 까지 허용합니다."));
        }

✅BindingResult순서가 중요!
    반드시 @ModelAttribute Item item 뒤에와야함

    bindingResult에 에러를 담는다.

bindingResult.addError
메서드 이름처럼 Error가 발생하면 Error를 추가 해주는 메서드
bindingResult.addError
  ❗특정 필드가 아닐경우 ObjectError를 사용!
  FieldError는 ObjectError를 상속하기 때문에 메서드 인자로 넣을 수 있다.

thymeleaf - 입력 폼V2

<!--글로벌 오류 처리 -->
<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>

${#fields} : bindingResult가 제공하는 검증 오류에 접근할 수 있다.
th:errorclass : th:field 에서 지정한 필드에 오류가 있으면 class 정보를 추가한다. (V1 보다 간결해짐)
th:errors : 해당 필드에 오류가 있으면 출력해준다 th:if 편의 버전 (V1 보다 간결해짐)

✔남은 문제
1.검증 오류가 있으면 입력값이 사라진다. 이것은 사용자 입장에선 불편함.
2.타입오류가 있을시 400에러 페이지는 사라 졌지만 화면에 에러메시지가 생긴다.


FieldError - 두번째 생성자
입력한 데이터를 유지 하기 위해선 FieldError 두번째 생성자를 사용 해야한다. 방금 위에선 사용한 건 첫번째 생성자

//오류 발생시 사용자가 입력한 데이터 유지x
public FieldError(String objectName, String field, String defaultMessage);

//오류 발생시 사용자가 입력한 데이터 유지o
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 : 기본 오류 메시지
  • rejectedValue 가 바로 오류 발생시 사용자 입력 값을 저장하는 필드다.
  • bindingFailure 는 타입 오류 같은 바인딩이 실패했는지 여부를 적어주면 된다. 여기서는 바인딩이
    실패한 것은 아니기 때문에 false 를 사용한다

ControllerV2 - addItemV2

@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 까지 허용합니다."));
 }
 //...

오류 코드와 메시지 처리

FieldError두번째 생성자중 파라미터 String[] codes, Object[] arguments 에서 메시지 처리를 사용할 수 있다.

메시지 처리 방법
1. application.properties에 errors.properties를 인식할 수 있도록 아래코드 추가 만약 코드 생략시 기본 (messages)만 인식

spring.messages.basename=messages,errors

errors.properties

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

Controller - addItemV3

   @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, "상품 이름은 필수 입니다."));
        }
    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}, "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
        }
    if(item.getQuantity() == null || item.getQuantity() > 10000) {
        bindingResult.addError(new FieldError("item", "quantity",
           item.getQuantity(),false,new String[]{"range.item.quantity"},new Object[]{9999},"수량은 최대 9,999 까지 허용합니다."));
        }
   //특정 필드가 아닌 복합 필드 검증
    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}, "가격 * 수량의 합은 10,000원 이상이어야합니다. 현재 값 = "+ resultPrice));
            }
        }
 //...

📋 설명
codes : required.item.itemName 를 사용해서 메시지 코드를 지정한다. 메시지 코드는 하나가 아니라
배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.
arguments : Object[]{1000, 1000000} 를 사용해서 코드의 {0} , {1} 로 치환할 값을 전달한다.

BindingResult2 - rejectValue() , reject()

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

field : 오류 필드명
errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다.messageResolver를 위한 오류 코드이다.)
errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값
defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

rejectValue 동작 방식
1. rejectValue() 호출
2. MessageCodesResolver 를 사용해서 검증 오류 코드로 메시지 코드들을 생성
3. new FieldError() 를 생성하면서 메시지 코드들을 보관
4. th:erros 에서 메시지 코드들로 메시지를 순서대로 메시지에서 찾고, 노출

핵심은 MessageCodesResolver의해 자세히 작성된 메시지 -> 단순한 메시지 순으로 적용 된다는 것!

controller - addItemV4

@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
  //간결해진 코드
 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);
 }

❗rejectValue는 range라고만 적어도 에러코드를 잘 찾아 주는 이유는MessageCodesResolver가 내부에서 동작 하기때문이다.

MessageCodesResolver

검증 오류 코드로 메시지 코드들을 생성한다.
MessageCodesResolver는 인터페이스이고 DefaultMessageCodesResolver 는 기본 구현체이다

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

이런 식으로 자세한 메시지 작성도 가능하고 좀 더 범용적으로 쓰이는 메시지 작성도 가능하다.
resolver가 내부에서 4가지 오류 코드를 생성해서 가지고 있는다.

codes [range.item.price, range.price, range.java.lang.Integer, range];

resolver 생성 규칙
객체 오류
1.: code + "." + object name
2.: code
예) 오류 코드: required, object name: item
1.: required.item
2.: required

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

validation - 2가지 분리방법

controller에서 검증 로직을 분리

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) {...}

validate메서드안에 검증 로직을 옮겨 넣으면 된다.
이러면 컨트롤러 부분에서 검증로직을 지워 깔끔 해진다.

controller - addItemV5

private final ItemValidator itemValidator;
@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
itemValidator.validate(item, bindingResult);//분리된 검증을 처리하는 부분
//...

@InitBinder
원하는 controller에 검증기를 달아 줄 수 있다.
1. itemValidator.validate(item, bindingResult); 삭제
2. @Validated 어노테이션 입력
controller - addItemV6

 @InitBinder
public void init(WebDataBinder dataBinder) {
log.info("init binder {}", dataBinder);
dataBinder.addValidators(itemValidator);
}

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

@Validated VS @Valid
@Validated 는 스프링 전용 검증 애노테이션이고, @Valid 는 자바 표준 검증 애노테이션이다
@Valid는 사용하려면 gradle에 의존 관계를 추가 해야한다

profile
공부하고 기록하기~

0개의 댓글