[Spring] @ModelAttribute 사용법

dondonee·2024년 3월 12일
0
post-thumbnail

@ModelAttribute 사용법

이 포스트는 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. Lookup: 모델에서 중 파라미터와 같은 이름을 가진 모델 애트리뷰트가 있는지 찾는다. (위 예제의 경우 “book”)
  2. Instantiation: 모델에서 맞는 모델 애트리뷰트를 찾을 수 없다면, 대상 클래스의 객체 인스턴스를 생성한다.
  3. Population: 폼 필드의 값을 대응하는 모델 프로퍼티에 넣는다. 이 때 객체의 setter를 사용한다.
  4. Addition to Model: 마지막으로 객체를 모델에 넣는다. 이로써 뷰를 렌더링할 때 객체를 사용할 수 있다.

참고)

여기서 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이라는 이름으로 데이터가 전달되었을 때, @ModelAttributeBook 모델 객체 내 Publisher.name에 성공적으로 바인딩한다.



모델 디자인

1) DTO 객체 사용

모델 객체로는 JPA의 엔터티와 같은 원형 클래스를 사용하지 않는 것이 좋다. 객체 그래프가 외부에 노출되면 위험할 수 있기 때문이다.

원형 객체 대신 뷰에서 컨트롤러로 데이터를 전달하는 역할만 갖는 DTO 객체를 만들어 사용하는 것이 좋은 방법이다.


@Getter @Setter
public class ChangeEmailForm {

	private String oldEmailAddress;
	private String newEmailAddress;
}

참고) 김영한 님의 스프링 강의에서는 뷰와 컨트롤러 사이의 DTO 객체를 form 이라는 이름으로 특수하게 명명해서 사용했는데(링크), 스프링 공식문서의 예제에서도 form이라는 이름이 보인다(링크). DTO 보다 직관적인 이름 같다.


2) 생성자 바인딩 사용

생성자를 사용하는 방법도 있다. 모델 객체가 생성될 때 생성자는 자신이 필요한 요청 파라미터만 사용하고 나머지 인풋은 무시한다. 프로퍼티 바인딩이 기본적으로 매칭되는 모든 파라미터를 바인딩하는 것과 대비된다.


3) 바인딩 허용 필드 지정

Form 객체나 생성자 바인딩으로 충분하지 않게 느껴진다면 WebDataBinder에 바인딩을 허용할 필드(allowedFields)를 지정하는 방법도 있다.

참고로 WebDataBinder에 바인딩을 허용하지 않을 필드(disallowedFields)를 지정하는 방법도 있지만 허용 필드를 지정하는 것이 실수를 줄이는 좋은 방법이다(블랙 리스트보다 화이트 리스트 권장).


@Controller
public class ChangeEmailController {

	@InitBinder
	void initBinder(WebDataBinder binder) {
		binder.setAllowedFields("oldEmailAddress", "newEmailAddress");
	}

	// @RequestMapping methods, etc.

}

파라미터 바인딩과 생성자 바인딩은 기본적으로 함께 사용할 수 있다. 만약 생성자 바인딩만 사용하고 싶다면 다음과 같이 @InitBinder를 통해 WebDataBinderdeclarativeBinding 플래그를 설정해주면 된다. 로컬로 설정할 수도 있고 @ControllerAdvice를 통해 글로벌 설정도 가능하다.

@Controller
public class MyController {

	@InitBinder
	void initBinder(WebDataBinder binder) {
		binder.setDeclarativeBinding(true);
	}

	// @RequestMapping methods, etc.

}
  • 참고) @InitBinderWebDataBinder를 초기화하는 메서드이다.


바인딩 예외

BindingResult

어떠한 이유로 바인딩에 실패했다면 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

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

@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 등)를 추출하기 위해 사용된다.

차이점 :

  1. 바인딩 단위 : @RequestMapping파라미터 레벨에서 동작하며, 한 번에 하나의 파라미터 값만 추출한다. 반면 @ModelAttribute객체 레벨에서 동작하여 여러 개의 파라미터를 객체 필드에 바인딩할 수 있다.
  2. 사용 맥락 : @RequestMapping은 보통 하나의 값만 추출하는데 사용되기 때문에 REST API에서 많이 사용된다. @ModelAttributeHTML 폼 파라미터들을 하나의 객체에 바인딩할 때 많이 사용된다.
  3. 데이터 타입 : @RequestMappingString, int기본형 타입의 바인딩에 사용되고 @ModelAttribute사용자 정의 객체 등 보다 복잡한 타입의 바인딩에 사용된다.

@RequestBody

@RequestBody는 HTTP 리퀘스트 바디를 읽어와 역직렬화한 뒤 자바 객체에 넣어준다. 주로 REST API에서 JSON이나 XML 페이로드를 다루기 위해 사용한다.

차이점 :

  1. 데이터 소스 : @RequestBody는 HTTP 리퀘스트 바디로부터 데이터를 직접 읽어오는 반면(setter 필요 X), @ModelAttribute는 HTML 폼 데이터나 URL 파라미터 데이터를 바인딩한다(setter 필요).
  2. Content Type : RequestBody는 보통 application/json 또는 application/xml과 사용된다. 반면 @ModelAttribute는 보통 application/x-www-form-urlencoded와 사용된다.
  3. 역직렬화 : @RequestBody는 Jackson 등 라이브러리를 사용하여 데이터를 자동으로 역직렬화하여 자바 객체에 넣어주고, @ModelAttribute는 프로퍼티 에디터(setter)나 사용자 정의 에디터 등 스프링의 데이터 바인더를 사용한다.
  4. 검증 : @RequestBodyJSON 기반 검증을 사용하고, @ModelAttribute@Valid와 같이 HTLM 폼 기반 검증과 함께 쓰인다.



🔗 References

0개의 댓글