| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
- 별찍기-10
- 큰수A+B
- 그리디
- 도커
- docker
- 좌표 압축
- AWS
- 신고결과목록
- Maven
- NEXUS
- springboot
- IntelliJ
- 프로그래머스
- repository url
- springdoc
- 15829
- EC2
- BOJ
- http파일
- 숫자 변환하기
- 뒤에있는큰수찾기
- 1064
- 인텔리제이
- 정렬
- 10989번
- 무인도 여행
- 5430
- 백준
- swapfile
- sort
- Today
- Total
Time to lazy
[SpringBoot + Redis-Cache] missing type id property '@class' 에러 본문
상황은 운영중인 서비스의 캐시 구현체를 [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));
// ...
}
이런 설정을 등록 한 후 재가동을 하자 위에 작성된 에러가 발생
원인을 찾아보니 ObjectMapper의 DefaultTyping.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}
- 초회차 or 캐시 미스된 상태에서 동작
- DB 조회 후
Map<Integer, Integer>타입의 데이터를 반환 ObjectMapper.DefaultTyping.NON_FINAL설정은 “final클래스가 아닌 “ 클래스에 대해서만 @class 정보를 추가함- 따라서 위와 같은 포맷의 정보를 저장함
- DB 조회 후
- 캐시 히트 시 동작
- Redis 내 JSON 데이터를 조회해옴
GenericJackson2JsonRedisSerializer는 역직렬화 시 기본타입인 Object로 변환 시도- Object는 final이 아님
- Jackson은 NON_FINAL인 Object를 역직렬화 하기 위해 어떤 구체적인 클래스로 만들어야 할지 알려주는 @class 속성을 찾음
- 따라서 저장할때는 @class 를 안쓰지만 조회 시점에는 @class 가 필요하게 되어 InvalidTypeIdException이 발생하게됨
해결방법을 찾아보면서 생긴 궁금증
일단 어떻게 해결했는지보다 더 궁금한 부분이 있었다 ( 실제로는 서비스가 에러가 났으므로 에러부터 대충 처리함 )
- 왜 기존엔 잘 되다가 (caffein) 패치 하니까 안됨?
- 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
/*
에러 해결
일단 찾아본 바로 적용해볼 수 있는 수정방법이 몇개 있었다.
- 설정을
ObjectMapper.DefaultTyping.NON_FINAL에서ObjectMapper.DefaultTyping.EVERYTHING으로 변경 - Map → DTO 리턴하도록 수정
- 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)이 다른 것을 보고 "이건 다형성이네"라고 인식한다.
따라서 MapN이 final 클래스인지 여부와 관계없이, 이 다형성을 처리하기 위해 @class를 추가합니다.
라고 확인이 되었다.
이 부분은 그냥 간단하게 남기고 딴거 하러 가자
'Spring' 카테고리의 다른 글
| SpringBoot - Springdoc (swagger) 버전 호환표 (0) | 2025.12.10 |
|---|---|
| [SpringBoot] 메세지 큐 Consumer 역할 코드 샘플 (0) | 2025.09.30 |
| API 공통 응답 객체 만들어보기 (0) | 2024.01.28 |
| @Value / @ConfigurationProperties 사용해보기 (0) | 2023.04.05 |
| [Spring Batch] 공부 내용 정리 (2) (0) | 2022.05.03 |