Spring + Redis 통합에서 직렬화가 모든 것을 결정한다
JDK 직렬화 기본값이 왜 금지 수준인지부터 @Cacheable AOP 체인, Redis 세션 구조, Redisson 분산 락까지, Spring-Redis 통합의 핵심 설계 결정을 추적한다.
- 01 Redis의 모든 선택은 하나의 질문에서 나온다
- 01 Redis는 왜 같은 데이터를 다르게 저장하는가
- 01 Redis 영속성은 어떻게 설계하는가
- 01 Redis 운영의 모든 병목은 단일 스레드에서 시작된다
- 01 Redis 복제는 왜 데이터를 잃는가
- 01 Spring + Redis 통합에서 직렬화가 모든 것을 결정한다
Spring에서 Redis를 연동하는 방법은 단순하다. 의존성을 추가하고, RedisTemplate을 주입하면 된다. 그런데 기본 설정 그대로 운영 환경에 올리면 데이터가 깨져 보이고, 캐시가 예상치 못한 순간에 무시되며, 분산 락이 30초 동안 서비스를 멈춘다. 왜 이런 일이 생기는가?
직렬화 선택이 모든 것의 시작이다
RedisTemplate<Object, Object>의 기본 직렬화는 JdkSerializationRedisSerializer다. Java ObjectOutputStream으로 바이너리를 생성하므로 redis-cli에서 읽을 수 없고, 저장 크기가 JSON 대비 7~12배 크며, 다른 언어 클라이언트와 호환이 안 된다. 클래스가 패키지를 바꾸면 ClassNotFoundException으로 역직렬화가 실패한다.
권장 조합은 단순하다.
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
StringRedisSerializer stringSerializer = new StringRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
Jackson2JsonRedisSerializer<Object> jacksonSerializer =
new Jackson2JsonRedisSerializer<>(mapper, Object.class);
template.setValueSerializer(jacksonSerializer);
template.setHashValueSerializer(jacksonSerializer);
template.afterPropertiesSet();
return template;
}
키는 StringRedisSerializer(redis-cli 가독성), 값은 Jackson2JsonRedisSerializer(가볍고, 빠르고, 언어 중립). GenericJackson2JsonRedisSerializer는 @class 타입 정보를 JSON에 포함시켜 다형성을 지원하지만, 패키지를 이동하면 기존 캐시 전체가 ClassNotFoundException으로 터진다. 다형성이 반드시 필요할 때만, PolymorphicTypeValidator로 허용 패키지를 제한하고 써야 한다.
JDK → JSON으로 직렬화를 바꾸면 기존 데이터를 새 방식으로 읽을 수 없다. 캐시 전용 Redis라면 FLUSHDB 후 배포가 가장 안전하다. 세션처럼 삭제가 불가능한 데이터라면 키 버전 관리(user:v2:1)나 점진적 마이그레이션이 필요하다.
@Cacheable AOP 체인의 숨겨진 규칙
@Cacheable은 CacheInterceptor가 메서드 앞에서 캐시를 조회하고, 없으면 실제 메서드를 실행한 후 결과를 저장한다. 핵심은 이것이 프록시 기반 AOP라는 점이다. 같은 클래스 안에서 this.findById(id)로 직접 호출하면 프록시를 우회하므로 캐시가 전혀 동작하지 않는다.
키 생성은 기본적으로 SimpleKeyGenerator가 담당한다. 파라미터 한 개면 "users::1", 여러 개면 "users::SimpleKey [Alice,30]" 형태다. SpEL 표현식(key = "#id")으로 명시적으로 제어하는 것이 더 안전하다.
@CacheEvict(allEntries = true)는 내부적으로 KEYS "users::*" 패턴을 실행한다. 100만 개 키 환경에서 이 명령어는 수백 ms 동안 Redis 이벤트 루프를 독점한다. Spring 3.x에서는 enableCleanup() 설정으로 SCAN 방식을 쓸 수 있고, 그 이전 버전에서는 SCAN 커서를 직접 순회해서 삭제해야 한다.
@Transactional과 @CacheEvict를 함께 쓸 때도 함정이 있다. AOP 체인 순서상 @CacheEvict가 메서드 완료 후 실행되지만, 트랜잭션 커밋은 그보다 늦다. 캐시가 삭제된 뒤 다른 스레드가 DB를 읽으면 아직 커밋되지 않은 구버전 데이터를 캐시에 올릴 수 있다. 안전한 패턴은 TransactionSynchronization.afterCommit()에서 캐시를 삭제하는 것이다.
@Transactional
public void updateUser(Long id, UserDto dto) {
userRepository.save(dto.toEntity(id));
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
cacheManager.getCache("users").evict(id);
}
});
}
Spring Session이 HttpSession을 가로채는 방법
@EnableRedisHttpSession은 SessionRepositoryFilter를 필터 체인 최상위에 등록한다. 이 필터가 HttpServletRequest를 래핑해서 getSession() 호출을 가로채고, 쿠키에서 세션 ID를 꺼내 HGETALL spring:session:sessions:{sessionId}로 Redis에서 세션을 로드한다.
Redis에 저장되는 키 구조는 세 가지다.
spring:session:sessions:{id} → 세션 데이터 (Hash)
spring:session:sessions:expires:{id} → 만료 트리거 (String, TTL 보유)
spring:session:expirations:{timestamp} → 만료 예정 세션 목록 (Set)
maxInactiveIntervalInSeconds는 Redis TTL로 직접 연결된다. 마지막 접근 시각이 갱신될 때마다 TTL이 리셋된다(슬라이딩 만료). 세션 만료 이벤트(SessionExpiredEvent)를 받으려면 Redis Keyspace Notification 설정이 필요하다.
redis-cli CONFIG SET notify-keyspace-events "Kgxe"
Redis Cluster 환경에서는 세션 관련 키들이 서로 다른 슬롯에 배치될 수 있어 CROSSSLOT 오류가 발생한다. 실무 권장은 세션 전용 Redis Sentinel을 분리해서 쓰는 것이다.
Redisson 분산 락의 세 가지 핵심 기제
직접 구현한 SET NX EX는 간단하지만, Watchdog(자동 TTL 갱신), Pub/Sub 대기, 재진입 지원을 모두 직접 만들어야 한다. Redisson RLock은 이 세 가지를 Lua 스크립트 기반 원자적 연산으로 제공한다.
락 획득은 Hash 자료구조에 clientId:threadId 필드로 저장된다. 같은 스레드가 중첩 락을 걸면 카운트가 증가하고, unlock() 때 감소해서 0이 되면 키가 삭제된다. 락 해제 시점에 PUBLISH로 대기 중인 클라이언트에게 알림을 보내는 Pub/Sub 방식 덕분에, 100개 서버가 동시에 대기해도 Redis 요청이 발생하지 않는다.
lock.lock()으로 leaseTime을 지정하지 않으면 Watchdog가 10초마다 TTL을 30초로 리셋한다. 서버가 kill -9로 죽으면 Watchdog 스레드도 종료되고 마지막 TTL이 소진된 후 자동 해제된다(최대 30초 대기).
RLock lock = redisson.getLock("order:lock:" + orderId);
boolean acquired = lock.tryLock(5, 60, TimeUnit.SECONDS);
if (!acquired) {
throw new BusinessException("현재 처리 중인 주문입니다");
}
try {
processOrder(orderId);
} finally {
lock.unlock();
}
RSemaphore는 N개 동시 실행을 허용할 때 쓴다. 외부 API 동시 호출 수 제한, 결제 프로세스 처리량 제어가 대표적인 사례다. trySetPermits(N) 초기화를 반드시 해야 하며, 서버 재시작 후 permits가 의도치 않게 변경되는 문제를 막으려면 시작 시점에 명시적으로 재초기화해야 한다.
RLock은 대부분의 중복 실행 방지 시나리오에 충분하다. 단일 Redis 노드 장애 시 이론적 위험을 막으려면 RedissonRedLock(5개 독립 Redis에서 과반수 획득)을 쓸 수 있지만, 실무에서는 RLock + 멱등성 설계로 대부분 해결된다. 이론적 완벽을 요구하면 ZooKeeper + Fencing Token이 더 적합하다.
정리
- 직렬화:
JdkSerializationRedisSerializer는 기본값이지만 사실상 금지 수준이다. 키는StringRedisSerializer, 값은Jackson2JsonRedisSerializer가 권장 조합이다. - @Cacheable: 같은 클래스 내 직접 호출은 AOP를 우회한다.
allEntries=true는 KEYS 명령어를 실행하므로 대규모 캐시에서 위험하다.@CacheEvict+@Transactional조합은 커밋 후 삭제 패턴이 안전하다. - Spring Session:
SessionRepositoryFilter가 HttpSession을 가로채 Redis Hash로 저장한다. Cluster 환경에서는 Sentinel 분리가 권장된다. - Redisson:
RLock은 Lua 원자성 + Pub/Sub 대기 + Watchdog으로 동작한다. 반드시finally에서unlock()을 호출해야 한다.
다음 글에서는 Redis가 메모리를 어떻게 관리하는지, maxmemory-policy의 각 정책이 어떤 계산 기반으로 동작하는지 추적한다.