글 작성자: beaniejoy

저번 편에서는 getter/setter 에 대한 직렬화, 역직렬화가 어떻게 이루어지는지 알아보았습니다. 관련 내용을 아직 보지 않으셨다면 먼저 저번 편 글을 보시고 이번 게시글을 읽으시는 것을 추천드립니다.
https://beaniejoy.tistory.com/75

 

[Spring] 1. jackson을 이용한 data binding 이해하기(ObjectMapper, field & getter/setter)

Spring Framework를 이용해 web application을 개발하다보면 request - POJO 객체 - response로 data가 흘러간다는 것은 누구나 다 알게 됩니다. 최근에는 web application에 들어오고 나가는 data를 대부분 JSON..

beaniejoy.tistory.com

이번 편에서는 constructor에서 jackson bind가 어떻게 작동하는지 여러 상황을 통해 알아보고자 합니다.


 

📌 1. 기본적인 형태

public class MemberConstructorDto {
    private Long id;
    private String name;
    private String address;
    private String email;
    
    public MemberConstructorDto() {
    	this.id = 0L;
        this.name = "no name";
        this.address = "no address";
        this.email = "no email";
    }
    
    public MemberConstructorDto(id, name, address, email) {
    	this.id = id;
        this.name = name;
        this.address = address;
        this.email = email;
    }
    
    // getter
    
    // setter
}

기본생성자, 모든 필드를 인자로 담은 생성자, getter, setter 모두 존재하는 JavaBean 형태입니다. 여기서는 당연히 Serialize/Deserialize 둘다 잘 이루어질 것입니다. 이러한 형태보다 좀더 특수한 형태를 한 번 살펴보면 좋을 것 같습니다.

 


 

📌 2. 기본 생성자만 없는 경우

 

2-1. ObjectMapper를 통해 json을 역직렬화하는 경우

public class MemberConstructorDto {
    private Long id;
    private String name;
    private String address;
    private String email;
    
    public MemberConstructorDto(id, name, address, email) {
    	this.id = id;
        this.name = name;
        this.address = address;
        this.email = email;
    }
    
    // getter
    
    // setter
}

기본 생성자만 없는 경우 역직렬화가 잘 이루어지는지 테스트해보았습니다.

@Test
@DisplayName("JavaBean 형태에서 기본 생성자만 없는 경우에서의 에러 발생 ObjectMapper 테스트")
void checkValidMappingWithNoDefaultConstructor() throws JsonProcessingException {
    // Cannot construct instance
    MemberConstructorDto dto = 
    	mapper.readValue(json.toString(), MemberConstructorDto.class);
    logger.info(dto.toString());
}

위와 같이 테스트를 진행할 경우 InvalidDefinitionException 에러가 발생하면서 테스트를 실패하게 됩니다. 에러 메시지를 확인해보면 다음과 같습니다.

Cannot construct instance of `...MemberConstructorDto` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)

말 그대로 creator가 없어서 instance를 생성할 수 없다고 나옵니다. 저번 편에서 getter/setter 여러 상황에 대한 ObjectMapper 테스트를 해봤는데요. getter만 있든 setter만 있든 기본생성자가 꼭 있어야 한다는 내용을 중간에 잠깐 언급했던 적이 있습니다.

비록 모든 필드를 포함하는 생성자를 가지고 있지만 ObjectMapper는 해당 생성자를 가지고 binding하라는 지시를 제공받지 않는 이상 일단 기본 생성자를 통해 해당 객체를 생성하고 거기에 setter를 통해 데이터를 주입하려고 합니다. 그렇기 때문에 위의 예제에서는 생성자가 없다는 내용과 함께 에러를 던지게 되는 것입니다.

위의 테스트는 다음과 같이 수정하는게 좋겠네요.

@Test
@DisplayName("JavaBean 형태에서 기본 생성자만 없는 경우에서의 에러 발생 ObjectMapper 테스트")
void checkValidMappingWithNoDefaultConstructor() throws JsonProcessingException {
    // Cannot construct instance
    InvalidDefinitionException exception = 
    	assertThrows(InvalidDefinitionException.class, () -> {
        	MemberConstructorDto1 dto = 
            	mapper.readValue(json.toString(), MemberConstructorDto1.class);
        });
        
    logger.info(exception.getMessage());
}

 

근데 spring boot에 의한 api를 통해 json을 받아올 때는 얘기가 달라집니다. 다음을 한 번 볼까요.

 

2-2. Spring Boot Application을 통한 API Request 통해 json을 역직렬화하는 경우

@PostMapping("/api/member")
public MemberConstructorDto postMember(@RequestBody MemberConstructorDto requestDto) {
    logger.info(requestDto.toString());
    return requestDto;
}

간단하게 POST method handler를 하나 만들고 @RequestBody를 통해 위의 예제를 들었던 MemberConstructorDto 클래스를 받아오도록 구성해보았습니다. 이에 대해서 과연 MemberConstructorDto로 잘 역직렬화가 이루어지는지 테스트해보겠습니다.

@Test
@DisplayName("기본 생성자만 없는 경우 테스트")
public void bindingTestWhenNoDefaultConstructor() throws Exception {
    // 기본 생성자가 없어도 @RequestBody 에서는 값을 제대로 binding 해준다.
    // 기본 생성자 > 해당 필드를 넣을 수 있는 인자 있는 생성자 > setter 적용
    mvc.perform(post("/api/member")
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestJson.toString()))
        .andExpect(status().isOk())
        .andDo(print());
}

위 테스트를 실행하면 성공하는 것을 볼 수 있습니다. 오잇? ObjectMapper에서 readValue했을 때 분명 기본 생성자가 없어서 에러가 발생했는데 여기서는 성공적으로 binding되고 200 응답코드를 반환했습니다. 어떻게 이런 일이 발생한 것일까요.

 


 

📌 3. 기본 생성자만 없는 경우 (jackson-module-parameter-names)

기본생성자만 없는 형태의 클래스를 가지고 위에서 테스트했던 내용을 조금 수정해보도록 하겠습니다. 이번에는 ObjectMapper에 jackson-module-parameter-names 모듈을 등록하고 역직렬화를 해보겠습니다.

@Test
@DisplayName("JavaBean 형태에서 기본 생성자만 없는 경우에서의 ObjectMapper 역직렬화 테스트")
void checkValidMappingWithNoDefaultConstructorByParameterNamesModule() throws JsonProcessingException {
    mapper.registerModule(new ParameterNamesModule());
    MemberConstructorDto dto = 
    	mapper.readValue(json.toString(), MemberConstructorDto.class);
    
    logger.info(dto.toString());
    
    assertEquals(dto.getName(), "beanie");
    assertEquals(dto.getAddress(), "beanie's address");
}

위의 테스트는 성공적으로 통과합니다. jackson-module-parameter-names는 위에서 알 수 있듯이 기본 생성자가 없어도 역직렬화를 수행하게끔 도와주는 모듈이라고 할 수 있습니다.

보통 기본생성자를 통해 객체를 생성하고 setter를 통해 json 데이터를 필드에 넣는 방식으로 역직렬화가 이루어지는데 기본생성자가 없는 경우 이러한 프로세스를 진행할 수 없게 됩니다.

jackson-module-parameter-names 모듈은 기본생성자가 없는 경우 id, name, address, email 과 같은 필드에 데이터를 넣을 수 있는 다른 루트를 찾게되는데 인자가 있는 생성자가 바로 대상이 됩니다. id, name, address, email을 인자로 가진 생성자가 있으면 해당 생성자를 통해 역직렬화를 진행하게 됩니다. 그래서 기본생성자가 존재하지 않아도 다른 생성자에 역할을 위임해서 객체 binding을 진행한다고 볼 수 있습니다.

 

3-1. Spring Boot 상에서 했던 mockMvc 테스트는 왜 통과된 것인가요?

Controller에 간단한 Post Method를 작성한 예제를 봐도 그 어디에 ObjectMapper를 가지고 역직렬화하는 코드나 그전에 모듈을 등록하는 부분을 확인할 수 없습니다. Spring은 request로 들어온 json 데이터를 어떻게 성공적으로 역직렬화할 수 있었을까요. POST Method에 Debugging을 해보면 다음과 같은 내용을 확인할 수 있습니다.

Spring Boot는 기본적으로 jackson bind 라이브러리를 가지고 있는데요. 역직렬화, 직렬화할 때 ObjectMapper를 사용하게 됩니다. 근데 ObjectMapper에 등록된 모듈들을 확인해보면 jackson-module-parameter-names 모듈이 등록되어 있는 것을 확인할 수 있습니다.

Spring Boot 2.x 버전대로 들어오면서 jackson-module-parameter-names 모듈이 포함되면서 Spring Boot를 통해 웹 어플리케이션을 개발한다면 해당 모듈이 등록된 ObjectMapper를 내부적으로 사용하게 되는 것입니다. 우리는 이러한 내용을 모른 채로 너무나 당연하게 Controller에서 RequestBody를 통해 binding된 객체를 가져다 사용해온 것입니다.

 


 

📌 4. 인자가 한 개인 생성자만을 가진 형태

public class MemberConstructorDto {
    private String name;

    public MemberConstructorDto(String name) {
        this.name = name;
    }
    
    //...
}

기본생성자는 없는데 인자가 있는 생성자만 가진 클래스에 대해서 jackson-module-parameter-names 모듈이 있으면 역직렬화가 가능하다는 것을 위에서 확인할 수 있었습니다. 그런데 인자가 한 개인 생성자에 대해서는 또 얘기가 달라집니다.

위의 인자가 한 개인 생성자만 있는 클래스를 ObjectMapper를 통해 역직렬화를 시도해보면 에러가 발생합니다. 심지어 ParameterNamesModule을 ObjectMapper에 등록하고 시도해도 실패하게 됩니다. 에러 내용을 살펴보면 다음과 같습니다.

com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot construct instance of `...MemberConstructorDto` (although at least one Creator exists): cannot deserialize from Object value (no delegate- or property-based Creator)

no delegate, or property-based Creator가 없다는 내용과 함께 Exception 내용을 확인할 수 있습니다. 즉, 인자가 한 개인 경우 ObjectMapper가 모듈을 등록해도 역직렬화를 해주지 못함을 알 수 있습니다.

이것은 Controller를 통한 mockMvc Test에서도 똑같이 실패를 합니다.

@Test
@DisplayName("인자가 한 개인 생성자만 존재하는 경우 테스트")
public void bindingDtoOneArgConstructorCaseTest() throws Exception {
    // 400 에러는 여기서 @RequestBody에 지정한 객체로 역직렬화가 되지 않아 실패(HttpMessageNotReadableException)
    // cannot deserialize from Object value (no delegate- or property-based Creator)
    mvc.perform(post("/api/member")
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestJson.toString()))
        .andExpect(status().isBadRequest())
        .andExpect(result ->
                assertTrue(result.getResolvedException() instanceof HttpMessageNotReadableException)
        )
        .andDo(print());
}

HttpMessageNotReadableException이 발생하게 되는데 json으로 들어온 요청데이터를 제대로 역직렬화하지 못해 발생한 에러입니다.

인자 한 개인 생성자를 가진 클래스에 대해서는 어떤 방식으로 역직렬화 해야될까요.

@JsonCreator
public MemberConstructorDto(String name) {
    this.name = name;
}

인자 한 개인 생성자에 @JsonCreator를 지정해줍니다.

@JsonCreator
Marker annotation that can be used to define constructors and factory methods as one to use for instantiating new instances of the associated class.

기본 생성자를 통한 역직렬화를 하지 않고 지정해준 생성자를 가지고 역직렬화를 진행하겠다고 알려주는 역할이라 생각하시면 됩니다.

@Test
@DisplayName("Single Argument Constructor인 상황에서의 ObjectMapper 역직렬화 테스트")
void checkSerializeWithSingleArgumentConstructor() throws JsonProcessingException {
    mapper.registerModule(new ParameterNamesModule());
    assertTrue(String.valueOf(mapper.getRegisteredModuleIds()).contains("jackson-module-parameter-names"));

    MemberConstructorDto dto = 
    	mapper.readValue(json.toString(), MemberConstructorDto.class);
        
    logger.info(dto.toString());
    assertEquals(dto.getName(), "beanie");
}

@JsonCreator를 생성자에 지정하고 위의 테스트를 실행하면 통과되는 것을 확인할 수 있습니다. 보통 @JsonCreator@JsonProperty와 함께 쓰이는데 생성자 인자가 한 개뿐이라 따로 지정할 필요는 없다고 하네요. 

근데 여기서도 결국 기본 생성자의 중요성이 드러나는 것 같습니다. 인자가 한 개인 생성자만을 가진 클래스로 테스트를 해보았지만 많은 제약사항과 함께 역직렬화하는데 에러가 발생하기 쉽다는 취약점이 있는 것 같습니다. 결국 요청 dto에서 기본 생성자를 지정하는 것이 중요하다고 볼 수 있습니다.

 


 

📌 5. 인자가 있는 생성자 + setter로 구성된 형태

public class MemberConstructorDto {
    private String name;
    private String address;

    private String email;

    public MemberConstructorDto(String name, String address) {
        this.name = name;
        this.address = address;
    }

    public void setEmail(String email) {
        this.email = email;
    }
}

인자가 있는 생성자와 setter 조합에서도 테스트를 해보았습니다. name, address 필드에 대해서는 생성자로 받고 email 필드에 대해서는 setter로 받게 했습니다.

@Test
@DisplayName("인자가 있는 생성자와 setter로 역직렬화하는 테스트")
void checkDeserializeWithConstructorAndSetter() throws JsonProcessingException {
    // constructor에 지정되지 않는 필드(email)가 있으면 setter에 매핑되는 내용을 찾는다.
    mapper.registerModule(new ParameterNamesModule());
    MemberConstructorDto dto = mapper.readValue(json.toString(), MemberConstructorDto.class);
    logger.info(dto.toString());
}

위 테스트를 실행하면 통과되는 것을 확인할 수 있습니다. parameter names 모듈을 등록한 ObjectMapper에서는 생성자 인자를 토대로 json 데이터를 받아들이고 생성자 인자에 지정되지 않는 json 데이터(여기서는 email)가 있으면 해당 setter를 가지고 활용하게 됩니다.

 


 

📌 정리

  • ObjectMapper, jackson binding에는 객체 대상의 클래스 형태에서 기본 생성자가 있어야 합니다.
  • 기본 생성자가 없어도 역직렬화를 할 수 있습니다. jackson-module-parameter-names 모듈이 기본 생성자가 없어도 다른 생성자로 대체해서 역직렬화를 수행하게 됩니다.
  • Spring Boot에서는 jackson binding이 이루어질 때 사용하는 ObjectMapper에 기본적으로 jackson-module-parameter-names 모듈이 등록되어 있어서 Spring Controller를 통해 들어오는 request dto에 기본 생성자가 없어도 역직렬화를 수행할 수 있습니다.
  • 단, 인자가 한 개인 생성자만을 가진 형태에서는 binding error가 발생할 수 있으니 주의해야 합니다.
  • 위에 내용을 제쳐두고 기본적으로 dto에는 기본 생성자를 포함하는 것이 좋습니다.
    물론 다른 이유로 기본생성자를 막아둬야 하는 경우에는 위에 내용과 비슷하게 jackson에서 제공하는 여러 어노테이션을 통해 다른 생성자나 factory method로 대체해서 역직렬화를 수행할 수 있도록 설정하면 될 것으로 보입니다.

 

틀린 내용이 있을 수 있습니다. 코멘트 언제나 환영합니다.