2025-08-20
1일 1아티클
우아한기술블로그
스프링 캐시 사용 시 record 직렬화 오류
문제 상황
record
+@Cacheable
사용한 Redis 캐싱public record UserRecord(Long id, String name) {} @Service public class UserService { @Cacheable(value = "users", key = "#id") public UserRecord getUser(Long id) { return new UserRecord(id, "User-" + id); } }
- Redis의 캐시 설정
GenericJackson2JsonRedisSerializer
+ 커스텀된ObjectMapper
사용
- 요청 시 예외 발생
- 첫 요청은 정상 동작
- 두 번째 요청부터 예외 발생
com.fasterxml.jackson.databind.exc.InvalidTypeIdException: Could not resolve subtype of [simple type, class java.lang.Object]: missing type id property '@class'
원인 분석
@Cacheable
동작 과정- 캐시 저장 시
GenericJackson2JsonRedisSerializer
를 통해 객체 → JSON 직렬화 - 캐시 조회 시 JSON → 객체 역직렬화 (문제 발생)
- 캐시 저장 시
- 직렬화 시 Jackson의
ObjectMapper
사용mapper.activateDefaultTyping( mapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY );
final이 아닌 클래스만
타입 정보(@class)
를 JSON에 포함하여 직렬화 - 예상 JSON
{ "@class": "com.example.springcachetest.domain.UserRecord", "id": 1, "name": "User-1" }
- 실제 JSON
{ "id": 1, "name": "User-1" }
- 역직렬화 시 문제
- JSON 안에 타입 정보가 없음 → 역직렬화 대상 클래스(type) 유추 불가 → 예외 발생
InvalidTypeIdException: missing type id property '@class'
- JSON 안에 타입 정보가 없음 → 역직렬화 대상 클래스(type) 유추 불가 → 예외 발생
원인 파악의 걸림돌
- 컴파일 타임에서 문제 탐지 불가 (타입 정보가 런타임에 처리되므로)
- 첫 요청 정상 처리 (캐시에 데이터가 없어 원본 데이터 반환)
- 테스트나 코드 리뷰에서 탐지 어려움 (캐시 실제 동작 상황 테스트나 시뮬레이션을 하지 않으면 문제가 재현되지 않음)
Jackson의 설계 의도
- final class에 타입 정보를 포함하지 않는 이유
- 기본적으로 다형성 자동 지원 X → 타입 정보 명시적 포함 필요 →
ObjectMapper.activateDefaultTyping()
설정 사용됨 - record는 java에서 자동으로 final 설정되어 있음 →
ObjectMapper.activateDefaultTyping()
에서DefaultTyping.NON_FINAL
설정에 의해 타입 정보 생략됨
- 기본적으로 다형성 자동 지원 X → 타입 정보 명시적 포함 필요 →
문제의 본질
- Jackson 입장 : record는 다형성이 전제되지도 않고, 타입 정보가 필요하지 않다는 가정으로 동작하기에 타입 정보를 안 붙임
- Spring Data Redis 입장 : record에도 타입 정보가 있을 것으로 기대
∴ Jackson 설계 의도와 실제 라이브러리 사용 환경 간의 차이
기존 해결책
DefaultTyping.EVERYTHING
사용- 모든 클래스에 타입 정보를 포함해 직렬화
- 한계 : 불필요한 경우에도 타입 정보가 포함되어 직렬화 결과가 커짐, 성능 이점 X
- record 대신 class 사용
- 일반 클래스를 사용하여
DefaultTyping.NON_FINAL
조건을 충족시킴, 타입 정보 자동 포함 - 한계 : record가 많이 사용되는 상황 → 실수로 record에 캐싱 적용 시 여전히 문제 발생
- 일반 클래스를 사용하여
- Wrapper class +
@JsonTypeInfo
사용public class Wrapper { @JsonTypeInfo(use = Id.CLASS, include = As.WRAPPER_ARRAY) public Object cached; }
- record 클래스를 Wrapper 클래스 안에 넣은 뒤,
@JsonTypeInfo
를 붙임 - 다형성 처리의 base type을 java.lang.Object 로 강제하고, non-Final이므로 타입 정보 포함을 보장
- 한계 : 매번 Wrapper로 감싸야하여 번거로움
- record 클래스를 Wrapper 클래스 안에 넣은 뒤,
근본 해결책
GenericJackson2JsonRedisSerializer에서 사용하는 ObjectMapper에 대해
기본 모듈 등록이나 커스텀 설정은 Spring이 아닌 사용자 책임이라는 방침을 유지
- GenericJackson2JsonRedisSerializer 내부 로직
@Override public byte[] serialize(@Nullable Object value) throws SerializationException { if (value == null) { return SerializationUtils.EMPTY_ARRAY; } try { return writer.write(mapper, value); } catch (IOException ex) { throw new SerializationException("Could not write JSON: %s".formatted(ex.getMessage()), ex); } } @Override @SuppressWarnings("unchecked") public <T> T deserialize(@Nullable byte[] source, Class<T> type) throws SerializationException { Assert.notNull(type, "Deserialization type must not be null; Please provide Object.class to make use of Jackson2 default typing."); if (SerializationUtils.isEmpty(source)) { return null; } try { return (T) reader.read(mapper, source, resolveType(source, type)); } catch (Exception ex) { throw new SerializationException("Could not read JSON: %s".formatted(ex.getMessage()), ex); } } protected JavaType resolveType(byte[] source, Class<?> type) throws IOException { if (!type.equals(Object.class) || !defaultTypingEnabled.get()) { return typeResolver.constructType(type); } return typeResolver.resolveType(source, type); }
직렬화 : ObjectMapper의 writeValueAsBytes() 사용
역직렬화 : resolveType()으로 JSON 내부 타입 정보 확인 후 추론
→ 결론 : record에 타입 정보를 강제 추가하자.
- Record 전용 TypeResolver 구현
public class RecordSupportingTypeResolver extends DefaultTypeResolverBuilder { public RecordSupportingTypeResolver(DefaultTyping t, PolymorphicTypeValidator ptv) { super(t, ptv); } @Override public boolean useForType(JavaType t) { boolean isRecord = t.getRawClass().isRecord(); boolean superResult = super.useForType(t); if (isRecord) { return true; } return superResult; } } RecordSupportingTypeResolver typeResolver = new RecordSupportingTypeResolver(DefaultTyping.NON_FINAL, mapper.getPolymorphicTypeValidator()); StdTypeResolverBuilder initializedResolver = typeResolver.init(JsonTypeInfo.Id.CLASS, null); initializedResolver = initializedResolver.inclusion(JsonTypeInfo.As.PROPERTY); mapper.setDefaultTyping(initializedResolver);
- 장점
- record의 장점을 그대로 유지하면서도, 자동으로 타입 정보가 붙어 직렬화/역직렬화 동작
- 개발자의 누락 실수 등에 의한 장애 가능성 최소화
- 주의점
- 직렬화 방식이 변경되면 기존 캐시 데이터와 호환 문제로 역직렬화 오류 발생 가능
- 테스트 환경에서의 검증 및 충돌 방지책 필요
- 장점
record가 코드의 간결함과 개발자의 피로를 덜어주었지만, 편의를 위한 정책이 때로는 이렇게 걸림돌로 작용하는구나..를 느꼈다.
오늘 배운 것
- 이력서 수정
- 어제 상담에서 받은 피드백을 토대로, 이력서에서 모호한 표현 등 제거
- 포트폴리오도 수정 필요한데, 최우선 과제는 아님
내일 할 일
- 상담 피드백 바탕으로 포트폴리오 수정 (주말까지는 완료)