[1] 스프링의 마법(?) ModelAttribute를 알아보자
처음 스프링을 공부할 때 가히 '마법'이라 칭할 법한 경험들이 많이 있었습니다. 오늘은 그 경험 중에 하나 였던 @ModelAttribute
에 대해 알아보겠습니다.
프론트단에 해당하는 HTML 코드가 있다고 가정합시다.
<form method="post" action="/register">
<input type="text" name="memberName" />
<input type="email" name="email" />
<button type="submit">회원가입</button>
</form>
클라이언트가 회원가입 버튼을 누르면, input 태그의 memberName과 email의 값이 서버 경로(action의 /user
경로)로 HTML 요청을 보냅니다.
이때 get 방식으로 보내면 주로 쿼리 파라미터를 통해 input 태그의 value를 보내고 , post방식으로 보내면 message body를 통해 name의 value를 전달합니다.
(get 방식도 message body를 통해 전달할 수 있으나 지원하는 곳이 많지 않아 권장하지 않음)
그럼 서버단에서는 마법(?) 같은 일이 일어납니다. 우선 코드 먼저 봅시다.
@Controller
public class MemberController {
@PostMapping("/register")
public String register(@ModelAttribute MemberRequest request) {
...
}
}
public class MemberRequest {
private String memberName;
private String email;
... getter, setter 등등
}
@ModelAttribute
어노테이션 뒤에 온 MemberRequest 객체에는 프론트에서 input 태그의 name의 변수와 동일한 필드명이 존재합니다. 그렇다면 스프링은 프론트에서 넘어온 값들이 자동으로(!!) 바인딩 되어 서버에 전달됩니다.
아 그런가보다~ 하기에는 너무 신기했습니다. 그래서 오늘 포스팅은 메소드의 인자로 담긴 MemberRequest 객체에 어떤 조건으로 필드가 바인딩 되는지 알아보겠습니다.
[2] @ModelAttribute 어노테이션 설명
일단 스프링 공식문서를 확인해봅시다. 먼저 @ModelAttribute
에 대한 설명입니다.
메소드의 매개변수에서@ModelAttribute
어노테이션을 붙일 수 있으며, 이것을 통해 모델의 속성(attribute)에 접근하거나 속성이 없는 경우 인스턴스화하도록 할 수 있습니다. (블로그에서 모델의 예시로 MemberRequest를 들 수 있음)
또한 모델의 속성이 (input 태그의) name과 필드의 이름과 일치하다면, Http 서블릿 request 파라미터의 값으로 뒤덮습니다. 이를 데이터 바인딩이라 하며, 개별 쿼리 파라미터들을 하나하나 파싱하고 컨버팅하지 않아도 됩니다.
우리가 프론트에서 구현한 input 태그 name의 변수명과, 모델로 만든 MemberRequest 인스턴스 필드명이 일치하는 경우 데이터바인딩이 일어난다는 것을 알 수 있습니다. 개별 쿼리 파라미터를 하나하나 파싱하지 않고 컨버팅하지 않아도 된다는 뜻은 이런 뜻 같습니다. 만약 제가 들었던 예제 중, 컨트롤러에서 model로 받지 않았을 경우 다음과 같이 받아야 합니다.
@Controller
public class MemberController {
@PostMapping("/register")
public String register(@RequestParam String memberName, @RequestParam String email) {
...
}
}
하지만 모델을 사용하면, Http 서블릿 request 파라미터의 변수명과 MemberRequest의 필드명이 동일할 시 request 파라미터의 값들로 초기화된다는 것입니다.
MemberRequest는 다음과 같은 방법 중 하나로 인스턴스화됩니다.
- @ModelAttribute 메소드에 의해 추가되었을 수 있는 모델을 탐색한다.
- 만약 model attribute가 클레스 레벨에 @SessionAttribute 어노테이션이 붙었는지 Http Session을 통해 탐색한다
- model의 속성 이름과 path variable, request parameter와 같은 request 값이 매치되는 경우 컨버터를 얻는다.
- 기본 생성자를 통해 인스턴스화한다.
- 서블릿 request 파라미터들의 이름과 가장 일치하는 인자가 있는, 적절한 생성자를 통해(primary constructor) 인스턴스화된다. 인자의 이름은 JavaBeans의 @ConstructorProperties 또는 바이트코드의 런타임 시 존재하는(runtime-retained) 파라미터의 이름을 통해 결정된다.
공식 문서를 확인해보면, MemberRequest 객체의 필드명이 path variable 혹은, request parameter 등을 통해 넘어온 name의 이름이 일치하는 경우 컨버터를 얻는 것을 알 수 있습니다. 그럼, 기본 생성자를 통해 객체가 초기화 되든지, 서블릿 request 파라미터와 가장 일치하는 인자가 있는 적절한 생성자를 통해 인스턴스화되는 것을 알 수 있습니다.
스프링 프로젝트를 하면서 model이 바인딩되지 않았던 경험이 있었는데 이런 경우였습니다. 바로 기본 생성자와 모든 필드의 생성자가 있었던 경우입니다.
public class MemberRequest {
private String memberName;
private String email;
public MemberRequest() {
}
public MemberRequest(String memberName, String email) {
this.memberName = memberName;
this.email = email;
}
}
이렇게 두 가지의 생성자가 있는 경우 기본 생성자를 우선하기 때문에, 기본 생성자로 객체가 초기화 됩니다. 따라서 memberName과 email가 null로 초기화 되버린 것이죠.
제 블로그에서는 setter 패턴을 지양하라고 하였으나, setter 패턴을 사용하는 경우에도 초기화를 해줍니다. 해당하는 내용은 https://minchul-son.tistory.com/546님의 블로그에서 잘 설명되어 있으니 참조해주세요.
마무리로 스프링 공식 문서 DataBinder 파트에서 모델 설계에 대해 좋은 자료가 있어 공유해드리고 블로그 글은 마치도록 하겠습니다.
[3] 모델 설계
기본적으로, 스프링에서는 모델 객체 그래프의 모든 public 속성에 대한 데이터 바인딩을 허용합니다. 즉, 당신은 모델의 public 속성들에 대해 신중하게 고려해야 하는데, 클라이언트는 모든 public 속성의 경로를 타겟으로 설정할 수 있으며, 심지어 예상되지 않는 일부 경로를 통해 타겟될 수 있습니다.
예를 들어, Http 폼 데이터 엔드포인트(https://blog.naver.com/ghdalswl77/222401162545)가 주어진 경우 악의를 품은 클라이언트는 모델 객체 그래프에 있는 값들이 아닌, HTML 폼에 존재 하지 않는 값들을 제공할 수 있습니다. 이는 업데이트 되리라 예상하지 않은 모델 객체의 값이 세팅되는 일이 벌어질 수 있습니다.
권장되는 접근법은 form의 제출과 관련있는 model 객체를 사용하는 것입니다. (생략...)
도메인 모델을 직접 노출할 때에는, 필드 접근에 허용할 것과 허용하지 않아야 할 것을 적절하게 구성하는 것은 매우 중요합니다. 그렇지 않으면 보안에 큰 리스크가 있습니다. 더 나아가, JPA 또는 Hibernate 엔티티를 모델 객체로 사용하지 않는 것이 좋습니다.
https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann-modelattrib-methods
아시는 분들은 아시겠지만, html 전송 방식은 (크롬의 경우) F12 관리자 모드를 통해 form 안의 input 태그들을 수정하여 전송할 수 있습니다. 혹은, Postman과 같은 프로그램을 사용하여 공격할 수도 있습니다. 따라서, @ModelAttribute의 모델 객체는 (프론트의) form 태그의 제출과 관련 있는 model 객체를 사용하는 것이 바람직합니다.
예를 들어, 다음과 같은 객체가 있다 가정합시다.
public class Member {
private String memberName;
private int age;
private String loginId;
private String password;
private String email;
private int point;
}
만약 Member 객체에서 password만 변경되어야 할 때를 가정합시다. 그럼 컨트롤러의 메소드에서 Member 객체를 모델 객체로 노출시키는 것이 아닌, 다음과 같은 객체를 하나 만드는 겁니다.
public class changePasswordRequest{
private String password;
private String newPassword;
private String confirmPassword;
}
즉, 비밀번호 변경이라는 form의 제출과 연관된 DTO를 하나 만들고 그것을 노출시키는 것이 바람직합니다.
참고 자료
https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann-modelattrib-method-args
중간 중간 오역이 있을 수 있습니다. 피드백 환영합니다.