검증 - 직접구현부터 Spring Validation 까지
오류 발생시 기본적으로 :
- 오류처리해야 하는 필드의 태그에는 class="field-error"를 붙인다
- controller에서 폼에 빈 객체를 넘긴다.(Member) 이유는 여러가지가 있겠지만, 검증 실패시 이것을 재사용 할 수 있기 때문
- 검증에 실패하면 다시 입력 폼으로 보낸다.
1. 직접 처리
오류메시지가 하나라도 있으면 model에 Map(errors)를 담고 입력폼으로 보냄
StringUtils를 사용해서 if문으로 하나하나 조건을 달고 에러메시지를 담아줌.
- 필드에러: errors에 오류가 발생한 필드명(key), 오류(value) 저장
- th:classappend="${errors?.containsKey('필드명')}?'field-error' : _"
- field-error라는 클래스 정보를 더해서 색상 빨갛게, 에러없으면 _(No-Operation)으로 아무것도 안함
- 글로벌 에러: globalError(key), 오류(value) 저장
- th:if 문을 사용해 errors?.containsKey('globalError') 인 경우에만 오류메시지 출력
- errors가 null이라면 NullPointerException 발생
- 이것을 보완해 errors?.은 실패로 처리하고 오류메시지 출력 X (SpringEL)
문제점
- 중복 처리 많음
- 타입 오류 처리가 안됨(컨트롤러 진입 전 예외 발생, 바인딩 불가로 고객이 입력한 값 별도 관리 필요)
2. 스프링 검증 오류처리 방법
타입 오류로 바인딩에 실패하면 스프링은 FieldError를 생성하면서 사용자가 입력한 값을 넣어두고 오류를 BindingResult에 담아서 컨트롤러를 호출한다. 타임리프의 th:field는 오류가 발생하면 FieldError에서 보관한 값을 사용해 값을 출력
- Java: BindingResult의 FieldError, ObjectError 사용
- BindingResult는 검증해야 할 객체인 target 바로 다음에 와야한다.
- 이미 본인이 검증해야 할 객체인 target을 알고 있다. rejectValue(), reject()를 사용하여 FieldError, ObjectError를 직접 생성하지 않고 검증 오류 다룰 수 있음
- 오류 코드의 제일 앞단어만 입력해도 된다 (내부에서 MessageCodesResolver를 사용하여 여러 오류 코드를 자동으로 생성)
- MessageCodesResolver는 구체적인 에러 코드를 먼저 만들고 덜 구체적인 것을 가장 나중에 만든다.
- required.item.itemName(더 자세히 에러메시지를 작성할 시 이걸 추가하자)
- required(덜 중요하면 이걸 재활용하고)
- resoureces/errors.properties 파일을 생성해 오류메시지를 체계적으로 관리
#==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} 까지 허용합니다.
#==타입 mismatch==
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
- application.properties 설정
# 메시지 설정
spring.messages.basename=errors
- 타임리프
#fields
: 오류가 발생하면 FieldError에서 보관한 값을 사용해 값을 출력th:errors
: 오류가 있으면 우선 순위가 가장 높은 오류 메시지 코드를 출력th:errorclass
3. 스프링은 검증을 체계적으로 제공하기 위해 Validator 인터페이스 제공 : supports(), validate()
- 구현 클래스를 생성해서 검증 로직을 controller에서 분리하자 (스프링 빈으로 주입 받아서 직접 호출)
- 컨트롤러의 init 메서드(@InitBinder)에서 WebDataBinder에 Validator(검증기)를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용
- @Validated가 컨트롤러 메서드 파라미터에 붙으면 WebDataBinder에 등록한 검증기를 찾아서 실행. 이때 검증기 구분을 위해 supports()가 사용된다.
4. Bean Validation(빈 검증기)는 이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화/표준화 한 것
- build.gradle 의존성 추가 필요
- 일반적으로 사용하는 구현체는 하이버네이트 Validator (ORM과는 관련없음)
- 오류 코드는 애노테이션 이름으로 등록된다
- 특정 필드(FieldError)가 아닌 해당 오브젝트 관련 오류(ObejctError)는 직접 자바 코드로 작성하는 것을 권장
@NotBlank
@NotNull
@Range(min =1000, max = 100000) : 범위 안의 값이어야 함
@Max(9999) : 최대 9999까지만 허용
javax.validation을 시작하면 자바 표준, org.hibernate.validator는 하이버네이트 validator 구현체를 사용할 때만 제공되는 검증 기능(실무에서는 대부분 하이버네이트 validator 사용)
5. 스프링은 이미 빈 검증기를 통합해두어서 직접 사용하지 않고 그냥 가져다 쓰면 된다.
- 앞서 만든 Validator 구현 클래스(검증 로직이 포함되어 있음)를 제거하자.
- 스프링 부트는 LocalValidatorFactoryBean(애노테이션을 보고 검증을 수행)을 글로벌 Validator로 등록
- Bean Validation 애노테이션만 적용하면 검증 로직들이 실행된다.
- 바인딩에 성공한 필드만 Bean Validation이 적용된다. 바인딩에 실패시 typeMismatch FieldError를 추가하여 오류 메시지를 적용한다.
문제 : 등록과 수정의 요구사항은 다를 수 있다. (등록에는 필요없지만 수정에는 id가 필수임) 그런데 엔티티 클래스(예: Item)는 공용이므로, 애노테이션으로 등록과 수정에서의 검증 조건을 동시에 적용할 수는 없다.
해결: groups 기능은 실무에서 거의 쓰지 않고, 폼 객체를 분리하여 사용하는 방식을 주로 이용
예) Item 객체(Entity)의 검증코드(Bean Validation)을 모두 제거하고, ItemSaveForm과 ItemUpdateForm으로 분리하여 각각 등록/수정의 Bean Validation을 설정한다.
그러면 컨트롤러에서는 Form 객체로 전송 받아서 검증을 받고 Item 객체로 변환해서 Repository로 넘긴다.
타임리프 단계별 적용:
글로벌 에러
1
<div th:if="${errors?.containsKey('globalError')}">
<p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>
2
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
</div>
필드에러
1 직접 처리(classappend 사용해서 길이 줄임)
<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>
2 th:errorclass, th:errors
<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>