[Redis] @Cacheable 스프링 캐싱: TTL 설정, 캐시 무효화, 프록시 자기호출 함정
![[Redis] @Cacheable 스프링 캐싱: TTL 설정, 캐시 무효화, 프록시 자기호출 함정](https://blog.kakaocdn.net/dna/rnTUk/dJMcaar2JD4/AAAAAAAAAAAAAAAAAAAAAHeUWFrGHh0FKdkphgJSQB6PblumZwv3OzVgm_6HkCeo/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1785509999&allow_ip=&allow_referer=&signature=iseKU0fV6vT7GXVMSqiIAQ5xA44%3D)
[Redis] @Cacheable 스프링 캐싱: TTL 설정, 캐시 무효화, 프록시 자기호출 함정
Redis를 가장 많이 쓰는 곳이 캐시예요. 자주 조회되는데 자주 안 바뀌는 데이터를 Redis에 얹어두면, DB 조회를 줄여 응답이 빨라지고 부하가 내려가요. 스프링에서는 @Cacheable 한 줄이면 메서드 결과가 캐시돼요.
스프링 캐시 추상화가 무엇인지 본 뒤 Redis를 캐시 저장소로 붙여요. 이어서 TTL과 직렬화를 잡고 캐시 키를 설계하고 무효화까지 다룬 다음, 마지막으로 프록시 함정을 비롯해 자주 만나는 문제를 살펴봐요. Redis 기본 설정은 Spring Data Redis 설정 글을 먼저 보면 좋아요.
01. 스프링 캐시 추상화
스프링은 캐시를 애너테이션으로 추상화해요. 어떤 캐시 저장소를 쓰든(Redis·Caffeine 등) 코드는 같아요. @EnableCaching을 켜고 세 가지 애너테이션을 써요.
@Cacheable— 결과를 캐시. 있으면 캐시에서 주고, 없으면 메서드 실행 후 캐시.@CachePut— 메서드를 항상 실행하고 결과로 캐시를 갱신.@CacheEvict— 캐시를 비움(무효화).
@Cacheable(value = "user", key = "#id")
public User getUser(String id) {
return userRepository.findById(id).orElseThrow();
}

호출되면 user::id 키로 캐시부터 봐요. 있으면 DB를 안 거치고 바로 반환해요. 없으면 메서드를 실행해 결과를 캐시에 넣고 반환해요. 이게 cache-aside 패턴이고, @Cacheable이 그걸 대신 해줘요.
02. Redis를 캐시 저장소로
캐시 저장소를 Redis로 두려면 RedisCacheManager를 설정해요. 스프링 부트는 spring-boot-starter-data-redis가 있으면 Redis 캐시를 자동 구성하지만 TTL과 직렬화는 직접 잡는 게 좋아요.
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory cf) {
var config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10)) // 기본 TTL
.serializeValuesWith(SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer())); // 값 직렬화
return RedisCacheManager.builder(cf)
.cacheDefaults(config)
.build();
}
03. TTL, 캐시는 반드시 만료를 둬요
캐시에 TTL을 안 두면 데이터가 영원히 남아요. 원본이 바뀌어도 캐시가 안 바뀌면 낡은 값을 계속 주게 돼요. 그래서 캐시는 거의 항상 TTL을 둬요.
캐시마다 성격이 다르면 TTL도 다르게 줘요. 거의 안 바뀌는 코드 테이블은 길게, 자주 바뀌는 건 짧게요. RedisCacheManager.builder에서 캐시 이름별로 설정을 따로 줄 수 있어요.
RedisCacheManager.builder(cf)
.withCacheConfiguration("user", config.entryTtl(Duration.ofMinutes(10)))
.withCacheConfiguration("code", config.entryTtl(Duration.ofHours(24)))
.build();
04. 직렬화, 캐시도 같은 함정
캐시 값도 Redis에 저장되니 직렬화 문제를 똑같이 만나요. 기본 JDK 직렬화를 쓰면 사람이 못 읽는 바이트가 되고 다른 서비스에서 못 읽어요. 그래서 위 설정처럼 GenericJackson2JsonRedisSerializer로 JSON 직렬화를 지정해요.
객체에 LocalDateTime 같은 날짜가 있으면 JavaTimeModule을 등록한 ObjectMapper를 직렬화기에 넣어야 해요. 이건 Spring Data Redis 설정 글에서 다룬 그 직렬화 함정과 같아요.
05. 캐시 키 설계
캐시 키는 key 속성에 SpEL로 정해요. 보통은 메서드 인자로 만들어요.
@Cacheable(value = "order", key = "#userId + ':' + #status")
public List<Order> getOrders(String userId, String status) { ... }
키 설계에서 주의할 건, 인자가 많거나 객체면 키가 의도와 다르게 만들어질 수 있다는 거예요. 명시적으로 key를 지정해 어떤 키로 캐시되는지 분명히 하는 게 안전해요. 키 충돌이 나면 엉뚱한 데이터가 반환되니까요.
06. 캐시 무효화, 일관성의 핵심
원본이 바뀌면 캐시를 비워야 해요. 안 그러면 낡은 값을 줘요. 수정·삭제 메서드에 @CacheEvict를 붙여요.
@CacheEvict(value = "user", key = "#user.id")
public void updateUser(User user) {
userRepository.save(user);
}

비울지(@CacheEvict) 갱신할지(@CachePut)의 선택에 함정이 있어요. 갱신은 동시 수정에 약해요 — A 수정과 B 수정이 겹쳐서 "DB는 B인데 캐시 갱신은 A가 나중"이 되면, 낡은 값이 TTL이 끝날 때까지 캐시에 고착돼요. 비우기는 다음 조회가 DB에서 다시 읽으니 최종적으로 DB 값으로 수렴해요. 그래서 보통은 비우는 쪽이 단순하고 안전해요.
07. 조건부 캐싱, 캐시할 것만 캐시
모든 결과를 캐시할 필요는 없어요. condition과 unless로 캐시 여부를 조건화할 수 있어요.
// VIP만 캐시 (condition: 캐시 적용 조건)
@Cacheable(value = "user", key = "#id", condition = "#vip == true")
// 결과가 비어 있으면 캐시 안 함 (unless: 캐시 제외 조건)
@Cacheable(value = "orders", key = "#userId", unless = "#result.isEmpty()")
condition은 메서드 실행 전 인자로 판단하고, unless는 실행 후 결과로 판단해요. 그래서 "빈 결과는 캐시하지 않기"처럼 결과를 보고 거르려면 unless를 써요. 캐시할 가치가 없는 걸 캐시에 채우면 메모리만 낭비되니, 캐시 대상을 좁히는 게 좋아요.
08. 로컬 캐시와 함께 쓰기 (2-tier)
아주 자주 조회되는 데이터는 Redis도 부담일 수 있어요. 매번 네트워크를 타니까요. 이럴 때 애플리케이션 메모리의 로컬 캐시(Caffeine 등)를 앞에 두고 그 뒤에 Redis를 두는 2단 구조를 써요.
흐름은 로컬 캐시 먼저 보고, 없으면 Redis, 그래도 없으면 DB예요. 로컬 캐시는 가장 빠르지만 서버마다 따로라 일관성이 약해요. Redis는 여러 서버가 공유하지만 네트워크를 타요. 그래서 로컬은 TTL을 짧게 둬서 잠깐의 불일치만 허용하는 식으로 균형을 잡아요. 트래픽이 아주 큰 핫 데이터에만 써요. 보통은 Redis 한 단으로 충분해요.
09. 자주 만나는 문제
같은 클래스 안에서 호출하면 캐시가 안 먹어요
가장 유명한 함정이에요.

@Cacheable은 스프링 프록시로 동작하는데, 같은 클래스 안의 다른 메서드를 this.getUser()로 부르면 프록시를 안 거쳐서 캐시가 동작 안 해요. 캐시 로직은 프록시에 있는데, 자기 자신을 직접 부르면 그 프록시가 낄 자리가 없거든요. 캐시 대상 메서드는 외부(다른 빈)에서 호출되게 구조를 잡아야 해요. @Transactional이 자기호출에서 안 먹는 것과 같은 원리예요.
null 처리, 기본 동작을 거꾸로 알기 쉬워요
스프링의 Redis 캐시는 기본적으로 null도 캐시해요(NullValue 표식 저장). 그래서 기본 설정이라면 "없는 데이터" 조회도 두 번째부터는 DB를 안 쳐요. null을 캐시에 넣기 싫으면 disableCachingNullValues()나 unless = "#result == null"을 쓰는데, 이러면 없는 데이터 조회가 매번 DB로 가요(캐시 관통). 캐시 관통은 캐시 스탬피드와 3대 장애 편에서 깊게 다뤄요.
역직렬화가 터져요
캐시 값 직렬화기를 안 맞춘 거예요. GenericJackson2JsonRedisSerializer로 지정하고 날짜 타입이면 JavaTimeModule을 등록해요.
캐싱 전 체크리스트
스프링 캐싱은 결국 @Cacheable로 cache-aside를 선언적으로 구현하고 Redis를 저장소로 붙이는 것이에요. 붙이기 전에 이 다섯 가지만 짚으면 캐시 한 줄로 DB 부하를 크게 덜어요.
- TTL을 반드시 둬서 낡은 값이 영원히 남지 않게 해요.
- 값 직렬화는 JSON으로 맞추고 날짜 타입이면
JavaTimeModule을 등록해요. - 수정·삭제에는
@CacheEvict로 무효화해 일관성을 지켜요. - 캐시 대상 메서드는 외부에서 호출되게 둬서 프록시 자기호출 함정을 피해요.
- 캐시할 가치가 있는 것만
condition·unless로 걸러요.
캐시가 동시에 만료되며 트래픽이 폭주하는 문제는 결이 다르니, 캐시 스탬피드와 3대 장애에서 따로 깊게 풀어요.
출처: Spring Framework — Cache Abstraction · Spring Data Redis — Redis Cache
'백엔드 > 캐시' 카테고리의 다른 글
📚 같이 보면 좋은
"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."