티스토리 뷰

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<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);

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

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

    System.out.println("Serialized => " + new String(result));
    System.out.println("Deserialized Type => " + deserialized.getClass());
}

 

 

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

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

 

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

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

 

 

ClassCastException 예외가 발생하는 상황과 구체적인 이유를 알기 쉽게 RedisTemplate 바탕으로 살펴보도록 하자. 일반적으로 RedisTemplate의 Value가 될 수 있는 것은 Long이나 Integer 혹은 Map, Set 등을 포함하여 개발자가 비즈니스적으로 추가한 클래스들 등이 있으므로 무한하다고 볼 수 있다. 하지만 모든 타입 별로 RedisTemplate을 생성하는 것은 번거롭기 때문에 다음과 같이 범용적으로 사용 가능하도록 설정 한 것이다.

RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class, objectMapper));

 

 

위의 테스트 코드를 실행한 결과를 살펴보면, 출력 결과가 다음과 같음을 알 수 있다.

Serialized => {"name":"MangKyu","age":20}
Deserialized Type => class java.util.LinkedHashMap

 

 

데이터를 byte로 변환하고 byte를 데이터로 다시 변환하는 것은 전적으로 ObjectMapper의 책임이다. ObjectMapper는 {"name":"MangKyu","age":20}와 같은 Json 문자열을 어떤 클래스로 변환해야 하는데, 이때 어떠한 클래스인지 구체적인 타입 정보가 없고 Object 클래스로 타입을 설정했기 때문에 ObjectMapper가 임의로 LinkedHashMap 타입으로 변환한 것이다.

즉, 저장 공간을 줄이기 위해 직렬화 데이터에 클래스 정보를 제거했는데 이로 인해 변환해야 하는 타입 정보를 알 수 없어 우리가 변환되기를 원하는 클래스로 변환하지 못하는 것이다.

스프링에서는 API 요청의 Body로 전달된 Json 데이터를 역직렬화하기 위해 기본적으로 @RequestBody 애노테이션을 사용한다. 이를 위해 역시 내부적으로는 ObjectMapper가 사용되며, 전달되는 Json 데이터에 클래스의 타입 정보 역시 포함되지 않는 것도 동일하다. 그렇다면 @RequestBody를 사용해 역직렬화를 하는 경우에는 왜 문제가 발생하지 않을까?

### 초대 수락
POST {{host}}/api-web/v3/event/accept
content-type: application/json

{
  "referralKey": "my-key"
}

 

 

그 이유는 스프링 프레임워크가 내부적으로 리플렉션을 사용하여 ObjectMapper에게 변환할 타입 정보를 넘겨주고 있기 때문이다. 다음의 코드 부분에서 parameter.getNestedGenericParameterType()이 타입 정보에 해당한다. 즉, 구체적인 타입 정보를 스프링이 제공해주기 때문에 우리가 선언한 타입으로 변환이 가능한 것이다.

Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());

 

 

 

 

위와 같은 문제에 대비하여 클래스 정보를 넣게 되면 저장 공간을 추가적으로 사용할 뿐만 아니라 클래스 정보가 변경되었을 경우에 캐시 정합성 관련 문제가 생길 수 있다. 그리고 대부분의 일반적인 웹 애플리케이션을 기준으로는 CPU를 크게 사용하는 작업이 많지 않다.

따라서 압축을 적용하는 것이 대부분 합리적이라고 볼 수 있는데, 추가적인 CPU 처리를 하더라도 압축 기능을 적용하여 레디스 사용량을 줄일 수 있는 레디스 직렬화기를 직접 구현하여 적용해보도록 하자.

 

 

 

 

관련 포스팅

  1. 스프링이 제공하는 레디스 직렬화/역직렬화(Redis Serializer/Deserializer)의 종류와 한계
  2. 스프링에서 레디스 설정 및 직렬화/역직렬화(Redis Serializer/Deserializer) 고도화하기

 

 

 

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG more
«   2025/02   »
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
글 보관함