검색결과 리스트
글
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 9
인프런 강의 47일차.
- 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 1 (김영한 강사님)
- 1편에서 배운 MVC를 활용할 수 있는 기술 습득
- 타입 컨버터, 파일 업로드, 활용, 쿠키, 세션, 필터, 인터셉터, 예외 처리, 타임리프, 메시지, 국제화, 검증 등등
* intelliJ 단축키
- 사용할 클래스의 생성자 살펴보기 단축키 : Ctrl + P (Windows)
- for문 자동 생성 : iter + tab -> for(String msg : msgList) 가 자동 생성됨
- for문 내에서 필드 출력 : soutv -> System.out.println("msg = " + msg);가 자동 생성됨
4.8 FieldError, ObjectError
- 목표 : 사용자 입력 오류 메시지가 화면에 남도록 하자.
- 예) 가격을 1000원 미만으로 설정시 입력한 값이 남아있어야 한다.
@PostMapping("/add")
public String addItemV2(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//bindingResult가 v1.errors 역할을 해준다.
//1. 검증 오류 결과를 보관
// Map<String, String> errors = new HashMap<>(); //검증오류 정보를 보관할 변수
//2. 검증 로직
//검증 오류가 발생하면 errors 변수에 담아둔다. (import org.springframework.util.StringUtils 추가 필요)
if(!StringUtils.hasText(item.getItemName())) {
// errors.put("itemName", "상품 이름은 필수입니다."); //어떤 필드에서 오류가 발생했는지 구분하기 위해 오류가 발생한 필드명을 key 로 사용한다
//Ctrl + P를 통해 해당 메소드의 호출 가능 형태를 볼 수 있다,
//필드가 존재하는 오류이므로 FieldError에 binding
// bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
//FieldError 생성자 중 2번째 생성자를 사용해서 체크
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null, "상품 이름은 필수입니다."));
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
// errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다.");
// bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
// errors.put("quantity", "수량은 최대 9,999 까지 허용합니다.");
// bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9,999 까지 허용합니다."));
bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, null, null, "수량은 최대 9,999 까지 허용합니다."));
}
//3. 특정 필드 값이 아닌 복합 룰 검증
if(item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if(resultPrice < 10000){
//복합 룰에 의한 오류이므로 key를 globalError로 사용
// errors.put("globalError", "가격*수량의 합은 10,000원 이상이어야 합니다. 현재 값 = "+resultPrice);
//필드가 아닌 경우는 ObjectError에 binding. 대신 key를 따로 지정하지 않아도 된다.
// bindingResult.addError(new ObjectError("item", "가격*수량의 합은 10,000원 이상이어야 합니다. 현재 값 = "+resultPrice));
//Object의 경우 따로 bind하는 것이 아닌 bind 된 값을 조합해서 에러처리하는 것이므로 bind 관련 생성자는 없고, code와 arguments 파라미터만 추가로 받을 수 있다.
bindingResult.addError(new ObjectError("item", null, null, "가격*수량의 합은 10,000원 이상이어야 합니다. 현재 값 = "+resultPrice));
}
}
//4. 검증에 실패하면 다시 입력 폼으로 redirect
if(bindingResult.hasErrors()){ //!errors.isEmpty() -> bindingResult.hasErrors() 로 변경
//검증 실패 시 model에 errors를 담고 입력폼이 있는 뷰 템플릿으로 보낸다.
log.info("errors = {} ", bindingResult);
// model.addAttribute("errors", errors); //bindingResult는 자동으로 View에 같이 넘어가기 때문에 model에 안담아도 된다.
return "validation/v2/addForm";
}
//5. 성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
- hello.itemservice.web.validation.ValidationItemControllerV2
- bindingResult의 다른 생성자를 사용한 addItemV2 구현
* FieldError 생성자 파라미터 목록
- objectName : 오류가 발생한 객체 이름
- field : 오류 필드
- rejectedValue : 사용자가 입력한 값(거절된 값)
- bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
- codes : 메시지 코드
- arguments : 메시지에서 사용하는 인자
- defaultMessage : 기본 오류 메시지
* 오류 발생시 사용자 입력 값 유지
bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
- 사용자의 입력 데이터가 컨트롤러의 @ModelAttribute 에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력 값을 유지하기 어렵다.
- 예를 들어서 가격에 숫자가 아닌 문자가 입력된다면 가격은 Integer 타입이므로 문자를 보관할 수 있는 방법이 없다.
- 그래서 오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요하다.
- 그리고 이렇게 보관한 사용자 입력 값을 검증 오류 발생시 화면에 다시 출력하면 된다.
- FieldError 는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공한다.
- 즉, 사용자 입력 값에 대해 서버단 검증이 실패할 경우 브라우저로 다시 응답을 주는데, 이미 날아간 데이터(=사용자입력값) 을 화면에 그대로 보여줄 수 있다.
- 여기서 rejectedValue 가 바로 오류 발생시 사용자 입력 값을 저장하는 필드다. bindingFailure 는 타입 오류 같은 바인딩이 실패했는지 여부를 적어주면 된다. 예제에서는 바인딩이 실패한 것은 아니기 때문에 false 를 사용한다.
* 타임리프의 사용자 입력 값 유지
<input type="text" id="price" th:field="*{price}"
th:errorClass="field-error"
class="form-control" placeholder="가격을 입력하세요">
- th:field="*{price}"
- 타임리프의 th:field 는 매우 똑똑하게 동작하는데, 정상 상황에는 모델 객체의 값을 사용하지만, 오류가 발생하면 FieldError 에서 보관한 값을 사용해서 값을 출력한다.
* 스프링의 바인딩 오류 처리
- 타입 오류로 바인딩에 실패하면 스프링은 FieldError 를 생성하면서 사용자가 입력한 값을 넣어둔다.
- 그리고 해당 오류를 BindingResult 에 담아서 컨트롤러를 호출한다.
- 따라서 타입 오류 같은 바인딩 실패시에도 사용자의 오류 메시지를 정상 출력할 수 있다
4.9 오류 코드와 메시지 처리1
- 목표 : 오류 메시지를 체계적으로 다루어보자
- FieldError , ObjectError 의 생성자는 errorCode , arguments 를 제공한다. 이것은 오류 발생시 오류 코드로 메시지를 찾기 위해 사용된다.
- 중복되는 오류&메시지를 한번에 쉽게 관리하기 위함.
* 스프링 부트 메시지 설정 추가
spring.messages.basename=messages,errors
- resources/application.properties
- messages.properties 를 사용해도 되지만, 오류 메시지를 구분하기 쉽게 errors.properties 라는 별도의 파일로 관리
- 먼저 스프링 부트가 해당 메시지 파일을 인식할 수 있게 설정을 추가한다.
- 이렇게하면 messages.properties , errors.properties 두 파일을 모두 인식한다. (생략 시 messages.properties가 기본)
* errors 메시지 파일 생성
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
- resources/erros.properties
- errors_en.properties 파일을 생성하면 오류 메시지도 국제화 처리를 할 수 있다.
@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//2. 검증 로직
if(!StringUtils.hasText(item.getItemName())) {
//default 메시지는 삭제하고, errors.properties에 정의한 code 값으로 에러를 세팅해준다.
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, new String[]{"required.item.itemName"}, null, null));
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
//errors.properties에 파라미터를 받도록 정의한 경우 new Object[]{} 로 파라미터값을 넘겨야한다.
bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1000,1000000}, null));
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[]{9999}, null));
}
//3. 특정 필드 값이 아닌 복합 룰 검증
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, resultPrice}, null));
}
}
//4. 검증에 실패하면 다시 입력 폼으로 redirect
if(bindingResult.hasErrors()){ //!errors.isEmpty() -> bindingResult.hasErrors() 로 변경
//검증 실패 시 model에 errors를 담고 입력폼이 있는 뷰 템플릿으로 보낸다.
log.info("errors = {} ", bindingResult);
// model.addAttribute("errors", errors); //bindingResult는 자동으로 View에 같이 넘어가기 때문에 model에 안담아도 된다.
return "validation/v2/addForm";
}
//5. 성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
- hello.itemservice.web.validation.ValidationItemControllerV2
- errors.properties를 사용해 메시지 처리화 시킨 addItemV3 구현
- codes : "required.item.itemName" 를 사용해서 메시지 코드를 지정한다. 메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.
> new String[]{"required.item.itemName", "required.default"} 로 사용 후 required.item.itemName 못찾을 시 required.default 메시지를 출력한다.
- arguments : Object[]{1000, 1000000} 를 사용해서 코드의 {0} , {1} 로 치환할 값을 전달한다.
4.10 오류 코드와 메시지 처리2
- 목표 : FieldError , ObjectError 는 다루기 너무 번거롭다. 오류 코드를 좀 더 자동화 해보자.
- 컨트롤러에서 BindingResult 는 검증해야 할 객체인 target 바로 다음에 온다. 따라서 BindingResult 는 이미 본인이 검증해야 할 객체인 target 을 알고 있다.
//ValidationItemControllerV2.addItemV3 에서 출력
log.info("objectName={}", bindingResult.getObjectName());
log.info("target={}", bindingResult.getTarget());
//출력 결과
objectName=item //@ModelAttribute name
target=Item(id=null, itemName=상품, price=100, quantity=1234
- rejectValue() , reject() : BindingResult 가 제공하는 함수.
> FieldError , ObjectError 를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다.
@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
// 검증 로직
if(!StringUtils.hasText(item.getItemName())) {
// bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, new String[]{"required.item.itemName"}, null, null));
//new FieldError로 매번 생성해줄 필요 없이 bindingResult가 제공하는 rejectValue로 코드를 줄일 수 있다.
//여기서 두번째 인자인 errorCode는 required.item.itemName에서 맨 첫번째 값인 required로만 적으면 된다.
bindingResult.rejectValue("itemName", "required");
}
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}, null));
//argument가 있는 경우 new Object로 넘겨주고, defaultMsg는 null로 사용하면 된다.
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
// bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[]{9999}, null));
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.addError(new ObjectError("item", new String[]{"totalPriceMin"}, new Object[]{10000, resultPrice}, null));
//new ObjectError의 경우 rejectValue가 아닌 reject 메소드를 써야한다.
//default 메소드 없이 errorCode만 써주면 된다.
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
// 검증에 실패하면 다시 입력 폼으로 redirect
if(bindingResult.hasErrors()){ //!errors.isEmpty() -> bindingResult.hasErrors() 로 변경
//검증 실패 시 model에 errors를 담고 입력폼이 있는 뷰 템플릿으로 보낸다.
log.info("errors = {} ", bindingResult);
// model.addAttribute("errors", errors); //bindingResult는 자동으로 View에 같이 넘어가기 때문에 model에 안담아도 된다.
return "validation/v2/addForm";
}
// 성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
- hello.itemservice.web.validation.ValidationItemControllerV2
- bindingResult.reject, rejectValue를 사용해 코드를 간소화 시킨 addItemV4 구현
- errors.properties에 있는 errorcode를 전부 입력하지 않았는데 오류 메시지를 찾아온 이유?
* rejectValue() 메소드 동작 방식
void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage)
- field : 오류 필드명
- errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다. messageResolver를 위한 오류 코드이다.)
- errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값
- defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
- BindingResult 는 어떤 객체를 대상으로 검증하는지 target을 이미 알고 있으므로 target(item)에 대한 정보는 없어도 된다. 오류 필드명은 동일하게 price 를 사용했다.
* 축약된 오류 코드
- FieldError() 를 직접 다룰 때는 오류 코드를 range.item.price 와 같이 모두 입력했다.
- 그런데 rejectValue() 를 사용하고 부터는 오류 코드를 range 로 간단하게 입력했다.
- MessageCodesResolver 동작방식 덕분에 가능하며, 왜 이런식으로 코드를 구성하는지 자세히 알아보자.
4.11 오류 코드와 메시지 처리3
- 오류 코드는 다양한 방식으로 생성이 가능하다.
> required.item.itemName : 상품 이름은 필수 입니다.
> range.item.price : 상품의 가격 범위 오류 입니다.
> required : 필수 값 입니다.
> range : 범위 오류 입니다.
- 단순하게 만들면 범용성이 좋아서 여러곳에서 사용할 수 있지만, 메시지를 세밀하게 작성하기 어렵다.
- 반대로 너무 자세하게 만들면 범용성이 떨어진다. 가장 좋은 방법은 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우에는 세밀한 내용이 적용되도록 메시지에 단계를 두는 방법이다
#Level 1
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
#Level 2
required=필수 값 입니다.
range=범위는 {0} ~ {1} 까지 허용합니다.
max=최대 {0} 까지 허용합니다.
- errors.properties에 에러 코드 작성 후 실제 코드에서 상세 오류 코드를 먼저 호출하고, 없을 시 디폴트로 범용성 메시지를 호출하도록 구현하면 된다.
- new String[]{"required.item.itemName", "required"} : 상세 메시지 먼저 호출 후 없으면 범용성 메시지 호출
- 개발 소스 수정 없이 메시지 소스 수정 만으로 전체 코드에 적용이 가능하다.
> 해당 기능은 스프링에서 MessageCodesResolver 클래스로 제공하고 있다.
4.12 오류 코드와 메시지 처리4
- 오류 코드는 다양한 방식으로 생성이 가능하다.
> required.item.itemName : 상품 이름은 필수 입니다.
> range.item.price : 상품의 가격 범위 오류 입니다.
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;
import org.springframework.validation.ObjectError;
public class MessageCodesResolverTest {
MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();
@Test
void messageCodesResolverObject() {
String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
for(String message : messageCodes) {
//Detail 코드 먼저 표시됨
System.out.println("messageCode = "+message);
}
//bindingResult.rejectValue가 아래 로직을 수행시켜주는 역할이다.
//new ObjectError("item", new String[]{"required.item", "required"})
Assertions.assertThat(messageCodes).containsExactly("required.item", "required");
}
@Test
void messageCodesResolverField() {
String[] messageCodes = codesResolver.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"
);
}
}
- hello.itemservice.validation.MessageCodesResolverTest.java
- MessageCodesResolver 테스트를 위한 MessageCodesResolverTest 클래스 생성
- 검증 오류 코드로 메시지 코드들을 생성한다.
- MessageCodesResolver 인터페이스이고 DefaultMessageCodesResolver 는 기본 구현체이다.
- 주로 ObjectError, FieldError와 함께 사용 된다.
* 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"
* 동작 방식
- rejectValue() , reject() 는 내부에서 MessageCodesResolver 를 사용한다. 여기에서 메시지 코드들을 생성한다.
- FieldError , ObjectError 의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다.
- MessageCodesResolver 를 통해서 생성된 순서대로 오류 코드를 보관한다.
- 이 부분을 BindingResult 의 로그를 통해서 확인해보자.
> codes [range.item.price, range.price, range.java.lang.Integer, range]
* FieldError rejectValue("itemName", "required")
- 다음 4가지 오류 코드를 자동으로 생성한다.
> required.item.itemName
> required.itemName
> required.java.lang.String
> required
* ObjectError reject("totalPriceMin")
- 다음 2가지 오류 코드를 자동으로 생성한다.
> totalPriceMin.item
> totalPriceMin
* 오류 메시지 출력
- 타임리프 화면을 렌더링 할 때 th:errors 가 실행된다.
- 만약 이때 오류가 있다면 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 찾는다.
- 그리고 없으면 디폴트 메시지를 출력한다.
4.13 오류 코드와 메시지 처리5
- 오류 코드 관리 전략
* 핵심은 구체적인 것 -> 덜 구체적인 것으로
- MessageCodesResolver 는 required.item.itemName 처럼 구체적인 것을 먼저 만들어주고, required 처럼 덜 구체적인 것을 가장 나중에 만든다.
- 이렇게 하면 앞서 말한 것 처럼 메시지와 관련된 공통 전략을 편리하게 도입할 수 있다
* 왜 이렇게 복잡하게 사용하는가?
- 모든 오류 코드에 대해서 메시지를 각각 다 정의하면 개발자 입장에서 관리하기 너무 힘들다.
- 크게 중요하지 않은 메시지는 범용성 있는 requried 같은 메시지로 끝내고, 정말 중요한 메시지는 꼭 필요할 때 구체적으로 적어서 사용하는 방식이 더 효과적이다.
#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} 까지 허용합니다.
- resources/erros.properties
- 객체 오류와 필드 오류로 구분하였고, 범용성에 따라 레벨을 나누었다.
- itemName 의 경우 required 검증 오류 메시지가 발생하면 다음 코드 순서대로 메시지가 생성된다.
> 1. required.item.itemName
> 2. required.itemName
> 3. required.java.lang.String
> 4. required
- 위에서 생성된 메시지 코드를 기반으로 순서대로 MessageSource 에서 메시지에서 순서대로 찾는다.
- 이렇게 되면 만약에 크게 중요하지 않은 오류 메시지는 기존에 정의된 것을 그냥 재활용 하면 된다!
* ValidationUtils 사용
// ValidationUtils를 사용한 검증 로직
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
// 검증 로직
if(!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required");
}
- 위의 검증로직 2개는 실행 결과가 동일하다.
- ValidationUtils는 값이 empty 혹은 공백 같은 단순한 기능에 대한 밸리데이션만 제공한다.
* bindingResult.hasErrors() 메소드
//오류가 하나라도 있는 경우 바로 addForm으로 돌려 버리는 로직
if(bindingResult.hasErrors()) {
log.info("errors={} ", bindingResult);
return "validation/v2/addForm";
}
- bindingResult에 에러가 존재할 경우 바로 addForm을 호출하는 로직
- 입력값에 대한 모든 검증 후 전체 에러를 보여줄지, 에러가 존재하는 건 바이 건으로 화면에 바로 보여줄지 UX에 따라 선택하면 됨.
* 정리
- 1. rejectValue() 호출
- 2. MessageCodesResolver 를 사용해서 검증 오류 코드로 메시지 코드들을 생성
- 3. new FieldError() 를 생성하면서 메시지 코드들을 보관
- 4. th:erros 에서 메시지 코드들로 메시지를 순서대로 메시지에서 찾고, 노출
4.14 오류 코드와 메시지 처리6
- 스프링이 직접 만든 오류 메시지 처리
- 검증 오류 코드는 다음과 같이 2가지로 나눌 수 있다.
> 개발자가 직접 설정한 오류 코드 rejectValue() 를 직접 호출
> 스프링이 직접 검증 오류에 추가한 경우(주로 타입 정보가 맞지 않음)
* 지금까지 학습한 메시지 코드 전략의 강점을 지금부터 확인해보자.
- price 필드에 문자 "A"를 입력해보자.
- 로그를 확인해보면 BindingResult 에 FieldError 가 담겨있고, 다음과 같은 메시지 코드들이 생성된 것을 확인할 수 있다.
- codes[typeMismatch.item.price,typeMismatch.price,typeMismatch.java.lang.Integer,typ eMismatch]
- 다음과 같이 4가지 메시지 코드가 입력되어 있다.
> typeMismatch.item.price
> typeMismatch.price
> typeMismatch.java.lang.Integer
> typeMismatch
- 스프링은 타입 오류가 발생하면 typeMismatch 라는 오류 코드를 사용한다.
- 이 오류 코드가 MessageCodesResolver 를 통하면서 4가지 메시지 코드가 생성된 것이다.
* 실행 결과
- errors.properties 에 typeMismatch 코드가 없기 때문에 스프링이 생성한 기본 메시지가 출력된다.
> Failed to convert property value of type java.lang.String to required type
> java.lang.Integer for property price; nested exception is java.lang.NumberFormatException: For input string: "A"
* errors.properties에 typeMismatch 내용 추가
#추가
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
- 메시지 추가 후 재실행 시 추가한 메시지가 표시되는 것을 확인할 수 있다.
- 즉, 소스코드 수정 없이 원하는 메시지를 단계별로 설정할 수 있다.
4.15 Validator 분리1
- 목표 : 복잡한 검증 로직을 별도로 분리하자
- 스프링이 직접 만든 오류 메시지 처리
- 컨트롤러에서 검증 로직이 차지하는 부분은 매우 크기 때문에 별도의 클래스로 역할을 분리하는 것이 좋다.
- 이렇게 분리한 검증 로직은 재사용 할 수 있다.
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;
//Spring이 제공하는 Validator를 사용하여 구현. Component 스캔 처리하여 Spring Bean에 등록
@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; //Interface가 Object로 구현되어있으므로 캐스팅해서 사용
//Errors는 bindingResult의 부모 클래스이다.
// 검증 로직
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);
}
}
}
}
- hello.itemservice.web.validation.ItemValidator.java
- 검증 로직 클래스를 따로 구현한 ItemValidator 클래스
* 스프링은 검증을 체계적으로 제공하기 위해 다음 인터페이스를 제공한다
public interface Validator {
boolean supports(Class<?> clazz);
void validate(Object target, Errors errors);
}
- supports() {} : 해당 검증기를 지원하는 여부 확인(뒤에서 설명)
- validate(Object target, Errors errors) : 검증 대상 객체와 BindingResult
* ItemValidator 직접 호출하기
//@RequiredArgsConstructor를 사용하면 생성자가 하나일 경우 단순 선언만으로도 Autowired 주입이 된다.
private final ItemValidator itemValidator;
....
@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//1줄로 검증 로직 수행 가능
itemValidator.validate(item, bindingResult);
// 검증에 실패하면 다시 입력 폼으로 redirect
if(bindingResult.hasErrors()){ //!errors.isEmpty() -> bindingResult.hasErrors() 로 변경
//검증 실패 시 model에 errors를 담고 입력폼이 있는 뷰 템플릿으로 보낸다.
log.info("errors = {} ", bindingResult);
// model.addAttribute("errors", errors); //bindingResult는 자동으로 View에 같이 넘어가기 때문에 model에 안담아도 된다.
return "validation/v2/addForm";
}
// 성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
- hello.itemservice.web.validation.ValidationItemControllerV2
- ItemValidator를 사용해 검증로직을 분리한 addItemV5 구현
- ItemValidator 를 스프링 빈으로 주입 받아서 직접 호출했다.
4.16 Validator 분리2
- 스프링이 Validator 인터페이스를 별도로 제공하는 이유는 체계적으로 검증 기능을 도입하기 위해서다.
- 그런데 앞에서는 검증기를 직접 불러서 사용했는데, Validator 인터페이스를 사용해서 검증기를 만들면 스프링의 추가적인 도움을 받을 수 있다.
* WebDataBinder를 통해서 사용하기
- WebDataBinder 는 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함한다
@InitBinder
public void init(WebDataBinder dataBinder) {
log.info("init binder {}", dataBinder);
dataBinder.addValidators(itemValidator);
}
- hello.itemservice.web.validation.ValidationItemControllerV2
- itemValidator 검증기 추가
- 이렇게 WebDataBinder 에 검증기를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있다.
- @InitBinder 해당 컨트롤러에만 영향을 주기 때문에 글로벌 설정은 별도로 해야한다.
* @Validated 적용
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//@Validated 애노테이션을 추가하고, 메소드 내에 검증기 호출 로직은 주석처리한다. -> Item 에 대한 검증기가 자동으로 수행이 된다.
// itemValidator.validate(item, bindingResult);
// 검증에 실패하면 다시 입력 폼으로 redirect
if(bindingResult.hasErrors()){ //!errors.isEmpty() -> bindingResult.hasErrors() 로 변경
//검증 실패 시 model에 errors를 담고 입력폼이 있는 뷰 템플릿으로 보낸다.
log.info("errors = {} ", bindingResult);
// model.addAttribute("errors", errors); //bindingResult는 자동으로 View에 같이 넘어가기 때문에 model에 안담아도 된다.
return "validation/v2/addForm";
}
// 성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
- hello.itemservice.web.validation.ValidationItemControllerV2
- 메소드 호출 때 자동으로 아이템 검증기를 수행하고 오는 addItemV6 메소드 구현
- databinder에 있는 validator를 수행하므로 미리 추가되어있어야 한다.
- 실제 검증을 원하는 객체에 @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();
}
}
- hello.itemservice.ItemServiceApplication.java
- 글로벌 설정을 적용하기 위해서는 WebMvcConfiguer를 추가하고, 적용하고 싶은 Validator를 추가하면 된다.
- 선언한 Validator를 적용하고 싶은 메소드에서 @Validated 를 호출해보면 @InitBinder를 제거해도 정상적으로 Validator가 동작되는 것을 확인할 수 있다.
- 글로벌 설정을 하면 BeanValidator가 자동 등록되지 않는다.
* 참고
- 글로벌 설정을 직접 사용하는 경우는 드물다.
- 검증시 @Validated @Valid 둘다 사용가능하다.
- javax.validation.@Valid 를 사용하려면 build.gradle 의존관계 추가가 필요하다.
- implementation 'org.springframework.boot:spring-boot-starter-validation'
- @Validated 는 스프링 전용 검증 애노테이션이고, @Valid 는 자바 표준 검증 애노테이션이다.
- 자세한 내용은 다음 Bean Validation에서 설명.
'Spring 정리' 카테고리의 다른 글
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 11 (0) | 2022.07.01 |
---|---|
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 10 (0) | 2022.06.29 |
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 8 (0) | 2022.06.12 |
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 7 (0) | 2022.06.12 |
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 6 (0) | 2022.06.12 |