@ModelAttribute클라이언트로부터 일반 HTTP 요청 파라미터multipart/form-data 형태의 파라미터를 받아 객체로 사용하고 싶을 때 이용된다.

사용 방법

@ModelAttribute 는 parameter, method 레벨로 두 가지의 방식을 지원하고 있다.

@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ModelAttribute {
    ...
}

@ModelAttribute 를 이용할 DTO 클래스에서는 아래처럼 생성자를 구현해도 사용할 수 있다. 단, 요청 파라미터와 매개변수 이름이 서로 같아야 한다.

package com.example.demo;

public class FetchModelTestRequest {
    private String message;
    public FetchModelTestRequest(String message) {
        this.message = message;
    }
}

아니면 파라미터와 이름이 같은(JavaBean 명세에 따르는) Setter 가 있으면 해당 Setter 를 참조해서 값을 넣을 수도 있다.

 

@ModelAttribute 는 주로 Controller 에서 Endpoint 로 받을 파라미터 부분에 이용된다.

package com.example.demo;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping
public class TestController {
    @GetMapping
    public String testGet(@ModelAttribute FetchModelTestRequest request) {
        return "test";
    }
}

 

아래와 같이 ModelMap 이나 BindingResult 파라미터를 같이 넣어서 이용할 수도 있다.

@ModelAttribute 에 괄호를 열고 값을 하나 넣으면 ModelMap 객체(아래의 model 인자) 안에서 'testForm' 이라는 attribute 를 찾았을 때, 해당 객체를 통해 요청으로 받은 파라미터와 binding 시 유효성 검사 결과 등을 함께 데이터를 받을 수 있다.

package com.example.demo;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping
public class TestController {
    @GetMapping
    public String testGet(@ModelAttribute("testForm") FetchModelTestRequest request, 
                          ModelMap model) {
        return "test";
    }

    @ModelAttribute
    public void setAttributes(Model model) {
        model.addAttribute("something-code", "error");
    }
}

참고로 @ModelAttribute 를 Method 방식으로 달게 되면 Endpoint 를 호출할 때 Annotation 을 붙여준 해당 Method 가 먼저 실행되어 진행된다. (위에서 setAttributes)

 

작동 원리

@ModelAttribute 를 달아준 객체에 요청 파라미터의 데이터를 담을 때는 아래의 순서로 진행된다.

  1. 적절한 생성자를 찾아서 새 인스턴스를 생성한다.
    1. public 으로 선언된 생성자를 찾는다.
      1. 찾은 생성자가 없다면, public 이 아닌 생성자 중에 매개변수 개수가 제일 적은 생성자를 선택한다. (보통 기본 생성자)
      2. 찾은 생성자가 1개라면, 해당 생성자를 선택한다.
      3. 찾은 생성자가 여러 개라면, 매개변수 개수가 제일 적은 생성자를 선택한다.
    2. 선택한 생성자를 이용하여 인스턴스를 만들 때 생성자의 매개변수 이름 중 클라이언트가 요청한 파라미터 이름과 같은 것이 있다면 해당 매개변수에다가 요청한 파라미터의 값을 넣어 생성한다.
  2. 클라이언트가 요청한 파라미터들을 기준으로 setter 메서드를 찾아서 실행시킨다. (생성자 생성을 통해 값을 넣은 것과 상관없이)

 

상세

ModelAttribute 는 크게 ModelAttributeMethodProcessor 클래스의 resolveArgument 메서드 안에서 진행된다.

적절한 생성자 찾기

먼저, createAttribute 내에서 클라이언트의 요청을 통해 데이터를 전달받을 클래스의 적절한 생성자를 찾게 된다.

이때 BeanUtilsgetResolvableConstructor 메서드를 실행하는데 이 메서드는 대상이 되는 클래스의 사용 가능한 생성자를 찾는 메서드로서 위에서 설명했지만 아래와 같은 진행 과정을 거친다.

  • public 으로 선언된 생성자를 찾고 적합한 생성자를 선택한다.
    1. 찾은 생성자가 없다면, public 이 아닌 생성자 중에 매개변수 개수가 제일 적은 생성자를 선택한다. (보통 기본 생성자)
    2. 찾은 생성자가 1개라면, 해당 생성자를 선택한다.
    3. 찾은 생성자가 여러 개라면, 매개변수 수가 제일 적은 생성자를 선택한다.

먼저 적절한 생성자를 찾는다.

 

선택된 생성자 생성

다음으로 위 사진에 있는 constructAttribute 메서드를 실행하여 위에서 선택된 생성자의 매개변수 이름과 클라이언트가 요청한 파라미터 중 같은 이름의 파라미터가 있으면 클라이언트가 요청한 파라미터의 값을 전달해서 생성자를 생성한다.

 

예를 들어보자

String message 매개변수를 가진 생성자가 있고, String msg 매개변수를 가진 생성자가 있다.

이때 사용자가 message 라는 이름의 요청 파라미터로 "test" 값을 전달했을 때, msg 라는 매개변수를 가진 생성자에서는 값이 전달되지 않지만, message 라는 매개변수를 가진 생성자에서는 값이 전달된다.

올바르게 전달된 모습
전달되지 않는 모습

따라서, 적절한 생성자가 미리 만들어져 있으면 이를 통해 값을 바로 전달받을 수 있다.

 

바인드 실행

resolveArgument 메서드를 진행하면서 파라미터 바인딩을 시도한다.

웹 요청으로부터 파라미터 바인딩을 시도한다.

ServletRequestDataBinder 클래스의 bind 메서드를 실행하게 되는데 해당 메서드를 살펴보면 클라이언트의 요청을 어떤 유형으로 보냈느냐에 따라 데이터를 처리하는지 알 수 있다.

유형별로 추가적으로 진행되는 모습

계속 따라가면 AbstractPropertyAccessor 클래스의 setPropertyValues 에서 클라이언트가 요청한 파라미터들을 기준으로 바인딩 시도를 하는 것을 볼 수 있다.

요청한 파라미터들을 기준으로 찾는다.

이어서 AbstractNestablePropertyAccessor 클래스의 processLocalProperty 메서드에서 사용자가 요청한 파라미터의 이름을 가지고 파라미터로 받을 클래스 내에 있는 Setter 를 사용하기 위해 멤버변수의 이름을 통해 PropertyHandler 를 찾는다.

위에서 말한 Setter 는 나중에 PropertyDescriptor 로 writeMethod 를 이용해서 사용하게 된다.


여기서 나중에 데이터로 전달 받을 클래스는 BeanWrapper 로 관리되고 있는 것을 추가적으로 알 수 있다.

테스트를 위해 직접 Setter 를 만들어 주었다.
멤버변수 이름으로 Setter 가 있는지 확인한다. 없다면 해당 파라미터는 아래의 return 이 실행되면서 건너뛰게 된다.
Setter 로 만들어준 것이 있으면 위와 같이 찾을 수 있게 된다.

여기서 발견한 것이 없다면 해당 파라미터는 스킵하고, 있다면 BeanWrapperImpl 클래스의 setValue 메서드에서 해당 멤버 변수의 writeMethod 를 찾아 invoke 를 실행한다.

이때 아까 전에 만들었던 setter 메서드를 실행하게 되는데 ReflectionUtils 을 이용하므로 Javabeans 명세의 8.3.1절에 따르는 setter 메서드 네이밍에 해당되는 것에서만 적용이 된다.

writeMethod 로 invoke 를 진행한다.
setter 메서드가 실행된다.

모든 바인딩 시도가 이루어졌다면 바인딩 결과를 만들고 우리가 전달받을 요청 클래스를 반환한다.

최종적으로 요청 데이터를 정상적으로 잘 가져온 것을 확인할 수 있다.

 

결론

정리하면 아래처럼 setter 만 만들어서 사용해도 되고, 생성자를 통해 파라미터를 받으려면 public 생성자 중 제일 매개변수가 적은 생성자를 기준으로 생각하되, 매개변수의 이름을 파라미터 이름과 동일하게 해서 사용하면 된다.

package com.example.demo;

public class FetchModelTestRequest {
    private String message;
    public void setMessage(String message) {
        this.message = message;
    }
}

다른 차선책으로 @NoArgsContructor 와 @Setter 를 조합해서 사용할 수도 있다.