Redis에 Java 객체를 저장했는데, 클래스에 필드 하나를 추가한 뒤 갑자기 캐시에서 에러가 터진다면 — 원인이 뭘까요?

Redis는 모든 데이터를 바이트 배열로 저장합니다. Java 객체를 Redis에 넣으려면 반드시 **직렬화 **(serialization) 과정을 거쳐야 하고, 꺼낼 때는 ** 역직렬화 **(deserialization)를 해야 합니다. 어떤 직렬화 방식을 선택하느냐에 따라 성능, 가독성, 운영 안정성이 크게 달라집니다.

개념 정의

** 직렬화 **란 메모리에 있는 객체를 바이트 스트림으로 변환하는 과정입니다. Redis 관점에서 보면, 애플리케이션의 객체를 Redis가 저장할 수 있는 바이트 형태로 바꾸는 것이죠.

  • ** 직렬화 **: Java 객체 → 바이트 배열 → Redis에 저장
  • ** 역직렬화 **: Redis에서 읽은 바이트 배열 → Java 객체로 복원

단순해 보이지만, 이 과정에서 ** 클래스 구조 변경 **, ** 타입 정보 유실 , ** 크로스 언어 호환성 같은 문제가 발생합니다.

왜 직렬화 전략이 중요한가

직렬화 방식을 대충 선택하면 운영에서 아래와 같은 사고가 터집니다.

  • ** 역직렬화 실패 **: 클래스에 필드를 추가/삭제한 뒤 배포하면, 기존 캐시 데이터를 읽지 못해 SerializationException이 발생합니다
  • ** 디버깅 불가 **: JDK 직렬화는 바이너리라 Redis CLI에서 값을 확인할 수 없습니다. 장애 상황에서 "캐시에 뭐가 들어있는지" 파악이 안 됩니다
  • ** 보안 취약점 **: 타입 정보를 JSON에 포함하면, 공격자가 악의적인 클래스를 역직렬화시킬 수 있습니다
  • ** 메모리 낭비 **: 직렬화 크기가 크면 Redis 메모리를 불필요하게 많이 차지합니다

직렬화 전략은 "처음에 잘 골라야" 합니다. 운영 중에 바꾸는 건 기존 캐시 데이터를 전부 날리는 것과 같아요.

Spring Data Redis의 직렬화 구조

Spring Data Redis는 RedisSerializer<T> 인터페이스를 통해 직렬화를 추상화합니다.

JAVA
public interface RedisSerializer<T> {
    byte[] serialize(T value);    // 객체 → 바이트
    T deserialize(byte[] bytes);  // 바이트 → 객체
}

RedisTemplate에는 네 가지 직렬화기를 따로 설정할 수 있습니다.

설정용도권장
keySerializerRedis 키 직렬화StringRedisSerializer
valueSerializerRedis 값 직렬화JSON 또는 바이너리
hashKeySerializerHash 필드명 직렬화StringRedisSerializer
hashValueSerializerHash 필드값 직렬화JSON 또는 바이너리

키와 값의 직렬화기를 다르게 설정하는 게 일반적입니다. 키는 사람이 읽을 수 있어야 하고, 값은 효율성이 더 중요하니까요.

직렬화 방식 비교

JdkSerializationRedisSerializer (기본값)

Spring Data Redis의 ** 기본 직렬화기 **입니다. Java의 ObjectOutputStream을 그대로 사용합니다.

JAVA
// 별도 설정 없이 RedisTemplate을 사용하면 이 직렬화기가 적용됨
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
    RedisTemplate<String, Object> template = new RedisTemplate<>();
    template.setConnectionFactory(factory);
    // 기본값: JdkSerializationRedisSerializer
    return template;
}

Redis CLI에서 값을 확인하면 이런 모습입니다.

PLAINTEXT
127.0.0.1:6379> GET "\xac\xed\x00\x05t\x00\x04user:1"
"\xac\xed\x00\x05sr\x00\x1ecom.example.dto.UserDto..."

사람이 읽을 수 없는 바이너리 데이터가 저장됩니다.

** 문제점:**

  • serialVersionUID에 의존하므로, 클래스 구조가 바뀌면 역직렬화가 실패합니다
  • 바이너리라 Redis CLI에서 디버깅이 불가능합니다
  • Java 전용이라 다른 언어에서 읽을 수 없습니다
  • 직렬화 크기가 큽니다 (클래스 메타데이터까지 포함)

StringRedisSerializer

가장 단순한 직렬화기입니다. 문자열을 UTF-8 바이트로 변환합니다.

JAVA
template.setKeySerializer(new StringRedisSerializer());
PLAINTEXT
127.0.0.1:6379> GET "user:1"
"심정훈"

** 용도:**

  • ** 키 직렬화에 사실상 필수 **입니다. 키가 바이너리면 Redis CLI에서 관리가 안 됩니다
  • 단순 문자열 값 저장에 적합합니다
  • 복잡한 객체를 직접 직렬화할 수는 없습니다

Jackson2JsonRedisSerializer

특정 타입을 지정해서 JSON으로 직렬화합니다.

JAVA
// 특정 클래스를 명시해야 함
Jackson2JsonRedisSerializer<UserDto> serializer =
    new Jackson2JsonRedisSerializer<>(UserDto.class);

template.setValueSerializer(serializer);

Redis에 저장된 모습:

JSON
{"id": 1, "name": "심정훈", "email": "test@example.com"}

** 장점:**

  • 깔끔한 JSON 출력, 타입 힌트 없음
  • Redis CLI에서 바로 확인 가능

** 단점:**

  • ** 타입을 미리 지정 **해야 하므로, 여러 타입을 하나의 RedisTemplate으로 처리하기 어렵습니다
  • 타입별로 RedisTemplate을 만들거나 별도 처리가 필요합니다

GenericJackson2JsonRedisSerializer

타입을 미리 지정하지 않아도 되는 JSON 직렬화기입니다. ** 가장 많이 사용되는 선택지 **입니다.

JAVA
GenericJackson2JsonRedisSerializer serializer =
    new GenericJackson2JsonRedisSerializer();

template.setValueSerializer(serializer);

Redis에 저장된 모습:

JSON
{
  "@class": "com.example.dto.UserDto",
  "id": 1,
  "name": "심정훈",
  "email": "test@example.com"
}

@class 필드에 Java 클래스의 FQCN(Fully Qualified Class Name)이 들어갑니다. 역직렬화할 때 이 정보를 보고 정확한 타입으로 복원합니다.

** 장점:**

  • 하나의 RedisTemplate으로 여러 타입 처리 가능
  • JSON이라 사람이 읽을 수 있음
  • 필드 추가에 비교적 유연함 (Jackson의 기본 동작)

** 주의점:**

  • @class에 임의의 클래스명을 넣어 악의적인 역직렬화를 유도하는 공격이 가능합니다
  • ObjectMapperactivateDefaultTyping 설정 시 허용 타입을 제한해야 합니다
  • 클래스 패키지를 변경하면 역직렬화가 실패합니다
JAVA
// 보안 강화: 허용 타입을 명시적으로 제한
ObjectMapper mapper = new ObjectMapper();
mapper.activateDefaultTyping(
    mapper.getPolymorphicTypeValidator(),
    ObjectMapper.DefaultTyping.NON_FINAL,
    JsonTypeInfo.As.PROPERTY
);

바이너리 직렬화 (Protobuf, Kryo)

JSON보다 ** 크기가 작고 속도가 빠른** 바이너리 직렬화 방식입니다.

Protobuf:

  • Google이 만든 직렬화 프레임워크
  • .proto 스키마 파일을 미리 정의해야 합니다
  • 스키마 진화(schema evolution)를 지원합니다 — 필드 추가/삭제가 안전합니다
  • 다국어 지원 (Java, Go, Python 등)

Kryo:

  • Java 전용이지만 설정이 간단합니다
  • JDK 직렬화보다 10배 이상 빠릅니다
  • 스키마 정의가 필요 없습니다
JAVA
// Kryo 기반 커스텀 RedisSerializer 예시
public class KryoRedisSerializer<T> implements RedisSerializer<T> {

    private static final ThreadLocal<Kryo> kryoLocal = ThreadLocal.withInitial(() -> {
        Kryo kryo = new Kryo();
        kryo.setRegistrationRequired(false);
        return kryo;
    });

    @Override
    public byte[] serialize(T value) {
        if (value == null) return new byte[0];
        Output output = new Output(1024, -1);
        kryoLocal.get().writeClassAndObject(output, value);
        return output.toBytes();
    }

    @Override
    @SuppressWarnings("unchecked")
    public T deserialize(byte[] bytes) {
        if (bytes == null || bytes.length == 0) return null;
        Input input = new Input(bytes);
        return (T) kryoLocal.get().readClassAndObject(input);
    }
}

바이너리 직렬화는 "성능이 정말 중요한 곳"에서 고려하세요. 대부분의 서비스에서는 JSON이면 충분합니다.

비교 표

방식크기속도가독성스키마 변경크로스 언어
JDK 직렬화느림불가매우 취약Java만
StringRedisSerializer원본 그대로가장 빠름완전해당 없음가능
Jackson2Json중간중간가능양호가능
GenericJackson2Json중간+중간가능양호제한적
Protobuf작음빠름불가우수가능
Kryo작음매우 빠름불가보통Java만

"크기"는 같은 객체를 직렬화했을 때의 바이트 수입니다. Protobuf은 JSON 대비 50~70% 작고, JDK 직렬화는 JSON보다 큰 경우가 많습니다.

실전 설정 예제

RedisTemplate 설정

JAVA
@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);

        // 키는 문자열로 — CLI에서 읽을 수 있도록
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());

        // 값은 JSON으로 — 가독성과 유연성 확보
        GenericJackson2JsonRedisSerializer jsonSerializer =
            new GenericJackson2JsonRedisSerializer();
        template.setValueSerializer(jsonSerializer);
        template.setHashValueSerializer(jsonSerializer);

        return template;
    }
}

RedisCacheManager 설정

@Cacheable 등 Spring Cache 추상화를 사용할 때의 설정입니다.

JAVA
@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
        // Jackson ObjectMapper 커스터마이징
        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule()); // LocalDateTime 지원
        mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

        GenericJackson2JsonRedisSerializer serializer =
            new GenericJackson2JsonRedisSerializer(mapper);

        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            .serializeKeysWith(
                RedisSerializationContext.SerializationPair
                    .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair
                    .fromSerializer(serializer))
            .entryTtl(Duration.ofMinutes(30)); // 기본 TTL 30분

        return RedisCacheManager.builder(factory)
            .cacheDefaults(config)
            .build();
    }
}

운영 중 직렬화 변경 전략

이미 운영 중인 서비스에서 직렬화 방식을 바꿔야 한다면, 기존 캐시 데이터와의 호환성이 가장 큰 문제입니다.

1. 캐시 전체 Flush

가장 단순한 방법입니다. 배포 시점에 캐시를 모두 비우고, 새 직렬화 방식으로 다시 채웁니다.

  • ** 장점 **: 구현이 간단합니다
  • ** 단점 **: 배포 직후 캐시 미스가 몰려서 DB 부하가 급증합니다 (Cache Stampede)
  • ** 적용 **: 트래픽이 적은 시간대에 배포하거나, 캐시 워밍업 스크립트를 함께 실행합니다

2. 듀얼 리더 (Fallback)

새 직렬화기로 먼저 읽기를 시도하고, 실패하면 이전 직렬화기로 읽는 방식입니다.

JAVA
// 새 직렬화기로 읽기 시도 → 실패 시 이전 직렬화기로 fallback
public <T> T getWithFallback(String key, Class<T> type) {
    try {
        return newSerializer.deserialize(redisOps.get(key));
    } catch (SerializationException e) {
        // 이전 직렬화기로 재시도
        T value = oldSerializer.deserialize(redisOps.get(key));
        // 새 직렬화 방식으로 덮어쓰기 (점진적 마이그레이션)
        redisOps.set(key, newSerializer.serialize(value));
        return value;
    }
}
  • ** 장점 **: 무중단 마이그레이션이 가능합니다
  • ** 단점 **: 코드가 복잡해지고, 이전 직렬화기 의존성을 유지해야 합니다

3. 키 프리픽스 버전 관리

직렬화 방식이 바뀔 때 키 프리픽스에 버전을 붙이는 방법입니다.

PLAINTEXT
v1:user:1  → JDK 직렬화로 저장된 데이터
v2:user:1  → JSON으로 저장된 데이터
  • ** 장점 **: 이전 데이터와 새 데이터가 완전히 분리됩니다
  • ** 단점 **: 이전 버전 키가 TTL로 만료될 때까지 메모리를 이중으로 차지합니다

정리

대부분의 Spring Boot 프로젝트에서는 이렇게 선택하면 됩니다.

  • ** 키 **: StringRedisSerializer (거의 예외 없음)
  • ** 값 **: GenericJackson2JsonRedisSerializer (가장 무난한 선택)
  • ** 고성능이 필요하면 **: Protobuf 또는 Kryo
  • ** 절대 피할 것 **: JDK 직렬화를 운영 환경에서 사용하는 것

결국 직렬화 전략은 ** 가독성, 성능, 호환성** 사이의 트레이드오프입니다. 처음부터 JDK 직렬화 대신 JSON 직렬화기를 설정해두면, 나중에 "캐시가 왜 깨졌지?"라는 장애 대응을 하지 않아도 됩니다.

댓글 로딩 중...