backend

스프링에서 제공하는 Redis 직렬화/역직렬화 도구의 한계와 직접 구현 (Snappy 압축)

downfa11 2025. 2. 19. 22:04

Redis에서도 동일하게 클래스 정보를 넘겨주게 되면 저장공간을 사용하게 될 뿐만 아니라, 클래스 정보가 변경되는 경우 캐시 정합성 문제가 생길 수 있다.
 
따라서 압축을 적용하는 것이 합리적이라 볼 수 있고, 추가적인 CPU 처리를 하더라도 Redis 사용량을 줄일 수 있는 직렬화 도구를 직접 구현하여 적용해보자.
 
 
 

스프링에서 Redis 사용 (spring-data-redis)

Redis는 기본적으로 byte 배열을 사용해 데이터를 저장한다.

spring-data-redis에서는 데이터를 직렬화해서 Redis에 기록하고 역직렬화하는 도구를 제공한다.

 
하지만 스프링에서 제공하는 직렬화/역직렬화 도구를 그대로 활용하면 몇가지 문제가 있으며 어떻게 개선할 수 있을지 살펴보자.
 

Spring 진영에서는 Redis를 쉽게 사용할 수 있도록 캐시 추상화(@Cacheable, @CachePut, @CacheEvict)를 제공한다.

 

Spring의 캐시 추상화

캐시는 연산이 복잡하거나 외부 데이터베이스에서 조회하는데 시간이 걸리는 로직의 결과를 미리 저장해두고 불러온다.
특히 반복적으로 동일한 결과를 반환하는 경우, 서버의 부담을 줄이고 성능을 높힐 수 있다.
 
Spring에서는 트랜잭션과 마찬가지로 AOP를 이용해 메소드 실행 과정에 투명하게 적용된다.
이를 통해 핵심 비즈니스로부터 캐시 관련 로직을 분리할 뿐만 아니라, 손쉽게 캐시를 적용할 수도 있다.
 
Spring에서 제공하는 이런 추상화 기술은 애플리케이션 개발을 용이하게 해준다.
 
관련 기술을 적극 활용하여 개발 생산성을 높이고, 핵심 로직과 공통 관심사를 분리하는 이점을 얻어가자.
 
 

스프링에서 제공하는 직렬화/역직렬화 도구

  • StringRedisSerializer
    • 기본적으로 UTF-8 형식의 byte 배열로 변환하여 간단한 문자열 처리
  • JdkSerializationRedisSerializer
    • SpringBoot에서는 RedisAutoConfiguration을 통해 RedisTemplate를 빈으로 제공하는데, 별도로 등록된 Serializer가 없다면 기본적으로 사용
    • Serializable를 반드시 구현해줘야 하며, 클래스 정보를 포함해 용량을 많이 차지함
    • JDK 직렬화(Serializable 인터페이스)를 사용하여 객체를 처리
  • GenericJackson2JsonRedisSerializer
    • 패러미터로 전달되는 ObjectMapper가 없으면 직접 생성하여 사용
    • 마찬가지로 클래스 정보를 담아 그대로 바이트 배열로 변환하기에 저장 공간이 많이 사용된다
    • 내부적으로 ObjectMapper를 이용해서 객체를 JSON 형태로 직렬화한다.
  • Jackson2JsonRedisSerializer
    • 캐시는 여러 객체를 대상으로 범용적으로 사용되기 때문에, Jackson2JsonRedisSerializer를 사용하면 모든 객체별로 Jackson2JsonRedisSerializer 객체를 생성해주어야 한다.
    • 또 JSON 문자열 그 자체를 바이트 배열로 변환해서 저장하기에, 저장 용량을 많이 차지하는 문제 역시 남아있다.
    • 기본적으로 객체를 JSON 형태로 직렬화하지만, 위의 GenericJackson2JsonRedisSerializer와 다르게 클래스 정보는 포함하지 않고 타입을 지정해주어야 한다.

 

GenericJackson2JsonRedisSerializer vs Jackson2JsonRedisSerializer

주요 차이점은 클래스 타입 정보를 포함하는지 여부이다.
 
 
 

스프링에서 제공하는 Serializer들의 문제점

JdkSerializationRedisSerailizer, GenericJackson2JsonRedisSerializer: 저장 용량을 많이 차지할 뿐만 아니라, 패키지 이동 및 클래스명 변경 등의 경우에도 역직렬화에 실패할 수 있다.

직렬화된 바이트가 클래스패스/ 타입명에 종속적이게 됨
 

Jackson2JsonRedisSerializer:  클래스 정보가 포함되지 않지만 항상 타입을 지정해줘야한다.

그리고 JSON 문자열을 그대로 저장하기 때문에 저장 용량이 많이 차지한다는 문제점은 그대로 있다.
 
이때 데이터를 byte로 변환하고 다시 변환해오는 과정은 전적으로 ObjectMapper의 책임이다.
 
ObjectMapper는 JSON 문자열을 다시 객체로 변환하는 과정에서 어떤 타입인지 구체적인 정보가 없기 때문에 임의로 LinkedHashMap 타입으로 변환한다.
 
 
저장 공간을 줄이기 위해서 직렬화 데이터에 클래스 정보를 제거한건데, 정작 그로 인해서 우리가 변환되기 원하는 클래스로 변환하지 못하는 것이다.
 
 

에서의 JSON 역직렬화시 문제가 생기지 않는 이유

Spring에서 @RequestBody를 이용할때도 내부적으로 ObjectMapper를 사용하는데, 전달되는 JSON 데이터에 클래스 타입정보가 포함되지 않는 것은 동일하다.

 

근데 왜 @RequestBody에서 역직렬화할때는 문제가 발생하지 않는 걸까?

 
그 이유는 스프링 프레임워크가 내부적으로 Java의 리플렉션을 이용해서 변환할 타입 정보를 넘겨주기 때문이다.
(reflection: 런타임 시점에서 동적 바인딩을 통해 클래스의 정보를 가져오는 Java API)
 
 
 

Redis에서 직렬화에 사용할 CustomSerializer를 직접 구현해보자

보통의 조회 비즈니스에 대한 캐싱은 Gzip을 선택하는게 압축율이 가장 높아서 효과적이다.
 
본 게시글 출처 :
 

 

[Spring] 스프링에서 레디스 설정 및 직렬화/역직렬화(Redis Serializer/Deserializer) 고도화하기

이전 포스팅에서는 스프링이 제공하는 레디스 직렬화/역직렬화(Redis Serializer/Deserializer)이 갖는 한계점에 대해서 살펴보았다. 일반적인 현대의 애플리케이션에서는 CPU 사용량보다는 메모리 사용

mangkyu.tistory.com

 
 
이 분이 직접 gzip으로 구현한 Serializer가 더 잘되있는거 같은데, Gzip은 CPU 부담이 커서 상황에 따라 잘 구현해야할 것 같다.
 
Gzip : 속도는 느리지만 압축률을 최대로 하기에 CPU 자원 소모가 큼
Snappy : Google 개발, Kafka 사용 - 압축률보다는 속도에 초점을 맞춘 압축 방식
 
 
진행하는 프로젝트에서 캐싱 목적으로 Redis를 사용하는 경우는 보통 전적 결과 검색이나 통계 분석시 조회 성능을 개선하기 위한 목적이 크다.
 
하지만 아직 전적 결과 데이터에 대한 스키마가 확립되지 않았고, CPU를 희생해서 압축률을 크게 해야할 필요성을 느끼지 못했다.
 
캐싱할 데이터가 많아져서 압축률이 정 필요해진다면, 그때 가서 필요한 곳에 적재적소로 각 압축을 이용한 직렬화 도구를 사용하겠다.
 
아직은 성능에 초점을 맞춘 Snappy이 더 유연하게 사용 가능하리라 판단했다.
 
 

의존성 주입

Snappy에 대한 의존성만 가지고 오면 준비는 끝난다.

implementation 'org.xerial.snappy:snappy-java:1.1.8.4'

 
 

로직 구현

Snappy은 해당 데이터가 어떤 압축 기법인지 식별하기 위해 Magic Bytes라는 바이트 시퀀스를 사용한다.
데이터의 가장 시작 부분에 "0xFF", "0xF3" 의 2byte를 통해 Snappy 형식임을 알 수 있다.
 
아래 로직에서는 SNAPPY_MAGIC_BYTES 상수로 처리했다.
 

@RequiredArgsConstructor
public class CustomRedisSerializer<T> implements RedisSerializer<T> {

    private static final byte[] SNAPPY_MAGIC_BYTES = new byte[]{(byte) 0xFF, (byte) 0xF3};

    private final ObjectMapper objectMapper;
    private final TypeReference<T> typeReference;

    private final int minCompressionSize;


    public CustomRedisSerializer(ObjectMapper objectMapper, TypeReference<T> typeReference) {
        this.objectMapper = objectMapper;
        this.typeReference = typeReference;
        this.minCompressionSize = -1;
    }

    @Override
    public byte[] serialize(T value) throws SerializationException {
        if (value == null) {
            return null;
        }

        try{
            byte[] bytes = objectMapper.writeValueAsBytes(value);
            if (minCompressionSize == -1 || bytes.length > minCompressionSize) {
                return encodeSnappy(bytes);
            }

            return bytes;
        } catch(IOException e){
            throw new SerializationException("Error serializer");
        }
    }


    private byte[] encodeSnappy(byte[] original) {
        try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
             SnappyOutputStream snappyOutputStream = new SnappyOutputStream(byteArrayOutputStream)) {

            snappyOutputStream.write(original);
            snappyOutputStream.close();

            return byteArrayOutputStream.toByteArray();
        } catch (IOException ex) {
            throw new SerializationException("Error encode to snappy", ex);
        }
    }

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

        try{
            if (isSnappyCompressed(bytes)) {
                byte[] decodeBytes = decodeSnappy(bytes);
                return objectMapper.readValue(decodeBytes, 0, decodeBytes.length, typeReference);
            }
            return objectMapper.readValue(bytes, 0, bytes.length, typeReference);
        } catch(IOException e){
            throw new SerializationException("Error deserializer");
        }
    }

    private byte[] decodeSnappy(byte[] encoded) {
        try {
            return Snappy.uncompress(encoded);
        } catch (IOException ex) {
            throw new SerializationException("Error decode snappy", ex);
        }
    }

     private boolean isSnappyCompressed(byte[] bytes) {
        if (bytes.length < 8) {
            return false;
        }

        // \x82SNAPPY\x00
        return bytes[0] == (byte) 0x82 && bytes[1] == 'S' &&
                bytes[2] == 'N' && bytes[3] == 'A' &&
                bytes[4] == 'P' && bytes[5] == 'P' &&
                bytes[6] == 'Y' && bytes[7] == (byte) 0x00;
    }
}

 
Class 정보를 그대로 가져와도 되는데, jackson의 TypeReference를 활용해서 단일 클래스 외에도 List<클래스>같은 경우에도 고려했다.
 
 
 

실제 프로젝트 적용 (Webflux 환경, ReactiveRedis)

RedisTemplate을 정의하는 RedisConfiguration.java에서 다음과 같이 설정할 수 있다.

 

private final ReactiveRedisConnectionFactory redisConnectionFactory;
private final ObjectMapper objectMapper;

@Bean
public ReactiveRedisOperations<String, Result> resultRedisTemplate() {
   return createReactiveRedisTemplate(objectMapper, new TypeReference<>() {});
}

private <V> ReactiveRedisOperations<String, V> createReactiveRedisTemplate(ObjectMapper objectMapper, TypeReference<V> typeRef) {
   RedisSerializationContext.RedisSerializationContextBuilder<String, V> builder = RedisSerializationContext.newSerializationContext(new StringRedisSerializer());

   RedisSerializationContext<String, V> context =
         builder.key(new StringRedisSerializer())
               .value(new CustomRedisSerializer<>(objectMapper, typeRef))
               .build();

   return new ReactiveRedisTemplate<>(redisConnectionFactory, context);
}

 

단, 이때 minCompressionSize를 명시하지 않으면 모든 작업에 대해서 Snappy 압축을 진행한다.

minCompressionSize를 명시하면 해당 크기보다 작은 경우는 압축하지 않는다.

 
 

불필요한 복사가 이뤄지지 않도록 Java의 ByteArrayOutputStream처럼 스트림을 활용해서 압축을 적용할 수 있다.

Snappy에서는 SnappyOutputStream을 통해 스트림 기반 클래스를 제공해서 배열의 복사를 최소화할 수 있었다.

  • 배열 복사 최소화: SnappyOutputStreamwrite() 메서드를 호출하여 데이터를 스트림에 직접 압축하므로, 기존 배열을 복사할 필요 없이 스트림을 통해 압축된 결과만 얻음
  • 메모리 효율성: 메모리에서 데이터를 한 번만 처리하므로, 배열을 두 번 복사하는 것보다 메모리 사용 최적화
  • 압축 성능: 스트림을 이용하여 데이터를 처리하면 메모리에 데이터를 일괄로 올리지 않고 처리해서 성능 향상

 
 
 
이 경우 등록한 Configuration에 대해서 ReactiveRedisOperations를 주입해서 다음과 같이 사용할 수 있다.

resultRedisTemplate.opsForList()... // Result 객체 형태로 직렬화, 역직렬화

 
 
실제 압축된 데이터는 다음과 같이 나온다.

"\x82SNAPPY\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x03J\xf1\nD
{ \"spaceId\":\"dummy-\x05\x10\xf0i-id\",
\"state\":\"success\", \"channel\":9, \"room\":1,
\"winTeam\":\"Blue\",\"loseTeam\":\"Red\", \"blueTeams\":[{\"membershipId\"\x01A$socket\":11\x05b\x1cmpindex\"\x01\x1b\x1cuser_nam\x01\x87\x01^(Player1\",
\"t2s\x00\x15\x99\x043,\r\x99\xf0\xc98,\"kill\":4,\"death\":5,\"assist\":7,\"gold\":1578,\"level\":6,\"maxhp\":79,\"maxmana\":41,\"attack\":28,
\"critical\":25,\"criProbability\":78,\"attrange\":692,\"attspeed\":1.0888498,\"movespeed\":99,\"itemList\":[61,91,66]},
{\"me..\x01\x042,5.\x04546.\x01\x042,Z.\x01\x002\x8a.\x01\x0061.\x0065.\x0069.\x0081.\x0c37745.\x04155/\x001./\x01\x001\x01:-/\x0413./\x01\x0459\x05\x0e\x04Pr=/\x0489%]\x04ra
)/\x0420\x05\x0f-\x19$0.6923883,./\x01\x01G\x00i9/<46,86,29]}],\"red^j\x02\x0039<\x0086;\x01\x0036;\x01\bRedBh\x02\x00RE\xc9Ug\x001Qg\x00419\x00859\x01\xd1Mg\x00559\b85799\x0025
9\x002\x01\xda\x00aMh\x01\x181928\x01\x008a\x19:g\x02\x0441%)-8\x0444\x05\x0e57\x184880367!\xa2\bove\r\x17\b22,.8\x01\x1833,1,33Jf\x02\x0049*\x005\x01{\x00hy\x94\x004Z+\x01]e:+\x01\x000
1+\x0071+\x003N+\x01\x0061+\x0c17395+IX\x10hp\":4\t\x0b\x04maa\x92\x003I&\x00ae\x92IU\x00ti\x92\x0c87,\">+\x01\x0022\x92\x03\b2036,\x01\x10885166*\x01\x04852*\x01\x1c82,74,43Eb\x14dateTi
\x85\x92\xc82025-02-20T22:55:45.582179560\",\"gameDuration\":6854}”

 
 
원래 isSnappyCompressed 함수는 Snappy 압축 여부를 확인하는 메서드로, 첫 2바이트가 0xFF, 0xF3인지 확인하는 방식이었다.
 
근데 막상 압축한 바이트를 살펴보니 이상했다 잉???
 
그래서 찾아보니 Snappy 압축은 항상 특정한 매직 바이트로 시작하는게 아닐 뿐더러(raw 방식) 매직 바이트도 틀렸던거다.
 
SnappyOutputStream을 이용해서 압축했기에, Snappy 프레임 포맷(Snappy Framed Format)을 따라서 압축해제해야한다.
Snappy.uncompress()은 raw 압축 방식이고 프레임 방식의 경우 매직 바이트는 "\x82SNAPPY\x00"를 따른다고 한다.
 
따라서 압축 해제시 다음과 같이 SnappyInputStream을 사용해서 해제해야한다.

    private byte[] decodeSnappy(byte[] encoded) {
        try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(encoded);
             SnappyInputStream snappyInputStream = new SnappyInputStream(byteArrayInputStream)) {

            return snappyInputStream.readAllBytes();
        } catch (IOException ex) {
            throw new SerializationException("Error decoding snappy: " + ex.getMessage());
        }
    }

 
압축할때와 압축 해제할때 서로 다른 포맷을 사용하지 않도록 유의하도록 하자.
 
 
 
 

더 개선할 점

 

Snappy.uncompress(encoded) 안에서 내부적으로 배열 복사가 이뤄지는건 어떻게 해결해야할지 모르겠다.

 
불필요한 메모리 사용량을 증가시켜면 GC를 유발하는게 신경쓰인다.
복사하지 않고 직접 참조할 수 있도록 하면 좋을텐데 잘모르겠다.
 
 
 
출처 및 인용.
스프링이 제공하는 레디스 직렬화/역직렬화의 종류와 한계 - https://mangkyu.tistory.com/402
스프링에서 레디스 설정 및 직렬화/역직렬화 고도화하기 - https://mangkyu.tistory.com/411