Spring

[Spring] AllArgsConstructor가 있는데 왜 @ModelAttribute 객체에 값이 할당되지 않는걸까? (feat. ModelAttributeMethodProcessor의 코드를 뜯어보자)

feelcoding 2023. 3. 20. 02:13
728x90

안녕하세요

얼마 전 프로젝트를 진행하던 중 request로 받은 파라미터들이 @ModelAttribute 객체에 바인딩이 되지 않는 이슈를 겪고 @ModelAttribute에 대해 자세히 알아보게 되었습니다.

왜 그런 것이고 어떻게 해결해야 하는지 알아보겠습니다.

 

먼저 직접 DTO와 컨트롤러를 만들어 테스트해보겠습니다.

전체 프로젝트 코드는 여기에서 보실 수 있습니다.

🥑 NoArgsAndAllArgsDto

매개변수가 없는 생성자와, 모든 필드가 다 매개변수로 있는 생성자 이렇게 2개의 생성자가 있는 DTO 클래스입니다.

@Getter
public class NoArgsAndAllArgsDto {

    private String nickname;

    private Integer count;

    public NoArgsAndAllArgsDto() {
    }

    public NoArgsAndAllArgsDto(String nickname, Integer count) {
        this.nickname = nickname;
        this.count = count;
    }
}

🥑AllArgsDto

모든 필드가 다 매개변수로 있는 생성자만 있는 DTO 클래스입니다.

@Getter
public class AllArgsDto {

    private String nickname;

    private Integer count;

    public AllArgsDto(String nickname, Integer count) {
        this.nickname = nickname;
        this.count = count;
    }

    public void setNickname(String nickname) {
        this.nickname = nickname;
    }

    public void setCount(Integer count) {
        this.count = count;
    }
}

🥑NoArgsAndSetterDto

매개변수가 없는 생성자와 각각의 필드를 세팅할 수 있는 Setter가이 있는 DTO 클래스입니다.

@Getter
public class NoArgsAndSetterDto {

    private String nickname;

    private Integer count;

    public NoArgsAndSetterDto() {
    }

    public void setNickname(String nickname) {
        this.nickname = nickname;
    }

    public void setCount(Integer count) {
        this.count = count;
    }
}

🥑 TestController.java

테스트를 하기 위한 컨트롤러입니다.

@RestController
@RequestMapping("/test")
public class ModelAttributeTestController {

    @PostMapping("/no-args-and-all-args")
    public NoArgsAndAllArgsDto test1(@ModelAttribute NoArgsAndAllArgsDto requestDto) {
        return requestDto;
    }

    @PostMapping("/all-args-only")
    public AllArgsDto test2(@ModelAttribute AllArgsDto requestDto) {
        return requestDto;
    }

    @PostMapping("/no-args-and-setter")
    public NoArgsAndSetterDto test3(@ModelAttribute NoArgsAndSetterDto requestDto) {
        return requestDto;
    }

}

 

이제 테스트를 해봅시다.

🥑 NoArgsConstructor와 AllArgsConstructor가 있을 때

@Test
@DisplayName("NoArgsConstructor와 AllArgsConstructor가 있을 때 값이 할당이 잘 되는지 테스트")
void test1() throws Exception {
    NoArgsAndAllArgsDto requestDto = new NoArgsAndAllArgsDto("feelcoding", 100);

    mvc.perform(post("/test/no-args-and-all-args")
            .contentType(MediaType.APPLICATION_JSON)
                    .param("nickname", requestDto.getNickname())
                    .param("count", requestDto.getCount().toString()))
            .andExpect(status().isOk())
            .andExpect(content().json(objectMapper.writeValueAsString(requestDto)));

}

테스트 코드입니다.

테스트에 실패했습니다.

nickname 필드와 count 필드 모두 값이 할당되지 않고 null인 것을 볼 수 있습니다.

 

🥑 AllArgsConstructor만 있을 때

@Test
@DisplayName("AllArgsConstructor만 있을 때 값이 할당이 잘 되는지 테스트")
void test2() throws Exception {
    AllArgsDto requestDto = new AllArgsDto("feelcoding", 100);

    mvc.perform(post("/test/all-args-only")
                    .contentType(MediaType.APPLICATION_JSON)
                    .param("nickname", requestDto.getNickname())
                    .param("count", requestDto.getCount().toString()))
            .andExpect(status().isOk())
            .andExpect(content().json(objectMapper.writeValueAsString(requestDto)))
            .andDo(print());
}

테스트 코드입니다.

테스트에 성공했습니다.

 

🥑 NoArgsConstructor와 Setter가 있을 때

@Test
@DisplayName("NoArgsConstructor와 Setter가 있을 때 값이 할당이 잘 되는지 테스트")
void test3() throws Exception {
    NoArgsAndSetterDto requestDto = new NoArgsAndSetterDto();
    requestDto.setNickname("feelcoding");
    requestDto.setCount(100);

    mvc.perform(post("/test/no-args-and-setter")
                    .contentType(MediaType.APPLICATION_JSON)
                    .param("nickname", requestDto.getNickname())
                    .param("count", requestDto.getCount().toString()))
            .andExpect(status().isOk())
            .andExpect(content().json(objectMapper.writeValueAsString(requestDto)))
            .andDo(print());
}

테스트 코드입니다.

테스트에 성공했습니다.

 

✅ 테스트 결과

  • AllArgsConstructor만 있을 때는 분명히 값이 잘 할당되었지만 NoArgsConstructor와 AllArgsConstructor가 모두 있으면 값이 할당되지 않았습니다.
  • NoArgsConstructorSetter가 있을 때는 할당이 잘 되었습니다.

 

왜 그런 것일까요? 🤔

 

그것은 @ModelAttrubute가 어떻게 동작하는지 내부 구조를 살펴보면 알 수 있습니다.

 

🥑 @ModelAttribute의 동작 원리를 알아보자

전체 검색 (Windows는 ctrl + shift + f, MacOS는 cmd + shift + f)으로 ModelAttribute.class를 검색하고 Scope All Place로 해주면 ModelAttributeMethodProcessor.java 파일이 보입니다.

이 파일에 한 번 들어가보겠습니다.

ModelAttributeMethodProcessor

HandlerMethodArgumentResolver를 구현한 클래스네요. (HandlerMethodArgumentResolver에 대해서는 이전 글을 참고해주세요)

request를 어떻게 가공해서 Controller의 매개변수로 넣어줬는지 resolveArgument() 메소드를 확인해봅시다.

핵심 로직을 살펴보면 createAttribute()라는 메소드를 호출하여 attrubute에 할당한 후 attribute를 리턴하는 것을 볼 수 있습니다

그렇다면 createAttribute() 메소드에 가봅시다.

Reflection을 사용해서 해당 매개변수가 어느 클래스 타입인지 알아내고 해당 클래스를 getResolvableConstructor() 메소드의 인자로 넘겨 생성자를 받습니다.

그 생성자로 객체를 만들고 리턴하는 것을 볼 수 있습니다.

그러면 아마 범인은 저 getResolvableConstructor() 메소드일 것 같습니다. 해당 메소드로 가봅시다.

저희가 테스트했던 NoArgsAndAllArgsConstructor는 생성자가 2개이기 때문에 위의 if문과 else if문은 타지 않을 것입니다.

그렇다면 try 문을 실행하게 될텐데요, 위에 주석으로 '생성자가 여러 개면 기본 생성자로 하자' 라고 써있습니다.

(사실 엄격하게 말하면 제가 만든 생성자는 기본 생성자가 아닙니다. 단지 매개변수 없는 생성자입니다. 기본 생성자는 제가 아무런 생성자도 만들지 않았을 때 자동으로 만들어준 생성자를 말하는 것입니다.)

저 getDeclaredConstructor를 타고 들어가보면 너무 복잡하기 때문에 내부 구조를 파헤치는 것은 여기까지 하는 것으로 합시다..ㅎㅎ

(catch문에 Giving up... 이라고 써있는 거 너무 웃기네요..ㅎㅎ)

 

✅ 디버거를 통해 확인

디버거를 통해 정말 해당 로직을 타는지 확인해봅시다.

NoArgsAndAllArgsDto의 생성자가 2개라서 if와 else if 문을 안 타고 try문으로 오는 것을 확인할 수 있습니다.

getResolvableConstructor() 메소드 호출로 리턴 받은 생성자가 매개변수 없는 생성자임을 알 수 있다

getResolvableConstructor()의 호출하여 얻은 ctor가 매개변수 없는 생성자 NoArgsAndAllArgsDto()인 것을 볼 수 있습니다.

그러니까 AllArgsConstructor가 있는데도 NoArgsConstructor로 객체를 생성하고, Setter가 없어서 값을 세팅하지 못 한 것입니다.

 

✅ 정리

  • @ModelAttribute는 생성자가 1개면 그 생성자를 통해 객체를 생성합니다.
  • 생성자가 2개 이상이면 매개변수 없는 생성자를 통해 객체를 생성하고 Setter로 값을 세팅합니다.

✅ 결론

@ModelAttribute로 바인딩하려는 클래스에 NoArgsConstructor가 있다면 반드시 Setter를 만들어주세요!

아니면 NoArgsConstructor를 없애고 AllArgsConstructor만 두세요!

 

 

참고

https://steady-coding.tistory.com/489

728x90