Redis 직렬화 전략 — JSON, 바이너리, 커스텀 직렬화의 트레이드오프
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> 인터페이스를 통해 직렬화를 추상화합니다.
public interface RedisSerializer<T> {
byte[] serialize(T value); // 객체 → 바이트
T deserialize(byte[] bytes); // 바이트 → 객체
}
RedisTemplate에는 네 가지 직렬화기를 따로 설정할 수 있습니다.
| 설정 | 용도 | 권장 |
|---|---|---|
keySerializer | Redis 키 직렬화 | StringRedisSerializer |
valueSerializer | Redis 값 직렬화 | JSON 또는 바이너리 |
hashKeySerializer | Hash 필드명 직렬화 | StringRedisSerializer |
hashValueSerializer | Hash 필드값 직렬화 | JSON 또는 바이너리 |
키와 값의 직렬화기를 다르게 설정하는 게 일반적입니다. 키는 사람이 읽을 수 있어야 하고, 값은 효율성이 더 중요하니까요.
직렬화 방식 비교
JdkSerializationRedisSerializer (기본값)
Spring Data Redis의 ** 기본 직렬화기 **입니다. Java의 ObjectOutputStream을 그대로 사용합니다.
// 별도 설정 없이 RedisTemplate을 사용하면 이 직렬화기가 적용됨
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// 기본값: JdkSerializationRedisSerializer
return template;
}
Redis CLI에서 값을 확인하면 이런 모습입니다.
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 바이트로 변환합니다.
template.setKeySerializer(new StringRedisSerializer());
127.0.0.1:6379> GET "user:1"
"심정훈"
** 용도:**
- ** 키 직렬화에 사실상 필수 **입니다. 키가 바이너리면 Redis CLI에서 관리가 안 됩니다
- 단순 문자열 값 저장에 적합합니다
- 복잡한 객체를 직접 직렬화할 수는 없습니다
Jackson2JsonRedisSerializer
특정 타입을 지정해서 JSON으로 직렬화합니다.
// 특정 클래스를 명시해야 함
Jackson2JsonRedisSerializer<UserDto> serializer =
new Jackson2JsonRedisSerializer<>(UserDto.class);
template.setValueSerializer(serializer);
Redis에 저장된 모습:
{"id": 1, "name": "심정훈", "email": "test@example.com"}
** 장점:**
- 깔끔한 JSON 출력, 타입 힌트 없음
- Redis CLI에서 바로 확인 가능
** 단점:**
- ** 타입을 미리 지정 **해야 하므로, 여러 타입을 하나의
RedisTemplate으로 처리하기 어렵습니다 - 타입별로
RedisTemplate을 만들거나 별도 처리가 필요합니다
GenericJackson2JsonRedisSerializer
타입을 미리 지정하지 않아도 되는 JSON 직렬화기입니다. ** 가장 많이 사용되는 선택지 **입니다.
GenericJackson2JsonRedisSerializer serializer =
new GenericJackson2JsonRedisSerializer();
template.setValueSerializer(serializer);
Redis에 저장된 모습:
{
"@class": "com.example.dto.UserDto",
"id": 1,
"name": "심정훈",
"email": "test@example.com"
}
@class 필드에 Java 클래스의 FQCN(Fully Qualified Class Name)이 들어갑니다. 역직렬화할 때 이 정보를 보고 정확한 타입으로 복원합니다.
** 장점:**
- 하나의
RedisTemplate으로 여러 타입 처리 가능 - JSON이라 사람이 읽을 수 있음
- 필드 추가에 비교적 유연함 (Jackson의 기본 동작)
** 주의점:**
@class에 임의의 클래스명을 넣어 악의적인 역직렬화를 유도하는 공격이 가능합니다ObjectMapper에activateDefaultTyping설정 시 허용 타입을 제한해야 합니다- 클래스 패키지를 변경하면 역직렬화가 실패합니다
// 보안 강화: 허용 타입을 명시적으로 제한
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배 이상 빠릅니다
- 스키마 정의가 필요 없습니다
// 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 설정
@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 추상화를 사용할 때의 설정입니다.
@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)
새 직렬화기로 먼저 읽기를 시도하고, 실패하면 이전 직렬화기로 읽는 방식입니다.
// 새 직렬화기로 읽기 시도 → 실패 시 이전 직렬화기로 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. 키 프리픽스 버전 관리
직렬화 방식이 바뀔 때 키 프리픽스에 버전을 붙이는 방법입니다.
v1:user:1 → JDK 직렬화로 저장된 데이터
v2:user:1 → JSON으로 저장된 데이터
- ** 장점 **: 이전 데이터와 새 데이터가 완전히 분리됩니다
- ** 단점 **: 이전 버전 키가 TTL로 만료될 때까지 메모리를 이중으로 차지합니다
정리
대부분의 Spring Boot 프로젝트에서는 이렇게 선택하면 됩니다.
- ** 키 **:
StringRedisSerializer(거의 예외 없음) - ** 값 **:
GenericJackson2JsonRedisSerializer(가장 무난한 선택) - ** 고성능이 필요하면 **: Protobuf 또는 Kryo
- ** 절대 피할 것 **: JDK 직렬화를 운영 환경에서 사용하는 것
결국 직렬화 전략은 ** 가독성, 성능, 호환성** 사이의 트레이드오프입니다. 처음부터 JDK 직렬화 대신 JSON 직렬화기를 설정해두면, 나중에 "캐시가 왜 깨졌지?"라는 장애 대응을 하지 않아도 됩니다.