아무튼, 쓰기

레디스 직렬화 4총사 본문

CS/데이터베이스

레디스 직렬화 4총사

순원이 2025. 5. 16. 15:45

1. 직렬화(Serialization)란?

직렬화는 자바 객체를 저장하거나 네트워크로 전송할 수 있는 형태로 변환하는 과정입니다. 이 과정에서 객체는 일련의 바이트 스트림으로 변환됩니다.

레디스 직렬화를 이야기 하기 앞선 직렬화에 대해서 정리하겠습니다. 저는 직렬화를 객체를 Json 으로 변환하는 것이라고 생각하였습니다. 그런데 StringRedisSerializer 는 다른 JsonRedisSerializer 들과 이름이 구별됩니다. 그렇다면 제가 직렬화의 정의를 잘못알고 있는 거 아닐까라는 생각에 직렬화의 정의를 다시 살펴봤습니다.

직렬화(Serialization)는 객체(Object)의 상태를 저장하거나 전송하기 위해 바이트 스트림으로 변환하는 과정을 말합니다.

즉, 직렬화 결과는 Json 뿐만 아니라 언어나 프레임워크에 따라 다양합니다: Java의 기본 직렬화, JSON, XML, Protocol Buffers, MessagePack 등.

 

2. 레디스 직렬화 이름 오해 풀기: 직렬화 결과물에 관해

JsonRedisSerializer 이름 규칙에 따르면 객체를 Json으로 바꾸는 직렬화하려면 그럼 StringRedisSerializer 도 이름 규칙에 따라 문자열을 문자열로 바꿔야 하는 거 아니야? 왜 이거는 문자열을 바이트배열로 바뀌는 거지? **JsonRedisSerializer**도 최종 형태는 바이트 배열이잖아..

StringRedisSerializer의 직렬화 결과물이 String이 아니라 바이트 배열(byte[])입니다. 즉, String을 직렬화하는 것입니다. JsonRedisSerializer 의 직렬화 결과물은 클래스 → Json → 배열(byte[])입니다. 즉 **StringRedisSerializer**의 이름 규칙과 같게 Json을 직렬화 합니다. 그러나 Json 이전에 저희는 클래스를 JsonRedisSerializer 을 통해 Json으로 바꾸고 그 Json이 배열(byte[])이 되는 겁니다.

🔸 모든 직렬화기의 최종 결과는 byte[]이다.

🔸 JsonRedisSerializerStringRedisSerializer든 결국은 byte[]로 바꿔 Redis에 저장된다.

🔸 클래스 이름에서 Json, String 같은 단어는 입력 객체를 어떤 형식으로 인코딩할지를 나타낸다.


3. 스프링에서 제공하는 레디스 직렬화 방식

Spring Data Redis에서는 다양한 직렬화/역직렬화 방식을 제공합니다:

  • StringRedisSerializer
  • JdkSerializationRedisSerializer
  • GenericJackson2JsonRedisSerializer
  • Jackson2JsonRedisSerializer

3.1 StringRedisSerializer

원리: 문자열을 UTF-8 바이트 배열로 변환

특징:

  • 단순한 문자열만 처리 가능
  • redis-cli에서 사람이 읽을 수 있는 형태로 표시
  • 주로 키(Key) 값을 처리할 때 사용

코드 예시:

String key = "user:1001";
String value = "John";

// 문자열을 UTF-8 바이트로 직렬화
redisTemplate.opsForValue().set(key, value);

Redis에 저장된 형태:

> GET user:1001
"John"  // 읽기 쉬운 형태로 표시

3.2 JdkSerializationRedisSerializer

원리: Java의 기본 직렬화 메커니즘 사용 (Serializable 인터페이스)

특징:

  • RedisTemplate, @Cacheable의 기본 직렬화 방식
  • 직렬화 대상은 반드시 Serializable 인터페이스를 구현해야 함
  • 클래스 정보가 포함되어 용량이 큼
  • redis-cli에서 읽을 수 없는 바이너리 형태로 표시

단점:

  • serialVersionUID 관리 필요
  • 클래스 정보가 포함되어 용량 증가
  • 클래스 패키지 변경 시 역직렬화 실패
  • 보안 취약점 존재 (역직렬화 공격)

3.3 GenericJackson2JsonRedisSerializer

원리: ObjectMapper를 사용하여 객체를 JSON으로 직렬화

특징:

  • 객체를 JSON 형태로 직렬화하여 저장
  • 객체의 클래스 지정 없이 모든 Class Type을 JSON 형태로 저장할 수 있는 Serializer이다
  • 클래스 정보가 JSON에 포함됨
  • redis-cli에서 읽을 수 있는 JSON 형태로 표시

예시 결과:

"{\\"@class\\":\\"com.example.User\\",\\"name\\":\\"John\\",\\"age\\":30}"

{
    "@class": "java.util.Arrays$ArrayList",  // 컬렉션 타입
    "value": [
        {"@class": "java.lang.Long", "value": 1},  // 요소 타입
        {"@class": "java.lang.Long", "value": 2},
        {"@class": "java.lang.Long", "value": 3}
    ]
}

단점:

  • 클래스 정보가 포함되어 용량 증가
  • 패키지 정보도 포함되어 있기 때문에 패키지 변경시 역직렬화 실패
  • JSON은 binary 직렬화 방식보다 상대적으로 용량을 더 많이 차지할 수 있다.
    • 예: 숫자 123을 binary로 저장하면 1~2바이트면 충분한데, "123"은 문자열 3바이트입니다.

3.4 Jackson2JsonRedisSerializer

원리: 객체를 JSON으로 직렬화하지만 클래스 정보는 제외

특징:

  • 특정 타입을 명시해야 함 (예: new Jackson2JsonRedisSerializer<>(User.class))
  • 클래스 정보가 포함되지 않아 용량이 비교적 작음
  • redis-cli에서 읽을 수 있는 JSON 형태로 표시

예시 결과:

> GET user:1001
"{\\"name\\":\\"John\\",\\"age\\":30}"

단점:

  • 타입을 명시적으로 지정해야 함
  • List<User>로 저장했더라도, 역직렬화 시 List<Object>로 복원될 수 있습니다.
  • Object.class로 지정 시 역직렬화 결과가 LinkedHashMap으로 변환되는 문제
    • 위의 두 개의 단점이유로 컬렉션을 직렬화 시키려면 GenericJackson2JsonRedisSerializer를 사용하는 것이 권장됨
  • 타입 정보 부재로 ClassCastException 발생 가능
  • JSON은 binary 직렬화 방식보다 상대적으로 용량을 더 많이 차지할 수 있다.

4. Redis-CLI와 직렬화 결과의 표시 방식

Redis는 항상 바이트 배열로 데이터를 저장하지만, redis-cli가 데이터를 표시하는 방식에 차이가 있습니다:

  1. StringRedisSerializer 또는 JSON 직렬화:
    • 바이트 배열이 표준 인코딩(UTF-8)을 따르므로 redis-cli가 문자열로 해석하여 표시
    • 사람이 읽을 수 있는 형태로 출력
  2. JDK 직렬화:
    • 바이트 배열이 자바 전용 바이너리 형식이므로 redis-cli가 문자열로 변환할 수 없음
    • 이스케이프 시퀀스(\\xXX)를 사용해 원시 바이트 값을 표시 (예: \\xac\\xed\\x00\\x05t\\x00\\x08user:1001)

5. 각 직렬화 방식의 비교

직렬화 방식 직렬화 포맷 redis-cli 표시 장점 단점

StringRedisSerializer UTF-8 바이트 읽기 쉬운 문자열 간단함, 모든 시스템 호환 단순 문자열만 가능
JdkSerializationRedisSerializer 자바 바이너리 읽을 수 없는 바이너리 자바 객체 구조 보존 자바 전용, 용량 큼, 보안 취약
GenericJackson2JsonRedisSerializer JSON + 클래스 정보 읽을 수 있는 JSON 타입 자동 처리, 범용성 용량 큼, 클래스 변경 취약
Jackson2JsonRedisSerializer JSON(클래스 정보 없음) 읽을 수 있는 JSON Generic 보다는 용량 작음 타입 명시 필요, 컬렉션 직렬화 시 권장 안 함

6. 결론

Redis 직렬화 방식을 선택할 때는 다음 요소들을 고려해야 합니다:

  • 가독성: redis-cli로 확인 필요한가?
  • 타입 안정성: 다양한 객체 타입을 저장하는가?
  • 저장 공간: 용량 최적화가 중요한가?
  • 보안: 역직렬화 공격 위험이 있는가?
  • 유지보수: 클래스 패키지 변경 가능성이 있는가?

🔫 트러블슈팅1: JsonRedisSerializer<Object> 사용 시 문제점과 해결 방법

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

// 저장
List<Long> idList = List.of(1L, 2L, 3L);
template.opsForValue().set("ids", idList);

// 조회 - 문제 발생!
List<Long> cachedList = template.opsForValue().get("ids");
// 실제로는 List<LinkedHashMap>이므로 ClassCastException 발생

문제점: 캐시 히트 안 일어난다.

Jackson2JsonRedisSerializer<Object>는 직렬화 시 타입 정보를 포함하지 않습니다. Object.class를 사용하면 구체적인 타입 정보가 손실되어, 다양한 타입의 객체를 정확하게 역직렬화하기 어렵습니다.

List<Long>와 같은 제네릭 타입은 런타임에 타입 소거(type erasure)로 인해, Jackson은 내부 요소(Long)의 타입 정보를 저장하지 않습니다. 그 결과, 역직렬화 시에는 List<LinkedHashMap> 또는 List<Object>로 복원될 수 있습니다. 자바에서 equals() 비교가 실패합니다.

Redis가 키에 맞는 값을 성공적으로 반환하더라도, 역직렬화된 값의 타입이 애플리케이션에서 기대하는 타입과 다르면 사용에 문제가 생깁니다. 예를 들어, cached 변수를 List<Long>을 기대했는데 List<LinkedHashMap>이 반환되면, 애플리케이션은 이를 Long 값으로 바로 사용할 수 없어 추가 처리가 필요하거나 오류가 발생합니다. 즉, 캐시 히트는 성공하더라도 값의 타입 불일치로 인해 실질적인 캐시 활용에 실패할 수 있습니다.

해결 방법:

  1. 래퍼 클래스 사용
public record UserListWrapper(List<User> users) {}

UserListWrapper wrapper = new UserListWrapper(userList);
redisTemplate.opsForValue().set("users", wrapper);
UserListWrapper cached = (UserListWrapper) redisTemplate.opsForValue().get("users");

  1. Spring Cache 사용 시 캐시별 명시적 Serializer 지정
@Configuration
public class RedisCacheConfig {
    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        // 1. ObjectMapper 설정
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.activateDefaultTyping(
            LaissezFaireSubTypeValidator.instance,
            ObjectMapper.DefaultTyping.NON_FINAL,
            JsonTypeInfo.As.PROPERTY
        );

        // 2. 직렬화기 설정
        GenericJackson2JsonRedisSerializer serializer = 
            new GenericJackson2JsonRedisSerializer(objectMapper);

        // 3. 캐시 설정
        RedisCacheConfiguration cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(30))
            .serializeKeysWith(
                RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
            )
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(serializer)
            );

        // 4. 캐시 매니저 생성
        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(cacheConfiguration)
            .build();
    }
}
  1. GenericJackson2JsonRedisSerializer 사용(권장)
@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        
        // 타입 정보를 포함하는 직렬화기 사용
        GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer();
        
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(serializer);
        template.setHashValueSerializer(serializer);
        
        return template;
    }
}

🔫 트러블 슈팅2: @Cachable과 ImmutableCollections.ListN의 관계

  @Cacheable(value = "recommendation",
            key = "'user::' + #userId")
    public List<Long> getRecommendedBookIds(Long userId) {
        AiRecommendationResults recommendation = aiClient.getRecommendation(userId);
        List<Long> ids = recommendation.getRecommendedBooks().stream()
                .map(aiResultBookDto -> aiResultBookDto.getBookId())
                .toList();
        return ids;
    }

문제점:

Java 9 이상부터 List.of(...) 또는 .stream().toList() 같은 메서드는 ImmutableCollections.ListN 같은 특수한 내부 클래스를 반환합니다. 그런데 Spring에서 기본 설정으로 사용하는 Redis Serializer는 이 익명 내부 타입을 직렬화할 수 없음

해결: 반환값을 직렬화 가능한 List로 명시적 변환하기

@Cacheable(value = "recommendation", key = "'user::' + #userId")
public List<Long> getRecommendedBookIds(Long userId) {
    AiRecommendationResults recommendation = aiClient.getRecommendation(userId);
    List<Long> ids = recommendation.getRecommendedBooks().stream()
            .map(aiResultBookDto -> aiResultBookDto.getBookId())
            .toList();
    return new ArrayList<>(ids);  // 직렬화 가능한 타입으로 변환
}

참조

https://mangkyu.tistory.com/402