2023.11.10-개발일지

운영하던 API 서비스에서 혹시 모를 중복을 제거하기 위해 List 응답 형식을 Set으로 변경하여 반환하기로 개발했습니다. 변경시에는 kotlin 에서 제공하는 toSet() 함수를 사용하여 간단하게 처리했습니다. 그러나 실제 배포 후 순서를 보장하지 않는 이슈로 급하게 롤백을 처리했던 경험이 있습니다.

원인 파악하기

kotlin 에서 제공하는 toSet() 함수는 collection에서 사용 가능하며 내부적으로 LinkedHaseSet으로 랩핑되기에 순서가 반드시 보장되어야 합니다.

public fun <T> Iterable<T>.toSet(): Set<T> {
    if (this is Collection) {
        return when (size) {
            0 -> emptySet()
            1 -> setOf(if (this is List) this[0] else iterator().next())
            else -> toCollection(LinkedHashSet<T>(mapCapacity(size)))
        }
    }
    return toCollection(LinkedHashSet<T>()).optimizeReadOnlySet()
}

따라서 직렬화 시에 문제가 있을까 하여 jacksonMapper 의 코드를 추적했고 범인임을 확인할 수 있었습니다.

jackson

역직렬화 혹은 직렬화 하는 시점을 잡기위해 디버깅을 하다보면 collection의 경우 CollectionDeserializer.java 에서 하기 코드로 구현한 내용을 확인할 수 있습니다.

protected Collection<Object> _deserializeFromArray(JsonParser p, DeserializationContext ctxt,
        Collection<Object> result)
    throws IOException
{
    // [databind#631]: Assign current value, to be accessible by custom serializers
    p.setCurrentValue(result);

    JsonDeserializer<Object> valueDes = _valueDeserializer;
    // Let's offline handling of values with Object Ids (simplifies code here)
    if (valueDes.getObjectIdReader() != null) {
        return _deserializeWithObjectId(p, ctxt, result);
    }
    final TypeDeserializer typeDeser = _valueTypeDeserializer;
    JsonToken t;
    while ((t = p.nextToken()) != JsonToken.END_ARRAY) {
        try {
            Object value;
            if (t == JsonToken.VALUE_NULL) {
                if (_skipNullValues) {
                    continue;
                }
                value = _nullProvider.getNullValue(ctxt);
            } else if (typeDeser == null) {
                value = valueDes.deserialize(p, ctxt); // ... (1)
            } else {
                value = valueDes.deserializeWithType(p, ctxt, typeDeser);
            }
            result.add(value);

            /* 17-Dec-2017, tatu: should not occur at this level...
        } catch (UnresolvedForwardReference reference) {
            throw JsonMappingException
                .from(p, "Unresolved forward reference but no identity info", reference);
            */
        } catch (Exception e) {
            boolean wrap = (ctxt == null) || ctxt.isEnabled(DeserializationFeature.WRAP_EXCEPTIONS);
            if (!wrap) {
                ClassUtil.throwIfRTE(e);
            }
            throw JsonMappingException.wrapWithPath(e, result, result.size());
        }
    }
    return result;
}

실제로 Set으로 선언한 변수를 역직렬화시 (1) 의 코드를 실행합니다. 추적해보면 createUsingDefault() 함수를 통해 기본값? 을 반환하는 것을 확인 할 수 있습니다.

interface와 abstract 역/직렬화 처리

그런데 생각해보면 무언가가 이상합니다. jackson의 경우 reflection을 통해 기본 생성자를 호출하고 선언된 getter/settter 에 맞춰 내부 데이터를 역/직렬화 하는 것으로 알고 있습니다. inferface와 abstract 의 경우는 instance를 생성할 수 없지만, 생각해보면 List나 Set으로 선언시 별다른 문제가 없었던 것 같습니다. 그 이유는 jacskon 라이브러리에 기본값이 설정 되어 있었기 때문입니다.

protected static class ContainerDefaultMappings {
    // We do some defaulting for abstract Collection classes and
    // interfaces, to avoid having to use exact types or annotations in
    // cases where the most common concrete Collection will do.
    final static HashMap<String, Class<? extends Collection>> _collectionFallbacks;
    static {
        HashMap<String, Class<? extends Collection>> fallbacks = new HashMap<>();

        final Class<? extends Collection> DEFAULT_LIST = ArrayList.class;
        final Class<? extends Collection> DEFAULT_SET = HashSet.class;

        fallbacks.put(Collection.class.getName(), DEFAULT_LIST);
        fallbacks.put(List.class.getName(), DEFAULT_LIST);
        fallbacks.put(Set.class.getName(), DEFAULT_SET);
        fallbacks.put(SortedSet.class.getName(), TreeSet.class);
        fallbacks.put(Queue.class.getName(), LinkedList.class);

        // 09-Feb-2019, tatu: How did we miss these? Related in [databind#2251] problem
        fallbacks.put(AbstractList.class.getName(), DEFAULT_LIST);
        fallbacks.put(AbstractSet.class.getName(), DEFAULT_SET);

        // 09-Feb-2019, tatu: And more esoteric types added in JDK6
        fallbacks.put(Deque.class.getName(), LinkedList.class);
        fallbacks.put(NavigableSet.class.getName(), TreeSet.class);

        _collectionFallbacks = fallbacks;
    }
}

BasicDeserializerFactory.class 에 정의되어 있는 코드입니다. jackson instance가 생성될 때 내부적으로 context가 생성되고 위 설정등을 사용됩니다. 코드를 보면 ListLinkedListSetLinkedHashSet이 아닌 HashSet으로 선언된 것을 확인할 수 있습니다.

해결

LinkedHashSet으로 타입 지정하기

data class Response(
    val info: LinkedHashSet<String>
)

필드의 타입을 LinkedHashSet 지정하는 방법입니다. 구현체이기 떄문에 별 문제 없이 원하는 대로 실행되나 다형성을 해치기에 추천하지는 않습니다.

@JsonDeserialize 사용하기

역/직렬화 대상 필드에 @JsonDeserialize(as=LinkedHashSet.class) 와 같이 명시하여 해결합니다.

@JsonDeserialize(as=LinkedHashSet.class)
data class Response(
    val info: Set<String>
)

인터페이스를 유지하기에 유연한 처리가 가능합니다.

module 지정

jackson 에선 이런 기본 설정등을 사용자가 제어 할 수 있는 방법을 제공하며 module이 그 예시중 하나입니다. 일반적으로 objectMapper 를 사용시 static이나 bean으로 생성하여 재활용을 하게 될텐데 해당 코드에서 SimpleModule을 등록해줍니다.

@Bean
fun objectMapper(): ObjectMapper {
    val simpleModule = SimpleModule().apply {
        addAbstractTypeMapping(Set::class.java, LinkedHashSet::class.java)
    }

    return ObjectMapper().registerModule(simpleModule)
}

가장 간단하면서도 다형성을 유지하면서 global 하게 적용할 수 있는 방법입니다.

webclient

webclient 사용 시 spring boot에서 기본으로 사용하는 objectMapper를 사용하지 않기에 동일한 문제가 발생할 수 있음에 주의해야 합니다.

@Bean
fun webClient(objectMapper: ObjectMapper): WebClient =
    WebClient.builder().codecs {
        it.defaultCodecs().jackson2JsonDecoder(Jackson2JsonDecoder(objectMapper))
        it.defaultCodecs().jackson2JsonEncoder(Jackson2JsonEncoder(objectMapper))
    }.build()

webclient 생성 시 codec에 module을 등록한 objectMapper로 등록하여 해결합니다.

태그:

카테고리:

업데이트:

댓글남기기