Spring

@ModelAttribute와 @RequestBody 그리고 Setter

민철킹 2021. 11. 24. 17:02

@RequestBody와 Setter

본 주제에 대해 이야기하기 전에 먼저 @ModelAttribute에 대해 이야기해보려합니다.

 


@ModelAttribute

 

우리는 Spring에서 Reqeust Parameter를 얻기 위해 @ModelAttribute를 사용하곤합니다.

 

 

값을 바인딩하여 우리가 원하는 객체로 변환해주는 역할을 하는데

image

아래와 같이 형식에 맞춰 값이 넘어오면 원하는 객체로 손쉽게 변환할 수 있다는 큰 장점이 있습니다.

물론 타입이나 형식이 안맞으면 TypeConverter에서 예외가 발생함

 

 

image

값을 바인딩하길 원하는 객체인 RequestDtonameage필드 두가지를 가지고 있는데

 

실제 요청을 postman을 사용해 html-form 형식으로 전달해보겠습니다.(query parameter도 결과는 동일하다.)

 

image

 

image

 

디버거를 통해 breakpoint를 찍어보면 값이 전혀 찍히지 않았고 당연히 로그에도 null이 출력되는 것을 확인할 수 있

습니다.

 

 

사실, Spring을 좀 써본 사람이라면 문제가 무엇인지 다 알고 있을 것입니다.

 

@ModelAttribute는 값을 객체로 바인딩할 때 프로퍼티 접근법을 사용!!

  1. 해당 객체를 생성(기본 생성자)
  2. 프로퍼티 접근법인 setter를 사용하여 넘어온 값을 객체에 주입

 

보이진 않지만 Spring MVC는 아래와 같은 동작을 하고 있을 것입니다.

RequestDto dto = new RequestDto();
dto.setName(값);
dto.setAge(깂);

 

하지만 위의 RequestDto에는 기본 생성자는 존재하지만 setter가 존재하지않으므로 값을 바인딩하지 못해 null이 반환된 것이죠.

 

 

image

setter만 추가하주면 정상적으로 값이 바인딩됩니다.(기본 생성자는 자바가 알아서 만들어줌)

 

image

하지만, @Setter를 사용하는 것이 뭔가 꺼려지고 그러면 안될 것 같은 기분이 들 수 있는데(사실 나임)

 

 

그럴 땐, 모든 필드를 매개변수로 받는 생성자를 만들면됩니다..(@AllArgsConstructor)

 

저는 정적 팩토리 메서드를 사용하여 이를 만들어봤는데 setter가 없더라도 정상적으로 값이 바인딩되는 것을 확인할 수 있었습니다.

image

아마 setter가 존재하지않을때 요청을 통해 넘어온 값을 바로 넣어 Spring MVC가 생성해주는 것이 아닐까 생각됩니다.

RequestDto dto = new RequestDto(name, age);

 

이렇게!

 

 

@ModelAttribute 추가

어제 위 부분에 대한 궁금증으로 대략 2~3시간 동안 디버거 모드로 찍어보고 Spring MVC의 내부 코드를 뜯어보며 분석을 좀 해봤습니다.

 

뜯다보니 오랜만에 ArgumentResolver도 보이고해서 전체적인 개념을 다시 한번 잡을 수 있었는데

 

제가 알게된 점은 다음과 같습니다.

 

 

@ModelAttribute를 처리하기 위해서는 ModelAttributeMethodProcessor라는 ArgumentResolver를 사용합니다.

image

 

 

크게 역할은 값을 바인딩하는 역할과 값을 검증(bindingResult)하는 역할을 합니다.

 

내부를 살펴보니 가장 핵심 메소드는 createAttributeconstructAttribute였습니다.

createAttribute

image

javaDoc에 잘 설명이되어 있는데 기본적으로 기본 생성자인 NoArgsConstructor를 사용하지만 적절한 생성자가 존재

한다면 그것을 통해 객체를 생성합니다.

 

그 후, setter를 통해 값을 주입하는 방법을 사용하는 것이죠.

 

constructAttribute

image

접기/펼치기
    protected Object constructAttribute(Constructor<?> ctor, String attributeName, MethodParameter parameter,
            WebDataBinderFactory binderFactory, NativeWebRequest webRequest) throws Exception {

        if (ctor.getParameterCount() == 0) {
            // A single default constructor -> clearly a standard JavaBeans arrangement.
            return BeanUtils.instantiateClass(ctor);
        }

        // A single data class constructor -> resolve constructor arguments from request parameters.
        String[] paramNames = BeanUtils.getParameterNames(ctor);
        Class<?>[] paramTypes = ctor.getParameterTypes();
        Object[] args = new Object[paramTypes.length];
        WebDataBinder binder = binderFactory.createBinder(webRequest, null, attributeName);
        String fieldDefaultPrefix = binder.getFieldDefaultPrefix();
        String fieldMarkerPrefix = binder.getFieldMarkerPrefix();
        boolean bindingFailure = false;
        Set<String> failedParams = new HashSet<>(4);

        for (int i = 0; i < paramNames.length; i++) {
            String paramName = paramNames[i];
            Class<?> paramType = paramTypes[i];
            Object value = webRequest.getParameterValues(paramName);

            // Since WebRequest#getParameter exposes a single-value parameter as an array
            // with a single element, we unwrap the single value in such cases, analogous
            // to WebExchangeDataBinder.addBindValue(Map<String, Object>, String, List<?>).
            if (ObjectUtils.isArray(value) && Array.getLength(value) == 1) {
                value = Array.get(value, 0);
            }

            if (value == null) {
                if (fieldDefaultPrefix != null) {
                    value = webRequest.getParameter(fieldDefaultPrefix + paramName);
                }
                if (value == null) {
                    if (fieldMarkerPrefix != null && webRequest.getParameter(fieldMarkerPrefix + paramName) != null) {
                        value = binder.getEmptyValue(paramType);
                    }
                    else {
                        value = resolveConstructorArgument(paramName, paramType, webRequest);
                    }
                }
            }

            try {
                MethodParameter methodParam = new FieldAwareConstructorParameter(ctor, i, paramName);
                if (value == null && methodParam.isOptional()) {
                    args[i] = (methodParam.getParameterType() == Optional.class ? Optional.empty() : null);
                }
                else {
                    args[i] = binder.convertIfNecessary(value, paramType, methodParam);
                }
            }
            catch (TypeMismatchException ex) {
                ex.initPropertyName(paramName);
                args[i] = null;
                failedParams.add(paramName);
                binder.getBindingResult().recordFieldValue(paramName, paramType, value);
                binder.getBindingErrorProcessor().processPropertyAccessException(ex, binder.getBindingResult());
                bindingFailure = true;
            }
        }

        if (bindingFailure) {
            BindingResult result = binder.getBindingResult();
            for (int i = 0; i < paramNames.length; i++) {
                String paramName = paramNames[i];
                if (!failedParams.contains(paramName)) {
                    Object value = args[i];
                    result.recordFieldValue(paramName, paramTypes[i], value);
                    validateValueIfApplicable(binder, parameter, ctor.getDeclaringClass(), paramName, value);
                }
            }
            if (!parameter.isOptional()) {
                try {
                    Object target = BeanUtils.instantiateClass(ctor, args);
                    throw new BindException(result) {
                        @Override
                        public Object getTarget() {
                            return target;
                        }
                    };
                }
                catch (BeanInstantiationException ex) {
                    // swallow and proceed without target instance
                }
            }
            throw new BindException(result);
        }

        return BeanUtils.instantiateClass(ctor, args);
    }

createAttribute가 적절한 생성자를 찾고 constructAttribute를 통해 해당 생성자로 새로운 객체 인스턴스를 생성하는 구조입니다.

 

 

적절한 생성자를 찾기 때문에 매우 다양한 조합이 가능해집니다.

@Getter
@Setter
public class RequestDto() {
  private String name;
  private Long age;

  public RequestDto(String name) {
    this.name = name;
  }
}

위와 같은 경우에는 name을 받는 생성자를 통해 객체를 생성하고 setAge를 통해 age값을 바인딩할 것입니다.

 

물론, 위에서 언급했듯이 AllArgsConstructor또한 정상적으로 값을 바인딩할 수 있습니다!

 

결론은 적절한 생성자를 먼저 찾고 그 뒤에 바인딩되지 않은 값을 setter를 통해 바인딩해주는 순서로 @ModelAttribute는 동작합니다.




@RequestBody

그럼 원래 주제로 돌아가 @RequestBody는 값을 어떻게 바인딩할까??

 

@ModelAttribute와 값을 바인딩한다는 관점에서는 동일하지만 이는 HTTP Message Body를 읽는다는 점에서 다릅니다.

 

대체로 JSON을 통해 REST API로 애플리케이션을 구성하게 된다면 가장 많이 쓰게될 것인데,

이는 @ModelAttribute처럼 생략을 할 수 없습니다.

 

 

setter를 없애고 JSON으로 요청을 전달해보겠습니다.

imageimage

@RequestBody용 컨트롤러를 하나 생성하고 ReqeustDto는 필드와 getter만 남긴 채 모두 주석처리하였습니다.

{
    "name": "민철",
    "age": "25"
}

JSON 형식으로 Body에 담아 요청을 전송해보겠습니다.

image

 

어랍쇼??😳

 

 

맞습니다. @RequestBody를 사용하게되면 setter는 전혀 필요없습니다!

 

그 이유는 간단합니다.

 

 

HTTP Message Body를 읽기 위해서 SpringHTTP Message Converter를 사용합니다.

 

HTTP Message Converter를 구현한 Converter중에 넘어온 값을 읽을 수 있는 Converter를 찾습니다.

image

 

읽을 수 있는 Converter가 존재한다면 read메서드를 사용하여 값을 읽습니다.

image

앞선 예시에서 저는 JSON형식으로 값을 전달하였는데, 여기에는 Jackson2HttpMessageConverter 가 사용됩니다.

 

그리고 JSONJava객체와의 변환은 ObjectMapper를 사용하는데, Jackson2HttpMessageConverter에서도 마

찬가지입니다.

 

그러니 setter는 전혀 필요하지 않는 것입니다.

 

image

 

Jackson2HttpMessageConverter의 내부 구현을 살펴보면 read()메서드가 존재하고 그 안에서 readJavaType을 호출합니다.

image

 

그리고 호출된 readJavaType메서드는 ObjectMapper를 사용하여 값을 읽는 것을 확인할 수 있습니다!

반응형

'Spring' 카테고리의 다른 글

HttpMessageConverter 우선순위(feat. AWS S3)  (2) 2023.10.31
yml에서 List Object 사용법  (0) 2022.04.29
Spring과 Redis를 연동하여 Session Clustering  (0) 2021.11.19
패키지 구조 설계  (0) 2021.07.17
Thymeleaf와 Spring  (0) 2021.07.04