Spring validation - BindingResult

BindingResult

spring은 입력데이터에 대한 validation과 예외처리를 지원해준다. org.springframework.validation.BindingResult 가 validation 기능을 지원해주는 주요 객체 중 하나이다. BindingResult는 입력 form의 필드값 중에 오류가 있으면 오류정보를 담아둔다.

예제

아래와 같은 간단히 사람의 이름과 나이를 입력하고 입력한 정보를 조회할 수 있는 controller가 있다고 가정하자

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
@Slf4j
@Controller
@RequiredArgsConstructor
@RequestMapping("/member")
public class MemberController {

private final MemberRepository repository;

@GetMapping
public String getMember(Model model){
model.addAttribute(new Member());
return "member";
}

@PostMapping
public String addMember(@ModelAttribute Member member , RedirectAttributes redirectAttributes){
log.info("member : {}",member);
Member savedMember = repository.save(member);
Long id = savedMember.getId();
redirectAttributes.addAttribute("id",id);
return "redirect:/member/{id}";
}

@GetMapping("/{id}")
public String detailMember(@PathVariable(name = "id") Long id ,Model model ){
Member foundMember = repository.findById(id);
model.addAttribute("member",foundMember);
return "memberDetail";
}

}

getMember method 로 사람의 정보를 등록할 수 있는 member page가 호출된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!-- member.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="eg">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>

<form action="/member" method="POST" th:object="${member}" >
<div>
<label th:for="name">Enter your name: </label>
<input type="text" th:field="*{name}">
</div>
<div>
<label th:for="age">Enter your age: </label>
<input type="text" th:field="*{age}">
</div>
<button>submit</button>
</form>

addMember 를 호출하면 name=value&name=value 형태 (Content-Type: application/x-www-form-urlencoded) 로 데이터가 서버에 넘어가고, 서버에서 memberDetail page로 redirect 한다. 이를 PRG 패턴이라고 한다.

PRG pattern - Post/Redirect/Get

POST 방식으로 client에서 server로 데이터 전송 이후, Server가 바로 특정 page를 forward하게 되면 refresh 시 데이터가 중복 재전송된다. 이를 방지하기 위해서 다른 페이지로 GET (redirect) 한다.

(ref - https://en.wikipedia.org/wiki/Post/Redirect/Get)

Binding Result 적용 1

bidingResult는 에러를 포함한 객체 바로 다음에 와야 한다. 객체의 필드값별로 로직에 따라 추가할 에러를 설정할 수 있다.

  • bindingResult.addError(ObjectError)

참고로 FieldError는 Object Error의 자식임으로 addError parameter에 넘겨줄 수 있다.

1
public class FieldError extends ObjectError{}

field Error와 object error는 2개의 생성자를 overloading 하고 있는데
첫번쨰는 defaultMessage (view에 보여줄 메시지) 를 바로 명시하는 생성자 방식이 있고,
두번째는 messageCodeResolver에 의해 설정파일로부터 값을 읽어와 view에 보여줄 수 있는 생성자 방식이 있다.
두 방식에서 공통 매개변수는 객체와 해당 객체에 오류를 가졌다고 명시할 필드이다.

참고로 messageCodeResolver와 messages.properties 관련 내용은 spring internalization 관련 내용으로 , boot에서는 /resources/messages.properties 파일을 생성하고, message code를 입력하면 자동으로 등록해준다.

Object error는 해당 객체에 특정 필드가 가진 오류라기보다 global 오류 정보를 의미한다. 예를 들어 특정 필드간의 조합이 x 범위 이내를 만족하지 못할 경우이다.

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
@PostMapping
public String addMember(@ModelAttribute Member member , BindingResult bindingResult , RedirectAttributes redirectAttributes){
// binding result는 에러 필드를 가진 객체 바로 뒤에 와야한다.
// fieldErrors
log.info("member :{}",member);
if(!StringUtils.hasText(member.getName())){
bindingResult.addError(new FieldError("member","name",member.getName(),false,new String[]{"empty.name"},null,null));
}
if(member.getAge() == null){
bindingResult.addError(new FieldError("member","age",member.getAge(),false,new String[]{"empty.age"},null,null));
}
// objectErrors
if(member.getAge() != null && member.getAge() <= 10 && member.getName().startsWith("Kim")){
bindingResult.addError(new ObjectError("member",new String[]{"limit.member"},null,null));
}

// error가 있다면 바로 view 반환
if(bindingResult.hasErrors()){
return "member";
}
Member savedMember = repository.save(member);
Long id = savedMember.getId();
redirectAttributes.addAttribute("id",id);
return "redirect:/member/{id}";
}

위와 같이 controller에서 binding result에 error 정보를 포함해주고, 바로 view를 반환한다. template engine마다 다르겠지만 thymeleaf template engine은 spring binding result에 포함된 오류정보를 꺼내서 보여주는 기능을 가지고 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<form action="/member" method="POST" th:object="${member}" >
<!-- objectError -->
<div th:if="${#fields.hasGlobalErrors()}">
<p th:each="err : ${#fields.globalErrors()}" th:text="${err}"></p>
</div>
<div>
<label th:for="name">Enter your name: </label>
<input type="text" th:field="*{name}">
<!-- fieldError -->
<div th:errors="*{name}"></div>
</div>
<div>
<label th:for="age">Enter your age: </label>
<input type="text" th:field="*{age}">
<!-- fieldError -->
<div th:errors="*{age}"></div>
</div>
<button>submit</button>
</form>

th:object=”${객체명}” th:errors=”*{필드명}” 을 사용하면 해당 필드에 오류가 있으면 bindingResult에 저장된 오류 메시지를 출력해준다. thymeleaf에서는 th:object에 명시될 객체를 command object라고 한다.

Command object is the name Spring MVC gives to form-backing beans, this is, to objects that model a form’s fields and provide getter and setter methods that will be used by the framework for establishing and obtaining the values input by the user at the browser side.

(ref - https://www.thymeleaf.org/doc/tutorials/2.1/thymeleafspring.html#validation-and-error-messages)

Binding Result 적용 2

앞선 BindingResult는 오류를 검사할 객체 바로 뒤에 온다, 즉 오류를 검사할 target 객체를 알고 있으므로, bindingResult는 오류메시지를 간편하게 추가할 수 있는 method를 제공한다.

  • rejectValue(…) : FieldError 와 유사한 기능
  • reject(…) : ObjectError와 유사한 기능
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@PostMapping
public String addMember(@ModelAttribute Member member , BindingResult bindingResult , RedirectAttributes redirectAttributes){
// binding result는 에러 필드를 가진 객체 바로 뒤에 와야한다.
log.info("member :{}",member);
if(!StringUtils.hasText(member.getName())){
bindingResult.rejectValue("name","empty.name");
}
if(member.getAge() == null){
bindingResult.rejectValue("age","empty.age");
}
if(member.getAge() != null && member.getAge() <= 10 && member.getName().startsWith("Kim")){
bindingResult.reject("limit.member");
}
if(bindingResult.hasErrors()){
return "member";
}
Member savedMember = repository.save(member);
Long id = savedMember.getId();
redirectAttributes.addAttribute("id",id);
return "redirect:/member/{id}";
}

Valdiation 로직 분리

위의 검증 로직들은 별도의 validator에 의해 분리될 수 있다.
spring은 별도의 validator interface를 제공한다. (org.springframework.validation.Validator)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public interface Validator {

/**
* Can this {@link Validator} {@link #validate(Object, Errors) validate}
* instances of the supplied {@code clazz}?
*/
boolean supports(Class<?> clazz);

/**
* Validate the supplied {@code target} object, which must be
* of a {@link Class} for which the {@link #supports(Class)} method
* typically has (or would) return {@code true}.
* <p>The supplied {@link Errors errors} instance can be used to report
* any resulting validation errors.
* @param target the object that is to be validated
* @param errors contextual state about the validation process
* @see ValidationUtils
*/
void validate(Object target, Errors errors);

}

인터페이스 설명에 보면 2가지 method를 구현해야 하는데, 다음과 같이 정리하였다.

  • supports(…) : 해당 instance가 검증할 class의 instance가 맞는가?
  • validate(…) : 검증 후 , report할 오류는 errors parameter에 추가한다.

위 interface를 만든 예제에 적용하면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component // 굳이 검증기를 client요청시마다 생성할 이유가 없다, 동시성 이슈가 있는 것도 아니기 떄문에 싱글톤으로 관리하는게 적합하다고 한다. 
public class MemberValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return Member.class.isAssignableFrom(clazz);
}

@Override
public void validate(Object target, Errors errors) {
Member member = (Member) target;

if(!StringUtils.hasText(member.getName())){
errors.rejectValue("name","empty.name");
}
if(member.getAge() == null){
errors.rejectValue("age","empty.age");
}
if(member.getAge() != null && member.getAge() <= 10 && member.getName().startsWith("Kim")){
errors.reject("limit.member");
}
}
}

별도로 validator를 생성자 DI 받으면 controller의 책임과 validation 책임을 분리할수있다 (SRP)

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
@Slf4j
@Controller
@RequiredArgsConstructor
@RequestMapping("/member")
public class MemberController {

private final MemberRepository repository;
private final MemberValidator validator;

@GetMapping
public String getMember(Model model){
model.addAttribute(new Member());
return "member";
}

@PostMapping
public String addMember(@ModelAttribute Member member , BindingResult bindingResult , RedirectAttributes redirectAttributes){
// binding result는 에러 필드를 가진 객체 바로 뒤에 와야한다.
log.info("member :{}",member);
validator.validate(member,bindingResult);
if(bindingResult.hasErrors()){
return "member";
}
Member savedMember = repository.save(member);
Long id = savedMember.getId();
redirectAttributes.addAttribute("id",id);
return "redirect:/member/{id}";
}

@GetMapping("/{id}")
public String detailMember(@PathVariable(name = "id") Long id ,Model model ){
Member foundMember = repository.findById(id);
model.addAttribute("member",foundMember);
return "memberDetail";
}

}

추가로 해당 controller에 있는 method들에 validator를 적용하고 싶으면 다음과 같이 설정하면 된다.

  • local controller 적용

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public class MemberController {


    private final MemberRepository repository;
    private final MemberValidator validator;

    // controller 마다 적용
    @InitBinder
    public void init(WebDataBinder dataBinder){
    dataBinder.addValidators(validator);
    }

  • global 적용

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Configuration
    public class WebConfig implements WebMvcConfigurer {

    //global (전체 controller)에 해당 검증기 적용
    @Override
    public Validator getValidator() {
    return new MemberValidator();
    }
    }

정리

spring 이 제공하는 binding result를 활용하면 간결하게 검증 로직을 추가할 수 있다. 오류 메시지는 하드코딩하지말고 messages.propeties 에 설정정보를 가져오는 messageCodeResolver를 활용하면 오류 메시지 변경이 생길떄 빠르게 대처할 수 있다. spring 외에 다른 framework 적용시에도 위와 같은 흐름으로 오류 처리를 하면 좋을 것같다.

참고자료

인프런 - 김영한 개발자님의 강의를 듣고, 궁금한 부분은 추가로 레퍼런스를 찾고 정리한 글입니다.
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-1

Comments