Web/Spring

SpringMVC 2 - 검증

now0204 2023. 10. 24. 22:17

 

- 고객이 입력한 데이터를 유지한 채 어떤 오류가 발생했는지 친절하게 알려주자

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

 

 

1. 검증 V1 (직접검증)

 

- 사용자의 데이터가 정상이면, PRG

- 정상이 아니면, 모델에 사용자 입력 값과 오류 결과를 담아서 다시 상품등록폼으로 가자 

 

 

스프링MVC 2 (인프런) - 김영한

Map<String, String> errors = new HashMap<>(); // 오류 담을 것

 

1.1 특정 필드 검증로직 

 

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

1.2 복합 필드 검증 

//특정 필드가 아닌 복합 룰 검증
 if (item.getPrice() != null && item.getQuantity() != null) {
 
 int resultPrice = item.getPrice() * item.getQuantity();
 
 if (resultPrice < 10000) {
 
 errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
 
   }
 }

* StringUtils는 Spring꺼 쓰자 

 

 //검증에 실패하면 다시 입력 폼으로
 if (!errors.isEmpty()) {
 model.addAttribute("errors", errors);
 return "validation/v1/addForm";
 }

- 이때 validation/v1/addForm으로 forward! -> request,response가지고감 + model가지고감 (내부호출)

- @ModelAttribute 사용해서 Intem item -> item으로 저장되어 있음 

 

* /add + GetMapping에도 빈 item객체가 넘어가도록 설정해둠!! -> 오류처리 등의 재사용성을 높이기 위함

 

1.3 addform with 타임리프

 

- 글로벌 오류 

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

 - if errors에 globalError이 있을 때 -> div태그와 내용 출력 

 - errors?는 errors이 null이면 실행하지 않도록 하는 타임리프 문법 (NPE 대신 null반환) 

 

* key값을 확인하는 것과 값을 꺼내는 것 다름주의 

//필드오류처리
th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"
<div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">
 상품명 오류
 </div>
<input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'fielderror' : _"
 class="form-control">

- 주요 로직은 그냥 errors?.containsKey 있다면 조건으로 

   th:classappend나 th:text, th:if절 사용했다는 것 

 

-> 직접 검증처리시 문제점은 뷰 템플릿에 중복처리가 많다.

-> 타입 오류처리가 안됨!! (애초에 Integer에 String이 들어오는 등) 컨트롤러로 들어오기 전 발생한 오류임

-> 이러한 문제를 해결한 Spring의 오류처리를 보자 

 

2. 검증V2 (BindingResult)

 

- 스프링이 제공하는 검증 오류 처리의 핵심 BindingResult를 알아보자 

- BindingResult : 에러코드를 담아서 뷰로 같이 넘어감 (따로 Model에 담아 줄 필요 없음)

- BindingResult는 검증이 필요한 파라미터 바로 다음에 와야함

 

//필드 오류
if (!StringUtils.hasText(item.getItemName())) {
 bindingResult.addError(new FieldError("item", "itemName", "상품 이름은필수입니다."));
}

> 매개변수로, 객체명(@ModelAttribute 이름), 필드명, 기본오류메시지를 받는다.

> 필드오류는 FieldError

//글로벌오류
bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));

> 매개변수로 객체명(@ModelAttribute), 기본 오류 메시지를 받음 

> globalError은 ObjectError

 

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

 

타임리프에서 BindingResult를 활용할 수 있는 편의 기능 제공한다.

#fields로 BindingResult가 제공하는 검증 오류에 접근 가능 (view로 넘어온 BindingResult 활용)

th:errors: 오류가 있는 경우 해당 태그 출력 -> if 상위버전 

th:errorclass: 필드에 오류가 있다면, class정보 추가 -> if상위버전 

 

*th:object를 지정하면 th:field = *{필드} 혹은 *{필드} 사용할 수 있음!! object로 지정한 객체의 특정 속성 꺼내줌

*은 선택지정자 (특정)

*타임리프가 위와 같은 문법을 사용하므로, Field나 Object Error에서 객체 혹은 필드명 잘 지정해줘야 출력됨

 

2.2 글로벌 오류처리 


<div th:if="${#fields.hasGlobalErrors()}">
 <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="$
{err}">전체 오류 메시지</p>
</div>

 

2.3 필드 오류 처리 

<input type="text" id="itemName" th:field="*{itemName}"
 th:errorclass="field-error" class="form-control" placeholder="이름을
입력하세요">
<div class="field-error" th:errors="*{itemName}">
 상품명 오류
</div>

 

3. BindingResult 더 알아보기 

 

- BindingResult

- 스프링이 제공하는 검증 오류 보관 객체 -> BindingResult가 있으면, ModelAttribute에 데이터 바인딩 시 오류가 발생해도

컨트롤러가 호출됨!

- 오류정보(FieldError)을 알아서 만들어서 BindingResult에 담아서 컨트롤러 정상 호출

 

* BindingResult에 검증오류 작성 3가지 방법 

   > 스프링이 타입오류 넣어줌

   > 직접 Field,Object error을 만들어서 넣기

   > Validator를 사용 

 

*BindingResult와 Errors 

 -> BindingResult는 인터페이스, Errors인터페이스 상속받음 : 구현객체 BeanPropertyBindigResult임

 -> 인터페이스로 둘 중 아무거나 사용해도됨 단, BindingResult가 더 다양한 기능을 제공함

 

4. 검증V3

 

-> 현재 V2까지 문제는 오류메시지는 출력되는데, 사용자가 입력한 오류 값은 출력이 안됨 해결하자

-> bindingResult는 사용자 오류의 입력값을 저장할 수 있는 매개변수 제공함 (공간?) 

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: 메시지 코드

- argumetns : 메시지에 인자 

- defaultMessage: 기본 오류 메시지 

 

*주의 이때  bindingError를 false라고 하면, rejectedValue와 객체의 필드 사이의 타입이 맞아야한다. 

  > 타입오류 아니라고 했는데.. 거절값이 타입이 안맞는건 말이 안된다.

  > 필드명 잘못적으면 저장은 되지만 아마 타임리프에서 출력은 안될 것이다.

 

* 만약 타입 오류라면, 스프링이 알아서 

  new FieldError(item,price,"qqq",true..)식으로 넣어준다! 

* 향후 간편 오류처리들이 위의 메서드 호출하므로 잘 봐두자 

 

4.1 타임리프 field="*{필드}"

 

아주 똑똑하게 작용함 정상 상황에서는 모델 객체의 값을 사용하지만, 오류가 발생하면, FieldError에서 보관한 값을 사용해서 출력한다.

 

5. 오류 코드와 메시지 처리 (1) [그냥 그렇구나]

 

- 생성자에 codes와 메시지 arguments를 넘길 수 있다! 메시지를 관리해보자 

 

5.1 errors 메시지 파일 생성

 

- errors.properties파일을 생성해서 오류 메시지를 관리하자 

- 메시지 파일을 인식할 수 있도록 아래와 같이 설정 

- MessageSource 설정

spring.messages.basename=messages,errors

 

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

5.2 매개변수 전달 

new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}

- 메시지 코드를 매개변수로 전달해서 해당 코드의 메시지를 출력하도록 할 수 있다. (String[])

- 메시지 매개변수는 object[]로 전달할 수 있다.

 

 

6. 오류 코드와 메시지 처리 (2)

 

- 이제 FieldError,ObjectError보다 쉽게 오류코드를 처리하는 BindingResult에 검증 메서드를 사용하자

- 어차피 BindingResult는 검증할 객체 바로 다음에 온다. (target을 알고 있다)

log.info("objectName={}", bindingResult.getObjectName());
log.info("target={}", bindingResult.getTarget());

//출력결과
objectName=item //@ModelAttribute name
target=Item(id=null, itemName=상품, price=100, quantity=1234)

 

6.1 rejectValue(), reject()

 

- FieldError과 ObjectError를 생성하지 않고, 쉽게 다룰 수 있게 해준다.

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

//실제 사용
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)


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

filed : 오류 필드명

errorCode: 오류코드 -> messageResolver를 위한 오류 코드

errorArgs: 오류 메시지 치환 값

defaultMessage:기본 메시지 

 

> rejectValue는 필드명,오류 메시지 코드,메시지매개변수,기본 메시지 받으면 

   - 내부적으로 new Field만들어주는데, 거절값, 객체명, 오류메시지코드를 자동으로 생성해준다.

 

 

7. 오류 코드와 메시지 처리(3) [매우중요]

 

- 오류 코드를 만들 때 범용성 있게 만들기 위해서는 추상적인 것부터 만들어두고, 조금씩 구체적으로 내려가는 것이 최고다.

- 오류 메시지 코드의 level을 나누어 사용하는 것이다! 

-  스프링의 MessageCodeResolver가 이러한 범용성 있는 오류 메시지 만드는 것을 도와준다.

 

7.1 MessageCodeResolver

 

- 검증 오류 코드로 메시지 코드를 생성한다.

- 구현체로 DefaultMessageCodesResolver를 가지는 인터페이스 

- 주로 ObjectError과 FieldError이랑 함께 사용된다.

 

7.2 기본 메시지 생성 규칙 

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

 

- rejectValue(), reject()는 내부에서 MessageCodesResolver를 사용함 -> 메시지 코드들 생산

- FieldError, ObjectError을 생성하면서, MessageCodesResolver를 사용하여, 메시지 코드를 생산해서 주입함

- 보통 가장 구체적인 것부터 저장됨! 

 

*기본 메시지 지정 x시 메시지 코드들로 메시지 못찾으면 Exception발생 

 

7.3 오류 메시지 출력 

 

타임리프 화면을 렌더링시 th:errors가 실행됨 이때 오류가 있다면, 생성된 오류 메시지 코드를 순서대로 돌아가면서 

메시지를 찾음! 없으면 디폴트 메시지 출력 

 

7.4 오류 메시지 관리 전략

 

- 핵심은 구체적인 것에서 덜 구체적인 것으로 (MessageCodesResolver)

- 따라서 공통전략 (가장 낮은 레벨)은 한번에 관리할 수 있다.

- 대부분 공통전략을 따르고 필요한 경우에만 구체적으로 적어서 메시지를 출력하자 

#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라는게 있는데 걍 참고 

 

7.5 스프링이 만든 오류 메시지

 

- 타입 오류의 경우 스프링이 직접 오류 메시지 코드를 정의해 두었다.

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

 

8. Validator (1)

 

- 컨트롤러에서 검증하는 로직이 너무 크다. 별도의 역할로 분리하여 검증 로직을 재사용하자

- 스프링은 검증 로직을 체계적으로 제공하기 위해 인터페이스 제공함 

public interface Validator {
boolean supports(Class<?> clazz);
void validate(Object target, Errors errors);
}

 

- 해당 검증기가 지원하는 객체인지 확인

- 검증하기 (타겟 객체, bindingResult 넣기) 

- 어댑터 패턴과 매우 유사 (내부에 어답티만 없는..)

 

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

-> 컨트롤러 호출 코드 

@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult,RedirectAttributes redirectAttributes) {
 
 itemValidator.validate(item, bindingResult);
 
 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}";
}

 

9 Validator 분리 (2)

 

Validator 인터페이스를 사용하여 검증기를 만들면, 스프링의 추가적인 도움을 받을 수 있다.

 

9.1 WebDataBinder 

 

-  스프링의 파라미터 바인딩 역할 + 검증 기능도 내부에 포함 

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

- 위와 같이 검증기를 추가하면, 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있다.

@InitBinder는 해당 컨트롤러에만 영향을 준다 -> 글로벌 설정은 별도 

 

9.2 Validated 적용

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

@Validated는 WebDatabinder에 등록된 검증기로 가서 검증기를 실행하라는 어노테이션이다.

이때 WebDatabinder에 많은 검증기가 등록되어 있다면, 지원가능한 검증기 찾아야해서 

supports()가 있던 것이었다리 

- 여기서는 supports가 true니까 ItemValidator의 validate에 BindingResult와 item객체가 넘어가서 실행됨

 

*WebDatabinder 글로벌 설정은 PDF참고