캐시의 핵심은 "같은 요청이면 같은 결과를 돌려준다"는 것이다. 여기서 같은 요청을 어떻게 판별하느냐가 바로 캐시 키 전략이다. 키가 잘못 설계되면 다른 데이터가 같은 키로 엮이거나, 같은 데이터가 다른 키로 중복 저장된다.
기본 키 생성 전략
@Cacheable에 key 속성을 명시하지 않으면 Spring이 자동으로 키를 생성한다. 기본 키 생성기는 SimpleKeyGenerator다.
파라미터가 없는 경우
@Cacheable("config")
public AppConfig getConfig() { ... }
키는 SimpleKey.EMPTY다. 빈 키 객체가 하나 생성되어 모든 호출이 같은 캐시 항목을 공유한다.
파라미터가 하나인 경우
@Cacheable("weather")
public WeatherResponse getWeather(String city) { ... }
키는 파라미터 값 자체다. getWeather("서울")이면 키는 "서울".
파라미터가 여러 개인 경우
@Cacheable("weather")
public WeatherResponse getWeather(String city, String date) { ... }
키는 SimpleKey 객체다. 내부적으로 모든 파라미터의 hashCode()를 조합하여 생성한다. getWeather("서울", "2024-01-01")이면 키는 SimpleKey("서울", "2024-01-01").
파라미터가 객체일 때 hashCode()와 equals()가 제대로 구현되어 있지 않으면, 논리적으로 같은 요청이라도 다른 키가 생성된다. 예를 들어 WeatherRequest 객체에 hashCode()를 오버라이드하지 않으면 매번 새로운 키가 만들어져 캐시가 전혀 동작하지 않는다. JPA 엔티티나 DTO에 Lombok의 @EqualsAndHashCode를 습관적으로 붙이는 이유 중 하나가 이것이다.
SpEL을 활용한 커스텀 키
기본 키 생성 전략이 맞지 않을 때, SpEL로 키를 직접 지정한다. SpEL은 Spring Expression Language의 약자로, Spring이 제공하는 표현식 언어다.
파라미터 참조
@Cacheable(value = "weather", key = "#city")
public WeatherResponse getWeather(String city, boolean useCache) { ... }
#city는 메서드 파라미터 city의 값을 참조한다. useCache 파라미터는 키에서 제외된다.
객체의 필드 참조
@Cacheable(value = "feed", key = "#request.feedId")
public FeedResponse getFeed(FeedRequest request) { ... }
#request.feedId는 request 객체의 feedId 필드 값을 키로 사용한다.
여러 값을 조합한 복합 키
@Cacheable(value = "weather", key = "#city + ':' + #date")
public WeatherResponse getWeather(String city, String date) { ... }
문자열을 연결해서 복합 키를 만든다. getWeather("서울", "2024-01-01")이면 키는 "서울:2024-01-01".
SpEL에서는 Java의 정적 메서드를 호출할 수 있다.
```java
@Cacheable(value = "weather", key = "T(String).valueOf(#city).toUpperCase()")
```
이런 식으로 키를 정규화할 수 있지만, 복잡한 SpEL은 가독성을 해친다. 이 정도로 복잡한 키 생성이 필요하면 KeyGenerator를 직접 구현하는 편이 낫다.
SpEL에서 사용 가능한 변수
Spring Cache는 SpEL에서 다음 변수들을 제공한다.
- #root.method : 현재 메서드 (
Method객체) - #root.target : 대상 객체
- #root.caches : 관련된
Cache객체 컬렉션 - #root.methodName : 메서드 이름 (문자열)
- #root.targetClass : 대상 클래스
- #root.args[0] : 첫 번째 파라미터 (인덱스 접근)
- #result : 메서드 반환값 (
@CachePut과@CacheEvict의unless에서만 사용 가능)
@Cacheable(value = "feed", key = "#root.methodName + ':' + #feedId")
public FeedResponse getFeed(Long feedId) { ... }
// 키: "getFeed:123"
메서드 이름을 키에 포함시키면 같은 파라미터를 받는 다른 메서드와 키가 충돌하는 문제를 방지할 수 있다.
KeyGenerator 커스터마이징
SpEL로 표현하기 어려운 복잡한 키 로직이 필요하면, KeyGenerator 인터페이스를 직접 구현한다.
@Component("customKeyGenerator")
public class CustomKeyGenerator implements KeyGenerator {
@Override
public Object generate(Object target, Method method, Object... params) {
return method.getName() + ":"
+ Arrays.stream(params)
.map(String::valueOf)
.collect(Collectors.joining(":"));
}
}
등록한 KeyGenerator를 어노테이션에서 참조한다.
@Cacheable(value = "weather", keyGenerator = "customKeyGenerator")
public WeatherResponse getWeather(String city, String date) { ... }
// 키: "getWeather:서울:2024-01-01"
@Cacheable에 key와 keyGenerator를 동시에 지정하면 예외가 발생한다. 둘 중 하나만 사용해야 한다.
KeyGenerator를 쓰면 좋은 경우는 다음과 같다.
- 모든 캐시 메서드에 동일한 키 생성 규칙을 적용하고 싶을 때
- 키 생성 로직에 조건문이나 변환 로직이 포함될 때
- 테스트에서 키 생성 로직을 독립적으로 검증하고 싶을 때
condition — 캐시 적용 조건
condition은 캐시 자체를 사용할지 말지 결정한다. 조건이 false면 캐시를 아예 무시하고 항상 메서드를 실행한다.
@Cacheable(value = "weather", condition = "#city.length() > 2")
public WeatherResponse getWeather(String city) { ... }
도시 이름이 2자 이하면 캐시를 사용하지 않는다. 매번 메서드가 실행되고, 결과도 캐시에 저장되지 않는다.
condition은 메서드 실행 전에 평가된다. 따라서 #result를 사용할 수 없다.
실용적인 condition 예시
// 페이지 크기가 기본값(20)일 때만 캐시
@Cacheable(value = "feedList", condition = "#size == 20")
public List<FeedResponse> getFeedList(int page, int size) { ... }
// 특정 사용자만 캐시 (관리자는 항상 최신 데이터 조회)
@Cacheable(value = "dashboard", condition = "!#isAdmin")
public DashboardResponse getDashboard(Long userId, boolean isAdmin) { ... }
unless — 결과 기반 캐시 제외
unless는 condition과 반대 방향으로 동작한다. 메서드 실행 후 결과를 보고, 특정 조건에 해당하면 캐시에 저장하지 않는다.
@Cacheable(value = "weather", unless = "#result == null")
public WeatherResponse getWeather(String city) { ... }
조회 결과가 null이면 캐시에 저장하지 않는다. null이 아니면 정상적으로 캐시에 저장된다.
condition과 unless의 차이
| 속성 | 평가 시점 | true일 때 동작 | #result 사용 |
|---|---|---|---|
| condition | 메서드 실행 전 | 캐시 사용 | 불가 |
| unless | 메서드 실행 후 | 캐시에 저장하지 않음 | 가능 |
이름이 헷갈릴 수 있다. condition은 "이 조건이면 캐시한다", unless는 "이 조건이면 캐시하지 않는다"로 기억하면 된다.
실용적인 unless 예시
// null 결과는 캐시하지 않음
@Cacheable(value = "feed", unless = "#result == null")
public FeedResponse getFeed(Long feedId) { ... }
// 빈 리스트는 캐시하지 않음
@Cacheable(value = "feedList", unless = "#result.isEmpty()")
public List<FeedResponse> getFeedList(String category) { ... }
// 에러 응답은 캐시하지 않음
@Cacheable(value = "weather", unless = "#result.statusCode != 200")
public WeatherApiResponse getWeather(String city) { ... }
null이 캐시에 저장되면 이후 해당 키로 조회할 때 계속 null이 반환된다. 실제 데이터가 나중에 추가되어도 캐시에는 여전히 null이다. 대부분의 @Cacheable에 unless = "#result == null"을 기본으로 포함하는 것이 안전하다.
condition과 unless 조합
두 속성을 함께 사용할 수 있다. 이 경우 동작 흐름은 다음과 같다.
condition을 먼저 평가한다 (메서드 실행 전)condition이false면 → 캐시 무시, 메서드 직접 실행condition이true면 → 캐시 조회 시도- Cache Miss면 → 메서드 실행
unless를 평가한다 (메서드 실행 후)unless가true면 → 결과를 캐시에 저장하지 않음unless가false면 → 결과를 캐시에 저장
@Cacheable(
value = "feedList",
condition = "#size == 20",
unless = "#result == null || #result.isEmpty()"
)
public List<FeedResponse> getFeedList(int page, int size) { ... }
페이지 크기가 20일 때만 캐시를 사용하고, 결과가 null이거나 빈 리스트이면 캐시에 저장하지 않는다.
캐시 이름 설계 전략
캐시 키뿐 아니라 캐시 이름(cacheName)도 전략적으로 설계해야 한다. 캐시 이름은 캐시 영역을 구분하는 네임스페이스 역할을 한다.
좋은 캐시 이름 규칙
- 도메인 중심으로 명명 :
feed,weather,user처럼 도메인 엔티티 이름을 사용한다. - 단수형 vs 복수형으로 단건/목록 구분 :
feed는 단건 조회,feeds는 목록 조회. - 일관된 네이밍 컨벤션 : 프로젝트 전체에서 camelCase든 kebab-case든 하나로 통일한다.
// 좋은 예 — 의미가 명확하다
@Cacheable("weather")
@Cacheable("feeds")
@Cacheable("userProfile")
// 나쁜 예 — 무슨 데이터가 캐시되는지 알 수 없다
@Cacheable("cache1")
@Cacheable("data")
@Cacheable("temp")
서로 다른 서비스 클래스에서 같은 캐시 이름을 사용하면서 키 체계가 다르면 충돌이 발생한다. WeatherService와 ClothingService가 모두 "data"라는 캐시를 사용하되 키 타입이 다르면, 예상치 못한 데이터가 반환될 수 있다.
자주 하는 실수
기본 키 생성 전략은 파라미터의 hashCode()를 사용한다. hashCode()가 오버라이드되지 않은 객체를 파라미터로 넘기면, 객체의 메모리 주소가 해시값으로 쓰인다. 논리적으로 같은 요청이라도 매번 새로운 키가 생성되어 캐시가 아무 효과가 없다. 캐시 키에 관여하는 파라미터 객체에는 반드시 hashCode()와 equals()를 구현하라.
[!DANGER] 불필요한 파라미터를 키에 포함시키기
파라미터가 (String city, HttpServletRequest request) 같은 조합일 때, 기본 키 전략은 두 파라미터 모두를 키에 포함시킨다. HttpServletRequest는 요청마다 다른 객체이므로 캐시가 절대 Hit되지 않는다. 반드시 key = "#city"처럼 의미 있는 파라미터만 키로 지정해야 한다.
[!DANGER] condition에서 #result를 사용하려고 시도하기
condition은 메서드 실행 전에 평가되므로 #result를 사용할 수 없다. 결과 기반 조건은 반드시 unless를 사용해야 한다. condition에 #result를 쓰면 항상 null로 평가되어 의도하지 않은 동작이 발생한다.