Spring/[인프런 김영한 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술]
[인프런 김영한 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술] 검증 - Validation
h2boom
2024. 10. 14. 14:20
Validation
검증 요구사항
- 검증 로직 예시
- 타입 검증 - 가격, 수량에 문자가 들어가면 검증 오류 처리
- 필드 검증 - 상품명(필수, 공백x), 가격(1,000원이상, 100만원 이하), 수량(최대 9999개)
- 특정 필드의 범위를 넘어서는 검증 - 가격 * 수량은 10,000원 이상
- 컨트롤러의 중요한 역할중 하나는 HTTP 요청이 정상인지 검증하는 것
- 클라이언트 검증 vs 서버 검증
- 클라이언트 검증은 조작할 수 있기에 보안에 취약
- 서버 검증은 즉각적인 고객 사용성이 부족
- 둘을 적절히 섞어서 사용하되 최종적인 서버 검증 필수!
//검증 오류 결과를 보관
Map<String, String> errors = new HashMap<>();
//검증 로직
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 까지 허용합니다.");
}
//특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
}
}
//검증에 실패하면 다시 입력 폼으로
if (!errors.isEmpty()) { //부정의 부정은 이해하기 어렵기에 리팩토링을 통해 가독성을 높이면 좋다.
log.info("errors ={}", errors);
model.addAttribute("errors", errors);
return "validation/v1/addForm";
}
//성공 로직
...
- 컨트롤러에 검증 로직을 작성
- Map에 검증 시 발생한 오류 정보를 담아둔다.
- 검증 실패 시 다시 입력폼으로 돌아가도록 작성
- 검증에 실패하더라도 고객이 작성한 내용 + 검증 오류 메시지를 Model에 담아서 입력 폼으로 다시 돌아가도록 해야한다.
- !errors.isEmpty() => 부정의 부정은 가독성이 좋지 않기에 사용하는 것이 좋지 않다.
- 리팩토링을 통해 hasError()와 같이 별도의 메소드로 만들어 가독성을 높여주는 것이 좋다.
- 검증 로직을 다 통과 시 성공 로직 실행
<div th:if="${errors?.containsKey('globalError')}">
- ?. : Safe Navigation Operator이다. (SpringEL이 제공하는 문법)
- errors가 null인 경우 errors.containsKey()를 호출하면 NPE가 발생한다.
- errors?.는 errors가 null일 때 NPE 대신 null을 반환하는 문법이다.
- th:if에서 null은 실패로 처리되므로 오류 메시지가 출력되지 않는다.
//리팩토링 전
th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"
//리팩토링 후
th:classappend="${errors?.containsKey('itemName')} ? 'fielderror': _" class="form-control"
- 삼항 연산자 대신 classappend를 사용해서 더 효율적으로 코드를 작성할 수 있다.
- 만약 잘못된 타입을 입력하는 경우 (ex 숫자 타입에 문자 입력)
- 스프링 MVC에서 컨트롤러에 진입하기도 전에 400 예외가 발생하기에 컨트롤러가 호출되지 않기에 문제가 생긴다.
- 고객이 입력한 문자를 화면에 남겨야하는데 숫자 타입에는 문자를 보관할 수 없기에 문제가 생긴다.
- 결국 고객이 입력한 값을 별도로 관리가 필요하다. => 이후 BindingResult로 해결
BindingResult
- BindingResult : 스프링이 제공하는 검증 오류를 보관하는 객체이다.
@PostMapping("/add")
public String addItem(@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() > 9999) {
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", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
}
}
//검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {
log.info("bindingResult ={}", bindingResult);
return "/validation/v2/addForm";
}
//성공 로직
...
}
- 스프링에서 제공하는 검증 오류 처리 방법 (BindingResult)
- BindingResult를 파라미터로 받아서 사용 => @ModelAttribute 객체 다음에 BindingResult가 와야한다.
- 필드에 오류가 있으면 FieldError 객체 생성 후 bindingResult에 담으면 된다.
- 생성자(객체명, 필드명, 오류 메시지)
- 객체명은 @ModelAttribute 객체명으로
- 필드명은 오류가 발생한 필드명
- 생성자(객체명, 필드명, 오류 메시지)
- 특정 필드를 넘어서는 글로벌 오류는 ObjectError 객체를 생성해서 BindingResult에 담는다.
- BindingResult는 스프링에서 제공하기 때문에 Model에 자동으로 포함된다.
<!-- 글로벌 오류 처리 -->
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
</div>
<!-- 필드 오류 처리 -->
<input type="text" id="itemName" th:field="*{itemName}"
th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:errors="*{itemName}">
상품명 오류
</div>
- 타임리프 스프링 검증 오류 통합 기능
- #fields : #fields로 BindingResult가 제공하는 검증 오류에 접근할 수 있다.
- th:errors : 해당 필드에 오류가 있는 경우 태그를 출력한다. (th:if와 유사)
- 오류 메시지 출력도 해준다.
- th:errorclass : th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.
- BindingResult가 있으면 @ModelAttribute에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다.
- @ModelAttribute에 바인딩 시 타입 오류 발생하면?
- BindingResult가 없으면 - 400 오류 발생, 컨트롤러가 호출되지 않고 오류 페이지로 이동
- BindingResult가 있으면 - 오류 정보(FieldError)를 BindingResult에 담아서 컨트롤러를 정상 호출
- BindingResult에 검증 오류를 적용하는 방법 3가지
- @ModelAttribute의 객체에 타입 오류등으로 바인딩이 실패하는 경우 스프링이 FieldError를 생성해서 BindingResult에 넣어준다.
- 개발자가 직접 넣어준다.
- Validator를 사용한다.
- 결국 BindingResult에서 발생하는 오류는 바인딩 자체가 실패하는 오류, 비지니스 검증 오류로 나뉜다.
- BindingResult는 인터페이스로 Erros 인터페이스를 상속받고 있기에 둘 중 아무거나 사용해도 된다.
- Errors는 단순한 오류 저장, 조회 기능만을 제공하기에 BindingResult가 추가적인 기능이 더 많다.
- 관례상 BindingResult를 더 많이 사용한다.
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)
- FieldError 생성자
- rejectedValue : 사용자가 입력한 값 (거절된 값)
- bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
- codes : 메시지 코드
- arguments : 메시지에서 사용하는 인자
- 사용자 입력 데이터가 컨트롤러의 @ModelAttribute에 바인딩되는 시점에 오류가 발생하면 Model에 사용자 입력 값을 유지하기 어렵다.
- 숫자 타입에 문자가 입력되는 경우 보관할 수 있는 방법이 없다.
- FieldError는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공한다. (rejectedValue 필드)
- 타임리프에서 th:field는 정상적인 상황에서는 Model 값을 사용하지만 오류가 발생 시 FieldError에서 보관한 값을 사용해서 값을 출력한다.
- 스프링에서는 타입 오류로 바인딩 실패 시 FieldError를 생성하면서 사용자 입력 값을 넣어 BindingResult에 담아 컨트롤러를 호출한다.
- 그렇기에 타입 오류로 바인딩 실패 시에도 사용자 오류 메시지를 정상 출력할 수 있다.
- 오류 메시지도 MessageSource의 메시지 기능을 통해 하나의 파일에서 관리할 수 있다.
//메시지 기능을 사용하기 위한 application.properties 설정
spring.messages.basename=messages,errors
//errors.properties 메시지 관리 파일
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
//메시지 기능 사용 예시
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(),
false, new String[]{"required.item.itemName"}, null, null));
- FieldError, ObjectError의 필드
- codes : required.item.itemName을 사용해서 메시지 코드를 지정했다.
- 메시지 코드는 String 배열로 여러 값을 전달할 수 있으며 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.
=> 먼저 적은 값일수록 우선순위가 높다.
- 메시지 코드는 String 배열로 여러 값을 전달할 수 있으며 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.
- arguments : Object 배열로 여러 파라미터 값을 전달할 수 있으며 {0}, {1}...로 치환할 값을 전달한다.
- codes : required.item.itemName을 사용해서 메시지 코드를 지정했다.
- BindingResult는 검증해야할 객체 바로 다음에 온다.
- 그렇기에 BindigResult는 본인이 검증해야할 객체 target을 알고 있다.
=> 검증할 객체 명과 같은 코드를 더 간소화할 수 있다.
- 그렇기에 BindigResult는 본인이 검증해야할 객체 target을 알고 있다.
void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage);
//사용 예시
bindingResult.rejectValue("itemName", "required");
//FieldError 비교
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(),
false, new String[]{"required.item.itemName"}, null, null));
- addError() 대신 rejectValue()를 사용하면 코드를 간소화할 수 있다.
- 객체명을 따로 명시하지 않아도된다.
- errorCode : 오류 코드(메시지에 등록된 코드가 아닌 messageResolver를 위한 오류 코드)
- FieldError()를 사용할 때는 오류 코드를 모두 입력하지만 rejectValue()는 오류 코드를 간단하게 입력하면 MessageResolver가 규칙에 따라 찾아서 오류 메시지를 출력한다.
- 오류 코드 예시
- 자세한 오류 코드
required.item.itemName : 상품 이름은 필수 입니다.
range.item.price : 상품의 가격 범위 오류 입니다. - 단순한 오류 코드
required : 필수 값 입니다.
range : 범위 오류 입니다.
- 자세한 오류 코드
- 단순한 오류 코드는 범용성이 좋지만 메시지를 세밀하게 작성하기 어렵다, 너무 자세하게 만들면 범용성이 떨어진다.
//errors.properties 설정
#Level1
required.item.itemName: 상품 이름은 필수 입니다.
#Level2
required: 필수 값 입니다
- required라는 오류 코드를 사용했을 때 범용적인 required 보다 required.item.itemName과 같이 객체와 필드명을 조합한 세밀한 메시지 코드가 있으면 더 높은 우선순위로 사용한다.
- 스프링에서 MessageCodesResolver라는 것을 통해 이런 기능을 지원한다.
MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();
codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
- MessageCodesResolver의 resolverMessageCodes()를 사용하면 검증 오류 코드로 메시지 코드를 규칙에 맞게 생성한다.
- 예제의 결과 값
messageCode = required.item.itemName
messageCode = required.itemName
messageCode = required.java.lang.String
messageCode = required - 구체적(세부적)일수록 우선순위가 높다.
- 예제의 결과 값
- rejectValue(), reject() 동작 방식
- rejectValue() / reject() 호출 시 내부에서 MessageCodesResolver를 사용해서 메시지 코드를 생성한다.
- 생성한 메시지 코드로 FieldError / ObjectError 를 생성한다.
- MessageCodesResolver를 통해서 생성된 순서대로 오류 코드를 보관한다.
- FieldError, ObjectError의 필드인 오류 코드 String 배열에 생성한 메시지 코드들을 넣는다.
- 타임리프에서 화면을 렌더링할 때 th:errors가 실행된다.
- 오류가 있다면 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 찾는다.
해당 메시지가 없다면 디폴트 메시지를 출력한다.
- 오류가 있다면 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 찾는다.
- MessageCodesResolver의 기본 메시지 생성 규칙
- 객체 오류 ObjectError
- code + "." + object name
- code
- 필드 오류 FieldError
- code + "." + object name + "." + field
- code + "." + field
- code + "." + field type
- code
- 객체 오류 ObjectError
오류 코드 관리 전략
- 오류 코드 관리 전략
- 구체적인 것에서 덜 구체적인 것으로!
- MessageCodesResolver는 구체적인 것을 먼저 만들고 덜 구체적인 것을 나중에 만든다.
- 모든 오류 코드에 대해 메시지를 다 정의하고 관리하기 힘들기에 크게 중요하지 않은 메시지는 범용성 있는 메시지로, 중요한 메시지는 구체적으로 적어서 사용하는 방식이 효과적
- 구체적인 것에서 덜 구체적인 것으로!
//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} 까지 허용합니다.
- 객체 오류와 필드 오류로 나눈 후 범용성에 따른 레벨 분류
- 레벨이 낮을수록(구체적) 우선순위가 높음
//ValidationUtils 사용 전
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required");
}
//ValidationUtils 사용 후
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
- ValidationUtils는 Empty, 공백과 같은 단순한 기능을 제공해준다.
타입 바인딩 오류
- 타입 정보가 맞지 않는 경우 스프링이 직접 FieldError를 생성해서 BindingResult에 넣어준다.
- 이 경우 typeMismatch 에러가 발생하는데 에러 코드에 넣어서 BindingResult에 보내기 때문에 MessageCodesResolver에 의해 자동으로 메시지 코드가 생성된다.
- 일반적인 경우와 마찬가지로 typeMismatch에 대한 메시지 코드를 만들어 넣어놓으면 디폴트 메시지가 아닌 해당 메시지가 출력된다.
- ex) price라는 가격 (integer)에 문자를 입력한 경우 자동 생성되는 메시지 코드
typeMismatch.item.price
typeMismatch.price
typeMismatch.java.lang.Integer
typeMismatch - ex) errors.properties 설정
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.- 우선순위에 따라 디폴트 메시지가 아닌 숫자를 입력해주세요 라는 메시지가 출력되게 된다.
- 이 경우 typeMismatch 에러가 발생하는데 에러 코드에 넣어서 BindingResult에 보내기 때문에 MessageCodesResolver에 의해 자동으로 메시지 코드가 생성된다.
Validator 분리
- 컨트롤러에서 검증 로직이 차지하는 부분이 큰 경우 별도의 클래스로 검증 로직을 분리하는 것이 좋다.
@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;
if (!StringUtils.hasText(item.getItemName())) {
errors.rejectValue("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() > 9999) {
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);
}
}
}
}
- 스프링에서 검증을 제공하기 위해 Validator 인터페이스를 제공한다.
- supports() : 해당 검증기를 지원하는 여부 확인
- isAssignableFrom()은 ==과 달리 자식 클래스까지 확인 가능하다.
- validate(Object target, Errors errors) : 검증 대상 객체와 BindingResult를 파라미터로 받아서 검증 로직 수행
- supports() : 해당 검증기를 지원하는 여부 확인
//별도의 검증 클래스를 통한 검증 예제
@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
if (itemValidator.supports(item.getClass())) {
itemValidator.validate(item, bindingResult);
}
//검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {
log.info("bindingResult={}", bindingResult);
return "validation/v2/addForm";
}
//성공 로직
...
}
- 검증기인 ItemValidator를 스프링 빈으로 주입 받아서 사용
- WebDataBinder 기능
- 스프링 파라미터 바인딩 역할
- 검증 기능
@InitBinder
public void init(WebDataBinder dataBinder) {
dataBinder.addValidators(itemValidator);
}
- 컨트롤러에 WebDataBinder 검증기 추가
- @InitBinder 어노테이션을 사용하고 WebDataBinder에 ItemValidator 검증기를 추가
- WebDataBinder 검증기를 추가하면 해당 컨트롤러에서 검증기를 자동으로 적용할 수 있다.
- 해당 컨트롤러만이 아닌 글로벌하게 설정하려면 별도의 설정이 필요하다.
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {
log.info("bindingResult={}", bindingResult);
return "validation/v2/addForm";
}
//성공 로직
...
}
- WebDataBinder를 사용하면 별도의 validator를 직접 호출하지 않고 검증 대상 앞에 @Validated를 붙여주면 된다.
- 예제에서는 검증 대상인 Item 객체 앞에 @Validated 사용
- @Validated, @Valid 모두 동일하다.
- @Validated는 스프링 전용 검증 어노테이션
- @Valid는 자바 표준 검증 어노테이션으로 build.gradle에 별도의 의존관계 추가가 필요하다.
- WebDataBinder 검증 동작 방식
- @Validated는 검증기를 실행하는 어노테이션이기에 WebDataBinder에 등록한 검증기를 찾아서 실행한다.
- 등록된 검증기가 여러 개인 경우 실행할 검증기를 구분하기 위해 supports() 메소드가 호출된다.
- 실행할 검증기를 찾은 후 validate()가 호출된다.
@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
public static void main(String[] args) {
SpringApplication.run(ItemServiceApplication.class, args);
}
@Override
public Validator getValidator() {
return new ItemValidator();
}
}
- 검증기를 모든 컨트롤러에 다 적용하는 글로벌 설정
- 글로벌 설정을 하면 각 컨트롤러마다 별도로 @InitBinder로 설정해 줄 필요가 없다.
- 글로벌 설정 시 주의할 점
- 글로벌 설정을 하면 BeanValidator가 자동 등록되지 않기에 직접 사용하는 경우는 드물다.
출처 : [인프런 김영한 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술]
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의 | 김영한 - 인프런
김영한 | 웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습
www.inflearn.com