캐시에 적합한 데이터는 읽기가 압도적으로 많고 변경이 드문 데이터입니다. 공통 코드, 권한 목록, 설정값, 환율처럼 "자주 읽고 가끔 바뀌는" 데이터가 여기에 해당합니다. 이런 데이터는 잠깐 오래된 값을 보여줘도 업무에 지장이 없습니다.
반대로 캐시에 넣으면 안 되는 데이터도 있습니다. 계좌 잔액, 재고 수량처럼 정합성이 중요한 값, 사용자별로 매번 다른 개인화 데이터, 그리고 한 번만 쓰고 버리는 값입니다. 이런 데이터를 캐시하면 "오래된 잔액을 보여줬다"는 사고로 이어집니다.
캐시 키는 조회 조건을 빠짐없이 반영해야 합니다. 예를 들어 findUsers(deptId, status) 를 캐시한다면 키는 dept:1:status:ACTIVE 처럼 두 파라미터를 모두 포함해야 하고, 하나라도 빠지면 다른 조건의 결과가 섞여 나옵니다.
Spring @Cacheable 은 기본적으로 파라미터 전체를 조합해 키를 만듭니다. 다만 파라미터가 없거나 null 이면 키가 겹칠 수 있어 key = "#p0 + ':' + #p1" 처럼 명시하는 편이 안전합니다.
null 값 자체를 캐시할지도 결정해야 합니다. null 은 기본적으로 캐시되지 않는 구현이 많아, 없는 값을 조회할 때마다 매번 DB 를 다시 때리는 문제가 생깁니다. 이 문제의 해법이 2.5 의 음수 캐시입니다.
TTL(Time To Live)은 항목이 캐시에 머무는 최대 시간입니다. TTL 만 있고 크기 제한이 없으면, 키 종류가 계속 늘어나는 상황(예: 사용자별 캐시)에서 TTL 이 지나기 전까지 힙 메모리가 끝없이 커질 수 있습니다.
크기 제한(LRU, Least Recently Used)은 오래 안 쓴 항목부터 밀어내 메모리 상한을 지킵니다. TTL 과 크기 제한은 서로 다른 문제를 막습니다. TTL 은 "오래된 값을 계속 보여주는 문제"를, 크기 제한은 "메모리가 무한정 느는 문제"를 막습니다. 그래서 둘 다 함께 설정하는 것이 실무 기본값입니다.
데이터를 수정했을 때 캐시를 최신 상태로 맞추는 방법은 크게 두 가지입니다.
| 전략 | 동작 | 적합한 경우 |
|---|---|---|
| evict | 수정 시 해당 키만 삭제, 다음 조회에서 재계산 | 일반적인 갱신 |
| put | 수정 시 새 값을 캐시에 바로 채움 | 조회 비용이 커서 재계산을 피하고 싶을 때 |
| clear | 캐시 전체 비움 | 코드 테이블 대량 갱신, 배치 후 |
evict 는 구현이 단순하고 다음 조회 시점에 최신 값을 보장하므로 가장 흔히 씁니다. put 은 수정된 값을 이미 알고 있을 때(예: update 메서드가 갱신된 엔티티를 반환) DB 재조회 없이 캐시를 갱신할 수 있어 효율적입니다.
캐시 스탬피드는 인기 있는 키 하나가 만료되는 순간 동시에 들어온 여러 요청이 전부 캐시 미스로 판단해 DB 를 동시에 때리는 현상입니다. 트래픽이 많은 서비스에서는 이 순간 DB 커넥션 풀이 순식간에 고갈될 수 있습니다.
해법은 "같은 키에 대한 계산은 한 번만" 실행하도록 락을 거는 것입니다. ConcurrentHashMap.computeIfAbsent 나 compute 는 같은 키에 대해 여러 스레드가 동시에 진입해도 계산 함수(loader)를 단 한 번만 실행하도록 보장합니다. 이 레슨의 Cache.get 이 이 방식을 씁니다.
음수 캐시(negative cache)는 "값이 없다"는 결과 자체를 짧게 캐시하는 기법입니다. 존재하지 않는 사용자 ID 를 반복 조회하는 요청이 매번 DB 까지 가는 것을 막아 줍니다. 다만 TTL 을 일반 값보다 짧게 둬서, 실제로 데이터가 생겼을 때 오래 못 보는 일을 방지해야 합니다.
로컬 캐시는 인스턴스마다 별도의 메모리를 쓰기 때문에 서버가 여러 대(다중 인스턴스)면 인스턴스 간 캐시 내용이 서로 다를 수 있습니다. 한 서버에서 evict 해도 다른 서버는 여전히 옛날 값을 들고 있습니다. 또한 캐시가 커지면 JVM 힙을 그만큼 점유하고, 힙 덤프를 뜨면 캐시에 담긴 개인정보가 그대로 노출될 위험도 있습니다.
다음 조건 중 하나라도 해당하면 Redis 같은 분산 캐시로 옮기는 것을 검토합니다.
| 조건 | 이유 |
|---|---|
| 서버가 2대 이상 | 인스턴스 간 캐시 불일치 문제 |
| 데이터 변경 즉시 전 서버 반영 필요 | 로컬 evict 는 자기 서버만 지움 |
| 캐시 크기가 힙을 압박 | 별도 프로세스로 메모리 분리 필요 |
| 서버 재시작 시에도 캐시 유지 필요 | 로컬 캐시는 재시작하면 사라짐 |
폐쇄망 환경에서는 Redis 서버 반입·운영 부담이 있어, 인스턴스가 1대뿐이거나 데이터 불일치를 잠깐 허용해도 되는 경우 로컬 캐시만으로 충분한 사례가 많습니다.
@Cacheable 계열 대응표Spring 캐시 추상화는 로컬 캐시 구현(Caffeine, EhCache)이나 Redis 를 같은 애너테이션으로 다룰 수 있게 해 줍니다.
| 애너테이션 | 동작 |
|---|---|
@Cacheable |
캐시에 있으면 반환, 없으면 메서드 실행 후 저장 |
@CacheEvict |
메서드 실행 후 해당 키(또는 전체) 삭제 |
@CachePut |
항상 메서드를 실행하고 결과를 캐시에 저장 |
@Caching |
위 애너테이션을 여러 개 조합할 때 사용 |
가장 흔한 함정은 self-invocation 입니다. @Cacheable 은 프록시를 통해 동작하는데, 같은 클래스 안에서 this.getUser(id) 처럼 직접 호출하면 프록시를 거치지 않아 캐시가 전혀 동작하지 않습니다. 이 경우 별도 빈으로 분리하거나 AopContext.currentProxy() 로 우회해야 합니다.
설정은 application.yml 에 다음처럼 둡니다.
spring:
cache:
type: caffeine
cache-names: users, codes
caffeine:
spec: maximumSize=1000,expireAfterWrite=10m적중률(hit ratio) 측정은 운영에서 캐시가 실제로 효과가 있는지 확인하는 핵심 지표입니다. Caffeine 은 recordStats() 를 켜면 actuator 의 /actuator/caches 와 /actuator/metrics/cache.gets 로 hit·miss 수치를 노출할 수 있어, 캐시 크기나 TTL 을 튜닝할 때 근거로 씁니다.