본문 바로가기
Spring & JPA

[Spring MVC] @ModelAttribute 데이터 바인딩

by yhames 2024. 7. 15.
728x90

문제점

스프링을 처음 사용하게 되면 올바르게 요청을 보내도 데이터가 제대로 매핑되지 않아서 null값이 들어오는 경우를 만나게 됩니다.
 
대부분의 경우에 setter를 추가하게 되면 문제가 해결되지만, 객체지향적인 관점에서 setter는 지양해야하기 때문에 프로젝트에 setter를 작성하지 않는 규칙을 적용하는 경우가 있습니다.또한 기본 생성자 외에 다른 적절한 생성자가 있는 경우에도 데이터 바인딩이 제대로 되지 않는 경우도 있습니다.
 
여기서는 객체를 생성하고 초기화하는 경우의 수를 3가지로 나눠서 알아보고, 어떻게 작성하는 것이 좋을지 고민해보려고 합니다.

@ModelAttribute란?

@ModelAttribute를 사용하면 쿼리 파라미터 혹은 HTML Form를 통해 전달되는 파라미터를 객체에 매핑합니다. 이를 "데이터 바인딩"이라고 하며 HTTP 요청을 매번 파싱해야하는 번거로움을 줄여줍니다.
 

@PostMapping("/board/write")
public String write(@ModelAttribute BoardVO boardVO) {	// HERE!
	boardService.write(boardVO);
	return "redirect:/board/list";
}

 
위와 같이 컨트롤러에 @ModelAttribute를 사용하면 자동으로 데이터를 객체에 매핑하게 됩니다.
 

@ModelAttribute 동작 원리

@ModelAttribute는 다음의 3단계로 진행됩니다.
 

  1. 객체 생성 및 초기화 (Creation)
  2. 데이터 바인딩 (Data Binding)
  3. 검증 (Validation)

 

@ModelAttribute는 기본 생성자를 사용하여 객체를 생성하고, 프로퍼티 접근법을 사용하여 데이터를 바인딩합니다. 따라서 데이터 바인딩을 하기 위해서는 반드시 생성자와 getter/setter 메서드를 정의해야합니다. 또한 기본 생성자 외에 다른 적절한 생성자가 있다면 그것을 사용하여 객체를 생성 및 초기화하게 됩니다.
 

 

ModelAttributeMethodProcessor.createAttribute()

다음은 @ModelAttribute 어노테이션을 사용했을 때 실행되는 코드입니다.

// org.springframework.web.method.annotation.ModelAttributeMethodProcessor

protected Object createAttribute(String attributeName, MethodParameter parameter,
								 WebDataBinderFactory binderFactory, NativeWebRequest webRequest) throws Exception {
	MethodParameter nestedParameter = parameter.nestedIfOptional();
	Class<?> clazz = nestedParameter.getNestedParameterType();

	Constructor<?> ctor = BeanUtils.getResolvableConstructor(clazz);	// HERE!
	Object attribute = constructAttribute(ctor, attributeName, parameter, binderFactory, webRequest);	// HERE!
	if (parameter != nestedParameter) {
		attribute = Optional.of(attribute);
	}
	return attribute;
}

 
위 코드를 보시면 BeanUtils.getResolvableConstructor() 메서드를 호출하여 생성자를 가져오고, constructAttribute() 메서드를 호출하여 객체를 생성한다는 것을 알 수 있습니다.
 

BeanUtils.getResolvableConstructor()

그럼 먼저 BeanUtils.getResolvableConstructor()를 알아보겠습니다.

// org.springframework.beans.BeanUtils.getResolvableConstructor()

public static <T> Constructor<T> getResolvableConstructor(Class<T> clazz) {
	Constructor<T> ctor = findPrimaryConstructor(clazz);
	if (ctor != null) {
		return ctor;
	}

	Constructor<?>[] ctors = clazz.getConstructors();
	if (ctors.length == 1) {
		// A single public constructor
		return (Constructor<T>) ctors[0];
	}
	else if (ctors.length == 0) {
		// No public constructors -> check non-public
		ctors = clazz.getDeclaredConstructors();
		if (ctors.length == 1) {
			// A single non-public constructor, e.g. from a non-public record type
			return (Constructor<T>) ctors[0];
		}
	}

	// Several constructors -> let's try to take the default constructor
	try {
		return clazz.getDeclaredConstructor();
	}
	catch (NoSuchMethodException ex) {
		// Giving up...
	}

	// No unique constructor at all
	throw new IllegalStateException("No primary or single unique constructor found for " + clazz);
}

 
위 코드를 보시면 먼저 findPrimaryConstructor()를 호출하는데, 이는 Kotlin 관련 메서드인 것 같습니다.
아래로 내려가면 clazz.getConstructors()를 통해 생성자를 가져오는 것을 볼 수 있습니다.
 
이때 생성자의 개수에 따라 3가지 경우의 수로 나뉘게 됩니다.

  • public 생성자가 1개인 경우 해당 생성자를 반환합니다.
  • public 생성자가 없고, non-public 생성자가 1개인 경우 해당 생성자를 반환합니다.
  • 생성자가 2개 이상이라면 기본 생성자(default constructor)를 반환합니다.

즉, 아무리 적절한 생성자가 있더라도 기본 생성자가 있다면 기본 생성자가 반환됩니다.
 

ModelAttributeMethodProcessor.constructAttribute()

다음으로 constructAttribute() 메서드를 보겠습니다.

// org.springframework.web.method.annotation.ModelAttributeMethodProcessor

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);
	// ...

 
위 코드를 보면 생성자에 매개변수가 없는 경우, 즉 기본 생성자인 경우 BeanUtils.instantiateClass()를 호출하여 객체를 생성합니다.
 
만약 매개변수가 있다면 각 필드에 맞는 매개변수를 찾아서 객체를 생성하게 됩니다. 여기서는 리플렉션을 사용해서 데이터를 바인딩하고, 생성자를 사용하여 객체를 생성하기 때문에 setter가 없어도 바인딩이 가능합니다.
 

BeanUtils.instantiateClass() 

public static <T> T instantiateClass(Constructor<T> ctor, Object... args) throws BeanInstantiationException {
    Assert.notNull(ctor, "Constructor must not be null");
    try {
       ReflectionUtils.makeAccessible(ctor);
       if (KotlinDetector.isKotlinReflectPresent() && KotlinDetector.isKotlinType(ctor.getDeclaringClass())) {
          return KotlinDelegate.instantiateClass(ctor, args);
       }
       else {
          Class<?>[] parameterTypes = ctor.getParameterTypes();
          Assert.isTrue(args.length <= parameterTypes.length, "Can't specify more arguments than constructor parameters");
          Object[] argsWithDefaultValues = new Object[args.length];
          for (int i = 0 ; i < args.length; i++) {
             if (args[i] == null) {
                Class<?> parameterType = parameterTypes[i];
                argsWithDefaultValues[i] = (parameterType.isPrimitive() ? DEFAULT_TYPE_VALUES.get(parameterType) : null);	// HERE!!
             }
             else {
                argsWithDefaultValues[i] = args[i];
             }
          }
          return ctor.newInstance(argsWithDefaultValues);
       }
    }
    // ...
}

 
BeanUtils.instantiateClass() 메서드의 경우에는 primitive 타입이 아닌 경우 null로 할당되고, 이후에 resolveArgument() 메서드를 호출하여 데이터를 바인딩합니다.
 

ModelAttributeMethodProcessor.resolveArgument()

public final Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
			NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
	if (bindingResult == null) {
		// Bean property binding and validation;
		// skipped in case of binding failure on construction.
		WebDataBinder binder = binderFactory.createBinder(webRequest, attribute, name);
		if (binder.getTarget() != null) {
			if (!mavContainer.isBindingDisabled(name)) {
				bindRequestParameters(binder, webRequest);	// HERE!!
			}
			validateIfApplicable(binder, parameter);
			if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
				throw new BindException(binder.getBindingResult());
			}
		}
		// Value type adaptation, also covering java.util.Optional
		if (!parameter.getParameterType().isInstance(attribute)) {
			attribute = binder.convertIfNecessary(binder.getTarget(), parameter.getParameterType(), parameter);
		}
			bindingResult = binder.getBindingResult();
	}
}

 
resolveArgument()에서는 bindRequestParameters() 메서드를 통해 WebDataBinder의 bind() 메서드에 바인딩을 위임합니다. 그리고 계속 따라가다 보면 DataBinder.applyPropertyValues()가 호출되는 것을 볼 수 있습니다.
 

DataBinder.applyPropertyValues()

protected void applyPropertyValues(MutablePropertyValues mpvs) {
    try {
       // Bind request parameters onto target object.
       getPropertyAccessor().setPropertyValues(mpvs, isIgnoreUnknownFields(), isIgnoreInvalidFields());
    }
    catch (PropertyBatchUpdateException ex) {
       // Use bind error processor to create FieldErrors.
       for (PropertyAccessException pae : ex.getPropertyAccessExceptions()) {
          getBindingErrorProcessor().processPropertyAccessException(pae, getInternalBindingResult());
       }
    }
}

 
applyPropertyValues() 메서드를 보시면 PropertyAccessor를 가져온 후 setPropertyValues()를 호출하여 값을 할당합니다. 즉, setter를 가져와서 값을 할당하는 것입니다.
 

정리

 
위 흐름을 정리해보면 다음과 같은 세 가지 경우로 정리할 수 있을 것 같습니다.

  • 기본 생성자만 있는 경우
    • setter를 사용하여 데이터 바인딩 → setter 필요
  • 다른 생성자만 있는 경우
    • 리플렉션을 사용하여 데이터 바인딩
  • 여러 성생자가 있는 경우
    • 기본 생성사를 사용하여 객체를 생성 → setter 필요

 
여기까지 @ModelAttribute에 대해서 정리해보았습니다.
 
객체지향적인 관점에서 setter는 지양되기 때문에 @ModelAttribute를 사용할 때는 기본 생성자 없이 사용하는 것이 좋을 것 같습니다.
또한 Jaskson 라이브러리의 경우 기본 생성자가 있어도 setter 없이 매핑이 가능하기 때문에 @RequestBody를 고려할 수도 있습니다.

반응형