Spring boot

[Spring boot] @ModelAttribute에 대한 이해

Anna-Jin 2024. 1. 20. 17:54
728x90
반응형

들어가며

일반적으로 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를 사용하여 파라미터를 객체에 바인딩하기 위한 방법은 두가지가 있다.

 

  1. Setter 메소드를 사용한다.
    기본 생성자는 명시하지 않는다면 default로 생성되므로 신경쓰지 않아도 된다.
  2. 파라미터가 존재하는 생성자만 사용한다.
    이때, 기본생성자와 함께 사용하면 데이터 바인딩이 되지 않으므로 주의한다.

 


마치며

지금까지 '그냥 되는구나'라고만 생각하고 사용했던 @ModelAttribute 어노테이션에 대해 깊게 알아보는 시간을 가졌다.

내부적으로 동작하는 코드를 까보는게 생각보다 훨씬 공부에 도움이 된다는걸 한번 더 깨닫게 되었다.

 

 

앞으로도 그냥 쓰지 말고 동작원리를 알고 개발하는 습관을 가지기로 다짐하면서 이번 포스팅을 마무리한다.

 

728x90
반응형
댓글수2