이 글에선 Spring MVC의 핵심적인 annotation 중 하나, @ModelAttribute
에 대해 알아보겠다.
이름에서 유추가능하듯이 MVC의 model과 관련된 개념이다. 특정 method의 parameter, 혹은 return value에 대한 model attribute를 만드는데 사용된다. 그러면 view에서 추후 해당 model attribute를 활용해서 전시를 하는 것이 가능하겠죠.
Baeludng에서는 어떤 직장의 직원이 사이트에 특정 양식의 서류를 제출했을 때 나타나는 변화를 예시로 이 개념을 설명했다.
@ModelAttribute
in Depth시작하기 전에 한가지 언급하자면, Spring의
Model
instance는 session scope이다. request때마다 하나씩 새로 생성된다는 점에 유의. 그리고 유효기간도 그 request의 처리기간 동안에만 유효하다.
일단 위에 나온 내용으로 유추 가능한건, method나 method의 parameter에서 해당 annotation을 사용할 수 있다는 것이다. 전자부터 알아보자.
밑의 코드는 msg
라는 model attribute를 만든다.
@ModelAttribute
public void addAttributes(Model model) {
model.addAttribute("msg", "Welcome to the Netherlands!");
}
자, 만약 이게 일반 @Controller
annotated class에 들어있는 경우, 해당 controller이 처리하는 모든 request에 관한 controller method (@RequestMapping
이 annotate된 method) 실행 전에 위의 method를 실행하게 된다.
즉 method에 annotate하는 경우는 controller의 모든 request에 대해 통일된 model attribute를 넣고, 그것을 view가 렌더링에 활용할 수 있도록 하기 위해서다.
그러면 이왕 그런거 모든 controller들에 대해 request를 받았을 때 위와 같은 특정 model attribute를 넣도록 편하게 선언하는 법이 없나?라고 생각할 수 있다. 그리고 가능하다. @ControllerAdvice
를 annotate한 class에다가 위와 같이 @ModelAttribute
가 annotate된 method를 넣으면 된다.
@ControllerAdvice
는 모든 controller들이 가질 공통된 행동을 정의하는데 사용되는 annotation이다. 앞의@ModelAttribute
말고도@InitBinder
,@ExceptionHandler
등의 활용이 가능하다. javadoc
참고로 위와 같은 서순을 가지는 이유는 controller method를 통한 처리 전에 model object를 형성하기에 어쩔수없이 그렇다고 한다.
그러면 method argument에 사용되는 경우엔 어떨까? 대응되는 model attribute를 가지고 특정 객체를 initialize하기, 혹은 그 attribute에 해당하는 data가 model에 없는 경우 (현재 form 형식을 활용 중이므로) 자동으로 form의 attribute를 parameter의 class의 field들에 대응시키는 형태로 initialize할 때 사용이 된다.
말이 어려운데 예시를 가지고 알아보도록 하자. 밑의 코드를 보도록 하자.
@RequestMapping(value = "/addEmployee", method = RequestMethod.POST)
public String submit(@ModelAttribute("employee") Employee employee) {
// Code that uses the employee object
return "employeeView";
}
만약 저 /addEmployee
endpoint상의 POST
request와 엮인 model에 employee
라는 model attribute가 있으면 그걸 가지고 employee
를 구성할 것이다. 그러나 앞에 말했듯 model은 request-scope이기 때문에 우리가 @ModelAttribute
method같은걸로 미리 조작한게 아니면 그럴 확률이 없다.
그러면 employee
라는 model attribute가 없다는 것인데, 이 경우 POST
로 온 내용물이 form이라는 것을 활용, employee의 각 field의 이름과 대응되는 form의 field 이름들이 있는지를 확인해 있는경우 그 값들을 employee
의 field에다가 집어넣어서 initialize를 한다.
앞에서 employee
에 해당하는 model attribute가 없을 때 POST
로 온 내용물을 employee
의 field에 적절히 대응시키는 것을 data binding이라고 표현한다.
이 data binding 하는 방식은 사실 여러가지가 있고 우선순위도 정해져있는데, 다음과 같다.
- Accessed from the model where it could have been added by a @ModelAttribute method.
- Accessed from the HTTP session if the model attribute was listed in the class-level @SessionAttributes annotation.
- Obtained through a Converter if the model attribute name matches the name of a request value such as a path variable or a request parameter (example follows).
- Instantiated through a default constructor.
- Instantiated through a “primary constructor” with arguments that match to Servlet request parameters. Argument names are determined through runtime-retained parameter names in the bytecode.
앞의 예제에서 활용한 방식은 5번에 해당한다. 위에가 제일 우선순위가 높은것이길래 제일 우선순위가 낮다는 것을 알 수 있다.
이미 존재하는 경우에 대응시킨다고 설명한것은 위의 1번에 해당한다. 2~3번의 경우 이래저래 추가 코드를 작성하는 것이고 굳이 지금 알 필요는 없는 내용이며 (알고 싶으면 documentation을 참고하면 잘 나와있다.) 4~5번이 좀 중요한데, 보시다시피 default constructor이 있으면 그걸 우선으로 사용한다. 즉 만일 Employee
에 해당하는 default constructor이 존재시 위에서 언급한 data binding이 일어나지 않는다는 점... 주의바란다.
그리고 아까부터 form이라는 내용이 계속 나왔는데 이게 정확히 뭔지 모르는 사람들이 있을 수도 있다. 프론트엔드(정확히는 HTML)랑 관련된 내용인데, 이 글을 참고해보도록 하자.
저게 무슨 용도로 쓰이는지는 위 글을 보면 되고, 동작 부문에서 중요한 것은 form
의 <input>
tag다. 이 form을 기반으로 client가 GET
요청을 한 경우 input tag들이 URL의 query parameter에 달린 형태로 전달되며, POST
요청을 한 경우 그냥 저 form
내용물이 request body에 존재하게 된다. 뭐 GET
의 경우에는 DB 탐색을 해서 관련 내용물을 전달해줘야 할거고, POST
의 경우에는 받은 내용물을 DB에 저장하든가 해야 한다.
이 때 후자의 경우가 코드 예시에 해당한다. DB에 저장하려면 관련해서 적절한 Java object를 만들어야 하고 그 과정에서 앞의 5번에 해당하는 data binding이 필요하다. 이 5번이 동작하는 방식이 사실 form
HTML과 관련이 있는데, 채워넣는 객체의 type의 field랑 이름이 같은 form
의 <input>
tag의 name을 수색해서 그 값을 field에다가 채워넣는 것이다.
더 구체적으로 설명하자면 다음과 같다.
form
HTML파일은 POST
HTTP request를 보낼 때 encode 과정을 거친다. 기본은 application/x-www-form-urlencoded
로 설정되어 있는데, 그냥 field 이름과 value를 key-value pair 형태로 저장해서 전달을 한다.Content-Type
이라는 header을 통해 전달한다.@ModelAttribute
가 있는 것을 보고 ModelAttributeMethodProcessor
이라는, HandlerMethodArgumentResolver
의 파생 class의 instance를 가지고 parameter을 적절히 initialize한다.Content-Type
header에 application/x-www-form-urlencoded
가 있는것을 발견하면 받은 data를 기반으로 key-value map을 형성한다음에 parameter을 구성하고, 해당 model attribute를 model에다가 넣게 된다.물론 위는 이상적인 경우고, 만약 multipart/form-data
와 같이 다른 content-type의 형태로 data가 오면 또 그에 맞는 resolver을 활용해가지고 해결해야 한다.
이처럼 원리는 복잡하지만 data binding은 프로그래머가 받은 data를 어떻게 parse해야 하는지에 대한 고민을 줄여서 좀 더 logic에 집중할 수 있는 환경을 제공해준다.
위에까지는 이론적인 내용이며, 이를 실제로 어떻게 적용할 수 있는지 예시 코드를 한번 봐보도록 하자.
spring-mvc-basics/addEmployee
endpoint에 form 파일을 만들어서 직원이 내용을 기입해 서버에 전달하면, 서버에서 해당 내용물을 새로운 화면에다가 그대로 보여주는 것을 구현해 볼 것이다.
form
HTML 파일을 만들어보도록 하자.<form:form method="POST" action="/spring-mvc-basics/addEmployee"
modelAttribute="employee">
<form:label path="name">Name</form:label>
<form:input path="name" />
<form:label path="id">Id</form:label>
<form:input path="id" />
<input type="submit" value="Submit" />
</form:form>
보면 일반적인 HTML form
파일과 좀 다르다. Spring form tag library를 활용하기 때문.
먼저 modelAttribute
가 있는것을 볼 수 있는데 이는 explciit하게 form이 Employee
라는 model attribute에 대응된다는 것을 알린다.
그리고 form:label
과 form:input
을 활용해서 입력하는 칸을 만들었는데, 이 때 path
에 지정한 이름은 대응되는 Employee
의 field 이름이랑 똑같아야 한다. 그러면 이제 자동으로 위에서 설명한 형태로 request body가 만들어져서 Employee
instance를 구성하는 것이 가능해진다.
@Controller
@ControllerAdvice
public class EmployeeController {
private Map<Long, Employee> employeeMap = new HashMap<>();
@RequestMapping(value = "/addEmployee", method = RequestMethod.POST)
public String submit(
@ModelAttribute("employee") Employee employee,
BindingResult result, ModelMap model) {
if (result.hasErrors()) {
return "error";
}
model.addAttribute("name", employee.getName());
model.addAttribute("id", employee.getId());
employeeMap.put(employee.getId(), employee);
return "employeeView";
}
@ModelAttribute
public void addAttributes(Model model) {
model.addAttribute("msg", "Welcome to the Netherlands!");
}
}
BindingResult
는 data binding의 결과물을 저장하는 parameter이다. @ModelAttribute
에 대한 결과물이 저기에 들어가 있다. 어떻게 자동으로 들어가는거냐면 (model
처럼) Spring MVC에서 context configure할때 미리 해당 request에서 Model
, BindingResult
의 instance가 필요하다는 것을 파악하고, 실제로 해당 controller method가 실행될 예정일 때 mapping하면서 미리 준비한다음에 그대로 전달하는 것이다. 저 BindingResult
의 scope도 당연히 request scope이다.@XmlRootElement
public class Employee {
private long id;
private String name;
public Employee(long id, String name) {
this.id = id;
this.name = name;
}
// standard getters and setters removed
}
앞에 말한 이유로 default constructor이 없다.
@XmlRootElement
는 여기선 딱히 의미를 가지지 않는걸로 보여서 생략.
form
html로 request body가 controller에게 전달@ModelAttribute
annotated method에 의해 model에 msg
attribute 추가@ModelAttribute
annotated parameter employee
에 request body를 기반으로 한 map을 통해 Employee
instance가 형성.employeeMap
에 저장, 그리고 전시를 위해 내용물 관련 model attribute를 저장employeeview
이름을 return해가지고 결과물 전시를 할 View가 뭔지를 알림<h3>${msg}</h3>
Name : ${name}
ID : ${id}