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

조은지·2023년 8월 15일

검증 요구사항

크게 타입검증, 필드 검증, 특정 필드의 범위를 넘어서는 검증 3가지의 요구사항이 추가된 상황

=> 웹 서비스는 폼 입력 시 오류가 발생하면 고객이 입력한 데이터를 유지한 상태로 어떤 오류가 발생했는지 알려주어야 한다.


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

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

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

검증 직접 처리

  • 스프링 기능 사용안하고 자바로만 검증
Map<String, String> errors = new HashMap<>();

if (!StringUtils.hasText(item.getItemName())) {
 errors.put("itemName", "상품 이름은 필수입니다.");
}
  • 만약 검증시 오류가 발생하면 어떤 검증에서 오류가 발생했는지 정보를 담아둔다
  • 어떤 필드에서 오류가 발생했는지 구분하기 위해 오류가 발생한 필드명을 key 로 사용한다

타임 리프에서의 사용

<div th:if="${errors?.containsKey('globalError')}">
 <p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>
  • 모델에 에러 정보를 담아 넘기면 타임리프 단에서 동적인 화면을 보여줄 수 있다.

참고 ) Safe Navigation Operator

  • 맨 처음 등록 폼에 진입한 시점에는 errors 객체 자체가 존재하지 않는다.
  • errors?. 은 errors 가 null 일때 NullPointerException 이 발생하는 대신, null 을 반환하는 문법이다

남은 문제점

  • 뷰 템플릿에서 중복처리가 많다.
  • 타입 오류가 발생하면 스프링MVC에서 컨트롤러에 진입하기도 전에 예외가 발생하기 때문에, 컨트롤러가 호출되지도 않고, 400 예외가 발생하면서 오류 페이지를 띄워준다



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

  • 스프링이 제공하는 검증 오류를 보관하는 객체
  • BindingResult 가 있으면 @ModelAttribute데이터 바인딩 시 오류가 발생해도 BindingResult에 정보를 담아 컨트롤러가 호출된다!

@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
  • BindingResult bindingResult 파라미터의 위치는 @ModelAttribute Item item 다음에 와야 한다
  • Item 객체의 Binding 결과를 담고 있기 때문!!

필드 오류 - FieldError

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

글로벌 오류 - ObjectError

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

BindingResult와 Errors

  • BindingResult 는 인터페이스이고, Errors 인터페이스를 상속받고 있다
  • 실제 넘어오는 구현체는 BeanPropertyBindingResult 라는 것인데, 둘다 구현하고 있으므로 BindingResult 대신에 Errors 를 사용해도 된다
  • Errors 인터페이스는 단순한 오류 저장 및 조회 기능 제공, BindingResultaddError() 등의 기능을 제공

오류 발생 시 사용자 입력 값 유지

FieldError와 ObjectError는 각각 두 가지 생성자를 제공하고 있다.

new FieldError("item", "price", item.getPrice(),false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
  • objectName : 오류가 발생한 객체 이름
  • field : 오류 필드
  • rejectedValue : 사용자가 입력한 값(거절된 값)
  • bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
  • codes : 메시지 코드
  • arguments : 메시지에서 사용하는 인자
  • defaultMessage : 기본 오류 메시지

오류 코드와 메시지 처리 - errors.properties

  • FieldErrorObjectError에는 메시지 코드와 인자 값을 받는 생성자가 존재

  • application.properties 파일에 spring.messages.basename=messages,errors 설정을 추가


BindingResult - rejectValue(), reject()

  • FieldError와 ObjectError는 다루기 너무 번거롭다.

  • BindingResult는 이미 본인이 검증해야 할 객체인 target을 알고 있다.

=> rejectValue(), reject() 함수를 통해 기존 코드를 단순화 !

void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
  • 오류코드만으로 오류 메시지를 잘 찾아낸다 => MessageCodesResolver



MessageCodesResolver

  • 검증 오류 코드로 메시지 코드들을 생성한다.
  • 기본 구현체인 DefaultMessageCodesResolver 가 스프링에 기본적으로 등록되어 있음
  • ObjectError, FieldError 와 함께 사용된다.

동작 방식

  • rejectValue() , reject() 는 내부에서 MessageCodesResolver 를 사용한다.
  • MessageCodesResolver 를 통해서 생성된 순서대로 오류 코드를 보관한다.

DefaultMessageCodesResolver의 경우

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


필드 오류의 경우 다음 순서로 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"



오류 코드 관리 전략

  • MessageCodesResolverrequired.item.itemName 처럼 구체적인 것을 먼저 만들어주고, required 처럼 덜 구체적인 것을 가장 나중에 만든다

  • 크게 중요하지 않은 메시지는 범용성 있는 requried 같은 메시지로 끝내고, 정말 중요한 메시지는 꼭 필요할 때 구체적으로 적어서 사용하는 방식이 더 효과적

  • 가장 낮은 단계까지 오류 코드를 찾지 못한 경우 => 코드에 작성한 디폴트 메시지를 사용한다

  • 타입 오류의 경우도 errors.properties 에 등록해두면 오류 메시지를 커스텀할 수 있다.



ValidationUtils

  • Empty, 공백 같은 단순한 기능의 경우 ValidationUtils를 통해 간단히 처리가 가능하다.

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



Validator 분리

  • 예제코드의 경우 컨트롤러에서 검증 로직이 차지하는 부분이 매우 크다. => 별도의 클래스로 분리
  • 이 경우도 Validator 인터페이스를 상속받아 만들 수 있다.
public interface Validator {
    boolean supports(Class<?> clazz);
    void validate(Object target, Errors errors);
}

그러면 이 검증기를 어떻게 활용할까?

가장 단순한 방법으로는 해당 Validator를 직접 호출해서 사용하는 방식이 있다.

=> 이러면 Validator를 굳이 상속받지 않아도 됨



WebDataBinder

  • Validator를 사용하는 두번째 방법

  • 스프링이 Validator 인터페이스를 별도로 제공하는 이유는 체계적으로 검증 기능을 도입하기 위해서다. WebDataBinder를 사용하면 스프링의 추가적인 도움을 받을 수 있다.

  • 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함한다

@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) {
  • @InitBinder를 통해 해당 컨트롤러에서 사용하는 Validator를 등록하고, 검증 대상 앞에 @Validated 어노테이션을 붙인다.

동작 방식

  • @Validated 는 검증기를 실행하라는 애노테이션이다

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


개인 질문

0개의 댓글