프로젝트 중 @ModelAttribute
로 바인딩하는 과정에서 생긴 문제가 있어 디버깅을 통해 원인을 파악하고 해결하였다.
문제
@RestController
@RequestMapping("/comics")
@RequiredArgsConstructor
public class ComicController {
@PostMapping
public ResponseEntity<Void> createComic(
ModelAttribute @Valid ComicCreateRequest comicCreateRequest
) {
comicService.createComic(comicCreateRequest, userAuth.loginId());
return ResponseEntity.ok(null);
}
}
위와 같이 @ModelAttribute
를 사용하여 클라이언트에서 요청을 받아 ComicCreateRequest
를 바인딩하고자 했다. 그리고 ComicCreateRequest
는 다음과 같다.
public record ComicCreateRequest(
@Length(min = 1, max = 30)
@NotBlank
String comicName,
@NotNull
Genre genre,
List<ThumbnailCreateRequest> thumbnailCreateRequests,
@Length(min = 1, max = 500)
@NotBlank
String summary,
@NotNull
PublishDayOfWeek publishDayOfWeek
) {
record ThumbnailCreateRequest(
ThumbnailType thumbnailType
) {}
}
record
클래스로 만들었고 내부에 List
를 가지고 있다. 그리고 ThumbnailCreateRequest
도 record
클래스이다.
Postman
을 사용하여 다음과 같이 요청을 보냈지만 바인딩에 실패하고 에러가 발생했다.
@ModelAttribute
로 "."
을 이용하여 중첩 프로퍼티를 바인딩 할 수 있으며 "[index]"
를 이용하여 배열에 바인딩 할 수 있다.
원인
우선 문제의 원인을 알기 위해 @ModelAttribute
가 요청을 어떻게 바인딩하는지 살펴봤다.
위와 같이 Member
클래스가 있고 name
과 age
필드를 가지고 있다. 이 두 값을 바인딩하기 위해서 포스트맨으로 multipart/form-data 형식으로 name
과 age
값을 요청으로 보냈다.
1. 기본생성자 + Setter
바인딩할 객체에 기본 생성자와 Setter
를 정의하였다.
우선 ModelAttributeMethodProcessor
의 createAttribute()
메소드를 호출한다. 그 다음clazz
에 바인딩하려는 객체의 타입을 넣고 ctor
에 해당 객체의 생성자를 넣는다.
그 다음 constructAttribute()
메소드를 수행하는데 기본 생성자라면 모든 필드값이 null
인 인스턴스를 반환하고 인자가 있는 생성자라면 요청값을 확인하여 요청값과 이름이 같은 필드를 바인딩한 인스턴스를 생성하여 반환한다. 예를 들어, Member
클래스에 @AllArgsConstructor
가 있고 요청에 name
과 age
가 정상적으로 보내졌다면 Member(name=XXX, age=YYY)
인 인스턴스가 반환된다.
현재 시점에서는 Member
에 기본 생성자밖에 없기 때문에 모든 필드가 null
인 인스턴스가 반환되었다.
attribute
에 Member(name=null, age=0)
인 인스턴스가 들어오고 객체를 바인딩 하기 위해 바인더를 생성한 뒤 bindRequestParameters()
를 호출한다.
쭉 따라들어가면 mpvs
를 만드는데 mpvs
는 사용자의 요청에 대한 정보인 PropertyValue
를 List
로 가지고 있다. (mpvs=[PropertyValue(name="name", value="Kong"), PropertyValue(name="age", value="21")])
그리고 DataBinder.doBind()
메소드를 호출하여 바인딩을 시작한다.
메소드를 진행하면 setPropertyValues()
를 호출하는데 여기서 mpvs
의 propertyList
를 하나씩 돌면서 setPropertyValue()
메소드를 실행한다.
AbstractNestablePropertyAccessor.setPropertyValue()
메소드는 nestedPa
변수에 값을 할당하는데 이 변수는 getPropertyAccessorForPropertyPath()
메소드를 호출함으로써 할당된다.
이 메소드는 요청으로 들어온 값에 .
이 있다면 중첩 프로퍼티 객체에 대한 바인딩 요청이기 때문에 요청을 적절히 파싱하여 AbstractNestablePropertyAccessor
를 반환하는 역할을 한다. 현재 PropertyValue(name="age", value="24")
를 탐색하고 있기 때문에 중첩 프로퍼티 객체가 없으므로 this
를 반환한다.
여기서 this
는 현재 메소드를 실행하는 AbstractNestablePropertyAccessor
추상클래스이고, 이 추상클래스는 바인딩하려는 객체를 wrappedObject
로 가지고 있다.
setPropertyValue()
를 호출하여 최종적으로 processLocalProperty()
메소드가 호출되는데, 여기서 PropertyHandler
는 현재 탐색하는 PropertyValue(name="age", value="24")
의 name
값(age
)과 같은 이름을 가진 바인딩 대상 객체(Member(name=null, age=0)
)의 필드를 찾아서 해당 필드에 대한 정보(getter, setter 유무, 타입 등)에 대한 정보를 저장한다.
여기서 만약, 현재 바인딩 대상 객체에 Setter
가 없다면 createNotWritablePropertyException
을 던지게되고 있다면 바인딩을 시작한다.
여기서 pv(PropertyValue(name="age", value="24"))
의 value
는 "24"
인데 바인딩 대상 객체(Member(name=null, age=0)
)의 age
는 int
이기 때문에 타입을 변환시켜줘야 한다.
따라서convertForProperty()
메소드를 호출하여 타입을 변환시키고 최종적으로 ph.setValue()
메소드를 호출하여 바인딩 대상 객체에 타입을 변환한 value
를 바인딩한다. (PropertyValue(name="name", value="Kong")
도 같은 방식으로 바인딩한다.)
2. AllArgsConstructor + Getter
모든 필드에 바인딩 될 값을 인자로 가지는 생성자와 Getter
가 있는 경우도 살펴보자. 사실상 record
타입과 같은 조건이다.
요청은 이전과 같은 값을 보냈다.
마찬가지로 ModelAttribute.createAttribute()
메소드를 호출한다. clazz
또한 Member
클래스이다.
차이점은 ctor
에 모든 인자를 갖는 생성자가 들어온다.
그리고 constructAttribute()
메소드가 호출된다.
이 전에 기본생성자만 있을 때는 null
값을 채운 인스턴스를 반환했지만 생성자에 파라미터가 하나라도 있으면 다르게 동작한다.
paramNames
에 생성자의 파라미터 이름을 담고 paramTypes
에 파라미터의 타입을 담는다.
paramNames = ["name", "age"]
paramType = ["String", "int"]
그리고 for문을 돌면서 생성자 파라미터의 이름과 일치하는 사용자 요청의 값을 찾는다.
이때 paramHashValues
에서 값을 가져오는데 이는 요청(request)에 있는 데이터의 이름과 정확히 일치하는 값을 가져온다.
결론적으로 value
에는 요청에서 name
이라는 키와 매칭되는 값인 Kong
이 들어간다.
그리고 MethodParameter
는 생성자의 파라미터에 대한 정보(이름, 타입 등)를 담고 있고 생성자의 인자 타입으로 변환이 필요하다면 타입을 변환하고 args[] 배열에 담는다.
for문을 다 돌면 args[]는 다음과 같이 값이 담긴다.
args = ["Kong", 24]
그리고 생성자와 args[]
에 담긴 값들로 인스턴스를 생성한다.
attribute = Member(name=Kong, age=24)
그리고 다시 ModelAttributeMethodProcessor
로 돌아와서 NoArgsConstructor
+ Setter
조합일 때와 똑같이 DataBinder.doBind()
메소드를 호출하여 바인딩을 시작한다. 즉, 이미 생성자를 통해서 바인딩된 필드도 setter가 있다면 다시 값을 덮어쓴다.
하지만 현재 Getter
+ AllArgsConstructor
이기 때문에 Setter
가 없다. 따라서 객체의 필드에 요청 값을 바인딩하는 setPropertyValue()
메소드를 따라가다보면 값을 바인딩 하지 않고 빠져나온다.
결론적으로 이 방법도 잘 바인딩 된다.
중첩 프로퍼티인 경우
현재까지만 살펴보면, 어떤 방법을 사용하든 @ModelAttribute
어노테이션을 사용하면 알아서 잘 바인딩해주는 것처럼 보인다. 하지만 객체 안에 또 다른 객체(중첩 프로퍼티 객체라고 부르겠다.)가 있는 경우 @ModelAttribute
로 바인딩 되지 않는 경우가 있다.
우선 다음과 같이 Address
클래스를 만들고 기본생성자와 Setter
를 정의한다.
그리고 Member
클래스 내부에 Address
를 필드로 갖고 있다. 이때 모든 필드값을 인자로 갖는 생성자, Getter
, Setter
를 모두 정의한다.
요청은 다음과 같이 보낸다. 이때 중첩 프로퍼티에 바인딩 할 값은 .
을 이용한다.
마찬가지로 ModelAttributeMethodProcessor.createAttribute()
메소드를 호출한다.
ctor
에는 AllArgsConstructor
가 들어오고 constructAttribute()
메소드가 호출된다.
constructorAttribute()
메소드 내부에서 생성자의 파라미터 이름을 for문으로 돌면서 요청과 매핑한다.
여기서 잘 보면 생성자의 파라미터와 요청으로 들어온 데이터의 값을 매핑하는 기준은 생성자 파라미터의 이름과 요청의 키값이다. 앞서 포스트맨으로 요청을 보낼 때 중첩 프로퍼티 객체의 필드 값을 바인딩하기 위해 address.city
라는 키값으로 요청을 보냈다. 그리고 생성자의 파라미터 이름은 address
이다. 따라서 바인딩하고자 했던 Member.Address.city
는 생성자를 통해 바인딩 되지 않는다.
보다시피 name
이 address
일 때 values
에는 null
값이 채워지는 것을 볼 수 있다.
결국 생성자를 통해 만들어진 인스턴스는 다음과 같다.
Member(name=Kong, age=24, address=null)
이런 중첩 프로퍼티 객체의 값은 Setter
를 통해 바인딩하는 로직에서 처리된다.
DataBinder.doBind()
메소드를 타고 들어가서
setPropertyValue()
메소드로 들어가 바인딩을 하게되는데
여기서 getPropertyAccessorForPropertyPath()
메소드를 들어가면
이전에 일반 객체를 처리할 때와 다르게 .
을 기준으로 address
와 city
를 나누고
이 address
값을 먼저 바인딩 하기 위한 로직들을 수행한다.
계속 따라 들어가면 getPropertyValue()
메소드가 나오는데 이 메소드를 살펴보면 address
라는 이름으로 PropertyHandler
값을 구했을 때 Getter
가 없다면, 즉 Member.getAddress()
가 없다면 NotReadablePropertyException
을 던지는 것을 확인할 수 있다.
즉, 중첩 프로퍼티 객체를 바인딩할 때 바깥 클래스에 Getter
가 꼭 필요한 것을 알 수 있다.
Getter
로 값을 꺼내고 value
에 넣는다.
이때 만약 value
가 null
이라면 setDefaultValue()
메소드를 호출한다. tokens
에는 현재 바인딩하려는 요청에 대한 정보가 들어있다.
setDefaultValue()
에서는 address
라는 이름에 해당하는 필드 타입(Address
)을 알아내고 Address
인스턴스를 생성한다.
위 코드를 보면 중첩 프로퍼티 객체는 기본생성자가 있어야 함을 알 수 있고 또한 기본생성자의 접근제어자가 private
이면 에러가 발생한다는 것을 알 수 있다.
여기서 생성한 인스턴스를 defaultValue
변수에 할당하고 해당 값으로 PropertyValue
를 만들어 반환한다.
이어서 setPropertyValue()
를 호출해서 Member.Address
에 PropertyValue(name=address, value=Address(city=null))
을 이용하여 실제 바인딩 작업을 거치는데 해당 코드를 보면 ph
는 Address
타입이라는 내용을 담고있고 ph
에 Setter
가 없다면 바인딩 작업 없이 return
을 반환하는 것을 알 수 있다.
즉, 중첩 프로퍼티 클래스의 바깥 객체(Member
)에는 Setter
가 필요한 것을 알 수 있다.
그리고 Setter
를 통해 Member
인스턴스에 Address
인스턴스를 바인딩한다.
바인딩에 성공했다면 다시 setDefaultValue()
로 돌아와서 getPropertyValue()
를 호출하는데 이는 Member
인스턴스에서 address
필드 값을 가져온다. 앞서 중첩 프로퍼티 객체 바인딩에 성공했으므로 defaultValue
에는 Address(city=null)
가 할당된다.
만약 defaultValue
가 null
이면 위 에러가 발생하게 된다.
그리고 내가 디버깅을 하게 만든 원인이었다. 즉, defaultValue
에 null
값이 들어갔기 때문에 발생했던 에러였는데 왜 null
값이 들어오냐면, setPropertyValue()
메소드를 호출해서 중첩 프로퍼티 객체에 값을 바인딩하려 해도 Setter
가 없어서 값을 바인딩하지 못했기 때문이다. 왜 Setter
를 정의하지 않았냐면, RequestDTO
로 record
타입을 사용했고 record
는 불변 객체이기 때문에 Setter
를 정의할 수 없었다. 따라서 이 문제를 해결하기 위해 record
타입을 사용하지 않고 일반 클래스로 RequestDTO
를 생성하고 Setter
를 정의해주면 된다.
그리고 defaultValue
에 왜 null
값이 오면 안되나 생각해 봤는데 개인적인 생각이지만 지금 우리는 Address.city에 서울이라는 값을 바인딩하고자 한다. 그런데 defaultValue
가 null
이라는 것은 Member.Address
가 null
이라는 것이기 때문에 Address.city
에 값을 바인딩하려고 Getter
를 호출하는 순간 NullPointerException
이 발생하기 때문인 것 같다.
실제로 코드를 계속 진행하면 기존까지 AbstractNestablePropertyAccessor nestedPa
의 wrapping object
가 Member
타입이었는데 이후에는 Address
타입으로 바뀐다.
그리고 여기서 address.city
에 값을 바인딩 해줄때 Setter
를 이용한다. 역시 중첩 프로퍼티 객체에는 Setter
가 필요함을 알 수 있다.
최종적으로 값이 잘 바인딩 되었다.
정리
중첩 클래스를 @ModelAttribute
로 바인딩 하기 위한 조건
1. 바깥 객체에 Getter가 있어야 한다.
→ Getter
를 제거하자 바인딩에 실패했다.
2. 중첩 객체에 기본생성자가 있어야 하며 기본생성자의 접근제어자가 private
이면 안된다.
→ 기본생성자가 없거나 private
생성자인 경우 에러가 발생했다.
3. 바깥 객체에 Setter
가 있어야 한다.
→ Setter
를 제거하자 에러가 발생했다.
4. 중첩 프로퍼티 객체에 Setter
가 있어야 한다.
→ 중첩 객체에 Setter
를 제거하다 바인딩에 실패했다.
해결
ComicCreateRequest
, ThumbnailCreateRequest
를 record
타입에서 일반 클래스로 만들고 ComicCreateRequest
에 Getter
, Setter
, 기본생성자를, ThumbnailCreateRequest
에 Setter
와 기본생성자를 정의했다.
ComicCreateRequest(comicName=My Comic Name, genre=ACTION, thumbnailCreateRequests=[ComicCreateRequest.ThumbnailCreateRequest(thumbnailType=MAIN), ComicCreateRequest.ThumbnailCreateRequest(thumbnailType=SMALL)], summary=Funny Comic Summary, publishDayOfWeek=MON)