Spring

[Spring] 스프링이 제공하는 레디스 직렬화/역직렬화(Redis Serializer/Deserializer)의 종류와 한계 및 개선법

망나니개발자 2024. 11. 19. 10:00
반응형

 

 

1. 스프링이 제공하는 레디스 직렬화/역직렬화(Redis Serializer/Deserializer)의 종류와 한계 및 개선법


서비스를 개발하다 보면 레디스(Redis)는 사실상 필수불가결한 구성 요소라고 볼 수 있다. 따라서 스프링 진영 역시 레디스를 손쉽게 사용할 수 있도록 캐시 추상화를 해줄 뿐만 아니라, 레디스의 자동 구성(AutoConfiguration) 등을 제공한다. 뿐만 아니라 레디스는 기본적으로 byte 배열을 사용해 데이터를 저장하기 때문에, 스프링 데이터 레디스(spring-data-redis) 에서는 데이터를 직렬화하여 레디스에 저장하고, 필요 시에 역직렬화하는 직렬화/역직렬화 도구까지 기본적으로 제공한다.

하지만 스프링이 제공하는 레디스 직렬화/역직렬화(Redis Serializer/Deserializer)를 그대로 활용하면 몇 가지 문제가 있는데, 스프링이 어떤 도구들을 제공하고 어떤 문제가 있고 어떻게 개선할 수 있을지 살펴보도록 하자.

 

 

 

[ 스프링이 제공하는 레디스 직렬화/역직렬화(Redis Serializer/Deserializer) ]

  • StringRedisSerializer
  • JdkSerializationRedisSerializer
  • GenericJackson2JsonRedisSerializer
  • Jackson2JsonRedisSerializer

 

 

StringRedisSerializer

StringRedisSerializer는 스프링 프레임워크에서 Redis와의 데이터 통신을 위해 문자열(String)을 직렬화/역직렬화하는 데 사용되는 클래스이다. StringRedisSerializer는 기본적으로 문자열 데이터를 UTF-8 형식의 byte 배열로 변환하여 저장하고, byte 배열을 다시 문자열로 변환해준다. 일반적으로 Key-Value 구조에서 Key는 문자열에 해당하므로, Key 값을 처리하기 위해 StringRedisSerializer가 사용된다.

  • 문자열을 UTF-8로 인코딩하여 byte 배열로 변환함
  • 간단한 문자열 처리를 위해 사용되며, 주로 Key 값을 처리할 때 사용됨
class RedisSerializerTest {

    @Test
    void stringRedisSerializerTest() {
        // given
        StringRedisSerializer serializer = new StringRedisSerializer();

        // when
        byte[] result = serializer.serialize("MangKyu");

        // then
        assertThat(result).isNotEmpty();
    }
}

 

 

 

JdkSerializationRedisSerializer

JdkSerializationRedisSerializer는 Java의 기본 직렬화 기법인 JDK 직렬화(Serializable 인터페이스)를 사용하여 객체를 처리하는 데 사용된다. 따라서 직렬화 대상은 반드시 Serializable 인터페이스를 구현해야 한다.

스프링 부트(Spring Boot)에서는 기본적으로 레디스 자동 구성(RedisAutoConfiguration)을 통해 RedisTemplate을 빈으로 제공하는데, RedisTemplate에서 별도로 등록된 Serializer가 없다면 기본적으로 사용하는 것이 바로 JdkSerializationRedisSerializer이다. 마찬가지로 스프링이 제공하는 @Cacheable과 @CacheEvict 등과 같은 캐시 추상화 설정을 위한 RedisCacheConfiguration 내부에서 사용되는 기본 Serializer 역시 JdkSerializationRedisSerializer이다.

해당 Serializer는 자바의 기본 직렬화 기법을 사용하므로, 자바의 기본 직렬화가 갖는 문제들을 그대로 갖게 되는데, 대표적으로 Serializable를 반드시 구현해주어야 한다는 점과 serialVersionUID(클래스 해시값)를 설정하지 않았을 경우 문제가 생길 수 있다는 점, 클래스 정보가 포함되어 용량을 많이 차지할 뿐만 아니라 패키지 이동 및 클래스명 변경 등의 경우에도 역직렬화에 실패할 수 있다는 점 등이 있다.

  • 자바의 기본 직렬화 기법인 JDK 직렬화를 사용함
  • 별도의 설정이 없다면 RedisTemplate, @Cacheable 등에서 기본적으로 사용됨
  • JDK 직렬화를 사용할 때 발생할 수 있는 문제를 그대로 경험하게 됨
@Test
void jdkSerializationRedisSerializerTest() {
    // given
    JdkSerializationRedisSerializer serializer = new JdkSerializationRedisSerializer();

    // when
    byte[] result = serializer.serialize(new Member("MangKyu", 20));

    // then
    assertThat(result).isNotEmpty();
}

 

 

 

GenericJackson2JsonRedisSerializer

GenericJackson2JsonRedisSerializer는 객체를 JSON 형태로 직렬화한다. 내부적으로는 ObjectMapper를 사용하는데, 파라미터로 전달되는 ObjectMapper가 있을 경우에는 이를 활용하고 없다면 직접 ObjectMapper를 생성하여 사용한다.

다음과 같이 GenericJackson2JsonRedisSerializer 내부에서 ObjectMapper를 생성할 때, ObjectMapper의 defaultTyping을 활성화한다. 이를 별도의 코드로 때어내면 다음과 같은 코드가 ObjectMapper에 적용된다고 볼 수 있다.

ObjectMapper objectMapper = new ObjectMapper();
objectMapper.activateDefaultTyping(
    objectMapper.getPolymorphicTypeValidator(),
    ObjectMapper.DefaultTyping.NON_FINAL
);

 

 

문제는 해당 설정이 추가되면 JdkSerializationRedisSerializer와 마찬가지로 직렬화 시에 클래스 정보가 포함된다는 것이다. 다음과 같이 GenericJackson2JsonRedisSerializer를 이용해 직렬화하면 출력 시에 클래스 정보가 포함됨을 확인할 수 있다.

@Test
void genericJackson2JsonRedisSerializerTest() {
    // given
    GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer();

    // when
    byte[] result = serializer.serialize(new Member("MangKyu", 20));

    // then
    assertThat(result).isNotEmpty();

    System.out.println("Serialized: " + new String(result));
}

// 출력 결과
Serialized: {"@class":"com.mangkyu.app.RedisSerializerTest$Member","name":"MangKyu","age":20}

 

 

 

스프링은 기본적으로 ObjectMapper 빈을 제공하기 때문에, 해당 빈에 대한 커스터마이징 없이 주입받아서 사용하면, 이러한 문제를 피할 수 있다. 하지만 그럼에도 불구하고 JSON 문자열 그 자체를 저장하기 때문에 저장 용량을 많이 차지할 뿐만 아니라 패키지 이동 및 클래스명 변경 등의 경우에도 역직렬화에 실패할 수 있다는 등의 문제가 있다. 직렬화된 정보가 클래스패스/타입 명에 종속적이라는 단점이 생기는 것이다.

  • ObjectMapper를 사용하여 직렬화를 시도함
  • 설정에 따라 직렬화 시에 클래스 정보가 포함되어 여러 문제가 생길 수 있음
  • Json 객체를 그대로 바이트 배열로 변환하여 저장하므로, 저장 공간이 많이 사용됨

 

 

 

Jackson2JsonRedisSerializer

Jackson2JsonRedisSerializer 역시 기본적으로 객체를 JSON 형태로 직렬화하는데, 클래스 정보는 포함되지 않지만 항상 타입을 지정해주어야 한다는 점에서 차이가 있다.

@Test
void jackson2JsonRedisSerializerTest() {
    // given
    Jackson2JsonRedisSerializer<Member> serializer = new Jackson2JsonRedisSerializer<>(Member.class);

    // when
    byte[] result = serializer.serialize(new Member("MangKyu", 20));

    // then
    assertThat(result).isNotEmpty();

    System.out.println("Serialized: " + new String(result));
}

 

 

일반적으로 캐시는 여러 객체를 대상으로 범용적으로 사용되기 때문에, Jackson2JsonRedisSerializer를 사용한다면 모든 캐시 대상 객체 별로 Jackson2JsonRedisSerializer 객체를 생성해주어야 한다. 또한 JSON 문자열 그 자체를 저장하기 때문에 저장 용량을 많이 차지한다는 문제 역시 그대로 존재한다.

  • 항상 타입을 지정해주어야 하므로, 직렬화 대상 별로 Jackson2JsonRedisSerializer를 생성해야 함
  • Json 객체를 그대로 바이트 배열로 변환하여 저장하므로, 저장 공간이 많이 사용됨

그 외에도 의도치 않은 ClassCastException 문제가 생기기도 한다.

java.lang.ClassCastException: java.util.LinkedHashMap cannot be cast to com.mangkyu.app.RedisSerializerTest$Member

 

 

 

 

[ 개선된 레디스 직렬화/역직렬화(Redis Serializer/Deserializer) ]

키를 직렬화하기 위해서는 StringRedisSerializer를 사용하면 되지만, 값을 직렬화 하기 위한 3가지 JdkSerializationRedisSerializer, GenericJackson2JsonRedisSerializer, Jackson2JsonRedisSerializer 모두 각각의 장점과 단점을 가지고 있었다. JdkSerializationRedisSerializer는 기존 JDK 방식을 따라야 하기 때문에 해결 가능한 부분에 제약이 있었고, Jackson2JsonRedisSerializer 역시 타입을 구체적으로 지정해주어야 한다는 스펙에 의해 마찬가지로 제약이 있었다.

하지만 GenericJackson2JsonRedisSerializer의 경우 ObjectMapper의 defaultTyping을 비활성화하여 클래스 정보를 제거하고, Json 문자열에 압축 기능만 추가하면 대부분의 문제를 해결할 수 있다. 물론 압축 기능을 적용한다는 것은 저장 공간의 사용을 줄이기 위해 추가적인 CPU 처리가 진행됨을 의미하지만, 대부분의 일반적인 웹 애플리케이션을 기준으로는 CPU를 크게 사용하는 작업이 많지 않기 때문에 압축을 적용하는 것이 합리적이라고 볼 수 있다.

다음과 같은 커스텀 Serializer를 사용하면 GenericJackson2JsonRedisSerializer에 Gzip 기반의 압축 기능을 추가할 수 있을 뿐만 아니라, 용량이 큰 객체를 역직렬화하는 경우에도 스트림 처리를 통해 Humongous 객체의 생성 및 할당을 방지할 수 있다.

import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.serializer.RedisSerializer;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;

public class GzipJsonRedisSerializer<T> implements RedisSerializer<T> {
    private static final Logger log = LoggerFactory.getLogger(GzipRedisSerializer.class);
    private static final byte[] gzipMagicNumber = new byte[]{(byte) 0x1f, (byte) 0x8b};

    private final Class<T> clazz;
    private final ObjectMapper objectMapper;

    public GzipRedisSerializer(Class<T> clazz, ObjectMapper objectMapper) {
        this.clazz = clazz;
        this.objectMapper = objectMapper;
    }

    @Override
    public byte[] serialize(T t) {
        if (t == null) {
            return new byte[0];
        }

        try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
            try (GZIPOutputStream gzipOutputStream = new GZIPOutputStream(outputStream)) {
                objectMapper.writeValue(gzipOutputStream, t);
                gzipOutputStream.finish();
            }
            return outputStream.toByteArray();
        } catch (IOException e) {
            log.error("Redis serialize fail", e);
            throw new RuntimeException(e);
        }
    }

    @Override
    public T deserialize(byte[] bytes) {
        if (bytes == null) {
            return null;
        }

        try (ByteArrayInputStream inputStream = new ByteArrayInputStream(bytes)) {
            if (isGzipCompressed(bytes)) {
                try (GZIPInputStream gzipInputStream = new GZIPInputStream(inputStream)) {
                    return objectMapper.readValue(gzipInputStream, clazz);
                }
            } else {
                return objectMapper.readValue(inputStream, clazz);
            }
        } catch (IOException e) {
            log.error("Redis deserialize fail", e);
            throw new RuntimeException(e);
        }
    }

    private boolean isGzipCompressed(byte[] bytes) {
        return bytes.length >= 2 && bytes[0] == gzipMagicNumber[0] && bytes[1] == gzipMagicNumber[1];
    }
}

 

 

해당 클래스는 다음과 같이 레디스 템플릿(RedisTemplate) 객체를 생성할 때에도 사용할 수 있고, 캐시 추상화를 위한 @Cacheable과 @CacheEvict 등을 사용하는 경우에도 적용할 수 있다.

// RedisTemplate에 GzipJsonRedisSerializer 적용
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GzipJsonRedisSerializer<>(Object.class, objectMapper));

// 캐시 추상화에 GzipJsonRedisSerializer 적용
RedisCacheConfiguration
    .defaultCacheConfig()
    .serializeValuesWith(
        RedisSerializationContext.SerializationPair.fromSerializer(
            new GzipRedisSerializer<>(Object.class, objectMapper)
        )
    )
    .disableCachingNullValues();

 

 

 

 

 

 

본문에도 적어두었지만, 여기서도 마찬가지로 설계 트레이드 오프를 해야 한다. 압축 기능을 적용하면 저장 공간의 사용을 줄일 수 있겠지만, 압축 작업을 위한 추가적인 CPU 처리가 요구된다. 대부분의 일반적인 웹 애플리케이션에는 CPU 집약적인 작업이 많지 않기 때문에 압축을 적용하는 것이 합리적이다. 하지만 이러한 판단은 비즈니스에 따라서 혹은 상황에 따라서 달라질 수 있기 때문에 이를 인지하는 것 자체가 매우 중요하다.

 

 

 

 

 

 

반응형