이 글에서 얻는 것
- 스프링 캐시를 “어노테이션 몇 개”가 아니라 캐시 전략(Cache-aside) 구현 도구로 이해할 수 있습니다.
@Cacheable/@CachePut/@CacheEvict를 언제 써야 하는지, 그리고 “왜 캐시가 안 먹는지” 디버깅할 수 있습니다.- 캐시 키/TTL/무효화/직렬화 같은 실무 이슈를 설계 기준으로 정리할 수 있습니다.
- Redis/Caffeine을 붙일 때 흔히 터지는 문제(스탬피드, penetration, stale 데이터)를 예방할 수 있습니다.
0) 캐시는 “정답”이 아니라 트레이드오프다
캐시는 대부분 속도 ↔ 정합성(일관성) 의 교환입니다.
- 조회가 많고 변경이 적으면 캐시 효과가 큽니다.
- 변경이 잦으면 무효화/정합성 비용이 커져서 오히려 복잡해집니다.
그래서 “어디에 캐시를 둘지”가 더 중요합니다. 보통은:
- 비싼 조회(외부 API/복잡한 DB 조회/집계)
- 사용자별/리소스별 “핫 키”가 명확한 조회
- 실패/타임아웃이 잦은 의존성 앞단(단, 회복탄력성과 함께)
1) Spring Cache Abstraction: 캐시 구현체를 숨기고 ‘전략’에 집중
스프링 캐시 추상화는 “메서드 호출 결과를 캐시에 저장/조회”하는 형태로 캐시-aside 패턴을 간단히 구현합니다.
핵심 어노테이션 3가지:
@Cacheable: 캐시 조회 후 없으면 실행하고 저장@CachePut: 항상 실행하고, 결과를 캐시에 갱신(캐시 업데이트)@CacheEvict: 캐시 무효화(단일 키/전체)
@Cacheable(cacheNames = "user", key = "#id")
public UserDto getUser(Long id) { ... }
2) “캐시가 안 먹는” 대표 원인 5가지
- 프록시를 안 탐(self-invocation): 같은 클래스 내부 호출은
@Cacheable이 적용되지 않을 수 있습니다. - 키가 매번 달라짐: DTO/복잡 객체를 그대로 키로 쓰면
equals/hashCode/toString 때문에 키가 불안정해집니다. - 조건/예외로 저장이 안 됨:
condition/unless때문에 저장되지 않거나, 예외가 나서 저장이 안 됩니다. - TTL이 너무 짧음/없음: 기대한 시간보다 빨리 지워지거나(짧음), 영구히 남아서 stale 데이터가 됩니다(없음).
- 직렬화/역직렬화 실패(Redis): 클래스 변경/버전 불일치로 캐시 히트가 “실패처럼” 보입니다.
디버깅 팁:
- 캐시 hit/miss 로그를 임시로 넣어보거나, Redis라면 키를 직접 조회해 “실제로 저장이 됐는지”부터 확인합니다.
3) 키 설계: 캐시의 80%는 키에서 결정된다
좋은 키의 조건:
- 안정적: 동일한 요청이면 동일한 키
- 충돌 없음: 다른 의미의 요청이 같은 키로 합쳐지지 않음
- 버전 가능: 응답 스키마가 바뀌면 안전하게 분리 가능(버전 prefix)
권장 패턴:
user:v1:{id}product:v1:{id}search:v2:{normalizedQuery}:{page}:{size}
Spring Cache에서 키를 커스터마이징하는 방법:
- 단순한 경우:
key = "'user:v1:' + #id" - 복잡한 경우:
KeyGenerator를 구현해서 중앙에서 관리
4) TTL과 무효화: “언제까지 믿을 건가”
캐시 정합성을 맞추는 대표 방식은 두 가지입니다.
4-1) TTL 기반(시간이 지나면 자동 만료)
- 구현이 단순하지만, TTL 동안은 stale 데이터가 될 수 있습니다.
- “어느 정도 stale이 괜찮다”는 요구사항에 잘 맞습니다(프로필 조회, 공지, 카탈로그 등).
4-2) 쓰기 시 무효화/갱신(Write-through/Invalidate)
- 변경이 발생할 때
@CacheEvict또는@CachePut로 정합성을 끌어올립니다. - 단, “어떤 변경이 어떤 캐시를 깨야 하는지”를 정확히 관리해야 합니다(복잡도 상승).
실무 팁:
- 기본은 TTL + 중요한 변경 포인트에서 최소한의 evict를 섞는 방식이 관리하기 쉽습니다.
5) 캐시 스탬피드/penetration: 운영에서 진짜 터지는 문제
스탬피드(Cache stampede)
TTL 만료 순간에 다수 요청이 동시에 DB로 몰리는 현상입니다.
완화 방법:
@Cacheable(sync = true)로 “하나만 로드하고 나머지는 대기”하도록(캐시 구현체 지원/환경에 따라 차이)- TTL에 지터(jitter)를 넣어 만료 시점을 분산
- 핫 키는 사전 워밍업
캐시 침투(Cache penetration)
존재하지 않는 키 조회가 DB를 계속 때리는 현상입니다.
완화 방법:
- “없음”도 캐시하기(null 캐싱, 짧은 TTL)
- 입력 검증 강화(말도 안 되는 id를 빠르게 차단)
Spring/Redis에서는 null 캐싱 옵션이 구현체에 따라 다르므로, “없음 캐싱”을 쓸 때는 정책을 명확히 합니다.
6) Redis/Caffeine 구성: 최소한의 형태
Boot 설정만으로도 기본 구성이 됩니다.
spring:
cache:
type: redis
data:
redis:
host: localhost
port: 6379
TTL/직렬화를 더 제어하고 싶다면 RedisCacheManager를 명시적으로 구성합니다.
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(5));
Map<String, RedisCacheConfiguration> perCache = Map.of(
"user", defaultConfig.entryTtl(Duration.ofMinutes(1)),
"product", defaultConfig.entryTtl(Duration.ofMinutes(10))
);
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(perCache)
.build();
}
로컬 캐시(Caffeine) + 원격 캐시(Redis)로 2단계 캐시를 만들 수도 있지만, 정합성/무효화가 더 어려워지니 “정말 필요할 때”만 도입하는 편이 좋습니다.
연습(추천)
getUser(id)에 캐시를 붙이고, 같은 id를 연속 호출했을 때 DB 조회가 줄어드는지 로그로 확인해보기- “없는 id” 요청이 반복될 때 DB가 계속 맞는지 확인하고, 없음을 캐시했을 때 부하가 줄어드는지 비교해보기
- TTL 만료 순간에 동시 요청을 만들어(간단한 부하 도구) 스탬피드가 생기는지 관찰하고 완화책을 적용해보기
💬 댓글