BindingResult
- 스프링이 제공하는 검증 오류를 보관하는 객체이다. 검증 오류가 발생하면 여기에 보관하면 된다.
- BindingResult 가 있으면 @ModelAttribute 에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출
- 매개변수로 검증 객체 뒤에 위치한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@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() < 1_000 || item.getPrice() > 1_000_000) {
bindingResult.addError(new FieldError("item", "price","가격은 1,000 ~ 1,000,000까지 허용합니다."));
}
if (item.getQuantity() == null || item.getQuantity() >= 10000) {
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}";
}
1
2
3
4
5
6
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 : 메시지 코드
arguments : 메시지에서 사용하는 인자
defaultMessage : 기본 오류 메시지
1
public ObjectError(String objectName, String defaultMessage) {}
objectName : @ModelAttribute 의 이름
defaultMessage : 오류 기본 메시지
- 필드에 오류: FieldError 객체를 생성해서 bindingResult에 담아둔다.
- 특정 필드를 넘어서는 오류: ObjectError 객체를 생성해서 bindingResult에 담아둔다.
BindingResult 검증 오류 적용 3가지 방법
- @ModelAttribute의 객체에 타입 오류 등
바인딩이 실패하는 경우, 스프링이FiledError 자동 생성하여 BindingResult 에 넣어준다. - 개발자가 직접 FieldError / ObjectError 생성 후 넣어준다.
- Validator 사용한다.
BindingResult & Errors
- BindingResult 인터페이스는 Errors 인터페이스를 상속받고 있다.
- BeanPropertyBindingResult: 실제 넘어오는 구현체. BindingResult && Errors 둘다 구현하고 있으므로 Errors를 대신 사용 가능
- BindingResult가 Errors 보다 더 많은 기능을 제공한다.
- 주로 관례상 BindingResult를 많이 사용한다.
FieldError 객체의 추가 속성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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 || item.getPrice() > 1000000) {
bindingResult.addError(new FieldError("item", "price", item.getPrice(),
false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
}
if (item.getQuantity() == null || item.getQuantity() >= 10000) {
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 < 10000) {
bindingResult.addError(new ObjectError("item", null, null, "가격 * 수량 의 합은 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 객체 내 속성 code 에 메시지 코드를 넣는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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 || item.getPrice() > 1000000) {
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() > 10000) {
bindingResult.addError(new FieldError("item", "quantity",
item.getQuantity(), false, new String[]{"max.item.quantity"}, 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));
}
}
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}";
}
BindingResult.rejectValue() / BindingResult.reject()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/** FieldError, ObjectError 객체를 직접 생성하지 않고 깔끔하게 오류 검증 가능 **/
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 || item.getPrice() > 1000000) {
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
}
if (item.getQuantity() == null || item.getQuantity() > 10000) {
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[]{10000, 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}";
}
BindingResult 는 이미 Target 객체를 알고 있기에, 해당 정보는 넣어줄 필요가 없다.
1
2
void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage);
field: 오류 필드명errorCode: 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다. 뒤에서 설명할messageResolver를 위한 오류 코드)errorArgs: 오류 메시지에서 {0} 을 치환하기 위한 값defaultMessage: 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
MessageCodesResolver
1
2
3
4
5
#Level1
required.item.itemName: 상품 이름은 필수 입니다.
#Level2
required: 필수 값 입니다.
1
2
3
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required");
}
해당 코드처럼 required로 코드 지정 시 MessageCodesResolver가 메시지 코드를 생성한다.
required.item.itemName 처럼 구체적인 것을 먼저 만들어주고, required 처럼 덜 구체적인 것을 가장 나중에 만든다.
- 정리
- rejectValue() 호출
- MessageCodesResolver 를 사용해서 검증 오류 코드로 메시지 코드들을 생성
- new FieldError() 를 생성하면서 메시지 코드들을 보관
- th:erros 에서 메시지 코드들로 메시지를 순서대로 메시지에서 찾고, 노출
- 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
- “typeMismatch.user.age”
- “typeMismatch.age”
- “typeMismatch.int”
- “typeMismatch”
- 객체 오류
스프링이 직접 만든 오류 메시지 처리
- 숫자 값을 받는 input 란에 문자 입력 시 BindingResult 에 FieldError 가 담겨있고 하위 메시지 코드가 생성된 걸 확인할 수 있다.
codes[typeMismatch.item.price,typeMismatch.price,typeMismatch.java.lang.Integer,typeMismatch] - 스프링은 타입 오류 발생하면 typeMismatch 오류 코드를 사용하고 해당 오류 코드가 MessageCodesResolver를 통하여 4가지 메시지 코드가 생성된다.
1
2
3
4
# 스프링(MessageCodesResolver)에서 자동 생성하는 메시지 코드를 활용하기 위해 추가된 코드
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
Validator 분리
Validator 인터페이스 상속받아 ItemValidator 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Component
public class ItemValidator implements Validator {
@Override
public boolean supports(Class<?> aClass) {
return Item.class.isAssignableFrom(aClass);
}
@Override
public void validate(Object target, Errors errors) {
Item item = (Item) target;
// 상품명 검증(빈값, 공백)
ValidationUtils.rejectIfEmptyOrWhitespace(errors, "itemName", "required");
if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
errors.rejectValue("price","range", new Object[]{1_000, 1_000_000}, null );
}
if (item.getQuantity() == null || item.getQuantity() > 10_000) {
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[]{1_000, resultPrice}, null);
}
}
}
}
supports() {}: 해당 검증기를 지원하는 여부 확인
validate(Object target, Errors errors): 검증 대상 객체와 BindingResult
ItemValidator 호출
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private final ItemValidator itemValidator; // validator 생성자 주입
@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}";
}
Validator 분리 2
- WebDataBinder.addValidators()로 검증기를 등록
- 검증이 필요한 객체 앞에 @Validated 어노테이션 붙임
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@InitBinder
public void init(WebDataBinder webDataBinder) {
webDataBinder.addValidators(itemValidator);
}
@PostMapping("/add")
public String addItemV6(@Validated @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}";
}
// 글로벌 설정
@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
public static void main(String[] args) {
SpringApplication.run(ItemServiceApplication.class, args);
}
// WebMvcConfigurer 구현하여 추가된 코드
@Override
public Validator getValidator() {
return new ItemValidator();
}
}
WebDataBinder: 검증기를 추가하면 컨트롤러에서 자동으로 적용된다.
@InitBinder:
해당 컨트롤러에만 영향을 준다.
@InitBinder 를 제거해도 글로벌 설정으로 정상 동작한다.(글로벌 설정을 직접 사용하는 경우는 드물다.)
@Validated:
검증기를 실행하라는 어노테이션
WebDataBinder에 등록한 검증기를 찾아서 실행한다.
검증기 실행시 supports()가 사용되어 검증기를 특정하여 실행한다.
- 글로벌 설정을 하면 BeanValidator가 자동으로 등록되지 않는다.
- 검증 어노테이션으로
@Validated,@Valid둘 다 사용 가능- javax.validation.@Valid 를 사용하려면 build.gradle 의존관계 추가가 필요
- implementation ‘org.springframework.boot:spring-boot-starter-validation’