[스프링 MVC - 2편] 검증1 - Validation

지현·2021년 12월 30일
0

스프링

목록 보기
25/32

검증 요구사항

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

클라이언트 검증(주로 자바스크립트), 서버 검증

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

검증 직접 처리 - 개발


컨트롤러

    //검증 로직
        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()){
            log.info("errors={}",errors);
            model.addAttribute("errors",errors);
            return "validation/v1/addForm";
        }
  • 검증 오류 발생 시 errors에 저장
  • 등록폼을 다시 띄우고 거기에 저장된 errors와 저장된 입력 데이터들 보냄

뷰 템플릿

<!--글로벌 오류 처리-->
    <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>
  • errors가 없을 때는 errors.containsKey()를 호출하는 순간 NullPointerException이 발생
  • errors?. 은 errors 가 null 일때 NullPointerException 이 발생하는 대신, null 을 반환하는 문법

BindingResult1

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

bindingresult

  • 스프링이 제공하는 매커니즘, errors 대체 (bindingResult에 에러를 담아줌)
  • BindingResult bindingResult 파라미터의 위치는 @ModelAttribute Item item 다음에 와야 함 > item 객체의 바인딩 결과를 담고있기 때문에 순서가 중요
  • bindingResult는 따로 model에 담지 않아도 됨 > 자동으로 뷰에 같이 넘어감
  • FieldError
    • 필드 오류를 담음
    • bindingResult.addError(new FieldError(@ModelAttribute 이름,오류가 발생한 필드 이름, 오류 기본 메시지));
  • ObjectError
    • 글로벌 오류를 담음 (특정 필드를 넘어서는 오류)
    • bindingResult.addError(new ObjectError(@ModelAttribute 의 이름, 오류 기본 메시지));
    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";
        }
      
     ...
     
      }

타임리프 스프링 검증 오류 통합 기능

타임리프는 스프링의 BindingResult 를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공

  • #fields : #fields 로 BindingResult 가 제공하는 검증 오류에 접근
  • th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력, th:if 의 편의 버전
  • th:errorclass : th:field 에서 지정한 필드에 오류가 있으면 class 정보를 추가

글로벌 오류 처리

        <div th:if="${#fields.hasGlobalErrors()}">
            <p class="field-error" th:each="err:${#fields.globalErrors()}" 
               th:text="${err}">글로벌 오류 메시지</p>
        </div>
  • 글로벌 오류들을 th:each를 통해 모두 출력

필드 오류 처리

        <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}">
                상품명 오류
            </div>
        </div>

BindingResult2

  • 스프링이 제공하는 검증 오류를 보관하는 객체
  • 검증 오류가 발생하면 여기에 보관
  • BindingResult 가 있으면 @ModelAttribute 에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출됨 > 뭐가 문제가 있는지 여기에 담김
    • BindingResult가 있으면 바인딩에 문제가 생기면 오류 정보(FieldError)를 BindingResult에 담아 컨트롤러를 정상 호출 > 어디서 문제 발생했는지 에러를 출력
    • BindingResult가 없으면 스프링이 컨트롤러 호출 하지 않고 튕겨버림 > 400 오류가 발생하여 컨트롤러가 호출되지 않고, 바로 오류페이지로 보냄

BindingResult에 검증 오류를 적용하는 3가지 방법
1. @ModelAttribute 의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError 생성해서 BindingResult에 넣어줌 (스프링이 자동 처리)
2. 개발자가 직접 넣어줌 (검증 로직)
3. Validator 사용


FieldError, ObjectError

FieldError 생성자

 bindingResult.addError(
 	new FieldError("item","price",item.getPrice(),
 		false,null,null,"가격은 1,000~1,000,000 까지 허용합니다."));
  • 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);
  • 파라미터
    • objectName : 오류가 발생한 객체 이름, @ModelAttribute에 담기는 이름
    • field : 오류 필드 이름
    • rejectedValue : 사용자가 입력한 값 (거절된 값)
    • bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
    • codes : 메시지 코드
    • arguments : 메시지에서 사용하는 인자
    • defaultMessage : 기본 오류 메시지
  • rejectedValue를 사용하면 거절된 값을 넣어서 유지시켜주기 때문에 사용자가 입력 오류를 냈어도 값이 남아있음 (그전에는 값이 사라졌음)

오류 발생 시 사용자 입력 값 유지할 수 있는 이유

  • FieldError는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공
    • 이렇게 보관한 사용자 입력 값을 검증 오류 발생시 화면에 다시 출력
    • rejectedValue 가 바로 오류 발생시 사용자 입력 값을 저장하는 필드
  • 타임리프의 사용자 입력 값 유지
    • th:field="*{price}"
    • 정상 상황에는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError 에서 보관한 값을 사용해서 값을 출력

스프링의 바인딩 오류 처리

  • 타입 오류로 바인딩에 실패하면 스프링은 FieldError를 생성하면서 rejectedValue에 사용자가 입력한 값을 넣어둠
  • 해당 오류를 BindingResult 에 담아서 컨트롤러를 호출
  • 타입 오류 같은 바인딩 실패시에도 사용자가 입력한 값을 유지 할 수 있음

오류 코드와 메시지 처리 1 - FieldError, ObjectError

  • FieldError, ObjectError 의 생성자는 errorCode, arguments 를 제공 > 오류 발생시 오류 코드로 메시지를 찾기 위해 사용
  • 위에서 배운 메세지처럼 따로 별도의 파일로 메세지를 관리하여 사용할 수 있음
  • 국제화도 가능
  1. errors 메시지 파일 생성 errors.properties
  2. 스프링 부트 메시지 설정 추가 (application.properties) spring.messages.basename=messages,errors
//range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
bindingResult.addError(
new FieldError("item","price",item.getPrice(),false,
	new String[]{"range.item.price"},new Object[]{1000,1000000},null));
  • codes :
    • 메시지 코드를 지정
    • 메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용됨
    • codes에도 없으면 defaultMessage에 저장된 값 출력, 이마저도 없으면 오류 발생
  • arguments : Object[]{1000, 1000000} 를 사용해서 코드의 {0}, {1}로 치환할 값을 전달

오류 코드와 메시지 처리 2 - rejectValue() , reject()

컨트롤러에서 BindingResult 는 검증해야 할 객체인 target 바로 다음에 옴 > BindingResult 는 이미 본인이 검증해야 할 객체를 알고 있음

rejectValue() , reject()

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

bindingResult.rejectValue("price","range", new Object[]{1000,1000000},null);
  • void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
    • field : 오류 필드명
    • errorCode : 축약된 오류 코드(messageResolver를 위한 오류 코드)
    • errorArgs : 오류 메시지에서 {0}을 치환하기 위한 값
    • defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
  • 어떤 객체인지는 알기 때문에 object name은 작성하지 않아도 됨

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

  • 오류코드를 단순하게 만들면 범용성이 좋아서 여러곳에서 사용할 수 있지만, 메시지를 세밀하게 작성하기 어려움
  • 너무 자세하게 만들면 범용성이 떨어짐
  • 가장 좋은 방법은 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우에는 세밀한 내용이 적용되도록 메시지에 단계를 두는 방법

required 오류 코드를 사용한다고 가정할 때

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

#Level2
required: 필수 값 입니다.
  • 오류 메시지에 required.item.itemName 와 같이 객체명과 필드명을 조합한 세밀한 메시지코드가 있으면 이 메시지를 높은 우선순위로 사용
  • 개발 코드의 변경 없이 errors.properties의 수정 만으로도 전체 메세지 관리 가능
  • 스프링은 MessageCodesResolver 라는 것으로 이러한 기능을 지원

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

MessageCodesResolver

  • 검증 오류 코드로 메시지 코드들을 생성
//필드 에러일 때 (에러 코드, 객체 이름, 필드, 필드 타입)
String[] messageCodes = 
	codesResolver.resolveMessageCodes
    		("required", "item", "itemName", String.class);
 
 //객체 에러일 때 (에러 코드, 객체 이름)
 String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");

DefaultMessageCodesResolver의 기본 메시지 생성 규칙

객체 오류
객체 오류의 경우 다음 순서로 2가지 생성
1. : code + "." + object name
2. : code

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

자세한 순서에서 덜 자세한 순서로 생성

동작 방식

  • rejectValue(), reject() 는 내부에서 MessageCodesResolver를 사용 후 자동으로 FieldError , ObjectError를 생성하는 방식
  • FieldError , ObjectError는 MessageCodesResolver를 통해서 생성된 순서대로 오류 코드를 보관
  • rejectValue("itemName", "required")에서 MessageCodesResolver를 호출하여 4가지 오류 코드를 자동으로 생성하고 오류 코드들을 넣은 새로운 FieldError을 생성
  • reject("totalPriceMin")에서 MessageCodesResolver를 호출하여 2가지 오류 코드를 자동으로 생성하고 오류 코드들을 넣은 새로운 ObjectError을 생성

오류 메시지 출력

  • 타임리프 화면을 렌더링 할 때 th:errors 가 실행
  • 이때 오류가 있다면 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 찾음 - 없으면 디폴트 메시지를 출력

오류 코드와 메시지 처리 5

  • 애플리케이션 코드를 변경 할 필요 없이 errors.properties만 변경하면 됨
  • 구체적인 것에서 덜 구체적인 순서대로 찾음
  • 메시지에 1번이 없으면 2번을 찾고, 2번이 없으면 3번을 찾음
  • 크게 중요하지 않은 오류 메시지는 기존에 정의된 것을 그냥 재활용 하면 됨

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


오류 코드와 메시지 처리 6

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


  • 스프링은 타입 오류가 발생하면 typeMismatch 오류 코드를 사용
  • 오류 코드가 MessageCodesResolver를 통해 4가지 메세지 코드 생성
typeMismatch.item.price
typeMismatch.price
typeMismatch.java.lang.Integer
typeMismatch
  • 사용자가 errors.properties파일에 따로 지정해주지 않으면 해당 오류가 발생했을 때 스프링이 생성한 기본 메세지가 출력됨

Validator 분리 1

복잡한 검증 로직을 별도로 분리하기 위해서는 별도의 클래스로 역할을 분리

  1. Validator를 구현하는 클래스를 생성

    • supports() {} : 해당 검증기를 지원하는 여부 확인
    • validate(Object target, Errors errors) : 검증 대상 객체와 BindingResult, 검증 로직 여기에 넣음
  2. 생성한 클래스를 스프링 빈으로 주입받아 컨트롤러에서 호출


Validator 분리 2

컨트롤러에서 검증기를 자동으로 적용하는 법 > WebDataBinder를 통해서 사용

  1. WebDataBinder 에 검증기를 추가 > 해당 컨트롤러에서는 검증기를 자동으로 적용
    @InitBinder
    public void init(WebDataBinder dataBinder){
        dataBinder.addValidators(itemValidator);
        //이 컨트롤러가 호출될 때 마다 항상 불려져서 검증기를 적용
    }
  1. @Validated 적용
@PostMapping("/add")
    public String addItemV6(@Validated @ModelAttribute Item item, 
    	BindingResult bindingResult, RedirectAttributes redirectAttributes) {
        // @Validated를 넣어주면 해당 객체(item)에 대해서 자동으로 검증기가 실행 됨
        // 검증 다 하고 그 결과를 BindingResult에 담음
        
        ...

}
- 이 애노테이션이 붙으면 앞서 WebDataBinder에 등록한 검증기를 찾아서 실행
- 여러 검증기를 등록한다면 supports()를 사용하여 그 중에 어떤 검증기가 실행되어야 할지 구분


출처
[인프런] 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술

0개의 댓글