기존의 상품 등록 폼에서 잘못된 값 입력시(가격을 문자로,공백 입력) 검증 오류가 발생하여 바로 오류 화면으로 이동한다. 이렇게 되면 사용자는 해당 폼으로 돌아가 다시 입력을 해야한다. 이런 서비스는 고객 이탈율 증가,만족도가 떨어진다. 따라서 폼 입력시 오류가 발생하면 데이터는 유지한 상태로 어떤 오류가 발생했는지 친절하게 알려줘야 한다.
Javascript로 클라이언트 검증,Spring Boot 서버 검증 둘다 적절히 사용하되 최종적으로 서버 검증은 필수이다.
사용자가 GET으로 상품 등록 폼을 가져오고, 작성 후 POST로 요청을 보낼때 기존 Redirect/Get 방식이 아닌 컨트롤러에서 검증 과정을 거쳐 오류가 발생하면 모델에 데이터를 담고 기존 폼으로 돌려보내면 고객의 불편이 최소화된다.
검증 오류를 포함하기 위해서,
Map<String, String> errors = new HashMap<>();
if (!StringUtils.hasText(item.getItemName()))
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000)
if (item.getQuantity() == null || item.getQuantity() >= 9999)
... 이런식으로 조건을 확인하여
errors.put("itemName","상품 이름은 필수입니다.") 와 같이 담는다.
에러를 담는 hashMap을 정의해서 key에 field명,value에 빨간색으로 보여질 구문을 할당한다.
if (!errors.isEmpty()) {
model.addAttribute("errors", errors);
return "validation/v1/addForm";
}
최종으로 오류를 담은 HashMap의 size가 0보다 클때 model에 errors 자료구조를 담고 뷰를 반환한다.
addForm.html 추가
<div th:if="${errors?.containsKey('globalError')}">
<p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>
<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>
와 같이 검증 로직과 css조합으로 UX을 개선한다.
errors?. 구문을 사용하는 이유는 errors HashMap이 존재하지 않을때 NullPointerException을 예방한다.
남은 문제점:
BindingResult는 Spring에서 폼 데이터 바인딩 및 검증 시 사용되는 인터페이스이다. 주로 Spring MVC에서 사용되며, 컨트롤러에서 사용자 입력 데이터를 객체에 바인딩하고, 그 과정에서 발생할 수 있는 오류들을 처리하기 위해 사용된다.
BindingResult가 있는 경우 Spring은 검증 오류가 발생해도 예외를 발생시키지 않고 BindingResult 객체에 검증 오류를 저장한다. 이를 통해 컨트롤러에서 검증 결과를 확인하고 필요한 로직을 작성할 수 있다.
@PostMapping("/add")
public String addItem(@ModelAttribute Item item,BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model)
@ModelAttrubute를 통해 데이터가 담긴 item 객체에 오류가 있으면 bindgResult 인스턴스에 해당 오류가 저장된다.
bindingResult.addError(new FieldError("item","itemName","상품 이름은 필수 입니다."));
BindingResult 객체에 사용자 정의 오류를 추가하는 코드이다. 특정 필드에 대한 오류 메시지를 추가할 때 사용된다. 이를 통해 컨트롤러에서 bindingResult를 확인하여 오류를 처리하고, 사용자에게 오류 메시지를 전달할 수 있다.
필드 에러: new FieldError("인스턴스 이름","필드명","에러 메세지")
글로벌 에러: new ObjectError("인스턴스 이름","에러 메세지")
검증 로직
if (bindingResult.hasErrors()) {
log.info("errors={}",bindingResult);
return "validation/v2/addForm";
}
1.bindingResult는 model.addattribute() 필요 없이 view에 자동으로 넘어간다.
2.BindingResult bindingResult 파라미터의 위치는 @ModelAttribute Item item 다음에 와야 한다.
템플릿 코드
//글로벌 오류
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
</div>
each구문으로 err 인스턴스의 내용을 출력한다.
//필드 오류
<div class="field-error" th:errors="*{itemName}">
상품명 오류
</div>
th:if와 마찬가지로 error가 있다면 출력한다.
CSS
<input type="text" id="itemName" th:field="*{itemName}"
th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
th:field의 인스턴스 확인 후 필드에 error가 있다면 field-error css를 추가한다.
사용자 입력 값 유지
bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, null, null, "수량은 최대 9,999 까지 허용합니다."));
오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요하다. (ex.가격에 숫자가 아닌 문자 입력) 그 방법은 FieldError 객체를 생성하고 rejectedvalue에 저장, BindingResult의 매개 변수로 넣어준다. 그 다음 컨트롤러가 호출된다.
(인스턴스,필드명,사용자의 입력값(인스턴스.필드),바인딩 실패?(false),null,null,출력될 메세지) -> FieldError가 사용자가 입력한 값을 유지한다.
오류 메세지를 파라미터에 작성하는것이 아닌, 메세지 형식으로 사용해보자. 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", "price",
item.getPrice(), false, new String[]{"range.item.price"},
new Object[]{1000,1000000}, null));
와 같은 형식으로 String 배열에 에러 메세지 key값을 넣어주고 파라미터는 Object 배열에 key값에 맞는 타입을 넣어준다. Spring은 코드 배열을 사용하여 메세지 소스를 검색하고 일치하는 메세지를 반환한다.
reject(글로벌 오류),rejectValue(필드 오류) 메서드로FieldError,GlobalError 객체를 생성하지 않아도 된다.
//필드 오류
bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
//글로벌 오류
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
위와 같은 방법으로 첫번째 인자는 인스턴스의 필드명,두번째는 메세지에서 정의한 첫번째 단어이다.
오류 코드를 만들 때 다음과 같이 자세히 또는 단순하게 만들 수 있다.
required.item.itemName : 상품 이름은 필수 입니다.
required : 필수 값 입니다.
만약 위와 같이 메세지를 설정하고 아래와 같이 오류를 추가하면,
bindingResult.rejectValue("itemName", "required");
메세지에서 오류 코드(required)에 해당하는 자세한 단어 required.item.itemName부터 적용되고
MessageCodesResolver는 검증 오류나 메시지 처리 시 다양한 메시지 코드의 우선순위를 관리할 수 있도록 해준다.검증 오류가 발생했을 때, 해당 오류와 관련된 메시지 코드 배열을 생성하여 MessageSource를 통해 적합한 메시지를 찾을 수 있게 한다. 이로써 여러 수준의 메시지를 지원할 수 있어 좀 더 구체적인 오류 메시지부터 일반적인 오류 메시지까지 계층적으로 접근이 가능하다.
MessageCodesResolver codesResolver=new DefaultMessageCodesResolver();
@Test
void messageCodesResolverField(){
String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
for (String messageCode : messageCodes) {
System.out.println("messageCode = " + messageCode);
}
}
실행시 자세한 key값 -> 범용적 key값을 순서대로 포함되고 테스트를 통과한다. 해당 과정은 rejectValue()와 reject()안에서 codesResolver를 호출하고 new FieldError를 만들고 messageCodes 넘겨 우선순위별 매칭되는 메세지를 출력한다.
rejectValue()의 상세 동작 과정
[1] 사용자가 입력한 값이 Validator를 통해 검증됨
[2] 검증 실패 시, rejectValue()가 호출됨
[3] MessageCodesResolver가 오류 메시지를 찾음
[4] FieldError 객체가 생성되어 BindingResult에 추가됨
[5] 사용자의 입력값을 유지하면서 오류 메시지가 저장됨
[6] 뷰 템플릿(Thymeleaf)에서 오류 메시지를 출력할 수 있음
4-1.메세지 생성 규칙
(1) 객체 오류 (reject())
1. code + "." + object name
→ 예: "order.minimumPrice"(bindingResult.reject("minimumPrice")호출 시, 객체명이 order라면 minimumPrice.order을 찾음)
2. code (오류 코드 자체만 사용)
→ 예: "minimumPrice"
(2) 필드 오류(rejectValue())
1. required.item.itemName (객체명 item, 필드명 itemName, 오류 코드 required)
2. required.itemName (필드명 itemName, 오류 코드 required)
3. required.item (객체명 item, 오류 코드 required)
4. required (오류 코드 required 자체)
다음과 같이 가격을 제출하면 Integer 필드에 바인딩 되기 때문에 문자 입력시 오류가 발생한다. Spring은 Typemismatch라는 오류 코드를 사용한다. 문제는 default 메세지가 지저분하고 길어 고객에게 보여주기 어렵다. 해결 방법은 errors.properties에 Typemismatch 오류 코드를 직접 추가한다.
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
검증 로직이 너무 길고 복잡하여 컨트롤러가 많은 일을 하고 성공시 실제 로직을 확인하는데 불편함이 있다. 검증 로직을 별도로 모아두는 validator 클래스를 만들어 역할을 분리하자.
ItemValidator 클래스
@Component
public class ItemValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return Item.class.isAssignableFrom(clazz);
//Item == clazz
//item == subItem
}
@Override
public void validate(Object target, Errors errors) {
Item item=(Item) target;
이하 이전과 동일한 검증 로직...
}
}
컨트롤러
private final ItemValidator itemValidator;
@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item,BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
itemValidator.validate(item,bindingResult);
if (bindingResult.hasErrors()) {
return "validation/v2/addForm";
}
Spring의 Validator 인터페이스를 상속받는 이유는 체계적인 검증 기능을 도입하기 위해서다.
@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, Model model)
dataBinder에 직접 작성한 검증기 itemValidator를 추가하여 컨트롤러의 모든 메서드에 대해 요청이 처리될 때 바인딩된 객체에 대해 자동으로 검증을 수행한다.
메서드 파라미터의 @Validated 는 검증기를 실행하라는 애노테이션이다. 이 애노테이션이 붙으면 앞서 WebDataBinder에 등록한 검증기를 찾아서 실행한다.