들어가며
일반적으로 Spring boot에서 Get방식(혹은 쿼리파라미터가 필요한) 요청을 받을 때 사용되는 어노테이션에는 @RequestParam, @PathVariable, @ModelAttribute가 있다.
파라미터의 개수가 적은 경우에는 앞의 두개 어노테이션으로 충분하지만 파라미터가 많아질 수록 객체로 요청을 받는게 가독성 측면에도 관리 측면에도 수월해지기 때문에 @ModelAttribute를 사용한다.
종종 @ModelAttribute를 사용하면서 헷갈리거나 잊어버리는 개념이 있어 이번 포스팅에서는 @ModelAttribute 어노테이션에 대해 공부해보고자 한다!
@ModelAttribute
사용 이유
요청을 받을 때 다음과 같이 파라미터의 개수가 적다면 @RequestParam 어노테이션으로도 무리없이 코드 작성이 가능하다.
@GetMapping("/param")
public ApiResponse<StudyResponseDto> requestParam(
@RequestParam("name") String name,
@RequestParam("age") int age
) {
return ApiResponse.success(studyService.requestParam(name, age));
}
하지만 파라미터의 개수가 많아진다면 어떨까?
@GetMapping("/param")
public ApiResponse<StudyResponseDto> requestParam(
@RequestParam("name") String name,
@RequestParam("age") int age,
@RequestParam("email") String email,
@RequestParam("phone") String phone,
@RequestParam("nickname") String nickname,
@RequestParam("hobby") String hobby,
@RequestParam("job") String job
) {
return ApiResponse.success(studyService.requestParam(name, age, email, phone, nickname, hobby, job));
}
한 눈에 보더라도 가독성이 떨어질 뿐만 아니라 service로 파라미터를 전달할 때 실수할 가능성이 다분한 코드가 된다.
이때, 파라미터들을 객체로 관리하면 어떨까?
@GetMapping("/model")
public ApiResponse<StudyResponseDto> modelAttribute(
StudyRequestDto requestDto
) {
return ApiResponse.success(studyService.modelAttribute(requestDto));
}
@Getter
@Setter
public class StudyRequestDto {
private String name;
private int age;
private String email;
private String phone;
private String nickname;
private String hobby;
private String job;
}
코드가 한결 간결해질 뿐만 아니라 코드를 수정할 때 DTO 객체만 관리하면 되기 때문에 코드 수정도 용이해진다.
자, @ModelAttribute를 사용하는 이유는 이제 알겠는데, 이 어노테이션이 어떻게 동작하기에 요청 파라미터들을 자동으로 바인딩해주는 걸까?
동작방식
@ModelAttribute는 기본적으로 setter 메소드를 사용하여 데이터를 바인딩한다.
위의 코드를 사용해서 간단하게 테스트코드를 짜서 테스트해보자.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class StudyControllerTest {
@LocalServerPort
private int port;
@Test
public void modelAttribute() {
// given
RestAssured.baseURI = "http://localhost";
RestAssured.port = port;
given()
.log().params()
.param("name", "name")
.param("age", 10)
.param("email", "email")
.param("phone", "phone")
.param("nickname", "nickname")
.param("hobby", "hobby")
.param("job", "job")
// when
.when()
.get("/api/v1/study/model")
// then
.then()
.log().all();
}
}
결과 응답값
// 결과
{
"code": "200_0",
"message": "정상 처리 되었습니다.",
"data": {
"name": "name",
"age": 10,
"email": "email",
"phone": "phone",
"nickname": "nickname",
"hobby": "hobby",
"job": "job"
}
}
데이터가 잘 바인딩 되는걸 확인할 수 있다.
하지만 하나 불편한 점이 있다. 나는 개인적으로 꼭 필요한 경우가 아니면 DTO에 setter 사용을 지양하고 있다.
setter 대신 생성자를 사용할 수는 없는걸까?
코드를 다음과 같이 변경하고 테스트해보자.
@Getter
@NoArgsConstructor
@AllArgsConstructor // 편의를 위해 Lombok 사용
public class StudyRequestDto {
private String name;
private int age;
private String email;
private String phone;
private String nickname;
private String hobby;
private String job;
}
결과 응답값
// 데이터가 바인딩되지 않는다.
{
"code": "200_0",
"message": "정상 처리 되었습니다.",
"data": {
"name": null,
"age": 0,
"email": null,
"phone": null,
"nickname": null,
"hobby": null,
"job": null
}
}
setter를 사용할 때와 달리 데이터가 바인딩되지 않는다.
이유가 뭘까?
원인을 찾기 위해 @ModelAttribute의 구현체를 찾아가보도록 하자.
ModelAttributeMethodProcessor
ModelAttribute가 어떻게 동작하는 지 확인하고 싶을 때 디버깅을 찍을 수 있는 구현체이다.
이 클래스에서 주목해야할 부분은 constructAttribute()
메소드이다.
해당 클래스의 resolveArgument()
메소드부터 디버깅을 해서 차근차근 들어가보면 생성자에서 파라미터를 바인딩하는 메소드를 찾을 수 있는데, 이걸 결정하는 메소드가 바로 contructAttribute()
메소드이다.
protected Object constructAttribute(Constructor<?> ctor, String attributeName, MethodParameter parameter, WebDataBinderFactory binderFactory, NativeWebRequest webRequest) throws Exception {
if (ctor.getParameterCount() == 0) {
return BeanUtils.instantiateClass(ctor, new Object[0]);
} else {
String[] paramNames = BeanUtils.getParameterNames(ctor);
Class<?>[] paramTypes = ctor.getParameterTypes();
Object[] args = new Object[paramTypes.length];
WebDataBinder binder = binderFactory.createBinder(webRequest, (Object)null, attributeName);
String fieldDefaultPrefix = binder.getFieldDefaultPrefix();
String fieldMarkerPrefix = binder.getFieldMarkerPrefix();
boolean bindingFailure = false;
Set<String> failedParams = new HashSet(4);
//...
}
이 코드에서 ctro.getParaneterCount()
를 사용해서 생성자의 파라미터 개수를 가져온다.
만약 그 개수가 0개라면 기본생성자로, 0개가 아니라면 인자가 있는 (우리의 경우에는 AllAgrsConstructor) 생성자를 인식하고 파라미터 바인딩을 처리하게 된다.
따라서 @AllArgsConstructor
를 사용하더라도 @NoArgsConstrutor
에 의해 기본생성자가 사용되게 되므로, 만약 Setter 메소드를 사용하고 싶지 않다면 기본생성자를 명시하지 않고 특정 파라미터에 대한 생성자만을 명시해주면 된다.
그렇다면 왜 Setter 메소드가 없으면 데이터 바인딩이 되지 않는 걸까?
Setter 메소드가 없으면 안되는 이유
ModelAttributeMethodProcessor의 메소드 중 하나인 resolveArgument()
메소드를 확인해보면 다음과 같이 WebDataBinder
클래스를 사용하여 데이터를 바인딩 한다.
if (bindingResult == null) {
WebDataBinder binder = binderFactory.createBinder(webRequest, attribute, name);
if (binder.getTarget() != null) {
if (!mavContainer.isBindingDisabled(name)) {
this.bindRequestParameters(binder, webRequest);
}
this.validateIfApplicable(binder, parameter);
if (binder.getBindingResult().hasErrors() && this.isBindExceptionRequired(binder, parameter)) {
throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
}
}
if (!parameter.getParameterType().isInstance(attribute)) {
attribute = binder.convertIfNecessary(binder.getTarget(), parameter.getParameterType(), parameter);
}
bindingResult = binder.getBindingResult();
}
WebDataBinder
는 요청 파라미터를 객체에 바인딩해주는 역할을 한다.
그리고 이 클래스는 Java Beans 규약을 준수하여 동작하고 있는데, Java Beans 규약은 getter와 setter를 사용하여 프로퍼티에 접근한다.
따라서 setter 메소드를 명시하지 않는다면 WebDataBinder는 데이터를 바인딩할 방법이 없는 것이다.
결론
@ModelAttribute를 사용하여 파라미터를 객체에 바인딩하기 위한 방법은 두가지가 있다.
- Setter 메소드를 사용한다.
기본 생성자는 명시하지 않는다면 default로 생성되므로 신경쓰지 않아도 된다. - 파라미터가 존재하는 생성자만 사용한다.
이때, 기본생성자와 함께 사용하면 데이터 바인딩이 되지 않으므로 주의한다.
마치며
지금까지 '그냥 되는구나'라고만 생각하고 사용했던 @ModelAttribute 어노테이션에 대해 깊게 알아보는 시간을 가졌다.
내부적으로 동작하는 코드를 까보는게 생각보다 훨씬 공부에 도움이 된다는걸 한번 더 깨닫게 되었다.
앞으로도 그냥 쓰지 말고 동작원리를 알고 개발하는 습관을 가지기로 다짐하면서 이번 포스팅을 마무리한다.
'Study > Spring boot' 카테고리의 다른 글
[Spring boot] WebClient 사용해보기 - 비동기 통신 subscribe() (0) | 2023.11.11 |
---|---|
[Spring boot] SSH 터널링 구현하기 (0) | 2023.10.13 |
[Spring boot] WebClient 사용해보기 - 모듈화 1-2 (0) | 2023.08.26 |
[Spring boot] WebClient 사용해보기 - 모듈화 1-1 (0) | 2023.08.25 |
[JPA] 프록시(Proxy), 지연 로딩(LAZY Loading), 즉시 로딩(EAGER Loading) (0) | 2022.06.23 |