Time to lazy

[SpringBoot + Redis-Cache] missing type id property '@class' 에러 본문

Spring

[SpringBoot + Redis-Cache] missing type id property '@class' 에러

skw 2025. 10. 23. 15:52

상황은 운영중인 서비스의 캐시 구현체를 [caffeine → redis-cache] 로 변경 후 패치를 진행하자
기존에 정상동작 중이던 API 에서 에러 발생 (정확히는 @Cacheable 이 등록된 서비스 메소드)
에러 내용은 다음과 같고, 확인해보면 역직렬화 도중 @class 라는 속성을 찾을 수 없다는 부분이 확인되었다

Caused by: com.fasterxml.jackson.databind.exc.InvalidTypeIdException: Could not resolve subtype of [simple type, class java.lang.Object]: missing type id property '@class'
 at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); line: 1, column: 579]
        at com.fasterxml.jackson.databind.exc.InvalidTypeIdException.from(InvalidTypeIdException.java:43)
        at com.fasterxml.jackson.databind.DeserializationContext.missingTypeIdException(DeserializationContext.java:2050)
        at com.fasterxml.jackson.databind.DeserializationContext.handleMissingTypeId(DeserializationContext.java:1622)
        at com.fasterxml.jackson.databind.jsontype.impl.TypeDeserializerBase._handleMissingTypeId(TypeDeserializerBase.java:307) at com.fasterxml.jackson.databind.jsontype.impl.AsPropertyTypeDeserializer._deserializeTypedUsingDefaultImpl(AsPropertyTypeDeserializer.java:211)
        at com.fasterxml.jackson.databind.jsontype.impl.AsPropertyTypeDeserializer.deserializeTypedFromObject(AsPropertyTypeDeserializer.java:145)
        at com.fasterxml.jackson.databind.jsontype.impl.AsPropertyTypeDeserializer.deserializeTypedFromAny(AsPropertyTypeDeserializer.java:240)
        at com.fasterxml.jackson.databind.deser.std.UntypedObjectDeserializerNR.deserializeWithType(UntypedObjectDeserializerNR.java:112)
        at com.fasterxml.jackson.databind.deser.impl.TypeWrappedDeserializer.deserialize(TypeWrappedDeserializer.java:74)
        at com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:342)
        at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4905)
        at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3950)
        at org.springframework.data.redis.serializer.JacksonObjectReader.lambda$create$0(JacksonObjectReader.java:54)
        at org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer.deserialize(GenericJackson2JsonRedisSerializer.java:303)
        ... 89 common frames omitted

관련 서비스 클래스와 레디스 캐시 설정 클래스
(소스코드는 간략화 및 이름 변경이 적용되어 있다)

AService.java

@Transactional(readOnly = true)
@Cacheable(value = "cache-key")
public Map<Integer, Integer> a() {
    // DB 조회 후 Map으로 반환하는 로직
    return aRepository.findAllBy(...).stream()
            .collect(Collectors.toUnmodifiableMap(...));
}

RedisCacheConfig.java

@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
    // ...
    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.registerModule(new JavaTimeModule());

    // 역직렬화 시 클래스 타입 정보를 명시하여 다형성 지원
    BasicPolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
            .allowIfBaseType(Object.class)
            .build();
    objectMapper.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);

    // Serializer 생성
    GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(objectMapper);

    // ...

    // Redis 캐시 기본 설정
    RedisCacheConfiguration cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
            // ...
            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(serializer));
    // ...
}

이런 설정을 등록 한 후 재가동을 하자 위에 작성된 에러가 발생
원인을 찾아보니 ObjectMapperDefaultTyping.NON_FINAL 설정과 GenericJackson2JsonRedisSerializer 의 동작 방식이 충돌하며 발생했다.
에러가 난 상황에서 Redis에 직접 접속은 해서 기존과 동일한 key-value 쌍으로 저장이 된 부분은 확인이 되었다.

당시 저장된 데이터는 이런 모습이였다.

{"214":0,"216":50,"217":50,"218":50,"219":50,"220":50,"221":50,"222":50,"223":50,"224":50,"225":50,"226":55,"227":55,"228":55,"229":55,"230":55,"231":55,"232":55,"233":55,"234":55,"235":55,"236":60,"237":60,"238":60,"239":60,"240":60,"241":60,"242":60,"243":60,"244":60,"245":60,"246":100,"247":100,"248":100,"249":100,"250":100,"251":100,"252":100,"253":100,"254":100,"255":100,"256":110,"257":110,"258":110,"259":110,"260":110,"261":110,"262":110,"263":110,"264":110,"265":110,"266":120,"267":120,"268":120,"269":120,"270":120,"271":120,"272":120,"273":120,"274":120,"275":120}

 

  1. 초회차 or 캐시 미스된 상태에서 동작
    1. DB 조회 후 Map<Integer, Integer> 타입의 데이터를 반환
    2. ObjectMapper.DefaultTyping.NON_FINAL 설정은 “final 클래스가 아닌 “ 클래스에 대해서만 @class 정보를 추가함
    3. 따라서 위와 같은 포맷의 정보를 저장함
  2. 캐시 히트 시 동작
    1. Redis 내 JSON 데이터를 조회해옴
    2. GenericJackson2JsonRedisSerializer 는 역직렬화 시 기본타입인 Object로 변환 시도
    3. Object는 final이 아님
    4. Jackson은 NON_FINAL인 Object를 역직렬화 하기 위해 어떤 구체적인 클래스로 만들어야 할지 알려주는 @class 속성을 찾음
    5. 따라서 저장할때는 @class 를 안쓰지만 조회 시점에는 @class 가 필요하게 되어 InvalidTypeIdException이 발생하게됨

 

해결방법을 찾아보면서 생긴 궁금증

일단 어떻게 해결했는지보다 더 궁금한 부분이 있었다 ( 실제로는 서비스가 에러가 났으므로 에러부터 대충 처리함 )

  1. 왜 기존엔 잘 되다가 (caffein) 패치 하니까 안됨?
  2. Map의 구현체들이 final임?

왜 기존(caffeine)엔 잘 되다가 패치 하니까 안됨?

이건 기존에 caffeine이 가지고 있는 특성때문에 그런데 caffeine은 In-memory 캐시이다.
애플리케이션(JVM) 내 메모리 내부에 Java 객체 자체를 그대로 저장한다 (객체 참조)
데이터를 네트워크로 보내거나 파일로 저장하는 방식이 아니므로 직렬화/역직렬화 과정이 필요가 없다
따라서 이 경우에는 에러가 발생하고 있지 않았다.

Map의 구현체들이 final임?

Map의 구현체들이 다 final이 아니라
현재 코드에서 Map을 반환하기 위해서 Collectors.*toUnmodifiableMap* 를 사용하고 있는데 이럴 경우 final class가 된다.
(이 부분은 내가 정확히는 문서나 IDE에서 final class 라는 부분을 찾진 못했지만 해당 코드를 사용해 확인이 가능하다.)

import java.lang.reflect.Modifier;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class ClassTypeCheck {
    public static void main(String[] args) {
        Map<Integer, Integer> testMap = Stream.of(
                        new Integer[]{0, 11},
                        new Integer[]{1, 22},
                        new Integer[]{2, 33}
                )
                .collect(Collectors.toUnmodifiableMap(data -> data[0], data -> data[1]));

        Class<?> actualClass = testMap.getClass();
        System.out.println("실제 클래스 이름: " + actualClass.getName());
        boolean isFinal = Modifier.isFinal(actualClass.getModifiers());
        System.out.println("이 클래스는 final 입니까? " + isFinal);
    }
}
/** 
출력
실제 클래스 이름: java.util.ImmutableCollections$MapN
이 클래스는 final 입니까? true
/*

 

에러 해결

일단 찾아본 바로 적용해볼 수 있는 수정방법이 몇개 있었다.

  1. 설정을 ObjectMapper.DefaultTyping.NON_FINAL 에서 ObjectMapper.DefaultTyping.EVERYTHING 으로 변경
  2. Map → DTO 리턴하도록 수정
  3. Map을 쓰되 HashMap, LinkedHashMap 을 사용

내가 선택한거는 2. Map 말고 DTO 방식으로 수정하여 후딱 처리했다.
1번 방식으로 처음엔 적용해보려고 알아보니 EVERYTHING 속성 자체가 deprecated 된 상태이고 더하여 보안 문제도 있다고 했다.
그리고 이미 서비스중인 상황에서 설정클래스를 변경하자니 뒤에 또 뭐가 튀어나올지 모르는 상황이므로 선택하지 않았다.
3번 방식은 해당 글을 작성하면서 알게되었으므로 이런 방식도 있다~ 라는 정도만 (확인도 안했음)

그래서 최소한의 변경만 하고 싶어 다음과 같이 간단한 방식으로 처리했다.

@Transactional(readOnly = true)
@Cacheable(value = "cache-key")
public ResponseDto a() {
    // DB 조회 후 Map으로 반환하는 로직
    Map<Integer, Integer> map = aRepository.findAllBy(...).stream()
            .collect(Collectors.toUnmodifiableMap(...));
    return new ResponseDto(map);
}

이와 같이 변경하고 기존 Redis에 저장되어있던 데이터를 삭제하고 새로 조회 시
다음과 같이 저장되어 있는걸 확인할 수 있었고 정상동작 함을 확인하였다.

{"@class":"...ResponseDto","aaa":{"@class":"java.util.ImmutableCollections$MapN","214":0,"216":50,"217":50,"218":50,"219":50,"220":50,"221":50,"222":50,"223":50,"224":50,"225":50,"226":55,"227":55,"228":55,"229":55,"230":55,"231":55,"232":55,"233":55,"234":55,"235":55,"236":60,"237":60,"238":60,"239":60,"240":60,"241":60,"242":60,"243":60,"244":60,"245":60,"246":100,"247":100,"248":100,"249":100,"250":100,"251":100,"252":100,"253":100,"254":100,"255":100,"256":110,"257":110,"258":110,"259":110,"260":110,"261":110,"262":110,"263":110,"264":110,"265":110,"266":120,"267":120,"268":120,"269":120,"270":120,"271":120,"272":120,"273":120,"274":120,"275":120}}

추가로

작성하고 보니 우아한 형제들에서도 완전 똑같은 상황은 아니지만 같은 에러에 대해 자세하게 글을 올려두어 링크를 남겨둔다
Spring Cache(@Cacheable) + Spring Data Redis 사용 시 record 직렬화 오류 원인과 해결

그리고 왜 DTO 내부의 Map<>에 대해선 또 @class 가 붙었나?

일단 DTO에 @class가 붙기 시작하자, Jackson은 DTO 내부의 MapN 필드도 검사한다.
이때 선언된 타입(Map)과 실제 타입(MapN)이 다른 것을 보고 "이건 다형성이네"라고 인식한다.
따라서 MapNfinal 클래스인지 여부와 관계없이, 이 다형성을 처리하기 위해 @class를 추가합니다.

라고 확인이 되었다.

이 부분은 그냥 간단하게 남기고 딴거 하러 가자