Spring validation - BindingResult

2021-12-30 20:11:01

BindingResult

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

예제

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

@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가 호출된다.

<!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

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

Binding Result 적용 1

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

  • bindingResult.addError(ObjectError)

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

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 범위 이내를 만족하지 못할 경우이다.

@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에 포함된 오류정보를 꺼내서 보여주는 기능을 가지고 있다.

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

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

(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와 유사한 기능
@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)

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를 만든 예제에 적용하면 다음과 같다.

@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)

@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를 적용하고 싶으면 다음과 같이 설정하면 된다.

public class MemberController {


    private final MemberRepository repository;
    private final MemberValidator validator;

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

@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

프로필 이미지
@chani
바둑 좋아하는 개발자의 의미있는 학습 기록을 위한 공간입니다.

댓글

이 게시글에 대한 의견을 공유해주세요!

댓글