@ModelAttribute 실험

hyungjunn·2024년 8월 24일
post-thumbnail

많은 블로그글에서 @ModelAttribute 어노테이션을 통해 데이터를 바인딩 할 때 setter 메서드가 필요하다고도 하고 필요하지 않다고도 한다. 실험 그리고 디버깅을 통해 애매한 내용을 알고자 한다.

가정하는 상황은 /members/add에 get 요청을 통해 회원가입 페이지를 렌더링하고, /members/add에 post 요청을 통해 회원가입을 하는 상황이다. 이 글에서는 Member의 생성자, setter를 통제 변인으로 두어 비교해보고자 한다.

다음과 같이 path가 /members/add에 get 요청을 보내는 메서드가 있다.

@Controller
@RequestMapping("/members")
public class MemberController {

    private final MemberRepository memberRepository;

    public MemberController(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @GetMapping("/add")
    public String addForm(@ModelAttribute Member member) {
        return "members/addMemberForm";
    }

}

기본 생성자와 다른 생성자들이 여러개 있고, setter가 있는 경우

/members/add에 get 요청, post 요청을 보내면 무난하게 데이터들이 잘 바인딩된다.

기본 생성자와 다른 생성자들이 여러개 있고, setter가 없는 경우

아래는 @ModelAttribute에 바인딩되는 Member class이다.

public class Member {

    private Long id;

    @NotEmpty
    private String loginId;

    @NotEmpty
    private String name;

    @NotEmpty
    private String password;

    public Member() {
    }

    public Member(String loginId, String name, String password) {
        this(null, loginId, name, password);
    }

    public Member(String loginId, String name) {
        this(null, loginId, name, null);
    }

    public Member(Long id, String loginId, String name, String password) {
        this.id = id;
        this.loginId = loginId;
        this.name = name;
        this.password = password;
    }
    
    //...이하 필요한 getter들이 있다
    

setter가 없는 상황에서 이렇게 클래스를 구성했을 경우 /members/add에 get 요청을 보내면 어떻게 될까?

로깅을 편하게 보기 위해 toString()을 이용해서 콘솔창을 보면 다음과 같은 결과가 나온다.

INFO 1435 --- [nio-8080-exec-6] hello.login.web.member.MemberController  : member=Member{id=null, loginId='null', name='null', password='null'}

입력창을 보여주기만 하면 되는 화면을 요청한 것이기 때문에 전혀 문제 없다. 이제 이 화면에서 회원가입을 한다고 했을 때, post 요청을 확인해보자.

    @PostMapping("/add")
    public String save(
    	@Valid @ModelAttribute Member member,
	    BindingResult bindingResult
    ) {
        if (bindingResult.hasErrors()) {
            return "members/addMemberForm";
        }

        memberRepository.save(member);
        return "redirect:/";
    }

Spring 에서는 ModelAttributeMethodProcessorcreateAttribute(..)에서 바인딩 되는 인스턴스를 생성한다. 다음은 createAttribute(..)의 자바독 내용이다.

Extension point to create the model attribute if not found in the model, with subsequent parameter binding through bean properties (unless suppressed).
The default implementation typically uses the unique public no-arg constructor if available but also handles a "primary constructor" approach for data classes: It understands the JavaBeans ConstructorProperties annotation as well as runtime-retained parameter names in the bytecode, associating request parameters with constructor arguments by name. If no such constructor is found, the default constructor will be used (even if not public), assuming subsequent bean property bindings through setter methods.

  • 빈 프로퍼티를 통한 매개변수 바인딩이 이루어진다 (별도로 억제되지 않는 한).

  • 기본 구현에서는 일반적으로 사용 가능한 유일한 public 무인자 생성자를 사용한다.

  • 하지만 데이터 클래스를 위한 "주 생성자" 접근 방식도 처리한다. 이 방식에서는 JavaBeans의 ConstructorProperties 어노테이션을 이해하며, 바이트코드에 런타임에 유지되는 매개변수 이름도 인식한다. 이를 통해 요청 매개변수를 이름으로 생성자 인자와 연결한다.

  • 만약 이러한 생성자를 찾지 못하면, 기본 생성자를 사용한다 (public이 아니더라도). 이 경우 setter 메서드를 통한 후속 빈 프로퍼티 바인딩이 이루어질 것으로 가정한다.

이 자바독의 3번째 항목인 ConstructorProperties 어노테이션을 매개 변수가 있는 생성자에 붙혀주지 않았다.

이 자바독의 4번째 항목에 따라 기본 생성자를 사용하고 setter 메서드를 통한 후속 빈 프로퍼티 바인딩이 이루어진다. 그러나, 위에서 본 바와 같이 Member class에는 setter가 없기 때문에 결국 입력한 필드의 값들이 바인딩이 안된다. 따라서, Field error가 나게 된다.

기본 생성자가 없고, setter가 없는 경우 (1)

public class Member {

    private Long id;

    @NotEmpty
    private String loginId;

    @NotEmpty
    private String name;

    @NotEmpty
    private String password;

    public Member(String loginId, String name, String password) {
        this(null, loginId, name, password);
    }

    public Member(String loginId, String name) {
        this(null, loginId, name, null);
    }

    public Member(Long id, String loginId, String name, String password) {
        this.id = id;
        this.loginId = loginId;
        this.name = name;
        this.password = password;
    }
    
    //...이하 필요한 getter들이 있다
    

이 때는 아예 /members/add에 get 요청을 할 때부터 에러가 발생하여 500상태 코드 에러가 났다.

No primary or single public constructor found for class hello.login.domain.member.Member - and no default constructor found either

주 생성자도 못 찾겠고, 기본 생성자도 못 찾겠다고 한다. 딱 위에서 봤던 createAttribute(..)의 자바독 내용이다. 위의 Member class는 두가지 사항을 지키지 못하고 있다.

  • ConstructorProperties 어노테이션을 원하는 생성자에 달아주지 않았음

  • 혹은 기본 생성자가 없음.

따라서, get 요청때부터 아예 에러가 발생하게 된다. 그렇다면 기본 생성자가 없이 매개변수가 있는 생성자들과 setter가 없는 경우를 어떻게 코드를 작성해야 데이터가 바인딩 될까?

기본 생성자가 없고, setter가 없는 경우 (2)

createAttribute(..)에서 브레이킹 포인트를 찍은 줄인 BeanUtils.getResolvableConstructor(clazz)를 들어가보면 다음과 같은 메서드가 나온다.

public static <T> Constructor<T> getResolvableConstructor(Class<T> clazz) {
        Constructor<T> ctor = findPrimaryConstructor(clazz);
        if (ctor == null) {
        	// 리플렉션을 이용해 주어진 클래스의 모든 public 생성자를 가져온다.
            Constructor<?>[] ctors = clazz.getConstructors();
            // 생성자가 하나일 경우
            if (ctors.length == 1) {
                ctor = ctors[0];
            } else {
                try {
                	// 생성자가 여러개일 경우, 기본 생성자를 가져온다.
                    ctor = clazz.getDeclaredConstructor();
                } catch (NoSuchMethodException var4) {
                    throw new IllegalStateException("No primary or single public constructor found for " + clazz + " - and no default constructor found either");
                }
            }
        }

        return ctor;
    }

getResolvableConstructor(Class<T> clazz)를 요약하면,

리플렉션을 이용하여 생성자가 하나일 경우엔 그 생성자를 반환. 여러 개일 경우엔 기본 생성자를 반환한다. 앞에 (1)의 경우, public 생성자가 여러 개였고, 기본 생성자가 없었기 때문에 결국 throw new IllegalStateException("No primary or single public constructor found for " + clazz + " - and no default constructor found either"); 이 에러를 똑같이 만나게 됐던 것이다.

즉, 만약 setter도 쓰지 않고, 기본 생성자를 사용하지 않을 때는 바인딩 되고 싶은 public 생성자를 단 1개만 만들어야 한다. 따라서, @ModelAttribute의 원리에 맞게끔 Member 클래스를 구성하려면 다음과 같이 구성해야 한다.

public class Member {

    private Long id;

    @NotEmpty
    private String loginId;

    @NotEmpty
    private String name;

    @NotEmpty
    private String password;

	// 바인딩되고자 하는 상태값을 가진 생성자
    public Member(String loginId, String name, String password) {
        this(null, loginId, name, password);
    }

	// 1. 이렇게 private로 선언하여 생성자가 찾아지지 않게 한다.
    // 2. 또는 아예 만들지 않고 위의 생성자만 만든다.
    private Member(Long id, String loginId, String name, String password) {
        this.id = id;
        this.loginId = loginId;
        this.name = name;
        this.password = password;
    }
    
    //...이하 필요한 getter들이 있다
    

정리

정리하면 ModelAttribute에 정확하게 바인딩되게끔 하기 위해선,
1. 원하고자 하는 생성자에 ConstructorProperties 어노테이션을 붙여준다.
2. 기본 생성자와 setter
3. setter와 기본 생성자가 없는 경우 혹은 만들기 싫은 경우엔 public 생성자를 하나만 만든다.

0개의 댓글