본문 바로가기
kotlin

kotlin data class 와 jackson 역직렬화 & spring과의 통합

by 권성호 2023. 3. 26.

사실이 아니라 공부한 내용과 생각을 정리한 글입니다. 언제든 가르침을 주신다면 감사하겠습니다.

1. jackson 역직렬화 기본 동작과 kotlin data class

1-1. jackson 역직렬화 기본 동작

1. 기본생성자가 있을 때

  • 기본생성자를 호출해 객체를 생성 후 setter를 통해 필드를 초기화한다.

2. 기본생성자가 없을 때

  • 역직렬화 대상 객체의 필드 이름을 파라미터로 받는 생성자가 있는지 찾아본다.
  • 필드 이름을 파라미터로 받는 생성자가 있다면, 해당 생성자를 통해 객체를 생성 후 아직 초기화되지 않는 필드는 setter를 통해 초기화한다.
  • 이 경우에는 @JsonCreator와 @JsonProperty를 통해 역직렬화를 위한 생성자와 필드를 명시해줘야 한다.

3. 기본생성자도 없고 필드 이름을 파라미터로 받는 생성자도 없을 때

  • 역직렬화에 실패하고 예외를 던진다.

3번 경우 정말 역직렬화에 실패하는지 테스트코드를 작성해 보았다.

public class JacksonBasicTest {

    private ObjectMapper objectMapper = new ObjectMapper();

    @Test
    public void fail() throws JsonProcessingException {
        MakeAccountDtoCommand command = new MakeAccountDtoCommand("soungho", "1234");
        ImmutableAccountDto givenAccountDto = new ImmutableAccountDto(command);
        String json = objectMapper.writeValueAsString(givenAccountDto);
        InvalidDefinitionException exception = assertThrows(InvalidDefinitionException.class, () -> {
            objectMapper.readValue(json, ImmutableAccountDto.class);
        });
        assertEquals(exception.getMessage(), "Cannot construct instance of `com.soungho.studyolle.JacksonBasicTest$ImmutableAccountDto` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)\n" +
                " at [Source: (String)\"{\"email\":\"soungho\",\"password\":\"1234\"}\"; line: 1, column: 2]");
    }

    public static class ImmutableAccountDto {
        private String email;
        private String password;

        ImmutableAccountDto(MakeAccountDtoCommand command) {
            this.email = command.getEmail();
            this.password = command.getPassword();
        }

        public String getEmail() {
            return this.email;
        }

        public String getPassword() {
            return this.password;
        }
    }

    public static class MakeAccountDtoCommand {
        private String email;
        private String password;

        MakeAccountDtoCommand(String email, String password) {
            this.email = email;
            this.password = password;
        }

        public String getEmail() {
            return this.email;
        }

        public String getPassword() {
            return this.password;
        }
    }
}

테스트가 성공했다

테스트가 성공했다는 것은 역직렬화 과정에서 예외가 발생했다는 의미이다. 예외의 내용은 아래와 같다.

Cannot construct instance of `com.soungho.studyolle.JacksonBasicTest$ImmutableAccountDto` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
 at [Source: (String)"{"email":"soungho","password":"1234"}"; line: 1, column: 2]

대충 해석해 보면, 역직렬화를 위해서는 대상 객체를 생성해야 하는데 이를 위한 마땅한 생성자를 찾지 못해서 실패했다는 내용이다.

1.2 kotlin data class와 jackson 역직렬화

그렇다면 kotlin data class의 경우 jackson을 사용한 역직렬화가 잘 될까?

테스트코드를 작성해 봤다.

class JavaJacksonTest {

    private val javaObjectMapper = ObjectMapper()

    @Test
    fun `자바 objectMapper 의 data class 역직렬화는 실패한다`() {
        val givenAccountDto = AccountDto("soungho", "1234")
        val jsonAccountDto = javaObjectMapper.writeValueAsString(givenAccountDto)

        val thrownException = assertThrows<InvalidDefinitionException> {
            javaObjectMapper.readValue(jsonAccountDto, AccountDto::class.java)
        }
        assertEquals(thrownException.message, "Cannot construct instance of `com.soungho.studyolle.JavaJacksonTest\$AccountDto` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)\n" +
                " at [Source: (String)\"{\"email\":\"soungho\",\"password\":\"1234\"}\"; line: 1, column: 2]")
    }

    data class AccountDto(
        val email: String,
        val password: String
    )
}

테스트가 성공했다

테스트가 성공했다는 것은 역직렬화 과정에서 예외가 발생했다는 의미이다. 예외의 내용은 아래와 같다.

Cannot construct instance of `com.soungho.studyolle.JavaJacksonTest$AccountDto` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
 at [Source: (String)"{"email":"soungho","password":"1234"}"; line: 1, column: 2]

여기서도 마찬가지로 역직렬화에 사용할 생성자를 찾지 못해 예외가 발생했다!

data class는 기본 생성자를 만들지 않고 모든 프로퍼티를 갖는 생성자 하나를 만들기 때문에 @JsonCreator와 @JsonProperty 없이는 역직렬화하지 못한다.

참고로 위 테스트코드에서 AccountDto를 아래와 같이 수정하면 역직렬화에 성공한다.

data class AccountDto(
    @JsonProperty("email")
    val email: String,
    
    @JsonProperty("password")
    val password: String
)

그렇다면, kotlin에서 data class에 대한 역직렬화가 필요할 때마다 @JsonProperty와 같은 부가 어노테이션을 사용해야 할까? 이러한 부가 어노테이션 사용은 kotlin 이 추구하는 간결성에 반하는 요소이고 다행히도 kotlin에서는 이에 대한 해결책을 제공한다.


2. kotlin에서 jackson 역직렬화

 
kotlin 에서는 아래와 같은 extension 을 제공한다.
fun jacksonObjectMapper(): ObjectMapper = jsonMapper { addModule(kotlinModule()) }
 
jacksonObjectMapper 메소드를 사용해 생성한 objectMapper 를 통해 data class 에 대한 역직렬화 테스트코드를 다시 작성해 보았다.
class KotlinJacksonTest {

    private val kotlinObjectMapper = jacksonObjectMapper()

    @Test
    fun `코틀린 objectMapper 의 data class 역직렬화는 성공한다`() {
        val givenAccountDto = AccountDto("soungho", "1234")
        val jsonAccountDto = kotlinObjectMapper.writeValueAsString(givenAccountDto)

        val parsedAccountDto = kotlinObjectMapper.readValue(jsonAccountDto, AccountDto::class.java)
        assertEquals(parsedAccountDto.email, givenAccountDto.email)
    }

    data class AccountDto(
        val email: String,
        val password: String
    )
}
테스트가 성공했다

이 경우에는 data class에 대해 @JsonProperty와 같은 부가 어노테이션 없이도 역직렬화에 성공하는 것을 확인할 수 있다.

즉, kotlin에서 data class에 대한 역직렬화도 자연스럽고 간결하게 사용할 수 있도록 jackson과 관련한 extension을 마련해 둔 것을 알 수 있다.

 

그렇다면 코프링 환경에서는 어떨까?

spring 환경에서 jackson을 사용하는 가장 대표적인 요소는 controller에서 json으로 들어온 요청을 객체로 역직렬화하는 부분이다. 

아래에서 이 부분에 대해 kotlin과 spring 이 어떻게 통합되었는지 살펴보자.


3. spring 과의 통합

spring-boot-starter-web을 의존성으로 가지면 spring 애플리케이션 기동 시 Jackson2ObjectMapperBuilder#configure 메서드가 호출되면서 http message를 파싱 하기 위한 objectMapper에 대한 설정이 적용된다. configure 코드를 살펴보면 아래와 같은 코드가 있다.

	public void configure(ObjectMapper objectMapper) {
		Assert.notNull(objectMapper, "ObjectMapper must not be null");

		MultiValueMap<Object, Module> modulesToRegister = new LinkedMultiValueMap<>();
		if (this.findModulesViaServiceLoader) {
			ObjectMapper.findModules(this.moduleClassLoader).forEach(module -> registerModule(module, modulesToRegister));
		}
		else if (this.findWellKnownModules) {
			registerWellKnownModulesIfAvailable(modulesToRegister);
		}
		// ... 생략
	}

 

registerWellKnownModulesIfAvailable 메서드를 따라가 보면 아래와 같은 코드가 나온다.

private void registerWellKnownModulesIfAvailable(MultiValueMap<Object, Module> modulesToRegister) {
	// ... 생략
    
   // Kotlin present?
   if (KotlinDetector.isKotlinPresent()) {
      try {
         Class<? extends Module> kotlinModuleClass = (Class<? extends Module>)
               ClassUtils.forName("com.fasterxml.jackson.module.kotlin.KotlinModule", this.moduleClassLoader);
         Module kotlinModule = BeanUtils.instantiateClass(kotlinModuleClass);
         modulesToRegister.set(kotlinModule.getTypeId(), kotlinModule);
      }
      catch (ClassNotFoundException ex) {
         // jackson-module-kotlin not available
      }
   }
}

spring 기동시에 kotlin 환경으로 기동하고 있는지 확인해서 kotlin 환경이라면 kotlin 모듈을 modulesToRegister에 등록하는 것을 확인할 수 있다.

이렇게 modulesToRegister에 등록된 모듈들은 아래 코드에서 실제 objectMapper에 적용된다.

	public void configure(ObjectMapper objectMapper) {
		Assert.notNull(objectMapper, "ObjectMapper must not be null");

		MultiValueMap<Object, Module> modulesToRegister = new LinkedMultiValueMap<>();
		if (this.findModulesViaServiceLoader) {
			ObjectMapper.findModules(this.moduleClassLoader).forEach(module -> registerModule(module, modulesToRegister));
		}
		else if (this.findWellKnownModules) {
			registerWellKnownModulesIfAvailable(modulesToRegister);
		}

		if (this.modules != null) {
			this.modules.forEach(module -> registerModule(module, modulesToRegister));
		}
		if (this.moduleClasses != null) {
			for (Class<? extends Module> moduleClass : this.moduleClasses) {
				registerModule(BeanUtils.instantiateClass(moduleClass), modulesToRegister);
			}
		}
		List<Module> modules = new ArrayList<>();
		for (List<Module> nestedModules : modulesToRegister.values()) {
			modules.addAll(nestedModules);
		}
		objectMapper.registerModules(modules);	// <- 여기서 적용!!!

		// ... 생략
	}

objectMapper.registerModules(modules) 코드를 발견하였는가?

 

결국 spring-boot-starter-web 의존성이 있다면, spring 이 기동 되는 시점에 objectMapper를 생성하면서 kotlin extension을 적용하는 것을 알 수 있다.


참고

댓글