검증 - validation

Single Ko·2023년 6월 3일
0

Spring 강의 정리

목록 보기
15/31

아무 검증작업도 안해 놓으면 폼 입력시 빈 문자열이 오면 안되는데 빈 문자열이 온다거나, 숫자를 문자로 작성해서 검증 오류가 발생하면 오류화면으로 이동해버린다. 이렇게 되면 사용자는 다시 처음부터 작업을 해야된다. 이렇다면 이용자는 금세 떠나버릴 것이다. 고객이 입력한 데이터를 유지한 상태로 어떤 오류가 발생했는지 친절하게 알려줘야 된다.

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

참고 : 클라이언트 검증(자바스크립트로 보통 하는 검증), 서버 검증

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

검증 처리 - 기능 직접 구현

  1. 입력 폼 등록 성공
    • 별다른 조치가 필요하지 않다. 기존의 로직 흐름대로 가면됨
  1. 입력 폼 등록 실패
    • 잘못된 타입 입력이나 어떠한 이유로 오류가 났다면, 오류 화면으로 가는 것이 아니라 잘못된 부분에 대해 알려주고 기존의 입력 정보도 남겨줘야 한다.
Map<String, String> errors = new HashMap<>();

특정 필드 검증
if(!StringUtils.hasText(item.getItemName())) {
	errors.put("itemName","상품 이름은 필수입니다.");
}

if(item.getPrice == null || item.getPrice() <1000 || item.getPrice() >1000000) {
	error.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다.");
}

if(item.getQuantity() == null || item.getQuantity() >= 9999) {
	error.put("quantity", "수량은 최대 9,999까지 허용합니다.");
}

복합 필드 검증
if(item.getPrcie() != 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/addForm";
}
  • Map에 검증시 오류가 발생하면 그 정보를 담아두게 만듬.
  • key는 필드명에 맞추었다. 복합 필드검증의 경우에는 globalError라는 key를 사용.
  • 이렇게 만든 에러를 화면단에 표현을 하면 된다.

@ModelAttribute가 자동으로 넘겨주는 model 덕분에 우리는 폼에 따로 정보를 남겨둘 필요가 없이 새로 로딩되어도 정보가 남아 있다.

화면단에 에러 표현시

<input type="text" id="price" th:field="*{price}"
 	   th:class="${errors?.containsKey('price')} ? 'form-control field-error' : 'form-control'" 
       class="form-control"> // 오류시, 클래스를 다르게 표시함. 
      						   클래스에 css를 통해서 박스 색깔을 빨갛게해 경고
       
<div th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}상품 오류</div>

참고 : Safe Navigation Operator
만약 Map의 errors가 null이라면 어떻게 될까?
등록 폼에 진입한 시점에는 errors가 없다. 따라서 errors.containsKey()를 호출하는 순간 NullPointerException이 발생한다.
errors?.errors가 null일때 NullPointException이 발생하는 대신, null을 반환한다. th:if에서 null은 실패로 처리되므로 오류 메시지가 출력 되지않는다.

문제점

  • 뷰 템플릿에 중복 처리가 많다.
  • 아직 타입 처리가 안된다. 숫자 타입에 문자가 들어오면 오류화면으로 바로 넘어감. 그런데 이러한 오류는 스프링 MVC에서 컨트롤러에 진입하기도 전에 예외가 발생하기 때문에, 컨트롤러가 호출되지도 않고, 400 예외가 발생하며 오류 페이지를 띄운다.
  • 만약 컨트롤러가 호출된다고 해도, 문자를 Integer 타입에 보관할 수 없다. 결국 고객이 입력한 값도 어딘가에 보관 해야됨.

검증 처리 - 스프링 기능

1. BindingResult



@PostMapping("/add")
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() > 10000) {
		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", "만원 이상이어야 됨 현재 값="+resultPrice));
        }
	}
    
    //오류시 리턴
	if (bindingResult.hasErrors()) {
		log.info("errors={}", bindingResult);
		return "validation/v2/addForm";
	}
  • BindingResult 파라미터를 받아주면 된다. 이때 순서에 주의해야된다! 반드시 @ModelAttribute 파라미터 뒤에 와야 된다.

  • 필드 오류는 FieldError객체 생성해 bindingResult에 담아두면 됨.

  • FieldError(objectName(사용 객체 이름), field(오류 발생 필드이름), defaultMessage(기본 메시지));

화면에서 오류 출력

<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">
	<div class="field-error" th:errors="*{itemName}">상품명 오류</div>
</div>
  • if문이 아닌 타임리프에서 제공하는 th:errorsth:errorclass를 사용하면 훨씬 편리 해진다.
  • 기존의 조건문들을 대체하는 th:errors, th:errorclass. 해당 필드에 오류가 있는 경우 출력.
  • #fieldsBindingResult가 제공하는 검증 오류에 접근 가능.

중요
BindingResult는 스프링이 제공하는 검증 오류 보관 객체이다.
BindingResult는 데이터 바인딩시 오류가 발생해도 컨트롤러가 호출된다

BindingResult에 검증 오류를 적용하는 방법은 3가지가 있다.

  • 개발자가 직접 넣거나,
  • 데이터 바인딩이 실패하는 경우 스프링이FieldError를 생성해 BindingResult에 넣어준다.
  • Validator를 사용하는 방법

실제 타입 오류를 내서 확인해보니 이상한 오류 메시지가 밑에 나왔다.

2. FieldError, ObjectError

BindingResult로 바꾸고 나서는 오류가 발생하는 경우 고객이 입력한 내용이 모두 사라진다. 이 문제를 해결하기 위해 FieldErrorObjectError를 자세히 알아보자.

기존 FieldError에 받는 파라미터말고 더 많은 파라미터를 받을 수 있다.

  • FieldError(objectName(사용 객체 이름), field(오류 발생 필드이름), defaultMessage(기본 메시지));

  • FieldError(objectName, field, rejectedValue. bindingFailure, codes, arguments, defaultMessage);

    • Object rejectedValue : 사용자가 입력한 값(거절된 값 , ex: item.getItemName) 오류 발생시 사용자 입력값을 저장하는 필드
    • boolean bidingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
    • codes : 메시지 코드
    • arguments : 메시지에 사용하는 인자

ObjectError의 경우는 넘어오는 값이 있거나, 그런게 없기 때문에 bindingFailure가 발생하거나 필드 정보가 필요하지 않음. 여러 필드가 넘어오는 것을 조합해서 제공하기 때문에...

  • ObjectName, defaultMessage 만 넘겼줬었다.
  • codes와 arguments를 받을 수 도 있다.

타임리프의 사용자 입력 값 유지
th:field가 매우 많은 역할을 한다. 정상 상황에는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError에서 보관한 값을 사용해서 값을 출력한다.

오류 메시지 관리

FieldError에 defaultMessage를 적어준 것을 기억할 것이다. 이런 오류 메시지들도 일관성 있게 한곳에서 관리가 가능하다.

메시지 파트에서 사용했던 message.properties같은 파일을 만들어서 한곳에서 제공 가능.
오류 메시지도 message.properties에 추가해도 작동은 하지만, 관리를 위해 errors.properties를 만들어 관리하자.

이때, spring.messages.basename=messages,errors 로 추가해 줘야된다.

errors.properties에 추가

required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
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.getQuantity() != null) {
	int resultPrice = item.getPrice() * item.getQuantity();
	if (resultPrice < 10000) {
		bindingResult.addError(new ObjectError("item", new String[] {"totalPriceMin"}, 
        new Object[]{10000, resultPrice}, null));
	}
}
  • codes 부분에 String 배열로 errors.properties에 적었던 일므을 넣어주면 된다. 거기에 arguments는 Object배열로 넘긴다.
  • String배열로 왜 넘기는가? 만약 첫번째 메시지가 없으면 배열의 다음 메시지를 찾을 수 있다. 이 codes에 존재하지 않으면, defaultMessage가 출력이 된다. 만약 defautlMessage까지 없으면 오류가 난다.
  • 이 오류 메시지 기능도 국제화 기능이 적용 된다.

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

rejectValue() , reject()

BindingResult의 rejectValue()reject()를 사용하면 좀 더 간편하게 FieldErrorObjectError 를 사용하지 않고 사용 할 수 있다.
(BindingResult는 이미 Object나 target을 알고있다. @ModelAttribute객체 옆에 쓰는 이유)

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

//특정 필드 예외가 아닌 전체 예외
if (item.getPrice() != null && item.getQuantity() != null) {
	int resultPrice = item.getPrice() * item.getQuantity();
	if (resultPrice < 10000) {
		bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
	}
}

축약된 오류 코드
rejctValue의 작동 코드롤 보면 결국 우리가 이전에 했던 FieldError 생성을 대신 해준다. 무언가 다른 코드로 하는것이 아니다. 그냥 개발자가 쓰기 쉽게 줄여준것 뿐이다.

BindingResult는 어떤 객체를 대상으로 검증하는 target을 이미 알고있다. 따라서 target에 대한 정보를 입력해줄 필요가 없다.

rejectValue

  • field - 필드명
  • errorCode - 오류코드
  • errorArgs - 오류 메시지에서 값 치환
  • defaultMessage

오류 코드를 그 전에는 required.item.itemName과 같이 모두 입력했다. 그런데 rejectValue()를 사용하고 부터는 required라고 간단히 입력했다. 이는 바로 MessageResolver가 동작을 한 것이다.

오류 코드와 메시지 처리

//단순함. 범용적
required=필수 값 입니다.

//객체명과 필드명을 조합한 세밀한 메시지 코드가 있으면 그 메시지를 높은 우선순위로 사용한다.

//Level1 -> 객체명과 필드명을 조합한 디테일한 이름. 
required.item.itemName: 상품 이름은 필수입니다.

//Level2
required.tiem : 아이템은 필수 값입니다.

객체명과 필드명을 조합한 메시지를 사용하게 된다면, 메시지의 추가 만으로 매우 편리하게 오류 메시지를 관리하게 된다.

실제 코드에는 required만 적어두지만, 오류 메시지 관리 파일에서는 객체명과, 필드명을 조합해 더 세밀한 부분으로 조정이 가능한 것이다.

이런 것이 어떻게 가능할 것인가? 바로 MessageCodesResolver 덕분이다

MessageCodesResolver

MessageCodeResolver라는 인터페이스를 받아 테스트를 해본면 실제 나오는 값이 errorcode와 objectName, field, type를 조합한 몇가지 조합이 나오는 것을 확인할 수 있다.

이렇게 우선순위에 따라 우리가 만들둔 값을 꺼내서 온다.

객체 오류의 경우 다음 순서로 2가지 생성

1.: code + "." + object name
2.: code
예) 오류 코드: required, object name: item
1.: required.item
2.: required

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

오류 코드 관리 전략

  • 핵심은 구체적인 것에서 덜 구체적인 것으로!
  • 모든 오류 코드에 대해서 메시지를 각각 다 정의하면 너무 관리가 여려움. 핵심적인 것만 만들어 주고 나머지는 덜 구체적인 것으로 관리 하자.

ValidationUtils은 Empty , 공백 같은 단순한 기능만 제공. 만약 공백, Empty같은 것만 검증할 것 같으면 ValidtaionUtils를 쓰는게 더 편하긴 하다.
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName","required");

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

기존에 어떤 메시지를 선택하지 않았을때 스프링에서 기본적으로 뽑아낸 이상한 메시지가 바로 이런식으로 typeMismatch라는 오류코드를 불러왔던 것이다. 이는 사실 스프링에서 만들어서 codes로 날려준 것이다.

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

우리는 이런 typeMismatch를 실제 error.properties에 정의해서 사용 할 수 도 있다.

메시지 코드 생성 전략은 그냥 만들어진 것이 아니다. 조금 뒤에서 Bean Validation을 학습하면 그 진가를 더 확인할 수 있다.

Validator의 분리

검증로직이 너무 많다. 컨트롤러에 있는 실제 로직은 몇줄 되지 않지만 검증 로직은 수십줄이 넘는다. 이런 기능을 분리해서 만들면 검증은 검증클래스에, 컨트롤 역할은 컨트롤러에 확실하게 분리가능 하다.

Validator 인터페이스를 상속받아 사용하면 된다. 실제 검증 코드들은 여기에 들어가게 될 것이다.

@Override
public boolean supports(Class<?> clazz) {
	return Item.class.isAssignableFrom(clazz);
}

@Override
public void validate(Object target, Errors errors) {

}
  • 두가지 메서드를 정의하게 된다.
  • supports()는 해당 검증기를 지원하는 여부 확인, 부모뿐 아니라, 자식 객체까지 검증 가능.
  • validate(Object target, Errors errors) : 검증 대상 객체와 BindingResult. Errors의 자식이 BindingResult이다.
private final ItemValidator itemValidator;

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

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

그 많던 코드가 단 몇줄로 정의 되었다. 검증과 관련된 부분이 깔끔하게 분리되었는 것을 알 수 있다.

다만 왜 Validator 인터페이스를 별도로 제공받아서 구현을 할까? 이 Validator없이 우리가 그냥 정의해서 사용해도 아무 문제 없이 동작할텐데 말이다. 그런데 Validator 인터페이스를 사용해 검증기를 만들면 스프링의 추가적인 도움을 받을 수 있다.

Validator - 스프링

@InitBinder
public void init(WebDataBinder dataBinder) {
	dataBinder.addValidators(itemValidator);
}


@PostMapping("/add")
public String addItemV5(@Validated @ModelAttribute Item item, 
						BindingResult bindingResult, 
                        RedirectAttributes redirectAttributes) {
                        
	if (bindingResult.hasErrors()) {
		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라는 것을 검증기에 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적요할 수 있다.

  • validator를 직접 호출하는 부분이 메서드에서 사라지고, 대신에 검증 대상 앞에 @validated 를 객체 앞에 붙여준다.

  • 그런데 여러 검증기가 있다면 무슨 검증기가 호출되야 되는지 어떻게 아는가? 이는 support() 메서드가 자동으로 체크해준다.

참고
@Validated , @Valid 둘다 사용가능하다
@Valid를 사용하려면 의존관계 추가가 필요하다. @Validated는 스프링 전용 검증 애노테이션. @Valid는 자바 표즌 검증 애노테이션이다.

참고 : 본 글은 김영한님의 스프링 강의를 정리한 것이다.

profile
공부 정리 블로그

0개의 댓글