본문 바로가기
Spring

ObjectMapper

by ybs 2022. 1. 15.
반응형

1. serialize/deserialize 이슈

EDA 기반에서 메세지들의 serialize/deserialize 는 특히 더 주의해야한다. 시스템들이 분산 되어 있기 때문이다. 예를 들어 아래 그림 System1 에서 serialize 하고 메세지를 발행하는 로직에서 메세지 관련 객체를 수정했다. 하지만 다른 조직 시스템인 System2, System3 은  배포 타이밍이 다를 수 있고 또 변경 대상이 반영 안되서 consume 하는데 규칙이 안맞으면 장애가 발생할 수 있다. 그래서 objectMapper 관리를 잘 해야 한다.

 

1-1. FAIL_ON_UNKNOWN_PROPERTIES

FAIL_ON_UNKNOWN_PROPERTIES 옵션은 항상 신경써야한다. FAIL_ON_UNKNOWN_PROPERTIES 옵션은 serialize 시키는 객체에 새로운 필드를 추가해 배포했는데 consuming 하는 다른 시스템들에서는(deserialize 해서 받을 객체에서는) 해당 필드가 반영되어 있지 않은 상황에서, deserialize 할 때 맞는 필드가 없으면(unknown property) 실패 시킬건지에 대한 속성이다. 해당 속성이 true 면 JsonMappingException 예외가 발생하므로 장애다. 이걸 방어하기위해 false 로 설정하거나(unknown property 무시) deserialize 하는 객체에 @JsonIgnoreProperties 애노테이션을 추가로 지정해줘야 한다. 

 

1-2. Spring Boot FAIL_ON_UNKNOWN_PROPERTIES

spring boot 를 쓰면 기본적으로 Jackson2ObjectMapperFactoryBean 에서 FAIL_ON_UNKNOWN_PROPERTIES 옵션을 FALSE 로 조정한다. 하지만 new objectMapper 로 직접 생성하면 Jackson2ObjectMapperFactoryBean 을 거치지 않으므로 FAIL_ON_UNKNOWN_PROPERTIES=TRUE(디폴트값) 다. 

private static ObjectMapper objectMapper() {
	ObjectMapper objectMapper = JsonMapper.builder()
		.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
		.build()
}

 

1-3. codehaus 와 fasterxml 

org.codehaus.jackson(jackson 1.x) 이랑 com.fasterxml.jackson(jackson 2.x) 을 혼용하는 서비스도 있다. 역사가 긴 서비스다보니 여러 objectMapper 를 갖고 있다. 그래서 objectMapper 에 옵션을 추가하고 싶지만 영향받는 범위 예측이 어려워 수정이 힘들다. 즉 objectMapper 관리가 안됐을 때 리스크가 크다. objectMapper 설정을 변경 했을 때 영향범위 예측이 어려우면 모든 클래스에 안전하게 @JsonIgnoreProperties(ignoreUnknown = true) 애노테이션을 추가해줘야 한다.

 

실제로 작업하면서 문제가 발생한 적이 있다. com.fasterxml.jackson.annotation.JsonIgnoreProperties 를 import 해서 @JsonIgnoreProperties 애노테이션을 추가했지만 deserialize 하는 objectMapper 는 codehaus 를 사용 했던 것이다. 그래서 'Could not read JSON: Unrecognized field' 에러가 발생했다. 

 

import 할 때 순간의 실수로 문제가 발생할 수 있다. PR 리뷰에서도 놓칠 가능성이 크다. 이렇게 혼용하는 서비스인 경우는 codehaus 로 통일해서 쓰는게 좋다. codehaus 는 jackson 2에서도 지원하기 때문에 안전한 선택이다. 그리고 나중에 jackson 1을 걷어낼 때 일괄적으로 fasterxml 을 변경하는게 좋다.


2. deserialize 동작 원리 이해

jackson 이 아래 Sample 클래스 type 으로 deserialize 하기로 결정했으면, 먼저 Sample 클래스에 기본 생성자(no argument constructor) 를 찾는다. 만약 있다면 해당 생성자로 Sample 을 만든 후에 setter 를 찾는다. 그래서 json 문자열에 있는 키 이름하고 동일한 setter 가 있는지 확인해 주입한다.

public static class Sample {
	private String name;
	private String address;

	public Sample() { }

	public void setName(String name) {
		this.name = name;
	}

	public void setAddress(String address) {
		this.address = address;
	}
}

 

그런데 만약 json 이 아래와 같이 name, address, gender 데이터를 전달하고

name : "ybs",
address : "경기도",
gender : "male"

 

아래 Sample 객체에서 받는데, gender 필드에 대한 setter 가 없다. 이 경우 name, address 는 setter 를 통해 주입하고 gender 는 setter 가 없으므로 같은 이름의 필드가 있는지 확인해서 있으면 reflection 으로 주입한다. 그래도 없으면 FAIL_ON_UNKNOWN_PROPERTIES 설정에 따라 무시하거나 예외를 발생시킨다. 

// 1. setter
// 2. reflection
public static class Sample {
	private String name;
	private String address;
	private String gender;

	public Sample() { }

	public void setName(String name) {
		this.name = name;
	}

	public void setAddress(String address) {
		this.address = address;
	}
}

 

다음으로 all argument constructor 가 있는 경우는 생성자를 통해서 주입한다. 하지만 그냥 생성자만 추가해서는 안되고, @JsonCreator 와 @JsonProperty 애노테이션으로 알려줘야 한다.

// 1. constructor
// 2. setter
// 3. reflection
public static class Sample {
	private String name;
	private String address;
	private String gender;

	@JsonCreator
	public Sample(
    	@JsonProperty("name") String name,
        @JsonProperty("address") String address,
        @JsonProperty("gender") String gender
	) {
		this.name = name;
		this.address = address;
		this.gender = gender;
	}
    
	public void setName(String name) {
		this.name = name;
	}

	public void setAddress(String address) {
		this.address = address;
	}
}

 

하지만 위 방식은 번거롭기 때문에 아래와 같이 @ConstructorProperties 애노테이션을 사용하는 방법이 있다. 해당 애노테이션에 대한 추가적인 내용은 여기서 다뤘으므로 생략한다.

public static class Sample {
	private final String name;
	private final String address;
	private final String gender;

	@ConstructorProperties({"name", "address", "gender"})
	public Sample(String name, String address, String gender) {
		this.name = name;
		this.address = address;
		this.gender = gender;
	}
}

 

다음으로, 아래와 같이 상속 관계에서는 deserialize 가 어떻게 될까? 먼저 생성자를 통해 주입받는데 parent 필드는 없다. 즉 생성자를 통해 setting 이 안된다. 다음 순서에 따라 setter 를 찾지만 setter 도 없다. 마지막으로 reflection 으로 해당 필드가 있으니까 주입을 한다.

// 1. constructor
// 2. setter
// 3. reflection
public static abstract class Parent {
	private String parent;
}

public static class Sample extends Parent {

	@ConstructorProperties({"name", "address"})
	public Sample(String name, String address) {
		this.name = name;
		this.address = address;
	}
    
	public void setName(String name) {
		this.name = name;
	}

	public void setAddress(String address) {
		this.address = address;
	}    
}

 

하지만 reflection 보다는 setter 가 나으므로 아래와 같이 setter 를 만들어 줄 수 있다.

public static abstract class Parent {
	private String parent;

	void setParent(String parent) {
		this.parent = parent;
	}
}

 

 

Collection type 은 한 단계가 더 있다(getter). 아래 코드와 같이 setter 는 없는데 getter 는 있을 수 있다. jackson 은 Collection type 이면 getter 를 호출한다. 호출해서 반환되는 리스트가 null이면 다음으로 넘어가는데 null 이 아니라 만약 리스트가 나오면, 이 리스트에다가 add 를 해준다.

// 1. constructor
// 2. setter
// 3. getter !!
// 4. reflection
public static class Sample {
	private List<String> samples = new ArrayList<>();

	public List<String> getSamples() {
		return samples;
	}
}


그런데 주의해야 될게 있다. 아래와 같이 List.of operator 사용했을 경우인데 ImmutableCollections.emptyList() 다. 즉 수정할 수 없으므로 jackson 이 add 하는 순간 에러가 발생한다.

public static class Sample {
	private List<String> samples = List.of();

	public List<String> getSamples() {
		return samples;
	}
}

 

마찬가지로 getter 에서 unmodifiableList 로 감싸면 jackson 이 add 하는 순간 에러가 발생한다. 그래서 해결방법으로는 @JsonIgnore 처리를 하거나 unmodifiableList 를 없애야한다.

public static class Sample {
	private List<String> samples = new ArrayList<>();

	// @JsonIgnore 추가하거나 unmodifiableList 제거
	public List<String> getSamples() {
		return Collections.unmodifiableList(samples);
	}
}

3. deserialize 다형성

objectMapper 로 readValue 할 때 필요한 concrete class(Sample) 를 지정하면 알아서 Sample type 으로 deserialize 된다.

Sample sample = OBJECT_MAPPER.readValue(json, Sample.class);

 

하지만 interface 를 갖고 deserialize 해야할 때가 있다. 프레임워크가 deserialize 를 자동으로 해줄 때 발생할 수 있다. 예를 들어 아래와 같이 Event 인터페이스를 구현한 AEvent, BEvent, CEvent 가 있다. raiseEvent 메서드는 Event type 으로 전달 받지만 각 상황에 맞는 concrete class 로 deserialize 되서 받고 싶은거다.

public static interface Event {
  // 메서드 생략
}

public static class AEvent implements Event { }
public static class BEvent implements Event { }
public static class CEvent implements Event { }

... 

protected void raiseEvent(Event event) {
	delegate.raiseEvent(event);
}

 

cf) 물론 모든 메세지들을 다 담을 수 있는 클래스를 만들어 deserialize 할 수도 있다. 하지만 이 방법은 어떤 필드를 쓰고 안쓰는지 다 파악해야 되서 관리하기 어렵다. nullable 한 케이스가 많아져 만들 때는 편한데 유지보수하기 어렵다.

 

interface type 이지만 상황에 맞는 concrete class 로 deserialize 되려면 식별할 수 있는 뭔가가 있어야하는데, @JsonTypeInfo 를 사용하면 된다. 이 애노테이션을 붙이면 serialize 할 때 어떤 타입인지 같이 심어서 serialize 한다. 

@JsonTypeInfo(use = Id.NAME, include = As.WRAPPER_OBJECT)
private List<Event> events = new ArrayList<>();

 

다시말해 interface type 을 serialize 해서 저장한 후 deserialize 할 때 type 을 알 수 있도록 NAME 을 같이 serialize 한다. deserialize 할 때는 심은 id 값 가지고 선택해서 deserialize 한다. 그리고 WRAPPER_OBJECT 이면 wrapping 을 해서 데이터가 안으로 들어간다. 아래 json 을 보면 AEvent 를 key 로 다시 1 depth 들어간것을 확인할 수 있다.

{
  "events":
    [
      {
        "AEvent":{"sourceVersion":100,"id":1}
      }
        
        ...
    ]
}

 

cf) @JsonTypeInfo 설정 변경은 조심해야된다. deserialize 쪽에서 맞춰주지 않으면 에러가 발생할 수 있다. @JsonTypeInfo 에서 Id.NAME 을 썼기 때문에 클래스명으로만 serialize 해서 넘기니까 패키지가 바뀌는것에는 영향이 없다. 만약 Id.CLASS 면 fully-qualified Java class 이므로 패키지명까지 같이 serialize 되니까, 패키지가 바뀌면 deserialize 할 때 영향을 준다.

{
  "events":
    [
      {
        "com.toy.AEvent":{"sourceVersion":100,"id":1}
      }
        
        ...
    ]
}

 

WRAPPER_OBJECT 대신 PROPERTY 를 사용하면 @type 키로 전달한다.

{
  "events":
    [
      {
        "@type":"AEvent"
        "sourceVersion":100,
        "id":1
      }
        
        ...
    ]
}

 

다음으로 @JsonTypeInfo 에 매칭되는 @JsonSubTypes 애노테이션은 setter 에 따로 지정했다. interface type 을 deserialize 할 때, serialize 된 이름으로 복원 type 을 결정할 수 있도록 정보 매핑을 등록한다. 

 

주의 : 새로운 Event type이 추가될 때 여기에 반드시 추가해줘야 정상적으로 deserialize 할 수 있다.

@JsonSubTypes({
	@Type(value = AEvent.class, name = "AEvent"),
	@Type(value = BEvent.class, name = "BEvent"),
	@Type(value = CEvent.class, name = "CEvent"),
})
protected void setEvents(List<Event> Events) {
	this.Events = Events;
}

 

만약 DEvent 를 만들었지만 JsonSubTypes 에 추가를 안해주면 아래와 같은 에러가 발생한다.

com.fasterxml.jackson.databind.exc.InvalidTypeIdException:
Could not resolve type id 'DEvent' as a subtype of `com.toy.Event`: known type ids = [AEvent, BEvent, CEvent] (for POJO property 'events')
at [Source: (String)"{"events":[{"AEvent":{"type":"AEvent","sourceVersion":100,"id":1}},{"BEvent":{"type":"BEvent","sourceVersion":200,"id":2}},{"CEvent":{"type":"CEvent","sourceVersion":300,"id":3}},{"DEvent":{"type":"DEvent","sourceVersion":400,"id":4}}]}"; line: 1, column: 181] (through reference chain: com.toy.Process["events"]->java.util.ArrayList[3])

4. ObjectMapper 다른 옵션들

먼저 ALLOW_FINAL_FIELDS_AS_MUTATORS 설정을 껐다. 

.disable(MapperFeature.ALLOW_FINAL_FIELDS_AS_MUTATORS)

 

아래 코드에서 name 이라는 final 필드는 선언과 동시에 초기화를 했다. 그래서 이후에 값을 바꿀 수 없다. 그런데 jackson 에서 deserialize 할 때 reflection 으로 값을 바꿔버릴 수 있다. 그걸 막기 위해 ALLOW_FINAL_FIELDS_AS_MUTATORS 설정을 껐다.

public static class Sample {

	private final String name = "ybs";
}

 

FAIL_ON_EMPTY_BEANS 설정도 껐다. 

.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)

 

아래 Sample 은 클래스만 있고 필드가 없다. jackson 은 Sample 클래스를 deserialize 할 때 기본적으로 에러를 발생시킨다.  필드가 반드시 하나는 있어야하기 때문이다. 그래서 FAIL_ON_EMPTY_BEANS 설정을 껐다.

public static class Sample {	
}

 

이 옵션이 필요한 상황은 다음과 같다. kafka topic 에서 A, B, C type 이 내려오는데 C type은 무시하고 싶을 때다. 앞단에서 kafka header 값으로 filter 처리 할수도 있지만 deserialize 해봐야 알수 있는 경우도 있다. 하지만 쓰지도 않을 C type 필드들을 정의할 필요는 없으므로 클래스 껍데기만 만들고 스킵시킬 때 활용될 수 있다.

 

Type Cache

typeFactory cache 로 LRUMap 이 들어갔는데 jackson 이 serialize/deserialize 할 때, 해당 타입에 대한 정보들을 내부적으로 캐시해놓는다. 그래서 필요한 타입을 찾을 때 캐시를 먼저 확인하고 꺼내서 쓴다.

return objectMapper.setTypeFactory(
	objectMapper.getTypeFactory()
		.withCache((LookupCache<Object, JavaType>)new LRUMap<Object, JavaType>(5120, 5120))
);

 

캐시 디폴트는 200 개다.

protected TypeFactory(LookupCache<Object,JavaType> typeCache, TypeParser p,
                      TypeModifier[] mods, ClassLoader classLoader)
{
    if (typeCache == null) {
        // initialEntries : 16
        // maxEntries : 200
        typeCache = new LRUMap<>(16, 200);
    }
}

 

다 차면 새로운 타입이 들어왔을 때 내부적으로 synchronize 블록안에서 LRUMap 을 다 비우고 다시 처음부터 넣는다. 200 개는 금방 차므로 5120 개로 캐시 사이즈 늘리는 설정을 했다.

package com.fasterxml.jackson.databind.util;

public class LRUMap<K,V>
    implements LookupCache<K,V>, // since 2.12
        java.io.Serializable
{
    @Override
    public V put(K key, V value) {
        if (_map.size() >= _maxEntries) {
            // double-locking, yes, but safe here; trying to avoid "clear storms"
            synchronized (this) {
                if (_map.size() >= _maxEntries) {
                    clear();
                }
            }
        }
        return _map.put(key, value);
    }
    
    ...
}

 

마지막으로 정확히 무엇을 캐시하는지 살펴보자. LookupCache<Object, JavaType> 에서 key는 Object 그리고 value 는 JavaType 이다. JavaType(com.fasterxml.jackson.databind.JavaType) 은 Object(key) 에 대한 type 그리고 super class type, super interface type 등의 정보를 갖고 있다.

 

아래 json string 을 YBS 객체로 deserialize 한다고 했을 때, YBS 클래스의 items 필드는 List interface type 이지만 실제 생성된 concrete type 은 ArrayList 다.

// json = "{\"items\":[\"item1\",\"item2\"]}";

@Getter
@Setter
public class YBS {

	private List<String> items;

	@ConstructorProperties({"items"})
	public YBS(List<String> items) {
		this.items = items;
	}
}

 

그러다보니 ArrayList 와 관계된 모든 Object 들까지 JavaType 을 구하고 캐시한다. 

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{

 

최종적으로 List 필드 하나에 다양한 객체들이 캐시되어 있는것을 확인할 수 있다.

 

 

반응형