2025-08-20

1일 1아티클

우아한기술블로그

스프링 캐시 사용 시 record 직렬화 오류

문제 상황

  1. 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);
         }
     }
    
  2. Redis의 캐시 설정
    • GenericJackson2JsonRedisSerializer + 커스텀된 ObjectMapper 사용
  3. 요청 시 예외 발생
    • 첫 요청은 정상 동작
    • 두 번째 요청부터 예외 발생
       com.fasterxml.jackson.databind.exc.InvalidTypeIdException:
       Could not resolve subtype of [simple type, class java.lang.Object]:
       missing type id property '@class'
      

원인 분석

  • @Cacheable 동작 과정
    1. 캐시 저장 시 GenericJackson2JsonRedisSerializer 를 통해 객체 → JSON 직렬화
    2. 캐시 조회 시 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'
      

원인 파악의 걸림돌

  1. 컴파일 타임에서 문제 탐지 불가 (타입 정보가 런타임에 처리되므로)
  2. 첫 요청 정상 처리 (캐시에 데이터가 없어 원본 데이터 반환)
  3. 테스트나 코드 리뷰에서 탐지 어려움 (캐시 실제 동작 상황 테스트나 시뮬레이션을 하지 않으면 문제가 재현되지 않음)

Jackson의 설계 의도

  • final class에 타입 정보를 포함하지 않는 이유
    • 기본적으로 다형성 자동 지원 X → 타입 정보 명시적 포함 필요 → ObjectMapper.activateDefaultTyping() 설정 사용됨
    • record는 java에서 자동으로 final 설정되어 있음 → ObjectMapper.activateDefaultTyping() 에서 DefaultTyping.NON_FINAL 설정에 의해 타입 정보 생략됨

문제의 본질

  • Jackson 입장 : record는 다형성이 전제되지도 않고, 타입 정보가 필요하지 않다는 가정으로 동작하기에 타입 정보를 안 붙임
  • Spring Data Redis 입장 : record에도 타입 정보가 있을 것으로 기대

∴ Jackson 설계 의도와 실제 라이브러리 사용 환경 간의 차이

기존 해결책

  1. DefaultTyping.EVERYTHING 사용
    • 모든 클래스에 타입 정보를 포함해 직렬화
    • 한계 : 불필요한 경우에도 타입 정보가 포함되어 직렬화 결과가 커짐, 성능 이점 X
  2. record 대신 class 사용
    • 일반 클래스를 사용하여 DefaultTyping.NON_FINAL 조건을 충족시킴, 타입 정보 자동 포함
    • 한계 : record가 많이 사용되는 상황 → 실수로 record에 캐싱 적용 시 여전히 문제 발생
  3. 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로 감싸야하여 번거로움

근본 해결책

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가 코드의 간결함과 개발자의 피로를 덜어주었지만, 편의를 위한 정책이 때로는 이렇게 걸림돌로 작용하는구나..를 느꼈다.

오늘 배운 것

  1. 이력서 수정
    • 어제 상담에서 받은 피드백을 토대로, 이력서에서 모호한 표현 등 제거
    • 포트폴리오도 수정 필요한데, 최우선 과제는 아님

내일 할 일

  1. 상담 피드백 바탕으로 포트폴리오 수정 (주말까지는 완료)

참고자료

results matching ""

    No results matching ""