Spring form-url-encoded 방식의 파라미터에 배열이 대괄호로 표기될때 NPE 발생 해결법

실무중에 클라이언트에서 request를 form-url-encoded 방식으로 보내는데 그중에 배열의 표기가 [ ] 를 사용할때 매핑에러가 났었다

list[]=1&list[]=2 이런식으로 배열을 url 방식으로 표기할때 명확한 표준이 없는 것 같다. list=1&list=2도 가능하다

@RestController
class TestController {
    @PostMapping("/url-encode")
    fun urlEncode(body: TestBody): String {
        println(body)
        return "ok"
    }

    @PostMapping("/json")
    fun json(@RequestBody body: TestBody): String {
        println(body)
        return "ok"
    }
}

data class TestBody(
	val str: String,
	val num: Int,
	val list: List<Int>,
)

 

위와 같이 테스트코드를 작성했을때 json 방식은 잘된다.

 

그런데 url-encoded 방식으로 보낼때는 이렇게 list=1&list=2 방식으로 보내면 잘 동작한다.

 

하지만 아래처럼 list[]=1&list[]=2 방식으로 보내면 NPE 에러가난다. 일단 []를 매핑을 잘 못하는것 같다고 의심이된다.

 

 

java.lang.NullPointerException: Parameter specified as non-null is null: method com.example.learnmvc.TestBody.<init>, parameter list
	at com.example.learnmvc.TestBody.<init>(LearnMvcApplication.kt) ~[classes/:na]
	at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method) ~[na:na]
	at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:77) ~[na:na]
	at java.base/jdk.internal.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45) ~[na:na]
	at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:499) ~[na:na]
	at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:480) ~[na:na]
	at kotlin.reflect.jvm.internal.calls.CallerImpl$Constructor.call(CallerImpl.kt:41) ~[kotlin-reflect-1.8.22.jar:1.8.22-release-407(1.8.22)]
	at kotlin.reflect.jvm.internal.KCallableImpl.callDefaultMethod$kotlin_reflection(KCallableImpl.kt:188) ~[kotlin-reflect-1.8.22.jar:1.8.22-release-407(1.8.22)]
	at kotlin.reflect.jvm.internal.KCallableImpl.callBy(KCallableImpl.kt:111) ~[kotlin-reflect-1.8.22.jar:1.8.22-release-407(1.8.22)]
	at org.springframework.beans.BeanUtils$KotlinDelegate.instantiateClass(BeanUtils.java:892) ~[spring-beans-5.3.24.jar:5.3.24]
	at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:196) ~[spring-beans-5.3.24.jar:5.3.24]
	at org.springframework.web.method.annotation.ModelAttributeMethodProcessor.constructAttribute(ModelAttributeMethodProcessor.java:332) ~[spring-web-5.3.24.jar:5.3.24]
	at org.springframework.web.method.annotation.ModelAttributeMethodProcessor.createAttribute(ModelAttributeMethodProcessor.java:220) ~[spring-web-5.3.24.jar:5.3.24]

 

스택트레이스를 쭉 따라가다보면 ModelAttributeMethodProcessor 가 나오는데 공식문서에서 말하길

Resolve @ModelAttribute annotated method arguments and handle return values from @ModelAttribute annotated methods.

 

모델어트리뷰트 어노테이션을 처리한다고 한다. 그래서 constructAttribute 이쪽을 좀 디버깅해봤다. 

아래는 메서드의 일부를 뽑아온건데 dto의 생성자를 가지고 list라는 필드이름을 뽑아온후 request 객체에서 이름으로 값을 찾지만 list[] 이기 때문에 찾은값이 null 이여서 맨마지막 dto 클래스를 인스턴스화 하는 리턴문에서 예외가 터진다.

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

		...

		String[] paramNames = BeanUtils.getParameterNames(ctor); // 생성자에서 필드 이름 얻어옴 -> list
		Class<?>[] paramTypes = ctor.getParameterTypes(); // List
		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); // list 이름으로 찾음

			if (ObjectUtils.isArray(value) && Array.getLength(value) == 1) {
				value = Array.get(value, 0);
			}

			...
            
			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) {
				...
			}
		}

		...

		return BeanUtils.instantiateClass(ctor, args); // args 가 다 null임
	}
public static String[] getParameterNames(Constructor<?> ctor) {
		ConstructorProperties cp = ctor.getAnnotation(ConstructorProperties.class);
		String[] paramNames = (cp != null ? cp.value() : parameterNameDiscoverer.getParameterNames(ctor));
		Assert.state(paramNames != null, () -> "Cannot resolve parameter names for constructor " + ctor);
		Assert.state(paramNames.length == ctor.getParameterCount(),
				() -> "Invalid number of parameter names: " + paramNames.length + " for constructor " + ctor);
		return paramNames;
	}

 

보면 생성자로 필드명을가져올때 @ConstructorProperties 에 있는 값을 우선시하는걸 볼 수 있다. 이 부분은 공식문서에도 아래처럼 설명되어 있었다.(이게 이뜻이었구나...)

Instantiated through a “primary constructor” with arguments that match to Servlet request parameters. Argument names are determined through JavaBeans @ConstructorProperties or through runtime-retained parameter names in the bytecode.

 

그래서 아래처럼 TestBody 클래스에 명시적으로 어노테이션을 사용해서 지정해주면 잘 동작한다.

data class TestBody @ConstructorProperties("list[]") constructor(val list: List<Int>)

 

하지만 위 방식은 순서에 의존적이라 아래처럼 순서를 실수로 잘못쓴다면 또 에러가 발생하기때문에 그대로 실무에 쓰지는 못할 것 같다.

data class TestBody @ConstructorProperties("test", "list") constructor(
    val list: List<Int>, // => test 로 찾음
    val test: String, //    => list 로 찾음
)

 

공식문서를 보면 인스턴스를 만들고 WebDataBinder에 의해 바인딩된다하는데 아직 그 부분을 발견을 못한것 같아서 좀더 디버깅해봤다.

ModelAttributeMethodProcessor의 resolveArgument 메서드의 아래코드를 좀 더 살펴보면 createAttribute 통해서 위에 설명했던 어트리뷰트를 만드는 동작을하고 예외가 생기면 bindingResult를 변수에 담아둔다 그리고 예외가 없다면 bindRequestParameters 를 호출하며 바인딩을 한다.

import java.util.*

// Create attribute instance
try {
    attribute = createAttribute(name, parameter, binderFactory, webRequest)
} catch ( ex:org.springframework.validation.BindException){
    if (isBindExceptionRequired(parameter)) {
        // No BindingResult parameter -> fail with BindException
        throw ex
    }
    // Otherwise, expose null/empty value and associated BindingResult
    if (parameter.getParameterType() == Optional::class.java) {
        attribute = Optional.empty<Any>()
    } else {
        attribute = ex.getTarget()
    }
    bindingResult = ex.getBindingResult()
}

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); // 여기서 바인딩
		}
   .....중략

 

더 타고들어가면 WebDataBinder의 doBind 메서드에 가게되는데 여기서 adaptEmptyArrayIndices 메서드를 호출한다. 주석에도 []를 쓰는 몇몇 클라이언트를 지원하기 위해 만들었다 써져있다.

 list[]를 발견하면 []를 제외한 이름을 추출하고 dto의 해당 필드가 가변인지 isWritableProperty 를 통해서 확인하는것같다. 이후 MutablePropertyValues에서 list[]를 제거하고 list를 추가한다.

	/**
	 * Check for property values with names that end on {@code "[]"}. This is
	 * used by some clients for array syntax without an explicit index value.
	 * If such values are found, drop the brackets to adapt to the expected way
	 * of expressing the same for data binding purposes.
	 * @param mpvs the property values to be bound (can be modified)
	 * @since 5.3
	 */
	protected void adaptEmptyArrayIndices(MutablePropertyValues mpvs) {
		for (PropertyValue pv : mpvs.getPropertyValues()) {
			String name = pv.getName();
			if (name.endsWith("[]")) {
				String field = name.substring(0, name.length() - 2);
				if (getPropertyAccessor().isWritableProperty(field) && !mpvs.contains(field)) {
					mpvs.add(field, pv.getValue());
				}
				mpvs.removePropertyValue(pv);
			}
		}
	}

 

그래서 dto를 아래처럼 var로 선언해서 가변으로 바꿔주고 attribute에 list가 없어도 dto 객체가 생성될수 있게 초기화를 해주면 잘 동작하는 것을 볼 수 있다.

data class TestBody (
    var list: List<Int> = emptyList(),
    val test: String,
)

 

@ConstructorProperties 를 직접 써주는 방법은 너무 위험하기때문에 일단 이 방법으로 하고 있다. 

살짝 아쉬운점은

  • 누가 굳이 값을 바꾸지는 않겠지만 그래도 DTO 인데 가변으로 열어둬야 한다는것이 슬프다.
  • var 로 가변으로두고 빈 리스트로 초기화해둔다는것이 명시적으로 끝에 [] 가 붙는다는걸 의미하지 않는다.

 

이번 포스트는 여기서 마무리하고 불변으로 쓸 방법이 떠오르면 또 글을 써봐야겠다. 당장 생각 나는건 어노테이션을 따로 만들어서 어찌저찌 할수 있지 않을까 생각이든다.(스프링은 참 어려워)

 

혹시라도 좋은 방법이나 틀린정보같은게 있다면 피드백 바랍니다(_ _)