우리가 웹 어플리케이션을 개발할때 가격을 입력해야하는 부분에 문자가 들어온다던가 수량을 입력하는부분에 문자나 음수가 들어올 경우 등등 검증에 실패하면 오류 화면으로 이동한다.
스프링부트에서는 검증하기쉽게 많은 기능을 지원 해준다.
처음은 기능없이 직접 구현하고 뒤에선 기능을 사용해서 검증을 해보자
📌컨트롤러는 HTTP요청이 정상인지 검증하는 역할도 있다!
💡참고: 클라이언트 검증, 서버 검증
고객이 상품 등록 폼에서 상품명을 입력하지 않거나, 가격, 수량 등이 너무 작거나 커서 검증 범위를
넘어서면, 서버 검증 로직이 실패해야 한다. 이렇게 검증에 실패한 경우 고객에게 다시 상품 등록 폼을
보여주고, 어떤 값을 잘못 입력했는지 친절하게 알려주어야 한다.
ControllerV1
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes
redirectAttributes, Model model) {
//검증 오류 결과를 보관
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()) {
model.addAttribute("errors", errors);
return "validation/v1/addForm";
}
}
보면 addItem메서드 안에 검증하는 로직을 개발자가 직접 구현
error를 Map타입인 errors에 key값으로 field를 value값으로 message를 담아서 뷰로 전달한다.
Thymeleaf - 입력 폼V1
<!--css-->
.field-error {
border-color: #dc3545;
color: #dc3545;
}
<div class="container">
<div class="py-5 text-center">
<h2 th:text="#{page.addItem}">상품 등록</h2>
</div>
<form action="item.html" th:action th:object="${item}" method="post">
<div th:if="${errors?.containsKey('globalError')}">
<p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>
<div>
<label for="itemName" th:text="#{label.item.itemName}">상품명</label>
<input type="text" id="itemName" th:field="*{itemName}" th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"
class="form-control"
placeholder="이름을 입력하세요">
<div class="field-error" th:if="${errors?.containsKey('itemName')}"
th:text="${errors['itemName']}">상품명 오류
</div>
</div>
<!--th:classappend="${errors?.containsKey('itemName')} ? 'fielderror' : _"
class="form-control"> -->
//...
📋정리
✔남은 문제
BindingResult 사용 방법
ControllerV2 - addItemV1
@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
//단일 항목 검증, FieldError첫번째 생성자 사용
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 까지 허용합니다."));
}
✅BindingResult순서가 중요!
반드시 @ModelAttribute Item item 뒤에와야함
bindingResult에 에러를 담는다.
bindingResult.addError
메서드 이름처럼 Error가 발생하면 Error를 추가 해주는 메서드
❗특정 필드가 아닐경우 ObjectError를 사용!
FieldError는 ObjectError를 상속하기 때문에 메서드 인자로 넣을 수 있다.
thymeleaf - 입력 폼V2
<!--글로벌 오류 처리 -->
<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} : bindingResult가 제공하는 검증 오류에 접근할 수 있다.
th:errorclass : th:field 에서 지정한 필드에 오류가 있으면 class 정보를 추가한다. (V1 보다 간결해짐)
th:errors : 해당 필드에 오류가 있으면 출력해준다 th:if 편의 버전 (V1 보다 간결해짐)
✔남은 문제
1.검증 오류가 있으면 입력값이 사라진다. 이것은 사용자 입장에선 불편함.
2.타입오류가 있을시 400에러 페이지는 사라 졌지만 화면에 에러메시지가 생긴다.
FieldError - 두번째 생성자
입력한 데이터를 유지 하기 위해선 FieldError 두번째 생성자를 사용 해야한다. 방금 위에선 사용한 건 첫번째 생성자
//오류 발생시 사용자가 입력한 데이터 유지x
public FieldError(String objectName, String field, String defaultMessage);
//오류 발생시 사용자가 입력한 데이터 유지o
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 : 기본 오류 메시지
- rejectedValue 가 바로 오류 발생시 사용자 입력 값을 저장하는 필드다.
- bindingFailure 는 타입 오류 같은 바인딩이 실패했는지 여부를 적어주면 된다. 여기서는 바인딩이
실패한 것은 아니기 때문에 false 를 사용한다
ControllerV2 - addItemV2
@PostMapping("/add")
public String addItemV2(@ModelAttribute Item item,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName",
item.getItemName(), false, null, null, "상품 이름은 필수입니다."));
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.addError(new FieldError("item", "price", item.getPrice(),
false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
}
//...
FieldError두번째 생성자중 파라미터 String[] codes, Object[] arguments 에서 메시지 처리를 사용할 수 있다.
메시지 처리 방법
1. application.properties에 errors.properties를 인식할 수 있도록 아래코드 추가 만약 코드 생략시 기본 (messages)만 인식
spring.messages.basename=messages,errors
errors.properties
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
Controller - addItemV3
@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, "상품 이름은 필수 입니다."));
}
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}, "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
}
if(item.getQuantity() == null || item.getQuantity() > 10000) {
bindingResult.addError(new FieldError("item", "quantity",
item.getQuantity(),false,new String[]{"range.item.quantity"},new Object[]{9999},"수량은 최대 9,999 까지 허용합니다."));
}
//특정 필드가 아닌 복합 필드 검증
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}, "가격 * 수량의 합은 10,000원 이상이어야합니다. 현재 값 = "+ resultPrice));
}
}
//...
📋 설명
codes : required.item.itemName 를 사용해서 메시지 코드를 지정한다. 메시지 코드는 하나가 아니라
배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.
arguments : Object[]{1000, 1000000} 를 사용해서 코드의 {0} , {1} 로 치환할 값을 전달한다.
void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage);
field : 오류 필드명
errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다.messageResolver를 위한 오류 코드이다.)
errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값
defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
rejectValue 동작 방식
1. rejectValue() 호출
2. MessageCodesResolver 를 사용해서 검증 오류 코드로 메시지 코드들을 생성
3. new FieldError() 를 생성하면서 메시지 코드들을 보관
4. th:erros 에서 메시지 코드들로 메시지를 순서대로 메시지에서 찾고, 노출
핵심은 MessageCodesResolver의해 자세히 작성된 메시지 -> 단순한 메시지 순으로 적용 된다는 것!
controller - addItemV4
@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
//간결해진 코드
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);
}
❗rejectValue는 range라고만 적어도 에러코드를 잘 찾아 주는 이유는MessageCodesResolver가 내부에서 동작 하기때문이다.
검증 오류 코드로 메시지 코드들을 생성한다.
MessageCodesResolver는 인터페이스이고 DefaultMessageCodesResolver 는 기본 구현체이다
#Level1
required.item.itemName: 상품 이름은 필수 입니다.
#Level2
required: 필수 값 입니다.
이런 식으로 자세한 메시지 작성도 가능하고 좀 더 범용적으로 쓰이는 메시지 작성도 가능하다.
resolver가 내부에서 4가지 오류 코드를 생성해서 가지고 있는다.
codes [range.item.price, range.price, range.java.lang.Integer, range];
resolver 생성 규칙
객체 오류
1.: code + "." + object name
2.: code
예) 오류 코드: required, object name: item
1.: required.item
2.: required
필드 오류
1. : code + "." + object name + "." + field
2. : code + "." + field
3. : code + "." + field type
4. : code
controller에서 검증 로직을 분리
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) {...}
validate메서드안에 검증 로직을 옮겨 넣으면 된다.
이러면 컨트롤러 부분에서 검증로직을 지워 깔끔 해진다.
controller - addItemV5
private final ItemValidator itemValidator;
@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
itemValidator.validate(item, bindingResult);//분리된 검증을 처리하는 부분
//...
@InitBinder
원하는 controller에 검증기를 달아 줄 수 있다.
1. itemValidator.validate(item, bindingResult); 삭제
2. @Validated 어노테이션 입력
controller - addItemV6
@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) {...}
@Validated VS @Valid
@Validated 는 스프링 전용 검증 애노테이션이고, @Valid 는 자바 표준 검증 애노테이션이다
@Valid는 사용하려면 gradle에 의존 관계를 추가 해야한다