일단, 로컬환경에서 Redis를 띄워서 사용할 것이기 때문에, Docker Desktop을 이용해 로컬 서버에 Redis를 띄워줘야 한다.
docker-compose.yml
services:
redis:
image: redis
restart: always
container_name: redis7
ports:
- "6379:6379"
command: redis-server --port 6379
volumes:
- ./db/redis/data:/data
- ./db/redis/conf:/usr/local/etc/redis/redis.conf
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
application.yml
spring:
cache:
type: redis
data:
redis:
host: localhost
port: 6379
password:
RedisCacheConfig
@Configuration
@EnableCaching
public class RedisCacheConfig {
//캐싱 처리를 위한 빈 등록
@Bean
public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
//ObjectMapper에 JavaTimeModule 등록
ObjectMapper objectMapper = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
// 직렬화기 생성
GenericJackson2JsonRedisSerializer serializer =
new GenericJackson2JsonRedisSerializer(objectMapper);
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
)
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(serializer)
)
.entryTtl(Duration.ofSeconds(120))
.disableCachingNullValues();
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(config)
.build();
}
}
기본적으로 Redis는 Key와 Value가 모두 바이트 배열로 저장되므로,
자바 객체 → 바이트 배열로 바꾸어 Redis에 저장해야 하고, 꺼낼 때는 바이트 배열 → 자바 객체로 다시 바꿔야하는데, 이걸 담당하는게 직렬화기이다.

java.lang.ClassCastException: class java.util.LinkedHashMap cannot be cast to class
com.sprint.springcache.entity.User (java.util.LinkedHashMap is in module java.base of loader
'bootstrap'; com.sprint.springcache.entity.User is in unnamed module of loader 'app')
클래스 정보가 포함되어있지 않음 BasicPolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
.allowIfSubType("com.sprint.springcache") // 해당 패키지 하위만 허용
.build();
ObjectMapper objectMapper = new ObjectMapper()
.registerModule(new JavaTimeModule())
.activateDefaultTyping( // Redis 직렬화 시 타입 추론이 가능하도록 추가 설정
ptv,
ObjectMapper.DefaultTyping.NON_FINAL // 또는 EVERYTHING, OBJECT_AND_NON_CONCRETE 등 필요에 따라 선택
)
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
------
redisObjectMapper.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance, //(아무거나 허용)
DefaultTyping.EVERYTHING, //(모든 객체의 타입 정보 기록)
As.PROPERTY //(기본값, 타입 정보를 property로 기록 -> 역직렬화 가능)
);

// Jackson2JsonRedisSerializer
Jackson2JsonRedisSerializer<Product> productSerializer = new Jackson2JsonRedisSerializer<>(Product.class);
Jackson2JsonRedisSerializer<User> userSerializer = new Jackson2JsonRedisSerializer<>(User.class);
// StringRedisSerializer
String userJsonFromRedis = redisTemplate.opsForValue().get("user:1");
User user = objectMapper.readValue(userJsonFromRedis, User.class);
"[[\"com.sprint.mission.discodeit.dto.controller.notification.NotificationDto\",{\"id\":\"e31b7fe9-21ae-4651-a8d2-44d12bf61a33\",\"createdAt\":\"2025-07-17T17:04:03.787496Z\",\"receiverId\":\"f2421bd9-a4d9-4b9d-be6a-f1b2c555be55\",\"title\":\"admin (# \xec\xb1\x84\xeb\x84\x90)\",\"content\":\"\xec\xb9\xb4\xed\x94\x84\xec\xb9\xb4\xeb\xa1\x9c \xea\xb3\xbc\xec\x97\xb0\",\"type\":\"NEW_MESSAGE\",\"targetId\":\"6100760d-cb71-4e43-a03c-95910f2ba183\"}],[\"com.sprint.mission.discodeit.dto.controller.notification.NotificationDto\",{\"id\":\"89b691d8-d9ad-4fad-ac63-2e9e99bc23ec\",\"createdAt\":\"2025-07-17T17:04:08.531495Z\",\"receiverId\":\"f2421bd9-a4d9-4b9d-be6a-f1b2c555be55\",\"title\":\"\xea\xb6\x8c\xed\x95\x9c\xec\x9d\xb4 \xeb\xb3\x80\xea\xb2\xbd\xeb\x90\x98\xec\x97\x88\xec\x8a\xb5\xeb\x8b\x88\xeb\x8b\xa4.\",\"content\":\"[USER] -> [CHANNEL_MANAGER]\",\"type\":\"ROLE_CHANGED\",\"targetId\":\"f2421bd9-a4d9-4b9d-be6a-f1b2c555be55\"}],[\"com.sprint.mission.discodeit.dto.controller.notification.NotificationDto\",{\"id\":\"b6a2698e-4a3e-4548-b766-10e12d6756c9\",\"createdAt\":\"2025-07-17T17:04:04.920246Z\",\"receiverId\":\"f2421bd9-a4d9-4b9d-be6a-f1b2c555be55\",\"title\":\"admin (# \xec\xb1\x84\xeb\x84\x90)\",\"content\":\"\xec\x9e\x98\xea\xb0\x80\xeb\x82\x98\xec\x9a\x94?\",\"type\":\"NEW_MESSAGE\",\"targetId\":\"6100760d-cb71-4e43-a03c-95910f2ba183\"}]]"

"[\"java.util.ArrayList\",[[\"com.sprint.mission.discodeit.dto.controller.notification.NotificationDto\",{\"id\":\"42bb7941-4cef-4bc5-b651-325ce87c9a2f\",\"createdAt\":\"2025-07-17T17:25:32.602222Z\",\"receiverId\":\"51361ae6-30cc-4fa3-9f70-2f4a9a4bb663\",\"title\":\"yyjjmm2003 (# \xec\xb1\x84\xeb\x84\x90)\",\"content\":\"\xec\xba\x90\xec\x8b\x9c \xed\x99\x95\xec\x9d\xb8\",\"type\":\"NEW_MESSAGE\",\"targetId\":\"6100760d-cb71-4e43-a03c-95910f2ba183\"}]]]"

BasicPolymorphicTypeValidator typeValidator = BasicPolymorphicTypeValidator.builder()
.allowIfSubType("com.sprint.mission.discodeit") // 해당 패키지 하위만 허용
.allowIfSubType("java.util")
.build();
흐름
JdkSerializationRedisSerializer를 사용할 경우, 바이너리 코드로 저장되기 떄문에 모니터링이 어려움
-> GenericJackson2JsonRedisSerializer를 사용
-> LocalDateTime 등의 날짜/시간 타입이 처리되지 않을 위험이 있다
-> ObjectMapper에 JavaTimeModule 등록 필요
-> ObjectMapper를 커스텀해서 사용할 경우, 클래스 정보가 함께 직렬화되지 않음
-> 어떤 클래스 타입을 허용할지 명시적으로 작성해주기 위해 DefaultTyping을 적용해서 클래스 타입 함께 저장
-> List.of의 경우 컬렉션 정보가 직렬화할 때 저장되지 않아 역직렬화 문제 발생
-> collect(Collectors(toList)) 사용
기본 JdkSerializationRedisSerializer를 사용할 경우,
클래스 메타 정보가 포함되어 직렬화되기 때문에
Serializable 구현 +
collect(Collectors(toList()) 정도만 사용해주면 된다.
Redis에서 저장된 값을 역직렬화 할 때, 클래스에 대한 메타 정보가 필요합니다. 어떤 클래스인지 알아야 JSON 문자열이나 바이너리 데이터를 클래스로 역직렬화 할 수 있기 떄문입니다.
.toList()는 기본적으로 toList()는 ArrayList처럼 표준화된 포맷이 아닌 ImmutableCollection$ListN 같은 불변 리스트의 내부 구현을 사용합니다.
때문에 .toList() 결과값을 Redis에 캐싱할 경우, 직렬화 시에 컬렉션에 대한 메타데이터가 포함이 안되고, 역직렬화할 때 실패하는 문제가 발생합니다.
(정확한 원인은 모르겠으나, 예상하기엔 toList()가 자바16 이후에 나온 버전이라 Redis에서 지원하지 않는 컬렉션 타입인 것 같아서 발생하는 문제 같습니다.)
.toList() 대신 .collect(Collectors(toList())를 사용하여 결과값을 만든다면, ArrayList 형태로 저장되고, Redis에서도 해당 컬렉션은 지원하기 때문에 직렬화할 때 컬렉션 메타 데이터를 함께 저장할 수 있게 됩니다.
-> 메타 데이터가 있기 떄문에 역직렬화도 가능
이 문제가 아님에도 직렬화 예외가 터진다면 기본 직렬화기(JdkSerializationRedisSerializer)를 사용함에도 Serializable을 구현하지 않았거나 기타 등등의 문제가 너무 많아서.. 별도로 찾아보시면 될 것 같습니다.
요약: 역직렬화가 안된다면 toList()가 아니라 collect(Collectors(toList()) 사용
참고: https://stackoverflow.com/questions/51688838/unexpected-token-start-object-expected-value-string-need-json-string-that-co
https://github.com/FasterXML/jackson-databind/issues/3892
실제 사용
@CacheEvict(
value = "departmentDistribution",
allEntries = true,
cacheManager = "redisCacheManager"
)
public void createTodayStats() {
}
@Cacheable(
value = "departmentDistribution",
key = "#p0 + #p1.toString()",
cacheManager = "redisCacheManager"
)
public List<EmployeeDistributionDto> getDepartmentDistribution(EmployeeState status, LocalDate statDate) {
}

=> Prometheus에 Redis Exporter를 붙인다면, Redis 전체 캐시 히트/미스 메트릭 수집 가능!
(방법은 추후 정리 예정)
rate(redis_keyspace_hits_total[5m]) / (rate(redis_keyspace_hits_total[5m]) + rate(redis_keyspace_misses_total[5m]))
=> 조회(읽기) 작업에서 @Transactional과 @Cacheable의 순서 지정은 실제 시스템 동작에 영향을 주지 않음
@Transactional이 먼저 작동할 때 동작 흐름
1. 트랜잭션 생성
@Transactional이 바깥쪽 프록시로 동작하므로, 먼저 트랜잭션 경계를 시작합니다.
2. 캐시 조회 (@Cacheable)
트랜잭션 범위 진입 후, @Cacheable 어드바이스(CacheInterceptor)가 적용되어
캐시 키에 해당하는 값이 있는지 먼저 확인합니다.
3. 캐시 히트(일치하는 값이 있을 때):
비즈니스 메서드를 실행하지 않고, 캐시 값을 즉시 반환하며, 트랜잭션 내부 로직과 DB 접근도 수행되지 않습니다.
4. 캐시 미스(캐시에 값이 없을 때):
실제 비즈니스 메서드를 실행(이때 트랜잭션 경계 안에서 실행),
DB에서 데이터를 조회한 후, 그 결과를 캐시에 저장한 뒤 반환합니다.
AOP가 어떻게 동작하는지 생각해보면 됨
@Transational - 트랜잭션 생성 -> 원본 메서드 호출 -> 커밋 or 롤백
@Cacheable - 캐시 조회 -> 미스 -> 원본 메서드 호출
@CacheEvict - 원본 메서드 호출 -> 캐시 삭제