실무중에 클라이언트에서 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 로 가변으로두고 빈 리스트로 초기화해둔다는것이 명시적으로 끝에 [] 가 붙는다는걸 의미하지 않는다.
이번 포스트는 여기서 마무리하고 불변으로 쓸 방법이 떠오르면 또 글을 써봐야겠다. 당장 생각 나는건 어노테이션을 따로 만들어서 어찌저찌 할수 있지 않을까 생각이든다.(스프링은 참 어려워)
혹시라도 좋은 방법이나 틀린정보같은게 있다면 피드백 바랍니다(_ _)