728x90
검증 - Validation
- 컨트롤러의 중요한 역할 중 하나가 HTTP 요청이 정상인지 검증하는 것이다.
- 검증 요구사항 예)
- 타입 검증
- 예) 가격, 수량 입력에 숫자인지 아닌지 체크
- 필드 검증
- 예) 상품명에 빈 공백인 경우
- 예) 수량 최대치가 넘어갔는지 안넘어갔는지
- 특정 필드의 범위 검증
- 예) 가격 * 수량의 합이 1000원 이상인지 아닌지
- 타입 검증
🔍 클라이언트 검증 vs 서버 검증
- 클라이언트 검증은 외부 조작이 가능해서 보안에 취약하다.
- 클라이언트 검증 없이 서버 검증만 진행할 경우 즉각적인 고객 사용성이 부족해진다.
- 서버 검증은 클라이언트 검증보다 시간이 오래 걸린다.
- API방식을 사용할 경우 API 스펙을 잘 정의해서 검증 오류를 API 응답결과에 잘 남겨야 한다.
- 결국 클라이언트 검증과 서버 검증을 같이 적절히 사용하되, 최종 검증은 서버 검증으로 해야한다.
🔍 검증 과정
💡 검증 성공
- 사용자가 상품 등록 페이지에 접근한다. (HTTP GET / add)
- 사용자가 상품 정보를 입력 후 서버로 접속한다. (HTTP POST / add)
- 상품이 성공적으로 등록된 후 Location 정보로 상품정보 상세경로를 Redirect로 응답한다.
- 클라이언트에서는 응답받은 정보에 있는 Location 정보로 Redirect하여 신규 상세 페이지로 이동한다.
- 결론 : 사용자가 상품 등록 폼에서 정상 범위의 데이터를 입력하면 서버에서는 검증 로직이 통과하고, 상품을 저장하고 상품 상세 화면으로 redirect한다.
💡 검증 실패
- 사용자가 상품 등록 페이지에 접근한다. (HTTP GET / add)
- 사용자가 상품 정보를 입력 후 서버로 전송한다. (HTTP POST / add)
- 상품의 유효성 검증이 실패하여 검증 오류 결과가 포함된 정보를 담아 다시 상품 등록 페이지로 이동해야 한다.
- 검증에 실패의 주 원인은 Null / TypeMissMatch / 비즈니스 요구사항 부적합 등이 있다.
다양한 검증 방법
- 검증 방식은 다양하다.
- Map에다가 에러 내용을 담아서 모델에 담아서 반환하는 방식
- BindingResult를 사용하여 모델에 담아서 반환하는 방식
- Validator 라는 마커 인터페이스를 구현하는 방법
🔍 Map
- 클라이언트에서 전달받은 데이터를 서버가 직접 검증하여 Map에 담아서 RedirectAttributes에 담아 보내는 방법
📌 컨트롤러
@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 || 1_000_000<item.getPrice()) {
errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다.");
}
if(item.getQuantity()==null || 9999<=item.getQuantity()) {
errors.put("quantity", "수량은 최대 9,999 까지 허용합니다.");
}
if(item.getPrice()!=null && item.getQuantity()!=null) {
int resultPrice = item.getPrice() * item.getQuantity();
if(resultPrice<10_000) {
errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
}
}
// 검증 실패하면 다시 입력 폼으로
if(!errors.isEmpty()) {
log.info("errors={}", errors);
model.addAttribute("errors", errors);
return "validation/v1/addForm";
}
// 검증 성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v1/items/{itemId}";
}
- 검증에 실패하면 errors 라는 Map에 에러 내용을 담아서 뷰에 반환한다.
- @ModelAttribute 애노테이션이 붙은 Item 객체는 에러가 발생하면 다시 페이지 이동시 Item 필드 값들이 전부 다시 모델에 담겨서 뷰에 전송된다.
- RedirectAttributes는 Redirect시 보존할 데이터를 담을 수 있다.
- StringUtils
- String을 다루는데 편리한 기능을 제공하는 추상 클래스다.
- 스프링에서 지원한다.
- 주로 static 메서드로 구성되어 있다.
- hastText() 메서드는 파라미터가 문자열인지 확인해서 true 또는 fasle를 반환한다.
💡 검증 실패시 타임리프에서 검증 실패 메시지 출력하기
📌 HTML (전체 오류 메시지)
<div th:if="${errors?.containsKey('globalError')}">
<p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>
- 오류 메시지는 errors 객체에 내용이 있을 때만 출력하면 된다.
- th:if 를 사용하여 조건에 만족할 때만 HTML 태그를 렌더링 할 수 있다.
- errors?. 에서 .? 는 errors 객체가 null일 때 NullPointerException이 발생하는 대신, null을 반환하도록 하는 문법이다. null이 반환되면 실패로 처리되기 때문에 렌더링을 하지 않는다.
📌 HTML (오류 처리 메시지)
<div class="field-error" th:if="${errors?.containsKey('itemName')}"
th:text="${errors['itemName']}">
상품명 오류
</div>
📌 HTML (오류 처리시 입력폼 색상 적용)
<input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'fielderror' : _"
class="form-control">
- th:classappend를 사용하여 특정 조건일 때 클래스 속성명을 추가할 수 있다.
- 여기서는 errors 객체에 값이 있을 경우 해당하는 입력폼에 class 속성 명을 추가하여 입력폼 색상을 바꿨다.
🔍 BindingResult
- 컨트롤러의 매핑 메서드에 BindingResult를 매개변수로 받음으로써 타입 불일치에 대한 대응도 가능하다.
- BindingResult 파라미터는 무조건 뷰에게 전달할 객체의 다음번 째 파라미터에 위치해야한다.
- BindingResult를 사용할 경우 클라이언트에서 타입이 잘못된 내용이 전송되더라도 BindingResult에서 그 내용을 가지고 있기에 @ModelAttibute에 타입 불일치로 매칭이 되지 않더라도 예외가 발생하지 않고 BindingResult에서 가지고 있다.
- 그래서 400 오류가 발생하지 않는다.
- BindingResult에 담긴 값은 자동으로 Model에 담겨진다.
- BindingResult는 인터페이스다. Errors 인터페이스를 상속받고 있다.
- 그래서 컨트롤러에 넘어오는 구현체는 BeanPropertyBindingResult 클래스다.
- 주로 BindingResult를 사용한다.
예시
@PostMapping("/add")
myMethod(@ModelAttribute Item item, BindingResult bindingResult, ...) {
...
}
- BindingResult bindingResult 파라미터 위치는 @ModelAttribute Item item 바로 다음에 와야 한다.
💡 필드값 오류메시지 처리1 - FieldError
public FieldError(String objectName, String field, String defaultMessage) {}
- FieldError에는 생성자가 2개 존재한다. 위의 생성자는 클라이언트에서 에러가 발생하여 페이지로 다시 이동할 때 값을 다시 보여주지 못한다.
- 필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult에 담는다.
- objectName
- @ModelAttirbute 이름
- 뷰에게 전달할 객체 이름
- field
- 오류가 발생한 필드 이름
- defaultMessage
- 기본 오류 메시지
💡 글로벌 오류 메시지 처리 - ObjectError
public ObjectError(String objectName, String defaultMessage) {}
- 특정 객체의 필드값이 아닌 오류가 있으면 ObjectError 객체를 생성해서 bindingResult에 담는다.
- objectName
- @ModelAttirbute 이름
- 뷰에게 전달할 객체 이름
- defaultMessage
- 기본 오류 메시지
💡 BindingResult 사용
📌 컨트롤러
@PostMapping("/add")
public String addItemV1(@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 || 1_000_000<item.getPrice()) {
bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
}
if(item.getQuantity()==null || 9999<=item.getQuantity()) {
bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9,999까지 허용합니다."));
}
if(item.getPrice()!=null && item.getQuantity()!=null) {
int resultPrice = item.getPrice() * item.getQuantity();
if(resultPrice<10_000) {
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}";
}
📌 컨트롤러 (필드 오류)
if(!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
}
- 객체 필드값에 오류가 있으면 FieldError객체를 생성해서 bindingResult에 담아두면 된다.
- new FieldError("item", "itemName", "상품 이름은 필수입니다.")
- objectName : "item"
- @ModelAttibute 이름
- 뷰에게 전달할 객체 이름
- field : "itemName"
- 오류가 발생한 필드 이름
- 여기에 적힌 변수 명으로 타임리프에서 th:errors 를 통해 접근할 수 있다.
- 예) th:errors="*{itemName}"
- 예) th:errors="${item.itemName}"
- defaultMessage : "상품 이름은 필수입니다."
- 기본 오류 메시지
- objectName : "item"
📌 컨트롤러 (글로벌 오류)
if(resultPrice<10_000) {
bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
}
- 특정 객체의 필드값이 아닌 오류가 있으면 ObjectError 객체를 생성해서 bindidngResult의 파라미터로 전달하면 된다.
- new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice)
- ObjectName : "item"
- @ModelAttibute 이름
- 뷰에게 전달할 객체 이름
- defaultMessage : "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice
- 기본 오류 메시지
- ObjectName : "item"
📌 HTML (필드 오류 메시지)
<input type="text" id="itemName" th:field="${item.itemName}"
class="form-control"
placeholder="이름을 입력하세요"
th:errorclass="field-error">
<div class="field-error" th:errors="${item.itemName}">
상품명 오류
</div>
- th:errors
- 해당 필드에 오류가 있는 경우에 태그를 출력한다.
- th:if 의 편의 버전이다.
- th:errorclass
- th:field 에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.
📌 HTML (글로벌 오류 메시지)
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error"
th:each="err : ${fields.globalErrors()}"
th:text="${err}">글로벌 오류 메시지</p>
</div>
- #fields
- #fields 로 BindingResult가 제공하는 검증 오류에 접근할 수 있다.
💡 필드값 오류메시지 처리2 - FieldError
public FieldError(String objectName, // 오류가 발생한 객체 이름
String field, // 오류 필드
@Nullable Object rejectedValue, // 사용자가 입력한 값 (거절된 값)
boolean bindingFailure, //타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
@Nullable String[] codes, // 메시지 코드
@Nullable Object[] arguments, // 메시지에서 사용하는 인자
@Nullable String defaultMeesage) // 기본 오류 메시지
- 위의 생성자를 사용하면 오류 메시지를 더 자세히 작성할 수 있다.
- 여기서 codes, arguments 파라미터는 메시지 파일에서 읽어오는 것이기에 파일을 생성해줘야한다.
- rejectedValue 파라미터는 오류 발생시 사용자 입력값을 저장한다.
📌 컨트롤러
@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 || 1_000_000<item.getPrice()) {
bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
}
if(item.getQuantity()==null || 9999<=item.getQuantity()) {
bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, null, null, "수량은 최대 9,999까지 허용합니다."));
}
if(item.getPrice()!=null && item.getQuantity()!=null) {
int resultPrice = item.getPrice() * item.getQuantity();
if(resultPrice<10_000) {
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}";
}
- FieldError 객체는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공한다.
- 타입 오류로 바인딩에 실패하면 스프링은 FieldError를 생성하면서 사용자가 입력한 값 rejectedValue 파라미터에 넣어둔다. 그리고 해당 오류를 BindingResult에 담아서 컨트롤러를 호출한다. 따라서 타입 오류 같은 바인딩 실패시 사용자의 오류 메시지를 정상 출력할 수 있다.
📌 HTML (필드 오류 메시지 처리)
<label for="itemName" th:text="#{label.item.itemName}">상품명</label>
<input type="text" id="itemName" th:field="*{itemName}"
class="form-control"
placeholder="이름을 입력하세요"
th:errorclass="field-error">
<div class="field-error" th:errors="${item.itemName}">
상품명 오류
</div>
- th:field 는 정상 상황에서는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError에서 보관한 값을 사용해서 값을 출력한다.
오류 코드와 메시지 처리
🔍 방법 1
- 오류 메시지도 메시지 기능을 사용하여 한 곳에서 관리할 수 있다.
- FiiledError, ObjectError의 생성자는 errorCode, arguments를 제공한다.
- 이것은 오류 발생시 오류 코드로 메시지를 찾기 위해 사용된다.
💡 FieldError 생성자
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)
- ObjectName : 오류가 발생한 객체 이름
- field : 오류 필드
- rejectedValue : 사용자가 입력한 값 (거절된 값)
- bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
- codes : 메시지 코드
- 배열로 여러 값을 전달할 수 있는데 순차적으로 매칭된다. 처음 매칭되는 메시지가 사용된다.
- 첫 번째 인자 값이 없을 경우 두 번째 인자값을 매칭하고,,, 이런식으로 매칭이 될 때 까지 배열 인자를 매칭한다.
- 만약에 끝까지 매칭되는 값이 없을 경우 defaultMessage 파라미터 인자 값을 출력한다.
- errors.properties 파일에 적혀 있는 key 값을 적으면 된다.
- 배열로 여러 값을 전달할 수 있는데 순차적으로 매칭된다. 처음 매칭되는 메시지가 사용된다.
- arguments : 메시지에서 사용하는 인자
- Object 배열을 사용하며 메세지의 파라미터에게 전달할 값을 {0}, {1} 이렇게 인덱스 값에 맞춰서 매핑하여 전달한다.
- defaultMessage : 기본 오류 메시지
사용 예
if(!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError(
"item",
"itemName",
item.getItemName(),
false,
new String[]{"required.item.itemName"},
null,
null));
}
💡 오류 메시지 설정 파일 생성
📌 application.properties (스프링 부트 메시지 설정 추가)
spring.messages.basename=messages, errors
📌 errors.properties (src/main/resoucres/errors.peroperties)
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
- errors_en.properties 파일을 생성하면 오류 메시지도 국제화 처리를 할 수 있다.
📌 컨트롤러
@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, null));
}
if(item.getPrice()==null || item.getPrice()<1000 || 1_000_000<item.getPrice()) {
bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1_000, 1_000_000}, null));
}
if(item.getQuantity()==null || 9999<=item.getQuantity()) {
bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[] {9_999}, null));
}
if(item.getPrice()!=null && item.getQuantity()!=null) {
int resultPrice = item.getPrice() * item.getQuantity();
if(resultPrice<10_000) {
bindingResult.addError(new ObjectError("item", new String[]{"totalPriceMin"}, new Object[]{10_000 * 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}";
}
🔍 방법2
- FieldError, ObjectError 객체는 다루기 너무 번거롭다.
- 오류 코드를 자동화 할 수 있도록 해보자.
- 컨트롤러에서 BindingResult는 검증해야할 객체인 target 바로 다음 번째 파라미터로 위치한다.
- 따라서 BindingResult는 이미 본인이 검증해야할 객체인 target에 대해 알고 있다.
- 그 부분을 활용하면 된다.
- BindingResult가 제공하는 rejectValue() 와 reject() 메서드를 사용하면 FieldError, ObjectError 객체를 직접 생성하지 않고 깔끔하게 검증 오류를 다룰 수 있다.
📌 컨트롤러
@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
log.info("objectName={}", bindingResult.getObjectName());
log.info("target={}", bindingResult.getTarget());
// 검증 로직
if(!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required");
}
if(item.getPrice()==null || item.getPrice()<1000 || 1_000_000<item.getPrice()) {
bindingResult.rejectValue("price", "range", new Object[]{1_000, 1_000_000}, null);
}
if(item.getQuantity()==null || 9999<=item.getQuantity()) {
bindingResult.rejectValue("quantity", "max", new Object[]{9_999}, null);
}
if(item.getPrice()!=null && item.getQuantity()!=null) {
int resultPrice = item.getPrice() * item.getQuantity();
if(resultPrice<10_000) {
bindingResult.reject("totalPriceMin", new Object[]{10_000, 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()
void rejectValue(@Nullable String field,
String errorCode,
@Nullable Object[] errorArgs,
@Nullable String defaultMessage);
- filed : 오류명
- errorCode : 오류 코드
- 이 오류 코드는 메시지에서 등록된 코드가 아니라 messageResolver를 위한 오류 코드다.
- errorArgs : 오류 메시지에서 {0}, {1} 등을 치환하기 위환 값
- defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
- field와 errorCode 파라미터를 가지고 errors.properties에서 메시지를 찾아낸다.
- 스프링은 MessageCodeResolver를 통해서 찾아낸다.
사용 예
// FieldError 객체 사용
bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1_000, 1_000_000}, null));
// rejectValue() 메서드 사용
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)
- errorCode.객체.field
- 예) range.item.price
💡 글로벌 오류 메시지 처리 - reject()
void reject(String errorCode,
@Nullable Object[] errorArgs,
@Nullable String defaultMessage);
사용예
// ObjectError 객체 사용
bindingResult.addError(new ObjectError("item", new String[]{"totalPriceMin"}, new Object[]{10_000 * resultPrice}, null));
// reject() 메서드 사용
bindingResult.reject("totalPriceMin", new Object[]{10_000, resultPrice}, null);
🔍 오류 메시지 설계 방법
📌 errors.properties
#Level1
required.item.itemName: 상품 이름은 필수 입니다.
#Level2
required: 필수 값 입니다
- 오류 메시지를 Level1처럼 상세하게 작성할 수 있고 Level2처럼 범용성 좋게 간단하게 작성할 수도 있다.
- reject() 메서드, rejectValue() 메서드는 오류 메시지 설청 파일에서 key값을 매핑할 때 구체적인 key값일수록 우선순위를 갖는다.
🔍 MessageCodesResolver
- 스프링에서는 오류 메시지 코드 관리를 위해 MessageCodesResolver 인터페이스의 구현체인 DefaultMessageCodesResolver 클래스를 기본으로 사용한다.
- MessageCodesResolver는 오류 메시지 코드를 생성한다.
- 예) required.item.itemName, required.itemName, required
- rejectValue() 메서드와 reject() 메서드는 내부에서 MessageCodesResolver를 사용한다.
📌 MessageCodeResovler 인터페이스의 resolveMessageCodes() 메서드
String[] resolveMessageCodes(String errorCode, String objectName);
String[] resolveMessageCodes(String errorCode,
String objectName,
String field,
@Nullable Class<?> fieldType);
💡 DefaultMessageCodesResolver 클래스의 기본 메시지 생성 규칙
- MessageCodesResolver를 통해서 우선순위가 높은 오류 코드부터 생성하여 보관한다.
- 오류코드는 구체적일수록 우선순위가 높다.
- 타임리프는 렌더링 할 때 th:errors 가 실행된다. 이 때 오류가 있다면 생성된 오류 메시지 코드를 우선순위가 높은 순서대로 매핑한다. 만약에 매핑된 오류 코드가 없으면 defaultMessage를 출력한다.
✔ 객체 오류 코드 생성 규칙
객체 오류의 경우 다음 순서로 2가지 생성
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
예) 오류 코드: typeMismatch, object name "user", field "age", field type: int
1. "typeMismatch.user.age"
2. "typeMismatch.age"
3. "typeMismatch.int"
4. "typeMismatch"
💡 오류 코드 검증 (MessageCodesResolver 사용)
package hello.itemservice.validation;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.validation.DefaultMessageCodesResolver;
import org.springframework.validation.MessageCodesResolver;
public class MessageCodeResolverTest {
MessageCodesResolver codeResolver = new DefaultMessageCodesResolver();
@Test
void messageCodeResolverObject() {
String[] messageCodes = codeResolver.resolveMessageCodes("required", "item");
for (String messageCode : messageCodes) {
System.out.println("messageCode = " + messageCode);
}
Assertions.assertThat(messageCodes).containsExactly("required.item", "required");
}
@Test
void messageCodeResolverField() {
String[] messageCodes = codeResolver.resolveMessageCodes("required", "item", "itemName", String.class);
for (String messageCode : messageCodes) {
System.out.println("messageCode = " + messageCode);
}
Assertions.assertThat(messageCodes).containsExactly(
"required.item.itemName",
"required.itemName",
"required.java.lang.String",
"required"
);
}
}
📌 객체 오류 코드 검증
@Test
void messageCodeResolverObject() {
String[] messageCodes = codeResolver.resolveMessageCodes("required", "item");
for (String messageCode : messageCodes) {
System.out.println("messageCode = " + messageCode);
}
Assertions.assertThat(messageCodes).containsExactly("required.item", "required");
}
- reject("totalPriceMin")
- 2가지 오류 코드를 자동으로 생성한다.
- totalPriceMin.item
- totalPriceMi
📌 필드 오류 코드 검증
@Test
void messageCodeResolverField() {
String[] messageCodes = codeResolver.resolveMessageCodes("required", "item", "itemName", String.class);
for (String messageCode : messageCodes) {
System.out.println("messageCode = " + messageCode);
}
Assertions.assertThat(messageCodes).containsExactly(
"required.item.itemName",
"required.itemName",
"required.java.lang.String",
"required"
);
}
- rejectValue("itemName", "required")
- 4가지 오류 코드를 자동으로 생성한다.
- required.item.itemNam
- required.itemName
- required.java.lang.String
- required
오류 코드 관리 전략
- MessageCodeResolver는 required.item.itemName 처럼 구체적인 것을 먼저 만들어주고, required 처럼 덜 구체적인 것을 가장 나중에 만든다.
- 크게 중요하지 않는 메시지는 범용성 있게 required 같은 메시지로 끝내고, 정말 중요한 메시지는 꼭 필요할 때 구체적으로 적어서 사용하는 방식이 더 효과적이다.
- 이렇게 생성된 메시지 코드를 기반으로 우선순위가 높은 순서대로 오류 코드를 MessageSource에서 메시지를 찾는다.
사용 예)
#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} 까지 허용합니다.
- 레벨이 낮을수록 더 구체적이고 우선순위가 높다.
🔍 ValidationUtils
💡 ValidationUtils 사용 전
if(!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required");
}
💡 ValidationUtils 사용 후
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
- Empty, 공백 같은 단순한 기능을 제공한다.
- ValidationUtils 사용 전 코드를 한 줄로 줄 일 수 있다.
🧷 정리
- rejectValue0 메서드 또는 reject() 호출한다.
- MessageCodesResolver 를 사용해서 검증 오류 코드로 메시지 코드들을 생성한다.
- new FieldError() 또는 new ObejctError() 객체를 생성하여 메시지 코드들을 보관한다.
- th:errors 에서 메시지 코드들로 메시지를 우선순위대로 메시지 설정 파일에서 찾고 출력한다.
스프링이 직접 만든 오류 메시지 처리
- 검증 오류 코드는 2가지로 나뉜다.
- 개발자가 직접 설정한 오류 코드
- rejectValue() 메서드를 직접 호출한다.
- 스프링이 직접 검증 오류에 추가한 경우
- 주로 타입 정보가 맞지 않을 때
- 스프링은 타입 오류가 발생하면 typeMismatch라는 오류 코드를 사용한다. 이 오류 코드가 MessageCodesResolver를 통해 4가지 메시지 코드가 생성된다.
- 예)
- typeMismatch.item.price
- typeMismatch.price
- typeMismatch.java.lang.Integer
- typeMismatch
- 개발자가 직접 설정한 오류 코드
Validator 분리
- 오류 검증하는 코드 부분을 분리하여 모듈화할 수 있다.
- Validator 인터페이스를 사용해서 검증기를 만들면 스프링의 기능을 사용할 수 있다.
📌 Validator 인터페이스 (오류를 검증하는데 스프링에서 지원하는 기능)
public interface Validator {
boolean supports(Class<?> clazz);
void validate(Object target, Errors errors);
}
- supports() {} : 해당 검증기를 지원하는 여부 확인
- validate(Object target, Errors errors) : 검증 대상 객체와 BindingResult
- target : 검증 부분
- 예) item
- errors : 검증 오류가 발생할 경우 오류 코드 보관. BindingResult의 부모 객체다.
- target : 검증 부분
🔍 Validator 사용
📌 Validator 모듈 (ItemValidator)
package hello.itemservice.web.validation;
import hello.itemservice.domain.item.Item;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.validation.Errors;
import org.springframework.validation.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 || 1_000_000<item.getPrice()) {
errors.rejectValue("price", "range", new Object[]{1_000, 1_000_000}, null);
}
if(item.getQuantity()==null || 9999<=item.getQuantity()) {
errors.rejectValue("quantity", "max", new Object[]{9_999}, null);
}
if(item.getPrice()!=null && item.getQuantity()!=null) {
int resultPrice = item.getPrice() * item.getQuantity();
if(resultPrice<10_000) {
errors.reject("totalPriceMin", new Object[]{10_000, resultPrice}, null);
}
}
}
}
- 검증 부분 모듈화
- Item.class.isAssignableFrom(clazz) : 해당 Validator 구현체는 Item 클래스에 대한 검증을 수행할 수 있음을 의미한다.
📌 컨트롤러
@Controller
@Slf4j
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {
private final ItemValidator itemValidator;
@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
itemValidator.validate(item, bindingResult);
// 검증 실패하면 다시 입력 폼으로
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}";
}
}
- ItemValidator를 생성자 주입을 통해 의존관계를 형성한다.
🔍 Validator 분리 - 애노테이션
- WebDataBinder 클래스는 스프링의 파라미터 바인딩의 역할 및 검증 기능을 한다.
- @Validated / @Valid 둘다 사용 가능하다.
- javax.validation.@Valid를 사용하려면 build.gradle 의존관계 추가가 필요하다.
- @Validated 는 스프링 전용 검증 애노테이션이이다.
- @Valid 는 자바 표준 검증 애노테이션이다.
📌 WebDataBinder 검증기
@Controller
@Slf4j
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {
private final ItemRepository itemRepository;
private final ItemValidator itemValidator;
@InitBinder
public void init(WebDataBinder dataBinder) {
log.info("init binder={}", dataBinder);
dataBinder.addValidators(itemValidator);
}
}
- dataBinder.addValidators() 메서드를 사용해 검증기를 추가하면 해당 컨트롤러에서 검증기를 자동으로 적용된다.
- @initBinder 애노테이션은 해당 컨트롤러만 영향을 준다.
📌 컨트롤러
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
// 검증 실패하면 다시 입력 폼으로
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 애노테이션이 붙었다.
- @Validated 애노테이션은 검증기를 실행하라는 애노테이션이다.
- 이 애노테이션이 붙으면 앞서 WebDataBinder에 등록한 검증기를 찾아서 실행한다.
- 만약에 여러 검증기를 등록했다면, 그 중 어떤 검증기가 실행되어야할지 구분이 필요한데 이 때 supports() 메서드가 사용된다.
- supports(Item.class) 가 호출되고 결과가 true일 경우 ItemValidator의 validate() 메서드가 호출된다.
💡 검증기 글로벌 설정 - 모든 컨트롤러에 다 적용
package hello.itemservice;
import hello.itemservice.web.validation.ItemValidator;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.validation.Validator;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer{
public static void main(String[] args) {
SpringApplication.run(ItemServiceApplication.class, args);
}
@Override
public Validator getValidator() {
return new ItemValidator();
}
}
- 검증기를 글로벌 설정으로 하려면 @SpringBootApplication 설정 클래스에 WebMvcConfigurer 인터페이스를 구현받고 getValidator() 메서드를 호출해서 검증기를 리턴하면 된다.
- 글로벌 검증기를 사용하면 컨트롤러에 @InitBinder 애노테이션을 제거해도 된다.
- 글로벌 설정은 BeanValidator가 자동으로 등록되지 않는다.
👀 참교자료
https://amagrammer91.tistory.com/37
https://catsbi.oopy.io/f6bc86a1-d19d-4647-bd12-b2d1d7db1b4b
728x90
'[ Spring ] > SpringMVC 2편' 카테고리의 다른 글
[Spring] 로그인 처리1 - 쿠키, 세션 (0) | 2022.03.12 |
---|---|
[Spring] 검증2 - Bean Validation (0) | 2022.03.11 |
[Spring] 메시지, 국제화 (0) | 2022.03.06 |
[Spring] 타임리프 - 스프링 통합과 폼 (0) | 2022.03.05 |
[Spring] Thymeleaf 타임리프 - 기본 기능 (0) | 2022.03.02 |