[Spring] @ModelAttribute 처리 흐름

ArgumentResolver

Spring Framework에서 ArgumentResolver는 굉장히 중요한 핵심 개념이다

사용자의 Request가 들어오는 순간 처리 메커니즘을 간단하게 알아보자

1. 사용자의 Request가 DispatcherServlet으로 들어온다
2. DispatcherServlet은 들어온 Request를 처리할 수 있는 Handler를 조회
3. 조회한 Handler를 컨트롤할 수 있는 HandlerAdapter 조회
4. HandlerAdapter가 본인이 컨트롤하는 Handler를 invoke

 

4단계 :: Handler를 invoke하는 프로세스에서 ArgumentResolver가 활용된다

  • 이 말이 무슨 의미인지 정확하게 이해가 되지 않을 수 있으니 상세하게 풀어서 살펴보자

 

현재 RestController인 TestController에 Endpoint GET /hello가 존재한다

 

위의 과정에 대입해보면 다음과 같다

1. 사용자의 GET /hello가 DispatcherServlet으로 들어온다
2. DispatcherServlet은 들어온 Request를 처리할 수 있는 Handler를 조회
→ @GetMapping("/hello") invokeHello
3. 조회한 Handler를 컨트롤할 수 있는 HandlerAdapter 조회
→ RequestMappingHandlerAdapter
4. HandlerAdapter가 본인이 컨트롤하는 Handler를 invoke

 

여기서 RequestMappingHandlerAdapterinvokeHello를 invoke 하기 전에 Handler(invokeHello)가 Hello라는 Object Data를 활용할 수 있도록 @ModelAttribute Hello hello라는 파라미터를 정제하는 과정을 수행한다

 

HandlerMethodArgumentResolver

이 중에서 과연 위의 invokeHandler의 @ModelAttribute Hello hello를 정제할 수 있는 ArgumentResolver가 무엇인지 확인해보자

 

HandlerMethodArgumentResolverComposite - "getArgumentResolver"

무슨 ArgumentResolver가 선택될지 디버깅을 해보니 ServletModelAttributeMethodProcessor가 선택이 되었다

 

클래스 구조를 파악해보니 결국 @ModelAttribute에 대한 정제를 담당하는 ArgumentResolver는 ModelAttributeMethodProcessor임을 확인할 수 있다
  • 이 추정에 대한 확신을 가지기 위해서 디버깅을 통해서 실제로 ModelAttributeMethodProcessor로 흐름이 넘어가는지 확인해보자

 

ModelAttributeMethodProcessor로 넘어옴을 디버깅을 통해서 확인할 수 있다

 

 

ModelAttributeMethodProcessor

ModelAttributeMethodProcessor@ModelAttribute에 대한 처리 메커니즘을 가진다는 것을 위의 디버깅 과정을 통해서 확인하였다

그러면 이제 ModelAttributeMethodProcessor의 내부 동작 메커니즘을 확인해보자

 

supportsParameter

일단 @ModelAttribute라는 바인딩 애노테이션이 어떤 파라미터 타입에 적용되는지 살펴보자

  • 코드를 보면 !BeanUtils.isSimpleProperty라는 부분을 확인할 수 있다

따라서 @ModelAttribute는 위의 코드에서 볼 수 있는 타입이 아니면 적용될 수 있는 것이다

 

resolveArgument

어떤 파라미터에 적용 가능한지 확인을 하였으니 이제 resolveArgument의 동작 흐름을 알아보자

resolveArgument에 진입하게 되면 파라미터 이름, ModelAttribute 속성, ...등 여러가지 정보를 얻는다

 

그리고 가장 핵심적인 createAttribute부분으로 진입하게 된다

  • resolveArgument의 최종 응답 값은 attribute이다
  • 따라서 우리는 createAttribute를 통해서 Hello hello라는 Object에 RequestData가 Binding됨을 추측할 수 있다

 

 

resolveArgument의 세부적인 흐름 파악해보기

1. ServletModelAttributeMethodProcess - "createAttribute"

createAttribute의 getRequestValueForAttribute의 값이 null로 도출됨에 따라 super.createAttribute로 흐름이 넘어가게 된다

 

  • 이 구조를 통해서 super.createAttribute는 결국 ModelAttributeMethodProcessor로 넘어감을 확인할 수 있다

 

2. ModelAttributeMethodProcess - "createAttribute"

GET /hello : invokeHello의 파라미터인 Hello hello에 대한 생성자를 얻는듯한 로직을 볼 수 있다

 

@Getter
@AllArgsConstructor
static class Hello {
    private Long id;
    private String name;
}

현재 Hello는 위와 같이 구성되어 있고 위의 로직을 통해서 어떠한 생성자를 얻는지 확인해보자

  • 사실 당연히 @AllArgsConstructor가 존재하므로 public Hello(Long id, String name)을 얻을거라고 예상된다
    • 추가적으로 자바 기본 상식이지만 @AllArgsConstructor에 의해 생성자가 이미 존재하기 때문에 컴파일러가 기본 생성자는 만들어주지 않는다

 

3. BeanUtils - "getResolvableConstructor"

예상한대로 public Hello(Long id, String name) 생성자를 가져옴을 확인할 수 있다

 

4. ModelAttributeMethodProcess - "constructAttribute"

파라미터 Hello의 선언된 생성자를 얻어내고 constructAttribute흐름으로 넘어가보자

  • constructAttribute에서 요청으로 들어온 데이터들의 Binding이 이루어지는 느낌이다

 

현재 얻은 Hello의 생성자는 Long id, String name 총 2개의 파라미터를 보유하고 있기 때문에 위의 if 로직에는 포함되지 않는다

 

계속해서 다음 코드들을 살펴보자

  • 위의 코드들은 Binding을 위한 기본적인 프로세스를 진행한다고 생각하면 된다

 

  • 현재 요청은 http://localhost:8080/hello?id=1&name=spring형식으로 진행되고 있다

 

일련의 과정을 통해서 Object[] args현재 요청으로 들어온 데이터들을 담았고 이제 BeanUtils.instantiateClass로 흐름을 넘겨보자

 

5. BeanUtils - "instantiateClass"

첫번째로 일단 public Hello(Long id, String name)에 접근 가능하도록 ReflectionUtils를 통해서 설정해주었다

 

결정적인 코드가 도출되었다

 

@Getter
@AllArgsConstructor
static class Hello {
    private Long id;
    private String name;
}

현재 Hello Class의 구성은 위와 같고 @AllArgsConstructor을 가지고 있다

 

이에 따라서 BeanUtils의 instantiateClass에서는 생성자 : public Hello(Long id, String name) / 요청 데이터 : [id: 1, name: "spring"]을 이용하여 Constructor의 newInstance을 호출한다

Constructor의 newInstance를 통해서 완성된 Hello(id: 1, name: "spring") 인스턴스가 만들어진다

 

간단한 Reflection Test

@Test
void test() throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
    Object[] argsWithDefaultValues = {12345L, "Hello Spring World"};
    
    Constructor<TestController.Hello> constructor = TestController.Hello.class.getConstructor(Long.class, String.class);
    constructor.setAccessible(true);
    TestController.Hello helloWorld = constructor.newInstance(argsWithDefaultValues);

    System.out.println(helloWorld.getId());
    System.out.println(helloWorld.getName());
    assertThat(helloWorld.getId()).isEqualTo(12345L);
    assertThat(helloWorld.getName()).isEqualTo("Hello Spring World");
}

 

6. 결론

ModelAttributeMethodProcessor의 createAttribute는 Hello(id: 1, name: "spring")를 정제된 결과로써 받고 ArgumentResolver의 resolveArgument의 결과는 Hello(id: 1, name: "spring")로 마무리된다

 

 

의문점

하지만 여기서 다음과 같은 의문이 들 수 있다

어떤 블로그에서는 @ModelAttribute로 바인딩할때 setter가 없으면 안된다던데?
  • 이미 위에서 이런 의견에 대한 반례를 보였다

 

요청으로 들어온 데이터에 대한 DTO Binding을 다음 예시 상황을 통해서 알아보자

1. Default Constructor + getter + setter

@Getter
@Setter
static class Hello {
    private Long id;
    private String name;
}

일단 DTO에 기본 생성자 + setter가 존재할 경우 진행되는 프로세스는 다음과 같다

  • 여기서 @NoArgsConstructor가 없는데 기본 생성자가 어디있냐?라고 생각하는 사람 또한 존재할 수 있다
    • 위에서도 말했듯이 컴파일러는 클래스 내부적으로 생성자가 없을 경우 파라미터가 없는 기본 생성자 : public Hello(){}를 만들어준다

 

표시된 해당 블럭에서 ClassType에 대한 인스턴스를 생성한다

  • 물론 Object 내부 필드들은 ReferenceType = null / PrimitiveType = default value를 가지게 된다

 

그 이후 DataBinder에 의해서 값들이 binding되는것이다

1. 기본 생성자에 의해서 Hello (id: null, name: null) 객체 생성
2. DataBinder의 setValue를 통해서 setId, setName -> 완전한 Hello (id: 1L, name: "spring") 객체 생성

 

2. AllArgsConstructor + getter + setter

상세한 프로세스는 위에서 이미 디버깅을 통해서 확인하였기 때문에 넘어가고 중요한 부분만 살펴보자

모든 필드를 갖는 생성자가 존재하기 때문에 위의 로직 상에서 BeanUtils.instantiateClass를 통해서 Hello(id: 1, name: "spring) 객체가 탄생한다

 

그 이후 ModelAttributeMethodProcessor의 resolveArgument로 흐름이 넘어와서 진행되다가 bindRequestParameters를 호출함을 확인할 수 있다

 

마찬가지로 DataBinder로 들어와서 setter로 Binding됨을 확인할 수 있다

1. @AllArgsConstructor, Constructor.newInstance(args...)를 통해서 Hello (id: 1L, name: "spring") 객체 생성
2. DataBinder의 setValue를 통해서 setId, setName -> Hello (id: 1L, name: "spring")
→ 단순하게 객체 내부 필드 값을 덮어씌우는 것이다

 

3. AllArgsConstructor + getter

모든 필드가 있는 생성자만 존재하고 setter가 없는 상황은 위에서 디버깅을 통해서 파악을 하였다

@AllArgsConstructor, Constructor.newInstance(args...)를 통해서 Hello (id: 1L, name: "spring") 객체 생성

 

그런데 여기서 추가적으로 확인할 부분은 다음과 같다

  1. setter가 없는데 DataBinder로 흐름이 넘어가는지
  2. 만약 DataBinder로 흐름이 넘어간다면 어떤 형식으로 Binding되는지

결론적으로 말하면 DataBinder의 setValue로 흐름이 넘어가지 않는다

  • 그 이유는 위의 코드에서 보이는 PropertyHandler의 특정 속성 때문이다

 

setter 존재 O

@Getter
@Setter
@AllArgsConstructor
static class Hello {
    private Long id;
    private String name;
}

setter가 열려있기 때문에 PropertyHandler의 writable값이 true이고 그에 따라서 DataBinder의 setValue 부분으로 흐름이 넘어간다

 

setter 존재 X

@Getter
@AllArgsConstructor
static class Hello {
    private Long id;
    private String name;
}

setter가 닫혀있기 때문에 PropertyHandler의 writable값이 false이고 그에 따라서 DataBinder의 setValue 부분으로 흐름이 넘어가지 않는다

 

4. Default Constructor + getter

그러면 기본 생성자만 존재하고 setter가 없을 경우 어떤 상황이 벌어질지 한번 결과로 살펴보자

@Getter
static class Hello {
    private Long id;
    private String name;
}

Hello의 모든 필드가 null로 바인딩 됨을 확인할 수 있다

 

이 이유는 다음 흐름을 통해서 알아보자

  • 일단 createAttribute에서 Hello는 현재 기본 생성자만 보유하고 있기 때문에 Hello(id: null, name: null)을 반환하게 된다

 

  • 그 이후 resolveArgument 프로세스로 돌아와서 bindRequestParameters 흐름으로 넘어간다

 

바로 이 시점에서 위의 실험 결과에 따라 setter가 존재하지 않기 때문에 PropertyHandler의 writable값은 false로 설정되고 그에 따라서 DataBinder의 setValue로 흐름이 넘어가지 않는 것이다

 

  • 그 결과로 결국 resolveArgument의 최종 결과값은 값이 바인딩 되지 않은 Hello(id: null, name: null)가 되는 것이다

 

5. Default Constructor + AllArgsConstructor

과연 기본 생성자 + 모든 필드를 가지는 생성자가 동시에 존재할 경우 어떤 프로세스로 진행될지 한번 살펴보자

@Getter
@NoArgsConstructor
@AllArgsConstructor
static class Hello {
    private Long id;
    private String name;
}

보이는 결과로 알 수 있듯이 생성자가 여러개 존재하면 기본 생성자가 선택된다

 

 

@ModelAttribute 처리 흐름 결론

위의 여러 디버깅 및 실험을 통해서 @ModelAttribute 처리 흐름은 다음과 같이 구조화시킬 수 있다

1. @Getter 필수
2. @Getter + @Setter / @Getter + @AllArgsConstructor 조합이면 정상적으로 바인딩
3. 기본 생성자만 존재 or @NoArgsConstructor + @AllArgsConstructor 조합이면 setter는 강제적이다

 

1. 정상 바인딩 조합

@Getter + @Setter
@Getter + @AllArgsConstructor
@Getter + @Setter + @NoArgsConstructor
@Getter + @Setter + @AllArgsConstructor
@Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor

 

2. null 바인딩 조합

@Getter
@Getter + @NoArgsConstructor
@Getter + @NoArgsConstructor + @AllArgsConstructor

 

3. Resolved ~~ Could not find acceptable representation 예외 발생 조합

@Getter가 없는 모든 상황