검증 1 - validation

김성지·2022년 5월 25일
0

스프링기초

목록 보기
8/13

요구사항: 검증 로직 추가

  • 타입 검증
    • 가격, 수량에 문자가 들어가면 검증 오류 처리
  • 필드 검증
    • 상품명: 필수, 공백X
    • 가격: 1000원 이상, 1백만원 이하
    • 수량: 최대 9999
  • 특정 필드의 범위를 넘어서는 검증
    • 가격 * 수량의 합은 10,000원 이상

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

참고: 클라이언트 검증, 서버 검증
클라이언트 쪽은 보안에 취약, 서버만으로 하면 즉각적인 고객 사용성이 부족해짐 --> 둘을 적절히 사용해야됨

검증 직접 처리

이렇게 만들어 줄거다

그냥 Map<String,String> errors 에 각 필드마다 검증한 다음 넣어줌
만약 errors가 비지 않았다면 model 에 errors 를 담아서 넘겨준다.
이때 보통 다시 입력폼으로 되돌아 간다.


<div th:if="${errors?.containsKey('globalError')}">
 <p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>
 
<input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'fielderror' : _"
 class="form-control">

대충 이런식으로 사용한다.

개선하기

문제점들
1. 뷰 템플릿에서 중복 처리가 많음
2. 타입 오류 처리하기
3. 고객이 입력한 문자를 화면에 남겨둬야함

BindingResult 1

@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.getQuantity() != null) {
 int resultPrice = item.getPrice() * item.getQuantity();
 if (resultPrice < 10000) {
 bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은
10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
 }
 }

이렇게 사용한다.
주의할 점은 BindingResult bindingResult 파라미터의 위치는 @ModelAttribute Item item 다음에 와야 한다.

FieldError 생성자

public FieldError(String objectName, String field, 
String defaultMessage) {}

필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult에 담기

  • objectName: @ModelAttribute 이름
  • field: 오류가 발생한 필드 이름(여기서는 Item 객체에서 itemName,price,quantity)
  • defaultMessage: 오류 기본 메시지

ObjectError 생성자

public ObjectError(String objectName, String defaultMessage) {}

특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult에 담아 준다.
당연한거겟지만 field error가 아니므로... field명 필요 없다.

bindingResult에 값이 담겨있으면 저절로 model 에 추가되는 듯..?

#field로 bindingResult가 제공하는 검증 오류에 접근할 수 있음

BindingResult 2

  • @ModelAttribute의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError 생성해서 BindingResult에 넣어준다.
  • 개발자가 직접 넣어준다.
  • Validator 사용

목표 !

사용자 입력 오류 메시지가 화면에 남도록 해보자
FieldError, ObjectError에 대해서 더 자세히 알아보자

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: 오류가 발생한 객체 이름
  • field: 오류 필드
  • rejectedValue: 사용자가 입력한 값(거절된 값)
  • bindingFailure: 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
  • codes: 메시지 코드
  • arguments: 메시지에서 사용하는 인자
  • defaultMessage: 기본 오류 메시지

ObjectError 도 유사하게 두가지 생성자를 제공함

new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~
1,000,000 까지 허용합니다.")

FieldError 는 오류 발생 시 사용자 입력 값을 저장하는 기능을 제공한다.
여기서 rejectedValue가 바로 오류 발생 시 사용자 입력 값을 저장하는 필드이다.
타입은 Object 타입임

타임리프에서 쓸 때는 th:field="*{price}" 같이 써주면 댐
정상 상황에서는 모델 객체의 값을 사용하는데, 오류가 발생하면 FieldError 에서 꺼내와준다.

오류 코드와 메시지 처리 1

목표!: 오류 메시지를 체계적으로 다루어보자 하하

FieldError와 ObjectError의 생성자를 보면
errorCode,arguments를 제공하는데 이것은 발생 시 오류코드로 메시지를 찾기 위해 사용된다.

메시지를 사용하기위해서 errors 메시지 파일을 생성해야댐

messages.properties를 사용해도 되는데 구분하기 쉽게 errors.properties라는 별도의 파일로 관리해보장

applicaition.properties 에 해당 메시지 파일을 인식할 수 있게 추가설정해주야 된다!!
spring.messages.basename=messages,errors

일단 errors.properties에 이렇게 추가해보자

required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
@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";
 }

대충 이렇게 수정해보면

FieldError 생성자 부분에 codes와 arguments들이 추가 되었다.

codes: 메시지 코드를 지정해준다. 메시지 코드는 하나가 안리ㅏ 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.
arguments: 코드의 {0},{1},로 치환할 값을 전달해 주는 역할이다.

오류 코드와 메시지 처리 2

목표!:
FieldError,ObjectError는 다루기 너무 번거롭다..
오류 코드도 좀 더 자동화 할 수 있지 않을까 라는 생각을 해보자

컨트롤러에서 BindingResult는 검증해야 할 객체인 target 바로 다음에 온다. 따라서 BindingResult는 이미 본인이 검증해야 할 객체인 target을 알고 있음!!!

rejectValue(),reject()

BindingResult가 제공하는 이것들을 사용하면 FieldError,ObjectError를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다.

@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
 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,
1000000}, null);
 }
 if (item.getQuantity() == null || item.getQuantity() > 10000) {
 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}";
}

rejectValue()

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

field: 오류 필드명
errorCode: 오류 코드 (messageResolver를 위한 오류 코드임!!!)
errorArgs:오류 메시지에서 {0}을 치환하기 위한 값
defalutMessage: 오류 메시지 못찾으면 기본 메시지 출력함

bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)

이걸 보면 bidingResult는 어떤 객체를 대상으로 검증하는 지 target을 이미 알고 있으니깐.. 따라서 fielderror나 objecterror에서 사용하는 obejctName에 대한 정보가 넘어갈 필요가 없다.

축약된 오류 코드

FiledError()를 직접 다룰 때는 오류 코들르 range.item.price와 같이 모두 입력했다. 그런데 rejectValue()를 사용하고 부터는 오류 코드를 range로 간단하게 입력햇다.
이해하려면 MessageCodesResolver를 이해해야한다.

먼저 reject() 부터 살펴 보자
이건 걍 obejctError 랑 매칭되는 애 같음

오류 코드와 메시지 처리 3

오류 코드를 만들 때 다음과 같이 자세히 만들 수도 있고,

required.item.itemName : 상품 이름은 필수 입니다.
range.item.price : 상품의 가격 범위 오류 입니다.

또는 다음과 같이 단순하게 만들 수도 있다!
required : 필수 값 입니다.
range : 범위 오류 입니다.

단순하게 만들면 범용성이 좋아서 여러곳에서 사용할 수 있지만, 메시지를 세밀하게 작성하기 어렵다. 반대로 너무 자세학 ㅔ만들면 범용성이 떨어진다. 가장 좋은 방법은 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우에는 세밀한 내용이 적용되도록 메시지에 단계를 두는 방법이다.

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

구체적인 것이 우선순위가 높다.

오류 코드와 메시지처리 4

MessageCodesResolver를 알아보자

public class MessageCodesResolverTest {
 MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();
 @Test
 void messageCodesResolverObject() {
 String[] messageCodes = codesResolver.resolveMessageCodes("required",
"item");
 assertThat(messageCodes).containsExactly("required.item", "required");
 }
 @Test
 void messageCodesResolverField() {
 String[] messageCodes = codesResolver.resolveMessageCodes("required",
"item", "itemName", String.class);
 assertThat(messageCodes).containsExactly(
 "required.item.itemName",
 "required.itemName",
 "required.java.lang.String",
 "required"
 );
 }
}

!!!!!!!!!!!DefaultMessageCodesResolver의 기본 메시지 생성 규칙!!!!!!!!!!!!

1.객체 오류일 때(ObjectError)

객체 오류의 경우 다음 순서로 2가지 생성
1.: code + "." + object name
2.: code
예) 오류 코드: required, object name: item
1.: required.item
2.: required

참고로 reject()는 이거임

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

2.필드 오류일 때(FieldError)

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

참고로 rejectValue()는 이거임

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

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

오류 코드와 메시지 처리 5

오류 코드 관리 전략

핵심은 구체적인 것에서 덜 구체적인 것으로!

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

우선 errors.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

  1. 상품명을 빈칸으로 제출할 경우

다음 4가지 오류 코드를 자동으로 생성
1.required.item.itemName
2.required.itemName
3.required.java.lang.String
4.required

  1. 가격을 빈칸으로 제출할 경우

1.range.item.price
2.range.price
3.range.java.lang.String
4.range

여기서 1번이 걸리게 된다..

  1. 수량을 빈칸으로 제출할 경우

1.max.item.quantity
2.max.quantity
3.max.java.lang.String
4.max

여기서도 1번이 걸림..

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

오류 코드와 메시지 처리 6

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

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

price 필드에 문자 'A'를 입력해보면
BindingResult에 FieldError가 담겨있고, 다음과 같은 메시지 코드들이 생성된다.

스프링은 타입 오류가 발생하면 typeMismatch라는 오류 코드를 사용한다. 이 오류 코드가 MessageCodesResolver를 통하면서 4가지 메시지 코드가 생성된 것이다.

error.properties에 다음 내용을 추가하면 된다.

Validator 분리 1

validate 디렉토리 만들어서 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;
 ValidationUtils.rejectIfEmptyOrWhitespace(errors, "itemName",
"required");
 if (item.getPrice() == null || item.getPrice() < 1000 ||
item.getPrice() > 1000000) {
 errors.rejectValue("price", "range", new Object[]{1000, 1000000},
null);
 }
 if (item.getQuantity() == null || item.getQuantity() > 10000) {
 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);
 }
 }
 }
}

supports() {} : 해당 검증기를 지원하는 지에 대한 여부 확인
validate(Object target,Errors errors): 검증 대상 객체와 BindingResult

private final ItemValidator itemValidator ; 선언 후

itemValidator.validate(item,bindingResult); 넣어서 사용해주면 된다.

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

validator를 직접 호출하는 부분 사라지고, 검증 대상객체 앞에 @Validated 가 붙었다.

@Validated 는 검증기를 실행하라는 애노테이션이다.
이 애노테이션이 붙으면 앞서 WebDataBinder 에 등록한 검증기를 찾아서 실행한다. 그런데 여러 검증기를
등록한다면 그 중에 어떤 검증기가 실행되어야 할지 구분이 필요하다. 이때 supports() 가 사용된다.
여기서는 supports(Item.class) 호출되고, 결과가 true 이므로 ItemValidator 의 validate() 가
호출된다.

0개의 댓글