아무튼, 쓰기
레디스 직렬화 4총사 본문
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[]이다.
🔸 JsonRedisSerializer든 StringRedisSerializer든 결국은 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가 데이터를 표시하는 방식에 차이가 있습니다:
- StringRedisSerializer 또는 JSON 직렬화:
- 바이트 배열이 표준 인코딩(UTF-8)을 따르므로 redis-cli가 문자열로 해석하여 표시
- 사람이 읽을 수 있는 형태로 출력
- 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 값으로 바로 사용할 수 없어 추가 처리가 필요하거나 오류가 발생합니다. 즉, 캐시 히트는 성공하더라도 값의 타입 불일치로 인해 실질적인 캐시 활용에 실패할 수 있습니다.
해결 방법:
- 래퍼 클래스 사용
public record UserListWrapper(List<User> users) {}
UserListWrapper wrapper = new UserListWrapper(userList);
redisTemplate.opsForValue().set("users", wrapper);
UserListWrapper cached = (UserListWrapper) redisTemplate.opsForValue().get("users");
- 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();
}
}
- 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); // 직렬화 가능한 타입으로 변환
}
참조
'CS > 데이터베이스' 카테고리의 다른 글
| 레디스 읽기 & 쓰기 전략 총정리 (4) | 2025.07.19 |
|---|---|
| 레디스 철학, 자료구조, 클라이언트, 인코딩(’이것이 레디스다’ 책) (1) | 2025.05.16 |
| [Real MySQL 9장] 옵티마이저와 힌트 (0) | 2025.01.09 |
| [Real MYSQL 10장] 실행 계획 (0) | 2025.01.03 |
| 공유 락과 배타 락 (0) | 2024.12.10 |