김영한 강사님의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술을 듣고 정리한 내용입니다. 자세한 내용은 강의를 참고해주세요
addAttribute()로 Model에 넣어주거나, @ModelAttribute를 이용해 지정한 객체를, addAttribute()없이 Model에 넣어준다thymeleaf 템플릿을 이용해, Model에서 받은 데이터를 이용해 html문서를 http응답으로 넘겨서, 화면을 렌더링한다Argument Roslever가 컨트롤러가 원하는 전달 데이터를 생성해주고 이는 http message converter을 이용해서 생성해준다 @PathVariable로 url을 짤 수 있다.PRG방식으로 짤 때, 파라미터로 RedirectAttribute를 이용해 리다이렉트 url도 인코딩하고, pathVariable과 쿼리 파라미터또한 모두 처리해준다지금까지, 우리가 만든 웹 어플리케이션을 보면 꽤 잘 구현된듯 싶다. 그런데 아무것도 입력하지 않아도, 상품이 등록된다. 또 상품 가격에 문자열을 넣으면 오류가 난다... 이런 문제를 해결하기 위해서 검증 요구사항이 등장한다.

cf) 참고: 클라이언트 검증, 서버 검증

@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {
//검증 오류 결과를 보관
Map<String, String> erroers = new HashMap<>();
// 검증로직
if (!StringUtils.hasText(item.getItemName())){
erroers.put("itemName", "상품 이름은 필수 입니다");
}
if (item.getPrice() == null || item.getPrice()<1000 || item.getPrice()>1000000){
erroers.put("price", "가격은 1000 ~ 1,000,000 까지만 허용합니다");
}
if (item.getQuantity()== null || item.getQuantity() >9999){
erroers.put("quantity", "수량은 최대 9,999개 까지 허용합니다");
}
// 특정필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null){
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
erroers.put("globalError", "가격 * 수량의 합은 10,000원 이상이여야 합니다. 현재 값 = " + resultPrice);
}
}
// 검증에 실패하면 다시 입력 폼으로 이동해야함
if (!erroers.isEmpty()){
log.info("errors = {} ",erroers);
model.addAttribute("errors", erroers);
return "validation/v1/addForm";
}
// 성공 로직 !
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v1/items/{itemId}";
}
fieldError 즉 itemName ,price,quantity의 문제이고globalError가 된다.GetMapping의 addForm에서, item의 빈 객체를 생성하고 이를 thymeleaf에 넘겨서 html Form데이터를 받았었는데,
위 사진 처럼, global Error는 맨 위에, field Error는 각 상품 입력 밑에 넣어줄 것이다
<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>
th:if 문법을 이용해 우리가 error의 저장한 key를 containsKey로 조회하고, th:text 문법을 이용해 value값을 꺼내 출력한다@ModelAttribute Item item, BindingResult bindingResult,RedirectAttributes redirectAttributes) ` 이렇게 순서가 중요함!!!BindingResult객체를 불렀으니까, 객체에 넘길 바인딩 오류들을 추가해줘야 한다!addError을 이용해 추가해준다FieldError : 객체의 필드단위에서 오류가 가는 에러FieldError(): (바인딩객체명 , 필드명 , 오류메시지)ObjectError : 객체의(글로벌) 오류, 파라미터에 필드명은 이제 필요 없다ObjectError : (바인딩객체명 , 오류메시지)@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
// 검증로직
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", "가격은 1000 ~ 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("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}";
}
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors}" th:text="${err}">글로벌 오류 메시지</p>
</div>
BindingResult)에 접근하려면 "@{#fields}" 문법을 사용해야 한다th:each문법을 사용한다<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>
#fields : 스프링이 제공하는 BindingResult객체에 접근하는 방법th:errors : 해당 필드에 오류가 있는 경우 태그를 출력한다th:errors를 이용해 BindingResult 객체에 접근한다<div class="field-error" th:errors="상품 이름은 필수입니다">이렇게 thymeleaf가 동적으로 작동하고 출력까지 해준다.th: errorclass: th:field에서 지정한 필드에 오류가 있으면, class정보를 추가한다
정리!!!
BindingResult는 스프링이 제공하는 검증 오류를 보관하는 객체BindingResult가 있으면,@ModelAttribute에 데이터 바인딩시 오류가 발생해도 컨트롤러가 호출된다!BindingResult는 검증할 대상 바로 다음에 와야한다. 순서가 중요하다. 예를 들어서 @ModelAttribute Item item , 바로 다음에 BindingResult 가 와야 한다.BindingResult는 Model에 자동으로 포함된다.
예) @ModelAttribute에 바인딩 시 타입 오류가 발생하면?
BindingResult에 검증 오류를 적용하는 3가지 방법
지금까지 BindingResult , FieldError , ObjectError 를 사용해서 오류 메시지를 처리하는 방법을 알아보았다.
-> 그런데 오류가 발생하는 경우 고객이 입력한 내용이 모두 사라진다. 이 문제를 해결해보자.
그건 바로 FieldError의 생성을 오버로딩 된 다른 방법을 쓰는 것이다
지금까지FieldError를 생성할 때는 ObjectName,field,defaultMessage만 적어줬다면

objectName : 오류가 발생한 객체 이름field : 오류 필드rejectedValue : 사용자가 입력한 값(거절된 값)bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값codes : 메시지 코드arguments : 메시지에서 사용하는 인자defaultMessage : 기본 오류 메시지바인딩 시점에 오류가 발생하면, 타입이 다른 이유 등등으로 우리가 생각한 Model객체에 입력한 값을 유지하기 힘들다.
즉, Model객체에 담는게 아니라 별도의 객체가 필요하다
그것이 바로rejectedValue이다.
만약, fieldError가 발생하면, 스프링은 사용자가 입력한 값을rejectedValue에 담아서 컨트롤러를 호출한다
이렇게 되면, 바인딩 실패시에도 오류 메시지를 컨트롤러에서 뷰로 이동해, 화면에 렌더링이 정상적으로 된다
thymeleaf의th:field또한 정상 상황에서는Model객체의 값을 사용하지만, 검증 오류가 발생하는 상황에는FieldError에서 보관한 값(rejectedValue)을 사용해서 값을 출력한다
즉 에러페이지를 나타내는게 아니라, 오류를 낸 이유와 오류를 낸 데이터 모두 화면에 렌더링할 수 있다는 소리다!!!

codes와 arguments를 null로 두고 defaultMessage를 이용해 오류를 출력했었다면codes와 arguments를 사용해 오류들을 메시지화 해서message.properties를 이용하는 것이다!!! 여기에 메시지를 넣고 properties 파일만 수정해서 확장성을 높이는 방식이다!
그럴려면, application.properties파일의 설정을
spring.messages.basename=message,errors를 넣어주고src/java/resources경로에message.properties를 넣어준다

이렇게 메시지를 적어주고, 컨트롤러를 수정해 보자!!!


errors.properties에 설정한 key값을 배열안에 넣어서 파라미터 codes안에 넣어주고errors.properties에 설정한 key값의 value값을 code에 파라미터로 넣어준다BindingResult는 검증해야 할 객체인 target을 파라미터에 넣어주지 않아도 알고 있다 rejectValue(),reject()를 사용하면 FieldError,ObjectError를 직접 생성하지 않고, 메서드에서 자동으로 만들어준다.
rejectValue() : FieldError를 만들어준다
reject() : ObjectError를 만들어준다
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
if (bindingResult.hasErrors()){
log.info("errors={}",bindingResult);
return "validation/v2/addForm";
}
log.info("objectName={}", bindingResult.getObjectName());
log.info("target={}",bindingResult.getTarget());
// 검증로직
// ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult,"itemName","required");
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);
}
if (item.getQuantity()== null || item.getQuantity() >9999){
bindingResult.rejectValue("quantity","max",new Object[]{9999}, null);
}
// 특정필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null){
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{100000,resultPrice},null);
}
}
// 검증에 실패하면 다시 입력 폼으로 이동해야함
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}";
}
rejectValue, reject 메서드를 이용해서, 대신 생성을 한다오류 필드명, 오류코드의 첫 단어, 파라미터 리스트를 넣어준다...errors.properties에서 인식을 제대로 한다는 걸까???

rejectValue() 를 사용하고 부터는 오류 코드를 range 로 간단하게 입력했다. 그래도 오류 메시지를 잘 찾아서 출MessageCodesResolver 를 이해해야 한다오류 코드는 단순히 만들 수 있고, 자세히 만들 수 있다.
MessageCodesResolver가 해준다MessageCodesResolver 인터페이스이고 DefaultMessageCodesResolver 는 기본 구현체이다객체 오류
객체 오류의 경우 다음 순서로 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"
메시지 코드들을 생성하는 메서드들이다배열로 가지고 있는 것이다MessageCodesResolver 를 통해서 생성된 순서대로 오류 코드를 보관한다.ObjectError reject("totalPriceMin")#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} 까지 허용합니다.
## 추가
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
다음과 같이, 레벨에 맞게 오류 메시지를 범용성있고, 구체적이게 모두 구현한다
rejectValue, reject를 사용하면필드값과, 오류코드를 조합해 MessageCodesResolver가 오류 메시지들을 리스트로 만들어준다!bindingResult에 담아준다bindingResult를 컨트롤러에서 뷰로 이동시키면서rejectValue()로 직접 호출
error.properties에 메시지 코드를 설정해놓지 않았기 때문에, 스프링이 생성한 기본 메시지가 출력된다.그런데 우리가 배운걸
응용해보자!!!
오류 코드를 잘 읽어보면,TypeMismatch를 이용해 오류를MessageCodesResolver가 규칙을 가지고 4가지 메시지를 생성했네????
범용성있게,errors.properties에 메시지를 넣어주면????
Validator인터페이스를 상속 받아서 사용할건데, supports() 해당 검증기를 지원하는지Validate 검증 대상 객체와 BindingResult그런데 굳이 왜 Validator을 쓸까? 이는 스프링에서 추가적인 도움을 받을 수 있기 때문이다
@InitBinder
public void init(WebDataBinder dataBinder) {
log.info("init binder {}", dataBinder);
dataBinder.addValidators(itemValidator);
}
InitBinder는 컨트롤러가 호출될때 마다 항상 불려져서WebDataBinder가 내부적으로 항상 새로 만들어져서itemValidator을 넣어준다@Validated 이거를 넣으면 넣은 변수에 대해서 자동으로 검정을 한다@Component
public class ItemValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return Item.class.isAssignableFrom(clazz); // 파라미터로 들어오는 클래스가 Item에 지원이 되냐?
// Item == clazz
// Item == subItem
}
@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[]{100000,resultPrice},null);
}
}
}
}
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
// 검증에 실패하면 다시 입력 폼으로 이동해야함
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 에 등록한 검증기를 찾아서 실행한다.supports() 가 사용된다. 여기서는supports(Item.class) 호출되고, 결과가 true 이므로 ItemValidator 의 validate()가 호출된다.