2022. 7. 3. 20:41ㆍLanguage`/Spring
MVC 패턴에서 Controller는 request로 들어온 URL에 대한 처리도 하지만, HTTP Request 자체가 정상인지 "검증"하는 역할도 수행한다
물론 Front쪽에서 검증을 할 수 있지만, 이는 Client가 의도적인 조작을 통해서 우회해서 접근할 수 있기 때문에 Front쪽에서 1차 검증을 한 후, 2차적으로 Server에서 검증을 해야 한다
BindingResult
BindingResult를 활용하면 request로 들어온 data에 대한 오류를 간단하게 담을 수 있고 model을 통해서 자동으로 넘겨줄 수 있다
- BindingResult가 "없다면" 400 error가 발생하면서 컨트롤러 자체가 호출되지 않는다
- 반면에 BindingResult가 "있다면" 오류 정보를 BindingResult에 담아서 컨트롤러를 정상 호출할 수 있다
bindingResult의 "addError()"를 활용하면 로직을 검증해서 발생한 에러를 bindingResult에 담을 수 있다
ObjectError에는 크게 2개의 생성자를 넣을 수 있다
1. FieldError 생성자
Entity나 DTO의 각 field에 대한 error를 검증하고 필드에 오류가 존재한다면 FieldError 객체를 생성해서 bindingResult에 담아주면 된다
<생성자 1 : {objectName, field, defaultMessage}>
param 1) objectName : 필드가 속한 객체 이름
param 2) field : 필드 이름
param 3) defaultMessage : 에러 메시지
<생성자 2 : {objectName, field, [rejectedValue], bindingFailure, [codes], [arguments], [defaultMessage]}>
param 1) objectName : field의 객체 이름
param 2) field : 필드 이름
param 3) rejectedValue : 사용자가 입력한 값 (검증에 실패된 값이다)
param 4) bindingFailure : 바인딩 자체가 실패한지에 대한 여부
-> true = 데이터 바인딩 자체가 실패
-> false = 데이터 바인딩 자체는 성공 & 비즈니스 로직 검증 실패
param 5) codes : 메시지 코드
param 6) arguments : 메시지에서 사용하는 인자
param 7) defaultMessage : 기본 오류 메시지
2. ObjectError
특정 필드를 넘어서서 여러 필드에 대해서 합친 검증을 하는 경우 오류가 존재한다면 ObjectError 객체를 생성해서 bindingResult에 담아주면 된다
<생성자 1 : {objectName, defaultMessage}>
param 1) objectName : 객체 이름
param 2) defaultMessage : 에러 메시지
<생성자 2 : {objectName, [codes], [arguments], [defaultMessage]}>
param 1) objectName : 객체 이름
param 2) codes : 메시지 코드
param 3) arguments : 메시지에 사용하는 인자
param 4) defaultMessage : 기본 오류 메시지
그리고 주의할 점은 "BindingResult"는 반드시 검증할 객체 뒤에 위치해야 한다 (파라미터 상)
public String validation(
@ModelAttribute MemberDTO validationMember,
BindingResult bindingResult
)
>> MemberDTO에 대한 검증을 하기 위해서 BindingResult를 MemberDTO 뒤에 위치시켰다
@RequestMapping("/members")
public String validation(
@ModelAttribute MemberDTO validationMember,
BindingResult bindingResult
){
// 1. name이 정상적으로 들어온지
if(!StringUtils.hasText(validationMember.getName())){
bindingResult.addError(new FieldError(
"validationMember",
"name",
"이름을 입력해주세요"
));
}
// 2. email이 입력 되었는지 -> 입력 되었으면 지정된 패턴대로 들어왔는지
if(!StringUtils.hasText(validationMember.getEmail())){
bindingResult.addError(new FieldError(
"validationMember",
"email",
"이메일 입력해주세요"
));
} else if(!isValidEmail(validationMember.getEmail())){
bindingResult.addError(new FieldError(
"validationMember",
"email",
"이메일 형식에 맞춰서 다시 입력해주세요"
));
}
// 3. phone num이 입력 되었는지 -> 입력 되었으면 지정된 패턴대로 들어왔는지
if(!StringUtils.hasText(validationMember.getPhone())){
bindingResult.addError(new FieldError(
"validationMember",
"phone",
"핸드폰 번호를 입력해주세요"
));
} else if(!isValidPhone(validationMember.getPhone())){
bindingResult.addError(new FieldError(
"validationMember",
"phone",
"핸드폰 번호 형식에 맞춰서 다시 입력해주세요"
));
}
StringBuffer sb = new StringBuffer();
sb.append("Name : ").append(validationMember.getName())
.append("\nEmail : ").append(validationMember.getEmail())
.append("\nPhone : ").append(validationMember.getPhone())
.append("\n\nName Validation >> ").append(bindingResult.getFieldError("name"))
.append("\n\nEmail Validation >> ").append(bindingResult.getFieldError("email"))
.append("\n\nPhone Validation >> ").append(bindingResult.getFieldError("phone"));
return sb.toString();
}
private boolean isValidEmail(String email){
String regex = "^[a-zA-Z0-9]+@[a-zA-Z]+.[a-z]+$";
Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(email);
return matcher.find();
}
private boolean isValidPhone(String phone){
String regex = "^01(?:0|1|[6-9])-(?:\\d{3}|\\d{4})-\\d{4}$";
Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(phone);
return matcher.find();
}
Member의 {name, email, phoneNum}에 대한 간단한 검증 로직이다
일반적으로 어떤 reqeust data에 대해서 domain entity를 그대로 @ModelAttribute로 받는 경우도 있는데 이것은 별로 좋지 못한 방식이다
왜냐하면 항상 모든 경우에 domain entity의 모든 필드의 값을 입력받는게 아니기 때문이다
예를 들어서 회원가입/회원수정의 경우 reqeust data type이 서로 다르기 때문에 이러한 경우에 문제가 된다
따라서 검증 로직을 위한 DTO를 따로 설계해서 DTO로 request data를 받는 것이 더 나은 방법이다
(1) {name, email, phoneNum} 전부 없는 채로 request
Name : null
Email : null
Phone : null
Name Validation >> Field error in object 'validationMember' on field 'name': rejected value [
null
]; codes []; arguments []; default message [이름을 입력해주세요
]
Email Validation >> Field error in object 'validationMember' on field 'email': rejected value [
null
]; codes []; arguments []; default message [이메일 입력해주세요
]
Phone Validation >> Field error in object 'validationMember' on field 'phone': rejected value [
null
]; codes []; arguments []; default message [핸드폰 번호를 입력해주세요
]
(2) name은 입력받고, 나머지 필드(email, phone)을 입력받지 않은 경우
Name : Hong Gil Dong
Email : null
Phone : null
Name Validation >> null
Email Validation >> Field error in object 'validationMember' on field 'email': rejected value [
null
]; codes []; arguments []; default message [이메일 입력해주세요
]
Phone Validation >> Field error in object 'validationMember' on field 'phone': rejected value [
null
]; codes []; arguments []; default message [핸드폰 번호를 입력해주세요
]
(3) email이 정한 규약에 맞지 않는 경우
Name : Hong Gil Dong
Email : test@
Phone : 010-1234-5678
Name Validation >> null
Email Validation >> Field error in object 'validationMember' on field 'email': rejected value [
null
]; codes []; arguments []; default message [이메일 형식에 맞춰서 다시 입력해주세요
]
Phone Validation >> null
(4) phoneNum이 정한 규약에 맞지 않는 경우
Name : Hong Gil Dong
Email : test@gmail.com
Phone : 010-1234
Name Validation >> null
Email Validation >> null
Phone Validation >> Field error in object 'validationMember' on field 'phone': rejected value [
null
]; codes []; arguments []; default message [핸드폰 번호 형식에 맞춰서 다시 입력해주세요
]
(5) ObjectError 예제 - 아이템 등록 : {개수 최소 5개 / 가격 최소 1000 / 개수 * 가격은 최소 10000}
@RequestMapping("/items")
public String validation2(
@ModelAttribute ItemDTO validationItem,
BindingResult bindingResult
){
if(validationItem.getQuantity() == null){
bindingResult.addError(new FieldError(
"validationItem",
"quantity",
"수량을 입력하세요"
));
} else if(validationItem.getQuantity() < 5){
bindingResult.addError(new FieldError(
"validationItem",
"quantity",
"수량은 최소 5개 입니다. 다시 입력해주세요"
));
}
if(validationItem.getPrice() == null){
bindingResult.addError(new FieldError(
"validationItem",
"price",
"가격을 입력하세요"
));
} else if(validationItem.getPrice() < 1000){
bindingResult.addError(new FieldError(
"validationItem",
"price",
"가격은 최소 1000원 입니다. 다시 입력해주세요"
));
}
if(validationItem.getQuantity() != null && validationItem.getPrice() != null){
int result = validationItem.getPrice() * validationItem.getQuantity();
if(result < 10000){
bindingResult.addError(new ObjectError(
"validationItem",
"가격*수량은 최소 10000원입니다. 다시 입력해주세요"
));
}
}
StringBuffer sb = new StringBuffer();
sb.append("Quantity : ").append(validationItem.getQuantity())
.append("\nPrice : ").append(validationItem.getPrice())
.append("\n\nQuantity Validation >> ").append(bindingResult.getFieldError("quantity"))
.append("\n\nPrice Validation >> ").append(bindingResult.getFieldError("price"))
.append("\n\nQuantity*Price Validation >> ").append(bindingResult.getGlobalErrors());
return sb.toString();
}
Quantity : null
Price : null
Quantity Validation >> Field error in object 'validationItem' on field 'quantity': rejected value [
null
]; codes []; arguments []; default message [수량을 입력하세요
]
Price Validation >> Field error in object 'validationItem' on field 'price': rejected value [
null
]; codes []; arguments []; default message [가격을 입력하세요
]
Quantity*Price Validation >> []
Quantity : 5
Price : 999
Quantity Validation >> null
Price Validation >> Field error in object 'validationItem' on field 'price': rejected value [
null
]; codes []; arguments []; default message [가격은 최소 1000원 입니다. 다시 입력해주세요
]
Quantity*Price Validation >> [Error in object 'validationItem': codes []; arguments []; default message [가격*수량은 최소 10000원입니다. 다시 입력해주세요
]
]
Quantity : 10
Price : 1001
Quantity Validation >> null
Price Validation >> null
Quantity*Price Validation >> []
BindingResult에 검증 오류 적용 방법
1. @ModelAttribute로 request data를 받을 때 "타입 자체에 오류"가 존재하는 경우
이 경우 데이터 바인딩 자체의 오류이므로 Client가 데이터를 다시 보내야 한다
2. 개발자가 직접 에러 넣어주기
이 경우 데이터 바인딩은 정상적으로 처리되었지만, 비즈니스 로직 자체에 대한 검증을 통과하지 못한 경우
3. Validator 사용
reject() & rejectValue()
BindingResult는 애초에 검증할 객체 뒤에 파라미터상 위치하기 때문에 어떤 객체를 검증하는지 이미 알고있다
따라서 FieldError나 objectError에서 어떤 객체인지 명시해줄 필요가 없다
1. reject()
user가 입력한 값을 유지할 필요가 없을 경우
- password의 경우 user가 입력하고 나서 검증 오류가 발생했을 경우 자동으로 유지되지 않고 제거된다
param 1) errorCode : 오류 코드
param 2) errorArgs : 오류 메시지에 필요한 파라미터들
param 3) defaultMessage : 오류 메시지를 찾을 수 없을 경우 기본 적용되는 메시지
2. rejectValue()
user가 입력한 값을 유지할 필요가 있을 경우
- 로그인의 경우 user가 입력한 ID는 맞지만 password가 잘못되었을 경우 굳이 form data에서 입력한 ID를 지울 필요는 없기 때문에 rejectValue()를 통해서 유지시킬 수 있다
param 1) field : 오류 필드명
param 2) errorCode : 오류 코드
param 3) errorArgs : 오류 메시지에 필요한 파라미터들
param 4) defaultMessage : 오류 메시지를 찾을 수 없을 경우 기본 적용되는 메시지
※ errorCode & errorArgs
여기서 errorCode & errorArgs는 이전에 fieldError나 objectError에서 사용하던 것과는 약간 다르다
여기서는 따로 메시지 처리를 원활하게 하기 위해서 모아놓은 파일이 필요하다
이렇게 따로 ~~.properties에 메시지 규약을 하나 만들어 놓으면 이제 사용할 수 있다
여기서 "MessageCodeResolver"가 errorCode를 통해서 메시지 코드를 생성해낸다
- MessageCodeResolver는 interface이고 이것의 구현체는 "DefaultMessageCodesResolver"이다
<객체 오류 - 생성 규칙>
1순위 : code + "." + <객체 이름>
2순위 : code
<필드 오류 - 생성 규칙>
1순위 : code + "." + <객체이름> + "." + <필드이름>
2순위 : code + "." + <필드이름>
3순위 : code + "." + <필드타입>
4순위 : code
BindingResult는 어떤 객체를 검증하는지 이미 알고있고, 따라서 위와 같은 생성 규칙에 의해서 "errorCode"만 알아도 메시지 코드를 생성해낼 수 있다
Validator Interface
지금까지 필드, 객체에 대한 검증을 시도했는데 Spring에서는 역할 분리를 위해서 Validator라는 인터페이스를 제공해준다
- 위의 코드를 보면 Controller의 대부분이 검증 로직인것을 확인할 수 있다
@Component
public class MyItemValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return ItemDTO.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
ItemDTO itemDTO = (ItemDTO) target;
if(itemDTO.getQuantity() == null){
errors.reject(
"required",
"상품 수량은 필수입니다."
);
} else if(itemDTO.getQuantity() < 5){
errors.rejectValue(
"quantity",
"min",
"상품 수량은 최소 5개입니다."
);
}
if(itemDTO.getPrice() == null){
errors.reject(
"required",
"상품 가격은 필수입니다."
);
} else if(itemDTO.getPrice() < 1000){
errors.rejectValue(
"price",
"min",
"상품 가격은 최소 10000원입니다."
);
}
if(itemDTO.getQuantity() != null && itemDTO.getPrice() != null){
int result = itemDTO.getPrice() * itemDTO.getQuantity();
if(result < 10000){
errors.reject(
"totalPriceMin",
"가격*수량은 최소 10000원이 되어야 합니다. [현재 값 = " + result + "]"
);
}
}
}
}
private final MyItemValidator validator;
@RequestMapping("/items")
public String validationV3(
@ModelAttribute ItemDTO itemDTO,
BindingResult bindingResult
){
validator.validate(itemDTO, bindingResult);
StringBuffer sb = new StringBuffer();
sb.append("Quantity : ").append(itemDTO.getQuantity())
.append("\nPrice : ").append(itemDTO.getPrice())
.append("\n\nQuantity Validation >> ").append(bindingResult.getFieldError("quantity"))
.append("\n\nPrice Validation >> ").append(bindingResult.getFieldError("price"))
.append("\n\nQuantity*Price Validation >> ").append(bindingResult.getGlobalErrors());
return sb.toString();
}
Controller에서 검증 로직을 호출하는 것 이외에는 거의 사라졌음을 확인할 수 있다
WebDataBinder
현재 Controller에서 검증기를 직접 호출해서 사용했지만 이제 Spring의 도움을 받아서 검증기조차 호출하지 않아도 된다
@InitBinder
public void init(WebDataBinder dataBinder){
dataBinder.addValidators(new MyItemValidator());
}
@RequestMapping("/items")
public String validationV3(
@Validated @ModelAttribute ItemDTO itemDTO,
BindingResult bindingResult
){
StringBuffer sb = new StringBuffer();
sb.append("Quantity : ").append(itemDTO.getQuantity())
.append("\nPrice : ").append(itemDTO.getPrice())
.append("\n\nQuantity Validation >> ").append(bindingResult.getFieldError("quantity"))
.append("\n\nPrice Validation >> ").append(bindingResult.getFieldError("price"))
.append("\n\nQuantity*Price Validation >> ").append(bindingResult.getGlobalErrors());
return sb.toString();
}
일단 main이 존재하는 @SpringBootApplication에 WebMvcConfigurer를 implements하고 난 후, "Validator getValidator"를 Override해서 내가 만든 Validator를 return해주면 된다
@InitBinder를 통해서 내가 만든 검증기를 스프링 부트 시작 할 때 추가해주면 된다
- @InitBinder는 컨트롤러 내부에서만 영향을 미치고 글로벌하게 영향을 미치기 위해서는 아래 설정처럼 해줘야 한다
@SpringBootApplication
public class MvcPracticeApplication implements WebMvcConfigurer {
...
...
@Override
public Validator getValidator() {
return new MyItemValidator();
}
}
이제 컨트롤러를 보면 검증기를 호출하는 로직자체가 사라져서 완전히 "검증기 & 컨트롤러"가 분리된 채로 개발이 되었다
여러 검증기가 등록된 상태라면 validator를 implements할 때 override해야 하는 "boolean supports(Class<?> clazz)"를 통해서 검증 대상 객체를 검증할 수 있는 검증기를 찾을 수 있다
그리고 검증기를 사용하려면 검증을 원하는 객체의 앞에 @Validated를 붙여줘야 검증기가 실행된다