이전 편에서 Spring Cache의 추상화 구조와 동작 원리를 살펴봤다. 이제 실제로 캐시를 적용할 때 사용하는 어노테이션들을 하나씩 파고들어보자. @Cacheable, @CachePut, @CacheEvict를 정확히 이해하면 대부분의 캐시 시나리오를 커버할 수 있다.
@Cacheable — 캐시 조회와 저장
가장 많이 쓰는 어노테이션이다. 메서드 실행 전에 캐시를 먼저 확인하고, Cache Hit이면 메서드를 실행하지 않고 캐시 값을 반환한다. Cache Miss면 메서드를 실행하고 결과를 캐시에 저장한다.
@Cacheable("weather")
public WeatherResponse getWeather(String city) {
log.info("외부 API 호출: {}", city);
return weatherApi.fetch(city);
}
getWeather("서울")을 처음 호출하면 로그가 출력되고 API를 호출한다. 두 번째부터는 로그도 안 찍힌다. 메서드 자체가 실행되지 않기 때문이다.
value와 cacheNames
캐시 영역의 이름을 지정한다. value와 cacheNames는 동일한 속성이다.
@Cacheable(value = "weather") // 이 둘은 같다
@Cacheable(cacheNames = "weather")
여러 캐시 영역에 동시에 저장할 수도 있다.
@Cacheable(value = {"weather", "weatherBackup"})
이 경우 두 캐시 영역 중 하나라도 Hit이면 캐시 값을 반환한다. 실무에서는 거의 쓰지 않지만, L1/L2 캐시 구조에서 활용되기도 한다.
key
캐시에서 값을 구분하는 키를 지정한다. 기본적으로 메서드 파라미터가 키로 사용된다.
// 파라미터가 하나면 그 값 자체가 키다
@Cacheable("weather")
public WeatherResponse getWeather(String city) { ... }
// 키: "서울", "부산" 등
// 파라미터가 여러 개면 모든 파라미터의 조합이 키다
@Cacheable("weather")
public WeatherResponse getWeather(String city, String date) { ... }
// 키: SimpleKey("서울", "2024-01-01")
SpEL을 사용해서 키를 직접 지정할 수도 있다. 키 전략의 세부 내용은 다음 편에서 다룬다.
@Cacheable(value = "weather", key = "#city")
public WeatherResponse getWeather(String city, boolean forceRefresh) { ... }
forceRefresh 파라미터는 키에서 제외하고, city만 키로 사용하겠다는 뜻이다.
sync
동시에 같은 키로 여러 요청이 들어올 때의 동작을 제어한다.
@Cacheable(value = "weather", sync = true)
public WeatherResponse getWeather(String city) { ... }
sync = true로 설정하면 같은 키에 대한 동시 요청 중 하나만 실제 메서드를 실행하고, 나머지는 그 결과를 기다린다. Cache Stampede 문제를 방지하는 데 유용하다.
기본값은 false다. 이 경우 같은 키로 동시에 10개의 요청이 오면 10개 모두 메서드를 실행한다. 외부 API 호출처럼 비용이 큰 작업이라면 비효율적이다.
모든 CacheManager 구현체가 sync를 지원하는 것은 아니다. ConcurrentMapCacheManager와 CaffeineCacheManager는 지원하지만, 일부 Redis 구현체에서는 지원하지 않을 수 있다. 사용하기 전에 구현체의 지원 여부를 확인하라.
@CachePut — 캐시 갱신
@Cacheable과 비슷해 보이지만 결정적인 차이가 있다. 항상 메서드를 실행하고, 그 결과를 캐시에 저장한다. 캐시를 조회하지 않는다.
@CachePut(value = "weather", key = "#city")
public WeatherResponse refreshWeather(String city) {
return weatherApi.fetch(city);
}
이 메서드를 호출하면 캐시에 데이터가 있든 없든 항상 weatherApi.fetch가 실행된다. 그리고 그 결과로 캐시를 갱신한다.
@Cacheable과의 차이
| 속성 | @Cacheable | @CachePut |
|---|---|---|
| 캐시 조회 | 한다 (Hit이면 메서드 스킵) | 하지 않는다 |
| 메서드 실행 | Cache Miss일 때만 | 항상 |
| 결과 캐시 저장 | Cache Miss일 때만 | 항상 |
| 용도 | 읽기 | 갱신 |
사용 시나리오
데이터를 수정한 후 캐시를 최신 상태로 유지하고 싶을 때 쓴다.
@CachePut(value = "feed", key = "#feedId")
public FeedResponse updateFeed(Long feedId, FeedUpdateRequest request) {
Feed feed = feedRepository.findById(feedId)
.orElseThrow(() -> new EntityNotFoundException("Feed not found"));
feed.updateContent(request.getContent());
return FeedResponse.from(feed);
}
수정된 피드 정보가 캐시에 바로 반영되므로, 다음에 이 피드를 조회할 때 DB를 거치지 않고 최신 데이터를 반환한다.
- @CachePut : 수정 후 캐시를 즉시 최신 데이터로 교체한다. 수정 직후에 읽기 요청이 많을 때 유리하다.
- @CacheEvict : 수정 후 캐시를 삭제만 한다. 다음 읽기 요청 시 DB에서 새로 가져온다. 수정 후 즉시 읽기 요청이 적을 때 더 간단하다.
어떤 것이 나은지는 상황에 따라 다르지만, 단순함을 위해 @CacheEvict를 먼저 고려하는 것이 일반적이다.
@CacheEvict — 캐시 제거
캐시에서 항목을 제거한다. 데이터가 변경되었을 때 오래된 캐시를 무효화하는 용도로 사용한다.
@CacheEvict(value = "weather", key = "#city")
public void deleteWeatherCache(String city) {
// 이 메서드가 실행되면 "weather" 캐시에서 해당 city의 항목이 제거된다
}
allEntries
해당 캐시 영역의 모든 항목을 한 번에 제거한다.
@CacheEvict(value = "weather", allEntries = true)
public void clearAllWeatherCache() {
// "weather" 캐시의 모든 항목이 제거된다
}
특정 키만 지우는 것이 아니라 전체를 날리므로 강력하지만 위험하다. 캐시가 전부 비워진 직후 대량의 요청이 들어오면, 모든 요청이 동시에 DB를 때리는 Cache Stampede가 발생할 수 있다.
"그냥 다 지우면 깔끔하니까"라는 생각으로 allEntries = true를 남용하면, 캐시 적중률이 급락하고 DB 부하가 순간적으로 치솟을 수 있다. 가능하면 변경된 항목의 키만 정확하게 지정해서 제거하는 것이 안전하다.
beforeInvocation
메서드 실행 전에 캐시를 제거할지, 실행 후에 제거할지를 결정한다.
// 기본값: false (메서드 실행 후 제거)
@CacheEvict(value = "feed", key = "#feedId")
public void deleteFeed(Long feedId) {
feedRepository.deleteById(feedId);
}
// 메서드 실행 전 제거
@CacheEvict(value = "feed", key = "#feedId", beforeInvocation = true)
public void deleteFeed(Long feedId) {
feedRepository.deleteById(feedId);
}
기본값인 false에서는 메서드가 예외를 던지면 캐시가 제거되지 않는다. true로 설정하면 메서드 성공 여부와 관계없이 캐시를 먼저 제거한다.
언제 beforeInvocation = true를 쓸까? 메서드 실패와 관계없이 캐시를 반드시 무효화해야 할 때다. 하지만 대부분의 경우 기본값인 false가 적절하다. 메서드가 실패했는데 캐시만 지워지면, 다음 요청에서 DB로 불필요한 조회가 발생하기 때문이다.
@Caching — 복합 캐시 연산
하나의 메서드에 여러 캐시 연산을 조합해야 할 때 사용한다.
@Caching(
evict = {
@CacheEvict(value = "feed", key = "#feedId"),
@CacheEvict(value = "feedList", allEntries = true)
}
)
public void deleteFeed(Long feedId) {
feedRepository.deleteById(feedId);
}
피드를 삭제하면 개별 피드 캐시와 피드 목록 캐시를 동시에 무효화해야 한다. @Caching으로 이 두 연산을 하나의 메서드에 묶을 수 있다.
@Caching은 cacheable, put, evict 세 가지를 조합할 수 있다.
@Caching(
cacheable = { @Cacheable("feed") },
evict = { @CacheEvict(value = "feedList", allEntries = true) }
)
@Caching 내부의 연산은 선언 순서대로 실행되지 않을 수 있다. Spring은 put → evict 순서로 처리한다. cacheable과 put을 동시에 사용하면 예상치 못한 동작이 발생할 수 있으므로, 하나의 @Caching 안에서 cacheable과 put을 함께 쓰는 것은 피하라.
@CacheConfig — 클래스 레벨 공통 설정
같은 클래스의 여러 메서드가 동일한 캐시 설정을 공유할 때 반복을 줄여준다.
@Service
@CacheConfig(cacheNames = "feed")
public class FeedService {
@Cacheable
public FeedResponse getFeed(Long feedId) { ... }
@CachePut(key = "#feedId")
public FeedResponse updateFeed(Long feedId, FeedUpdateRequest request) { ... }
@CacheEvict
public void deleteFeed(Long feedId) { ... }
}
@CacheConfig(cacheNames = "feed")를 클래스 레벨에 붙이면, 하위 메서드들의 @Cacheable, @CachePut, @CacheEvict에서 cacheNames를 생략할 수 있다. 메서드 레벨에서 다시 지정하면 메서드 레벨 설정이 우선한다.
@CacheConfig에서 지정할 수 있는 속성은 다음과 같다.
- cacheNames : 캐시 영역 이름
- keyGenerator : 키 생성기
- cacheManager : 사용할 CacheManager
- cacheResolver : 사용할 CacheResolver
어노테이션 선택 가이드
어떤 상황에서 어떤 어노테이션을 써야 하는지 정리하면 다음과 같다.
- 데이터를 읽는다 →
@Cacheable- "같은 파라미터로 반복 호출되는 조회 메서드"에 붙인다
- 데이터를 수정한다 →
@CachePut또는@CacheEvict- 수정 후 바로 캐시를 최신으로 유지 →
@CachePut - 수정 후 캐시를 지우고 다음 조회 시 갱신 →
@CacheEvict
- 수정 후 바로 캐시를 최신으로 유지 →
- 데이터를 삭제한다 →
@CacheEvict- 원본이 사라졌으니 캐시도 삭제해야 한다
- 여러 캐시 영역에 영향을 준다 →
@Caching- 단일 메서드에서 여러 캐시를 동시에 조작할 때
자주 하는 실수
@Cacheable은 "캐시가 있으면 메서드를 실행하지 않는다"이고, @CachePut은 "항상 실행한다"다. 이 둘은 서로 모순된다. 같이 붙이면 실행 순서에 따라 예측 불가능한 동작이 발생한다.
메서드를 분리하라. 조회 메서드에는 @Cacheable, 갱신 메서드에는 @CachePut을 붙이는 것이 올바른 설계다.
[!DANGER] 반환값이 void인 메서드에 @Cacheable 붙이기
@Cacheable은 반환값을 캐시에 저장한다. 반환값이 없으면 null이 캐시에 저장되어 이후 호출에서 항상 null이 반환된다. void 메서드에는 @CacheEvict만 사용할 수 있다.
[!DANGER] null 반환값을 고려하지 않기
@Cacheable은 기본적으로 null도 캐시에 저장한다. DB에서 조회 결과가 없어서 null을 반환하면, 이후 같은 키로 조회할 때 계속 null이 반환된다. 나중에 실제 데이터가 추가되어도 캐시에는 여전히 null이 남아 있다.
이 문제는 unless 속성으로 방지할 수 있다. 자세한 내용은 다음 편에서 다룬다.