본문 바로가기

스프링

@ModelAttribute의 중첩 프로퍼티 바인딩

프로젝트 중 @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를 가지고 있다. 그리고 ThumbnailCreateRequestrecord 클래스이다.

Postman을 사용하여 다음과 같이 요청을 보냈지만 바인딩에 실패하고 에러가 발생했다.

@ModelAttribute"."을 이용하여 중첩 프로퍼티를 바인딩 할 수 있으며 "[index]"를 이용하여 배열에 바인딩 할 수 있다.

 

원인

우선 문제의 원인을 알기 위해 @ModelAttribute가 요청을 어떻게 바인딩하는지 살펴봤다.

위와 같이 Member 클래스가 있고 nameage 필드를 가지고 있다. 이 두 값을 바인딩하기 위해서 포스트맨으로 multipart/form-data 형식으로 nameage 값을 요청으로 보냈다.

 

1. 기본생성자 + Setter

바인딩할 객체에 기본 생성자와 Setter를 정의하였다.

우선 ModelAttributeMethodProcessorcreateAttribute() 메소드를 호출한다. 그 다음clazz에 바인딩하려는 객체의 타입을 넣고 ctor에 해당 객체의 생성자를 넣는다.

 

그 다음 constructAttribute() 메소드를 수행하는데 기본 생성자라면 모든 필드값이 null인 인스턴스를 반환하고 인자가 있는 생성자라면 요청값을 확인하여 요청값과 이름이 같은 필드를 바인딩한 인스턴스를 생성하여 반환한다. 예를 들어, Member 클래스에 @AllArgsConstructor가 있고 요청에 nameage가 정상적으로 보내졌다면 Member(name=XXX, age=YYY)인 인스턴스가 반환된다.

 

현재 시점에서는 Member에 기본 생성자밖에 없기 때문에 모든 필드가 null인 인스턴스가 반환되었다.

attributeMember(name=null, age=0)인 인스턴스가 들어오고 객체를 바인딩 하기 위해 바인더를 생성한 뒤 bindRequestParameters()를 호출한다.

쭉 따라들어가면 mpvs를 만드는데 mpvs는 사용자의 요청에 대한 정보인 PropertyValueList로 가지고 있다. (mpvs=[PropertyValue(name="name", value="Kong"), PropertyValue(name="age", value="21")])

 

그리고 DataBinder.doBind()메소드를 호출하여 바인딩을 시작한다.

메소드를 진행하면 setPropertyValues()를 호출하는데 여기서 mpvspropertyList를 하나씩 돌면서 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))의 ageint이기 때문에 타입을 변환시켜줘야 한다.

따라서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는 생성자를 통해 바인딩 되지 않는다.

보다시피 nameaddress일 때 values에는 null값이 채워지는 것을 볼 수 있다.

결국 생성자를 통해 만들어진 인스턴스는 다음과 같다.

  • Member(name=Kong, age=24, address=null)

이런 중첩 프로퍼티 객체의 값은 Setter를 통해 바인딩하는 로직에서 처리된다.

 

 

DataBinder.doBind() 메소드를 타고 들어가서

setPropertyValue() 메소드로 들어가 바인딩을 하게되는데

여기서 getPropertyAccessorForPropertyPath() 메소드를 들어가면

이전에 일반 객체를 처리할 때와 다르게 .을 기준으로 addresscity를 나누고

address값을 먼저 바인딩 하기 위한 로직들을 수행한다.

계속 따라 들어가면 getPropertyValue() 메소드가 나오는데 이 메소드를 살펴보면 address라는 이름으로 PropertyHandler값을 구했을 때 Getter가 없다면, 즉 Member.getAddress()가 없다면 NotReadablePropertyException을 던지는 것을 확인할 수 있다.

즉, 중첩 프로퍼티 객체를 바인딩할 때 바깥 클래스에 Getter가 꼭 필요한 것을 알 수 있다.

Getter로 값을 꺼내고 value에 넣는다.

이때 만약 valuenull이라면 setDefaultValue() 메소드를 호출한다. tokens에는 현재 바인딩하려는 요청에 대한 정보가 들어있다.

setDefaultValue()에서는 address라는 이름에 해당하는 필드 타입(Address)을 알아내고 Address 인스턴스를 생성한다.

위 코드를 보면 중첩 프로퍼티 객체는 기본생성자가 있어야 함을 알 수 있고 또한 기본생성자의 접근제어자가 private이면 에러가 발생한다는 것을 알 수 있다.

 

여기서 생성한 인스턴스를 defaultValue 변수에 할당하고 해당 값으로 PropertyValue를 만들어 반환한다.

이어서 setPropertyValue()를 호출해서 Member.AddressPropertyValue(name=address, value=Address(city=null))을 이용하여 실제 바인딩 작업을 거치는데 해당 코드를 보면 phAddress타입이라는 내용을 담고있고 phSetter가 없다면 바인딩 작업 없이 return을 반환하는 것을 알 수 있다.

 

즉, 중첩 프로퍼티 클래스의 바깥 객체(Member)에는 Setter가 필요한 것을 알 수 있다.

 

그리고 Setter를 통해 Member인스턴스에 Address인스턴스를 바인딩한다.

바인딩에 성공했다면 다시 setDefaultValue()로 돌아와서 getPropertyValue()를 호출하는데 이는 Member인스턴스에서 address필드 값을 가져온다. 앞서 중첩 프로퍼티 객체 바인딩에 성공했으므로 defaultValue에는 Address(city=null)가 할당된다.

만약 defaultValuenull이면 위 에러가 발생하게 된다.

그리고 내가 디버깅을 하게 만든 원인이었다. 즉, defaultValuenull값이 들어갔기 때문에 발생했던 에러였는데 왜 null값이 들어오냐면, setPropertyValue() 메소드를 호출해서 중첩 프로퍼티 객체에 값을 바인딩하려 해도 Setter가 없어서 값을 바인딩하지 못했기 때문이다. 왜 Setter를 정의하지 않았냐면, RequestDTOrecord 타입을 사용했고 record는 불변 객체이기 때문에 Setter를 정의할 수 없었다. 따라서 이 문제를 해결하기 위해 record타입을 사용하지 않고 일반 클래스로 RequestDTO를 생성하고 Setter를 정의해주면 된다.

 

그리고 defaultValue에 왜 null값이 오면 안되나 생각해 봤는데 개인적인 생각이지만 지금 우리는 Address.city에 서울이라는 값을 바인딩하고자 한다. 그런데 defaultValuenull이라는 것은 Member.Addressnull이라는 것이기 때문에 Address.city에 값을 바인딩하려고 Getter를 호출하는 순간 NullPointerException이 발생하기 때문인 것 같다.

 

실제로 코드를 계속 진행하면 기존까지 AbstractNestablePropertyAccessor nestedPawrapping objectMember타입이었는데 이후에는 Address 타입으로 바뀐다.

그리고 여기서 address.city에 값을 바인딩 해줄때 Setter를 이용한다. 역시 중첩 프로퍼티 객체에는 Setter가 필요함을 알 수 있다.

최종적으로 값이 잘 바인딩 되었다.

 

정리

중첩 클래스를 @ModelAttribute로 바인딩 하기 위한 조건

 

1. 바깥 객체에 Getter가 있어야 한다.

Getter를 제거하자 바인딩에 실패했다.

 

  2. 중첩 객체에 기본생성자가 있어야 하며 기본생성자의 접근제어자가 private이면 안된다.

→ 기본생성자가 없거나 private 생성자인 경우 에러가 발생했다.

 

  3. 바깥 객체에 Setter가 있어야 한다.

Setter를 제거하자 에러가 발생했다.

 

4. 중첩 프로퍼티 객체에 Setter가 있어야 한다.

→ 중첩 객체에 Setter를 제거하다 바인딩에 실패했다.

 

해결

ComicCreateRequest, ThumbnailCreateRequestrecord타입에서 일반 클래스로 만들고 ComicCreateRequestGetter, Setter, 기본생성자를, ThumbnailCreateRequestSetter와 기본생성자를 정의했다.

ComicCreateRequest(comicName=My Comic Name, genre=ACTION, thumbnailCreateRequests=[ComicCreateRequest.ThumbnailCreateRequest(thumbnailType=MAIN), ComicCreateRequest.ThumbnailCreateRequest(thumbnailType=SMALL)], summary=Funny Comic Summary, publishDayOfWeek=MON)