본문 바로가기

Hub Web/Spring

[Spring] Json과 Http Parameter의 데이터 바인딩 과정

728x90

📜 개요


🔹 지금까지 Entity 객체에는 Setter 어노테이션을 사용하는 것을 지양해 왔지만, DTO 객체에서는 Setter 어노테이션을 지속적으로 사용해 왔다. 물론, DTO 객체는 데이터의 전달이 목표이기 때문에 사용해도 상관이 없지만 @Setter를 제외하고 @Builder를 활용해서 만들어보고 싶었기 때문에 제외하고 코드를 작성해 봤다. 그 결과 아래와 같은 오류가 발생하게 된다.

java.lang.IllegalStateException: Cannot resolve parameter names for constructor public com.ssafy.member.dto.MemberDTO(java.lang.Long,java.lang.String,java.lang.String,java.lang.String)
 

@Setter를 사용했을땐 정상적으로 매핑이 되었지만 @Builder를 사용하는 순간 생긴 오류였다. 이런 오류가 발생한 원인을 이번 포스팅으로 알아보자.

 

👻 Json 데이터 바인딩 과정 ( @RequestBody, @ResponseBody )


🔹 Http Body의 Message를 읽기 위해서 스프링은 Http Message Converter에서 Jackson을 사용한다.

또한, Spring은 Json 데이터를 직렬화 / 역직렬화할 Jackson 라이브러리의 ObjectMapper를 사용한다.

 

Java DTO 클래스  Json ( 직렬화 )

  • 클라이언트에서 서버가 보낸 데이터를 받아와서 처리할 수 있도록 JSON 객체로 변환하는 과정
  • POJO의 getName()의 getter로 인해서 name 필드의 chris값이 { ”name” : ”chris” } 으로 직렬화된다.

Json  Java DTO 클래스 ( 역직렬화 )

  • 서버에서 클라이언트가 보낸 데이터를 받아와서 처리할 수 있도록 자바 객체로 변환하는 과정
  • JSON으로 날라온 값이 { ”name” : ”chris” } 일 때, 이 데이터를 객체로 바인딩 했을 때 POJO의 getter or setter로 인해서 name 필드와 매핑이 되며, 역직렬화된다.

[ 직렬화 / 역직렬화 대상 ]

🔹 ObjectMapper는 기본적으로 필드를 대상으로 하는데 그중에서 public 필드를 대상으로 삼는다. 아래와 같이 public으로 필드를 생성하고 직렬화, 역직렬화를 테스트해 보면 정상적으로 실행되는 것을 확인할 수 있다.

public class Person {
    public String name;

    public Person() {
    }

    public Person(String name) {
        this.name = name;
    }
}

@Test
void public_필드_직렬화() throws JsonProcessingException {
    //given
    Person person = new Person("joon");

    //when
    String result = mapper.writeValueAsString(person);

    //then
    assertThat(result, containsString("name"));
}

@Test
void public_필드_역직렬화() throws JsonProcessingException {
    //given
    String json = "{\"name\":\"joon\"}";

    //when
    Person result = mapper.readValue(json, Person.class);

    //then
    assertThat(result.name, equalTo("joon"));
}

 위의 코드를 통해 확인한 것처럼, 객체의 필드를 public으로 선언하면 Jackson은 해당 필드를 자동으로 매핑한다. 하지만, 통상 클래스 선언 시에 필드를 private로 선언하기 때문에 Jackson에서는 이를 위해 getter 혹은 setter가 있을 경우 해당 메서드를 통해 필드 이름을 유추하여 직렬화 / 역직렬화에 사용한다.

 

따라서 필드를 private로 선언하더라도 getter 혹은 setter가 있다면 문제없이 ObjectMapper를 통해 직렬화 / 역직렬화할 수 있다.

 

위에서 Getter와 Setter 모두 private 필드를 Jackson이 인식할 수 있도록 해준다고 했지만 정확하게는 Getter는 직렬화 / 역직렬화를 모두 가능케 하지만 Setter는 역직렬화만 가능하게 해 준다. 이는 getter를 가진 private filed는 property로 간주되지만 setter는 그렇지 않기 때문이다. 추가적으로  Jackson에서는 일반적인 getter, setter 외에 isGetter 또한 getter로 인식하여 자동으로 필드로 인식 후 직렬화, 역직렬화에 사용한다.

[ 의도치 않은 속성 바인딩 피하기 ]

🔹 직렬화로 인해 의도치 않은 프로퍼티가 생겨나는 것을 방지하려면 Jackson 라이브러리의 특징을 이용하여 아래와 같이 조치할 수 있다.

1) 메서드명 변경하기

getter, isGetter 메서드 네이밍 컨벤션을 피해 메서드명을 변경함으로써 Jackson의 프로퍼티 감지에서 벗어날 수 있다.

public class User {
    ...
    
    public boolean checkOld() {
        return age > 35;
    }
}

2) 어노테이션 사용하기

스프링에는 Json과 관련된 많은 어노테이션들이 존재하는데, 그중 Json value에서 특정 요소를 제외시킬 때 사용하는 @JsonIgnore 어노테이션을 사용하여 의도치 않은 매핑을 피할 수 있다.

public class User {
    ...
    
		@JsonIgnore
    public boolean isOld() {
        return age > 35;
    }
}

@JsonIgnore 외에도 @JsonAutoDetect 어노테이션을 이용하여 해당 객체에 대한 접근제한자 범위를 수정할 수 있다.

@JsonAutoDetect(isGetterVisibility=JsonAutoDetect.Visibility.NONE)
public class User {
    ...
    
    public boolean isOld() {
        return age > 35;
    }
}

3) ObjectMapper 설정 변경하기

ObjectMapper의 감지 접근제한자 범위를 수정하여 마찬가지로 의도치 않은 매핑을 피할 수 있다.

ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(
	mapper.getSerializationConfig().getDefaultVisibilityChecker()
		.withIsGetterVisibility(JsonAutoDetect.Visibility.NONE)
);

 

⚙️ Http Parameter의 데이터 바인딩 과정


🔹 클라이언트가 데이터를 전송할 때, URL에 파라미터로 보내는 형식이 있다. 이때는 @RequestParam,  @PathVariable, @ModelAttribute를 사용하여 역직렬화할 수 있다.
 
이 중 @ModelAttribute는 기본적으로 @Setter  + @NoArgsConstructor 혹은 @AllArgsConstructor통해서 데이터를 바인딩한다. 따라서, getter만 있다면 객체의 값이 바인딩되지 않는다. 기본형 타입의 필드는 null이, 참조형 타입의 필드는 0과 같은 초기값이 바인딩된다. @ModelAttribute 위와 같은 어노테이션이 필요한 이유는 내부적으로 아래와 같은 바인딩 과정을 갖고있기 때문일 것이다.
RequestDto dto = new RequestDto();
dto.setName(값);
dto.setAge(깂);

위의 과정은 @Setter  + @NoArgsConstructor 과정이고, 다음은 이 두개의 어노테이션 없이 @AllArgsConstructor만 존재하는 경우의 예시이다.

RequestDto dto = new RequestDto(name, age);

아래와 같은 경우에는 name을 받는 생성자를 통해 객체를 생성하고 setAge를 통해 age값을 바인딩할 것이다.

@Getter
@Setter
public class RequestDto() {
  private String name;
  private Long age;

  public RequestDto(String name) {
    this.name = name;
  }
}

결론은 적절한 생성자를 먼저 찾고 그 뒤에 바인딩되지 않은 값을 setter를 통해 바인딩해주는 순서로 @ModelAttribute는 동작한다.

 

위에서 언급했던 @Builder를 사용한 경우 에러가 난 이유도 이런 이유에서 발생했다. 아래의 코드를 통해 더 자세한 에러의 원인을 알아보자.

package com.ssafy.member.dto;

import com.ssafy.member.entity.MemberEntity;
import lombok.*;

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class MemberDTO {
    private Long id;
    private String memberEmail;
    private String memberPassword;
    private String memberName;

    public static MemberDTO toMemberDTO(MemberEntity memberEntity) {
        return MemberDTO.builder()
                .id(memberEntity.getId())
                .memberEmail(memberEntity.getMemberEmail())
                .memberName(memberEntity.getMemberName())
                .memberPassword(memberEntity.getMemberPassword())
                .build();
    }
}

@ModelAttribute 데이터 바인딩 언급을 보면 @AllArgsConstructor만을 통해서도 데이터가 바인딩되어야 하지만 위의 코드에서는 데이터가 바인딩되지 않고 에러가 나왔다. 그 이유는 로그인 입력에서 id, name 필드 값을 받지 않기 때문이다. @AllArgsConstructor 를 써도 id는 Auto Increment 로 채워지지만 name이 채워지지 않는다. 이런 경우에는 직접 Setter를 사용해야만 해결이 가능하기 때문에 @Setter를 붙여줘야 한다.

getter는 클라이언트가 model에 담긴 DTO를 조회할 때 사용된다. 즉, request 데이터의 바인딩 자체에는 getter가 필요없다.

 

또한, @NoArgsConstructor로 기본 생성자의 생성을 방지하고 @Builder를 이용하여 객체의 생성에 유연성을 더 해주면 좋겠지만, 이 두 개의 어노테이션을 함께 사용하려면 @AllArgsConstructor가 필요하다.

 

이유는 @Builder를 조금 더 살펴보면 이해할 수 있다. @Builder는 생성자 유무에 따라 다음과 같이 동작한다.

  • 생성자가 없는 경우 : 모든 멤버 변수를 파라미터로 받는 기본 생성자 생성
  • 생성자가 있을 경우 : 따로 생성자를 생성하지 않음

즉, 기본 생성자가 존재하기 때문에 @Builder에서는 생성자를 별도로 생성하지 않는데, 이 기본 생성자에는 접근 제한 속성이 부여되어있어 문제가 발생하는 것이다. 이 두 개의 어노테이션을 부여한 클래스를 컴파일 해보면 문제가 발생하는 위치를 쉽게 확인할 수 있다. 접근 제한 속성의 의미는 다음의 코드를 보면 알 수 있다.

 

개발자의 실수로 클래스의 필드들 중 하나의 필드에 대한 값 설정을 누락시켰을 경우 객체는 불완전한 상태가 되어버린다. 하지만, AccessLevel.PROTECTED 속성을 부여해주면 기본 생성자의 접근 제어가 되어 IDE 단계에서 누락을 방지할 수 있어 위의 문제를 해결할 수 있다. 즉, 기본 생성자의 생성을 방지하고 지정한 생성자를 사용하도록 강제하여 무조건 완전한 상태의 객체를 생성할 수 있도록 도움을 준다는 의미다.

 

AccessLevel.PROTECTED 에 의해 지정 생성자가 필요하지만 존재하지 않으니 에러가 발생하는 것이고 결국, 문제는 일치하는 생성자가 없어 발생하는 문제이니 생성자를 만들어주면 되는게 아닌가?! 이 말을 다른 말로 하자면, 모든 필드를 파라미터로 가지는 @AllArgsConstructor 하나만 추가해 주면 해결할 수 있다는 의미다. 혹은 생성자에 직접 @Builder를 해줘도 가능하다.

 

📜 결론


🔹 @RequestBody 혹은 @ResponseBody와 같은 JSON 형태를 사용하면 Content-Type이 application/json타입인 json 데이터를 Jackson라이브러리를 사용하여 직렬화 / 역직렬화 하게 되고, 이는 Getter만으로도 모두 해결이 가능하다. 물론, 기본생성자는 같이 들어가있어야 한다.


Form 데이터의 post 메서드의 경우, Content-Type이 application/x-www-urlencoded 타입의 쿼리파라미터 데이터이기 때문에 @RequestBody는 사용할 수 없고 @ModelAttribute를 사용하여 setter + @NoArgsConstructor 혹은 @AllArgsConstructor방식으로 데이터 바인딩을 해야 한다. 이때, @Builder를 사용하고 싶다면 기본 생성자 혹은 모든 필드를 데이터로 받는 경우가 아니라면 @Setter 가 필요하고 @Setter 를 사용하기 위해서는 @NoArgsConstructor  를 사용해야하는데  @Builder 와  @NoArgsConstructor 를 사용하고 싶다면 @AllArgsConstructor도 같이 사용해야 한다.

 

📸 참조


https://joon2974.tistory.com/25

 

https://velog.io/@bushyerin/RequestParam-RequestBody-ModelAttribute-DTO-%EB%A7%A4%ED%95%91-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%95%A0%EA%B9%8C