왜 추상화인가
캐시 라이브러리는 여러 가지다. Caffeine, Ehcache, Redis, Hazelcast… 각각 API가 다르다. Caffeine은 Cache.get(), Redis는 RedisTemplate.opsForValue().get(), Ehcache는 CacheManager.getCache().
만약 캐시 코드를 서비스 로직에 직접 작성하면, 나중에 Caffeine에서 Redis로 바꿀 때 캐시를 사용하는 모든 코드를 수정해야 한다.
Spring Cache 추상화는 이 문제를 해결한다. @Cacheable, @CacheEvict 같은 어노테이션만 쓰면, 밑에서 어떤 캐시 구현체가 동작하는지 서비스 코드는 몰라도 된다. Caffeine에서 Redis로 바꿀 때 설정만 변경하면 서비스 코드는 그대로 유지된다.
JDBC가 DB 종류를 추상화하는 것, SLF4J가 로깅 라이브러리를 추상화하는 것과 같은 패턴이다.
@EnableCaching
Spring Cache를 활성화하려면 @EnableCaching을 붙여야 한다.
@Configuration
@EnableCaching
public class CacheConfig {
}
이게 없으면 @Cacheable, @CacheEvict 등의 어노테이션이 무시된다. @EnableAsync 없이 @Async가 안 되는 것과 같은 패턴이다.
@Cacheable — 조회 결과 캐싱
가장 핵심적인 어노테이션이다. 메서드의 반환 값을 캐시에 저장하고, 같은 파라미터로 다시 호출되면 메서드를 실행하지 않고 캐시에서 반환한다.
@Cacheable("users")
public List<UserDto> getAllUsers() {
log.info("DB에서 사용자 목록 조회");
return userRepository.findAll().stream()
.map(UserDto::from)
.toList();
}
"users": 캐시 이름. 논리적인 저장 영역이다.- 첫 호출 시 : 메서드 실행 → 결과를
"users"캐시에 저장 → 결과 반환 - 두 번째 호출 시 : 메서드 실행 안 함 → 캐시에서 바로 반환
로그에 "DB에서 사용자 목록 조회"가 한 번만 찍히면 캐시가 동작하는 것이다.
캐시 키
파라미터가 있으면 파라미터 값이 캐시 키가 된다.
@Cacheable("channels")
public List<ChannelDto> getChannelsByUserId(UUID userId) {
return channelRepository.findByUserId(userId);
}
getChannelsByUserId("abc-123")→ 키 :"abc-123", 값 : abc-123의 채널 목록getChannelsByUserId("def-456")→ 키 :"def-456", 값 : def-456의 채널 목록
파라미터가 여러 개면 모든 파라미터를 조합해서 키를 만든다. 특정 파라미터만 키로 쓰고 싶으면 key를 지정한다.
@Cacheable(value = "channels", key = "#userId")
public List<ChannelDto> getChannels(UUID userId, boolean includeArchived) {
// userId만 캐시 키로 사용. includeArchived는 키에 포함되지 않음.
}
위 예시에서 includeArchived가 true일 때와 false일 때 다른 결과를 반환하는데 키에 포함하지 않으면, includeArchived=true로 조회한 결과가 캐시되어 false로 요청해도 같은 결과가 나온다. 결과에 영향을 주는 파라미터는 반드시 키에 포함해야 한다.
조건부 캐싱
특정 조건에서만 캐시하고 싶을 때 condition과 unless를 사용한다.
@Cacheable(value = "users", condition = "#userId != null")
public UserDto getUser(UUID userId) { ... }
@Cacheable(value = "posts", unless = "#result == null")
public PostDto getPost(Long postId) { ... }
condition: 메서드 실행 전 평가.false면 캐시를 아예 안 쓴다 (조회도, 저장도 안 함).unless: 메서드 실행 후 평가.true면 결과를 캐시에 저장하지 않는다. 이미 캐시된 값이 있으면 반환은 한다.
unless = "#result == null"은 "null 결과는 캐시하지 않는다"는 의미다. DB에서 못 찾은 결과를 캐시하면, 나중에 데이터가 추가돼도 계속 null을 반환하는 문제를 방지한다.
@CacheEvict — 캐시 무효화
데이터가 변경되면 캐시를 삭제해야 한다. 안 그러면 오래된 데이터를 계속 반환한다.
@CacheEvict(value = "users", allEntries = true)
public UserDto createUser(UserCreateRequest request) {
User user = userRepository.save(new User(request));
return UserDto.from(user);
}
allEntries = true: 해당 캐시("users")의 모든 항목을 삭제한다. 목록 캐시처럼 어떤 키가 영향 받는지 모를 때 사용한다.allEntries = false(기본값) : 메서드 파라미터를 키로 해당 항목만 삭제한다.
특정 키만 삭제하려면 key를 지정한다.
@CacheEvict(value = "channels", key = "#userId")
public void leaveChannel(UUID userId, UUID channelId) {
// userId의 채널 목록 캐시만 삭제
}
여러 캐시를 동시에 무효화
하나의 메서드에서 여러 캐시를 무효화해야 할 때 @Caching을 사용한다.
@Caching(evict = {
@CacheEvict(value = "users", allEntries = true),
@CacheEvict(value = "channels", allEntries = true)
})
public void deleteUser(UUID userId) {
userRepository.deleteById(userId);
}
사용자가 삭제되면 사용자 목록 캐시와 채널 목록 캐시 모두 영향을 받으므로 둘 다 무효화한다.
@CachePut — 캐시 갱신
@CachePut은 메서드를 항상 실행하고, 결과를 캐시에 저장한다. @Cacheable과의 차이는, @Cacheable은 캐시에 있으면 메서드를 실행하지 않지만, @CachePut은 항상 실행한다.
@CachePut(value = "users", key = "#userId")
public UserDto updateUser(UUID userId, UserUpdateRequest request) {
User user = userRepository.findById(userId).orElseThrow();
user.update(request);
return UserDto.from(user);
}
"데이터를 수정하면서 동시에 캐시도 최신 값으로 갱신한다"는 시나리오에 적합하다.
다만 실무에서 @CachePut보다 @CacheEvict를 더 많이 쓴다. 이유는 다음과 같다.
@CacheEvict: 캐시를 삭제. 다음 조회 시 DB에서 최신 값을 가져와서 다시 캐싱.@CachePut: 캐시를 갱신. 그런데 업데이트 메서드의 반환 값과 조회 메서드의 반환 값이 정확히 같은 형태인지 보장하기 어렵다.
- 단건 엔티티 업데이트 → @CachePut 가능 (반환 타입이 같을 때)
- 목록 캐시에 영향 → @CacheEvict(allEntries = true) (목록 전체를 재캐싱해야 하므로)
- 확실하지 않으면 @CacheEvict가 안전하다. 성능이 약간 떨어지더라도 데이터 일관성은 보장된다.
어노테이션 선택 가이드
"이 메서드가 데이터를 어떻게 다루는가"부터 생각하면 어노테이션이 자연스럽게 정해진다.
수행하나요?} Action -->|데이터 조회| Read["캐시에 데이터가 없으면 실행하고 저장,
있으면 메서드 실행 없이 반환"] Read --> Cacheable["@Cacheable"] Action -->|데이터 변경 또는 생성| Update{캐시를 어떻게
처리할까요?} Update -->|기존 캐시 삭제, 안전함| Evict1["@CacheEvict"] Update -->|새로운 값으로
캐시 덮어쓰기| Put["@CachePut"] Action -->|데이터 삭제| Delete["관련된 캐시 데이터 제거"] Delete --> Evict2["@CacheEvict"] style Cacheable fill:#e8f4f8,stroke:#2196F3,stroke-width:2px,color:#000 style Put fill:#e8f8e8,stroke:#4CAF50,stroke-width:2px,color:#000 style Evict1 fill:#fff3e0,stroke:#FF9800,stroke-width:2px,color:#000 style Evict2 fill:#fff3e0,stroke:#FF9800,stroke-width:2px,color:#000
- 조회 →
@Cacheable: 캐시에 있으면 메서드를 실행하지 않고 바로 반환한다. 조회 API의 기본 선택. - 변경/생성 → 두 가지 선택지가 있다.
@CacheEvict: 캐시를 삭제한다. 다음 조회 시 DB에서 최신 값을 가져온다. 확실하지 않으면 이쪽이 안전하다.@CachePut: 메서드를 항상 실행하고 결과로 캐시를 덮어쓴다. 반환 타입이 조회 메서드와 같을 때만 쓸 수 있다.
- 삭제 →
@CacheEvict: 데이터가 사라졌으니 관련 캐시도 제거해야 한다.
캐시 이름 설계
캐시 이름은 논리적 저장 영역을 나타낸다. 잘 설계하면 무효화 범위를 정밀하게 제어할 수 있다.
| 캐시 이름 | 저장 내용 | 무효화 시점 |
|---|---|---|
users | 전체 사용자 목록 | 사용자 추가/수정/삭제 |
channels | 사용자별 채널 목록 | 채널 추가/수정/삭제, 멤버 변경 |
notifications | 사용자별 알림 목록 | 알림 추가/읽음 처리 |
posts:popular | 인기 게시글 목록 | 게시글 추가/삭제, 조회수 변경 |
캐시 이름을 너무 넓게 잡으면 (예: "data" 하나에 모든 것) 작은 변경에도 전체 캐시가 날아간다. 너무 좁게 잡으면 (예: "user:abc-123:channels:active") 관리가 복잡해진다.
@CacheEvict를 빠뜨리면 데이터는 바뀌었는데 캐시는 옛날 값을 반환한다. 캐시를 적용할 때 이 데이터를 변경하는 모든 곳에 @CacheEvict가 있는지 반드시 확인하자. 이게 캐시에서 가장 흔한 버그다.
프록시 기반 동작
Spring Cache는 @Async와 마찬가지로 프록시 기반이다. Spring이 @Cacheable이 붙은 빈을 감싸는 프록시 객체를 만들고, 메서드 호출을 가로채서 캐시 처리를 한다. 같은 클래스 내에서 this.getCachedData()를 직접 호출하면 프록시를 거치지 않아서 캐시가 동작하지 않는다.
외부에서 호출하면 컨트롤러 → 프록시 → 실제 서비스 순서로 흐른다. 프록시가 중간에서 캐시 확인과 저장을 담당하고(초록 영역), 히트면 서비스를 아예 실행하지 않는다.
그런데 같은 클래스 내에서 this.메서드()로 직접 호출하면 이야기가 달라진다. 호출이 프록시를 거치지 않고 서비스 내부에서 직접 일어나기 때문에(분홍 영역), 캐시 로직이 전혀 개입하지 못한다.
캐시 로직이 전혀 개입하지 못함 end Service-->>Proxy: 결과 반환 (항상 새롭게 실행) Proxy-->>Client: 결과 반환
@Service
public class PostService {
public PostDto getPost(Long id) {
return getPostCached(id); // ❌ 캐시 안 됨. self-invocation.
}
@Cacheable("posts")
public PostDto getPostCached(Long id) { ... }
}
해결 방법은 @Cacheable 메서드를 외부에서 호출하도록 설계하거나, 캐시 로직을 별도 서비스로 분리하는 것이다. 컨트롤러 → 서비스 호출 구조에서는 자연스럽게 외부 호출이 되므로 대부분 문제없다.
Q&A
@Cacheable, @CacheEvict 어노테이션이 완전히 무시된다. 어노테이션이 붙어 있어도 캐시 로직이 전혀 실행되지 않고, 메서드가 항상 실행된다. 에러도 발생하지 않아서 캐시가 동작하지 않는지 모른 채로 지나칠 수 있다. 로그에 "DB에서 조회"가 매번 찍히면 제일 먼저 @EnableCaching을 확인하자.
[!QUESTION] @Cacheable과 @CachePut의 차이는?
@Cacheable은 캐시에 값이 있으면 메서드를 실행하지 않는다. 캐시 조회가 목적이다. @CachePut은 캐시 여부와 상관없이 항상 메서드를 실행하고 결과를 캐시에 저장한다. 캐시 갱신이 목적이다. 조회 API에는 @Cacheable, 수정 API에는 @CachePut 또는 @CacheEvict를 쓴다.
[!QUESTION] self-invocation 문제를 어떻게 해결하나요?
Spring Cache는 AOP 프록시 기반이라, 외부에서 호출해야 프록시가 개입한다. 같은 클래스 내 this.method() 호출은 프록시를 거치지 않아 캐시가 동작하지 않는다.
해결책 두 가지:
1. 설계 변경 : 캐시 적용 메서드를 별도 서비스 빈으로 분리. 컨트롤러 → 서비스A → 서비스B(캐시 적용) 구조.
2. 자기 주입 : @Autowired로 자기 자신을 주입받아 self.getPostCached(id)로 호출. 동작은 하지만 코드가 어색해서 설계 변경이 더 권장된다.
[!QUESTION] condition과 unless의 차이는?
실행 시점이 다르다. condition은 메서드 실행 전에 평가해서 false면 캐시를 아예 사용하지 않는다. unless는 메서드 실행 후에 평가해서 true면 결과를 캐시에 저장하지 않는다 (이미 캐시된 값이 있으면 반환은 한다). unless = "#result == null"이 자주 쓰이는 패턴이다. null 결과를 캐시해두면 데이터가 나중에 생겨도 계속 null을 반환하는 버그가 생긴다.