이 포스트는 Medium 블로그의 포스트 “Data Mapping with Spring’s @ModelAttribute Annotation (by Alexander Obregon)”과 스프링 공식 문서를 참조해서 작성했다.
@ModelAttibute
애노테이션의 핵심 기능은 데이터 바인딩이다.
데이터 바인딩(데이터 매핑)은 클라이언트 데이터와 서버 애플리케이션 로직을 이어주는 다리로, 웹 개발에서 아주 중요한 기능이다. @ModelAttibute
는 클라이언트가 보낸 요청 데이터를 서버의 모델 객체로 바인딩해준다.
@ModelAttribute
의 가장 기초적인 사용법은 @RequestMapping
메서드의 파라미터에 사용하는 것이다. 이를 통해 클라이언트에서 넘어온 데이터를 서버의 모델 객체에 바인딩할 수 있다.
바인딩은 폼 데이터와 쿼리 파라미터 뿐 아니라 세션 애트리뷰트까지 가능하다.
@Controller
public class BookController {
@PostMapping("/addBook")
public String addBook(@ModelAttribute Book book, Model model) {
// Business logic to add the book
model.addAttribute("book", book);
return "bookAdded";
}
}
위 예시의 addBook()
은 HTML 폼으로 책 정보를 받아 서버에 새로운 책을 등록하는 메서드이다.
Book
클래스의 프로퍼티에 대응하는 각 HTML 폼 필드의 값은 Book
객체에 자동으로 옮겨진다. 개발자가 필드 값을 하나씩 추출하지 않아도 되기 때문에 필드의 수가 많을 때 유용하다.
@ModelAttibute
를 메서드 파라미터에 사용했을 때, 스프링은 다음과 같은 단계를 거친다 :
여기서 1번이 조금 의아했다. 당연히 처음에는 모델에 애트리뷰트가 없는 게 아닐까? 🤔
그런데 GeeksforGeeks의 포스트 “Spring MVC @ModelAttribute Annotation with Example”에서 이러한 코드를 발견했다.
@RequestMapping("/home")
public String showHomePage(Model model) {
// Read the existing property by
// fetching it from the DTO
NameInfoDTO nameInfoDTO = new NameInfoDTO();
model.addAttribute("nameInfo", nameInfoDTO);
return "welcome-page";
}
@RequestMapping("/process-homepage")
public String showResultPage(NameInfoDTO nameInfoDTO, Model model) {
// writing the value to the properties
// by fetching from the URL
model.addAttribute("nameInfo", nameInfoDTO);
return "result-page";
}
위의 예시는 @ModelAttribute
자동화를 적용하기 전 코드로, welcome-page.jsp
에서 이름 정보(NameInfoDTO
)를 사용자에게 받은 뒤 result-page.jsp
에서 그 값을 보여주는 컨트롤러이다.
위쪽의 showHomePage()
메서드를 보면 DTO 객체를 생성하고 모델에 담아주는 부분이 있다. 이것이 바로 위 라이프사이클의 1번 단계인데, @ModelAttribute
애노테이션이 이 부분을 자동화해주는 것 같다.
@ModelAttibute
애노테이션은 메서드 자체에도 사용할 수 있다. 메서드 레벨에서 사용한다면 메서드의 리턴값이 자동으로 모델 애트리뷰트로 추가된다.
모델 애트리뷰트의 이름은 직접 지정할 수도 있고, 모델 속성의 이름을 지정하지 않으면 Object
타입과 컨벤션에 의해 자동 명명된다.
@ModelAttibute
를 메서드 레벨에서 사용하면 스프링은 @RequestMapping
메서드 호출 전에 모델을 초기화한다. 어떤 객체를 모델의 기본값으로 설정하고 싶을 때, 즉 컨트롤러의 메서드들이 공통으로 필요한 모델 애트리뷰트가 필요할 때 유용하다.
참고로 @ControllerAdvice
를 사용하면 모델 초기화를 여러 컨트롤러에 적용할 수도 있다.
@ModelAttribute("genres")
public List<String> populateGenres() {
return Arrays.asList("Science Fiction", "Drama", "Mystery", "Horror");
}
예를 들어 위와 같은 메서드를 컨트롤러에 작성했다면, 해당 컨트롤러에서 사용하는 모델에는 기본적으로 “genres”라는 이름의 애트리뷰트가 담길 것이다.
모델이 초기화될 때 여러 개의 값을 넣고 싶다면 반환 타입을 void
로 하고 리턴값 대신 model.addAttribute()
를 이용하면 된다.
// 모델에 여러 개의 값을 넣는 경우
@ModelAttribute
public void populateModel(@RequestParam String number, Model model) {
model.addAttribute(accountRepository.findAccount(number));
// add more ...
}
// 모델에 하나의 값만 넣는 경우
@ModelAttribute
public Account addAccount(@RequestParam String number) {
return accountRepository.findAccount(number);
}
모델 초기화를 동적으로 설정할 수도 있다.
// 예시1) 매개변수로 받은 type의 값이 “premium”과 일치하는 경우에만 모델 초기화를 하는 메서드
@ModelAttribute
public void loadDynamicData(@RequestParam("type") String type, Model model) {
if ("premium".equals(type)) {
model.addAttribute("features", getPremiumFeatures());
}
}
// 예시2) 날짜를 String 타입으로 받은 뒤 LocalDate 타입으로 변환하여 모델에 넣는 메서드
@ModelAttribute
public void transformDateFields(@RequestParam("date") String date, Model model) {
LocalDate formattedDate = LocalDate.parse(date, DateTimeFormatter.ofPattern("MM-dd-yyyy"));
model.addAttribute("formattedDate", formattedDate);
}
@ModelAttribute
는 단일 객체 뿐 아니라 중첩된 객체도 다룰 수 있다. 이것을 이해하면 불필요한 코드를 줄일 수 있다.
public class Book {
private Publisher publisher;
// Other fields, getters and setters
}
public class Publisher {
private String name;
// Other fields, getters and setters
}
예를 들어 위와 같이 Book
클래스가 Publisher
클래스를 프로퍼티로 갖고있다고 하자. HTML 폼에서 name
이라는 이름으로 데이터가 전달되었을 때, @ModelAttribute
는 Book
모델 객체 내 Publisher.name
에 성공적으로 바인딩한다.
모델 객체로는 JPA의 엔터티와 같은 원형 클래스를 사용하지 않는 것이 좋다. 객체 그래프가 외부에 노출되면 위험할 수 있기 때문이다.
원형 객체 대신 뷰에서 컨트롤러로 데이터를 전달하는 역할만 갖는 DTO 객체를 만들어 사용하는 것이 좋은 방법이다.
@Getter @Setter
public class ChangeEmailForm {
private String oldEmailAddress;
private String newEmailAddress;
}
참고) 김영한 님의 스프링 강의에서는 뷰와 컨트롤러 사이의 DTO 객체를 form 이라는 이름으로 특수하게 명명해서 사용했는데(링크), 스프링 공식문서의 예제에서도 form이라는 이름이 보인다(링크). DTO 보다 직관적인 이름 같다.
생성자를 사용하는 방법도 있다. 모델 객체가 생성될 때 생성자는 자신이 필요한 요청 파라미터만 사용하고 나머지 인풋은 무시한다. 프로퍼티 바인딩이 기본적으로 매칭되는 모든 파라미터를 바인딩하는 것과 대비된다.
Form 객체나 생성자 바인딩으로 충분하지 않게 느껴진다면 WebDataBinder
에 바인딩을 허용할 필드(allowedFields
)를 지정하는 방법도 있다.
참고로 WebDataBinder
에 바인딩을 허용하지 않을 필드(disallowedFields
)를 지정하는 방법도 있지만 허용 필드를 지정하는 것이 실수를 줄이는 좋은 방법이다(블랙 리스트보다 화이트 리스트 권장).
@Controller
public class ChangeEmailController {
@InitBinder
void initBinder(WebDataBinder binder) {
binder.setAllowedFields("oldEmailAddress", "newEmailAddress");
}
// @RequestMapping methods, etc.
}
파라미터 바인딩과 생성자 바인딩은 기본적으로 함께 사용할 수 있다. 만약 생성자 바인딩만 사용하고 싶다면 다음과 같이 @InitBinder
를 통해 WebDataBinder
의 declarativeBinding
플래그를 설정해주면 된다. 로컬로 설정할 수도 있고 @ControllerAdvice
를 통해 글로벌 설정도 가능하다.
@Controller
public class MyController {
@InitBinder
void initBinder(WebDataBinder binder) {
binder.setDeclarativeBinding(true);
}
// @RequestMapping methods, etc.
}
@InitBinder
는 WebDataBinder
를 초기화하는 메서드이다.어떠한 이유로 바인딩에 실패했다면 MethodArgumentNotValidException
이 발생하고 컨트롤러가 호출되지 않는다.
예외를 핸들링하려면 @ModelAttribute
바로 옆에 BindingResult
를 추가해주면 된다. 바인딩에 실패할 경우 스프링은 오류 정보를 BindingResult
에 담고 컨트롤러를 정상 호출한다.
@PostMapping("/owners/{ownerId}/pets/{petId}/edit")
public String processSubmit(@ModelAttribute("pet") Pet pet, BindingResult result) {
if (result.hasErrors()) {
return "petForm";
}
// ...
}
Bean Validation을 사용하려면 @Valid
또는 @Validated
애노테이션을 붙인다.
@PostMapping("/owners/{ownerId}/pets/{petId}/edit")
public String processSubmit(@Valid @ModelAttribute("pet") Pet pet, BindingResult result) {
if (result.hasErrors()) {
return "petForm";
}
// ...
}
@RequestMapping("/greet")
public String greet(@RequestParam(name = "name", defaultValue = "Guest") String name, Model model) {
model.addAttribute("name", name);
return "greeting";
}
@RequestMapping
은 HTTP 요청의 쿼리 파라미터, 폼 파라미터 혹은 URL의 일부(Path Variable 등)를 추출하기 위해 사용된다.
@RequestMapping
은 파라미터 레벨에서 동작하며, 한 번에 하나의 파라미터 값만 추출한다. 반면 @ModelAttribute
는 객체 레벨에서 동작하여 여러 개의 파라미터를 객체 필드에 바인딩할 수 있다.@RequestMapping
은 보통 하나의 값만 추출하는데 사용되기 때문에 REST API에서 많이 사용된다. @ModelAttribute
는 HTML 폼 파라미터들을 하나의 객체에 바인딩할 때 많이 사용된다.@RequestMapping
은 String
, int
등 기본형 타입의 바인딩에 사용되고 @ModelAttribute
은 사용자 정의 객체 등 보다 복잡한 타입의 바인딩에 사용된다.@RequestBody
는 HTTP 리퀘스트 바디를 읽어와 역직렬화한 뒤 자바 객체에 넣어준다. 주로 REST API에서 JSON이나 XML 페이로드를 다루기 위해 사용한다.
@RequestBody
는 HTTP 리퀘스트 바디로부터 데이터를 직접 읽어오는 반면(setter 필요 X), @ModelAttribute
는 HTML 폼 데이터나 URL 파라미터 데이터를 바인딩한다(setter 필요).RequestBody
는 보통 application/json
또는 application/xml
과 사용된다. 반면 @ModelAttribute
는 보통 application/x-www-form-urlencoded
와 사용된다.@RequestBody
는 Jackson 등 라이브러리를 사용하여 데이터를 자동으로 역직렬화하여 자바 객체에 넣어주고, @ModelAttribute
는 프로퍼티 에디터(setter)나 사용자 정의 에디터 등 스프링의 데이터 바인더를 사용한다.@RequestBody
는 JSON 기반 검증을 사용하고, @ModelAttribute
는 @Valid
와 같이 HTLM 폼 기반 검증과 함께 쓰인다.