@Cacheable 심화 — 캐시 추상화의 동작 원리와 설정 옵션
같은 데이터를 조회하는 요청이 초당 수천 번 들어온다면, 매번 DB를 조회하는 게 맞을까요?
캐싱은 반복적인 데이터 접근을 최적화하는 가장 효과적인 방법 중 하나입니다. Spring은 캐시 추상화를 통해 구현체에 독립적인 캐싱 기능을 제공합니다. @Cacheable 하나면 메서드 결과를 자동으로 캐시할 수 있습니다.
캐시 추상화란
Spring의 캐시 추상화는 어떤 캐시 구현체를 사용하든 동일한 어노테이션으로 캐싱 할 수 있게 해주는 계층입니다. ConcurrentHashMap이든 Redis든 Caffeine이든, 코드 변경 없이 구현체만 교체할 수 있습니다.
핵심 구성 요소는 이렇습니다.
- Cache: 실제 캐시 저장소 인터페이스
- CacheManager: Cache 인스턴스를 관리하는 팩토리
- ** 캐시 어노테이션 **:
@Cacheable,@CachePut,@CacheEvict,@Caching
@Cacheable — 캐시 조회 + 저장
가장 기본적인 캐시 어노테이션입니다. 캐시에 값이 있으면 메서드를 실행하지 않고 캐시된 값을 반환합니다.
@Service
public class ProductService {
@Cacheable("products")
public Product findById(Long id) {
// 캐시 미스일 때만 실행됨
log.info("DB에서 상품 조회: {}", id);
return productRepository.findById(id)
.orElseThrow(() -> new NotFoundException("상품을 찾을 수 없습니다"));
}
}
동작 흐름은 이렇습니다.
- 메서드 호출 시 캐시 키를 생성 (기본값: 메서드 파라미터)
"products"캐시에서 해당 키로 조회- ** 캐시 히트 **: 캐시된 값 반환 (메서드 실행 안 함)
- ** 캐시 미스 **: 메서드 실행 → 결과를 캐시에 저장 → 결과 반환
캐시 키 지정 — key 속성
기본적으로 메서드 파라미터가 캐시 키가 됩니다. SpEL(Spring Expression Language)로 키를 커스텀할 수 있습니다.
// 파라미터의 특정 필드를 키로 사용
@Cacheable(value = "users", key = "#request.email")
public User findByEmail(UserSearchRequest request) { ... }
// 여러 파라미터 조합
@Cacheable(value = "products", key = "#category + ':' + #page")
public List<Product> findByCategory(String category, int page) { ... }
// 메서드 이름 포함
@Cacheable(value = "reports", key = "#root.methodName + ':' + #date")
public Report getReport(LocalDate date) { ... }
자주 쓰는 SpEL 표현식을 정리하면 이렇습니다.
| 표현식 | 설명 |
|---|---|
#파라미터명 | 메서드 파라미터 참조 |
#p0, #a0 | 첫 번째 파라미터 (인덱스) |
#result | 메서드 반환값 (unless에서 사용) |
#root.methodName | 메서드 이름 |
#root.targetClass | 대상 클래스 |
condition과 unless — 조건부 캐싱
@Cacheable(
value = "products",
condition = "#id > 0", // 실행 전: id가 양수일 때만 캐시 사용
unless = "#result.price == 0" // 실행 후: 가격이 0이면 캐시하지 않음
)
public Product findById(Long id) { ... }
condition vs unless 차이:
| 속성 | 평가 시점 | true일 때 |
|---|---|---|
condition | 메서드 실행 전 | 캐시 사용 (조회/저장) |
unless | 메서드 실행 후 | 캐시에 저장하지 않음 |
unless에서는 #result로 반환값을 참조할 수 있지만, condition에서는 반환값을 사용할 수 없습니다.
// null 결과는 캐시하지 않기
@Cacheable(value = "users", unless = "#result == null")
public User findByEmail(String email) { ... }
// 특정 조건에서만 캐시 조회 (조건 불충족 시 항상 메서드 실행)
@Cacheable(value = "config", condition = "#env == 'production'")
public Config getConfig(String env) { ... }
@CachePut — 항상 실행하고 캐시 갱신
@CachePut은 메서드를 ** 항상 실행 **하고, 결과를 캐시에 저장합니다. 데이터를 갱신할 때 캐시도 함께 갱신하는 용도입니다.
@CachePut(value = "products", key = "#product.id")
public Product update(Product product) {
// 항상 실행됨
return productRepository.save(product);
}
@Cacheable과의 차이를 명확히 이해해야 합니다.
@Cacheable: 캐시 히트 시 메서드 실행 안 함@CachePut: 항상 메서드 실행, 결과를 캐시에 저장
@CacheEvict — 캐시 삭제
캐시된 데이터를 무효화할 때 사용합니다.
// 특정 키의 캐시 삭제
@CacheEvict(value = "products", key = "#id")
public void delete(Long id) {
productRepository.deleteById(id);
}
// 해당 캐시의 모든 엔트리 삭제
@CacheEvict(value = "products", allEntries = true)
public void bulkUpdate(List<Product> products) {
productRepository.saveAll(products);
}
// 메서드 실행 전에 캐시 삭제
@CacheEvict(value = "products", beforeInvocation = true)
public void riskyDelete(Long id) {
// 메서드 실행 전에 캐시가 삭제됨
// 메서드가 실패해도 캐시는 이미 삭제된 상태
productRepository.deleteById(id);
}
beforeInvocation 기본값은 false입니다. true로 설정하면 메서드 실행 전에 캐시를 삭제하므로, 메서드 실패 시에도 캐시가 이미 삭제된 상태입니다.
@Caching — 여러 캐시 연산 조합
하나의 메서드에 여러 캐시 연산을 적용할 때 사용합니다.
@Caching(
put = {
@CachePut(value = "productsById", key = "#result.id"),
@CachePut(value = "productsByCode", key = "#result.code")
},
evict = {
@CacheEvict(value = "productList", allEntries = true)
}
)
public Product create(ProductCreateRequest request) {
return productRepository.save(request.toEntity());
}
활성화 설정
캐시를 사용하려면 @EnableCaching을 활성화해야 합니다.
@Configuration
@EnableCaching
public class CacheConfig {
}
Spring Boot에서는 spring.cache.type으로 캐시 구현체를 지정합니다.
spring:
cache:
type: caffeine # none, simple, caffeine, redis 등
caffeine:
spec: maximumSize=1000,expireAfterWrite=10m
프록시 기반 동작의 주의점
Spring의 캐시 추상화는 AOP 프록시로 동작합니다. 이로 인한 주의점이 있습니다.
@Service
public class ProductService {
@Cacheable("products")
public Product findById(Long id) { ... }
public Product findWithDetails(Long id) {
// 같은 클래스 내부 호출 → 프록시를 거치지 않아 캐시가 동작하지 않음!
Product product = findById(id);
// ...
return product;
}
}
** 해결 방법:**
- 캐시 메서드를 별도 빈(서비스)으로 분리
self-injection패턴 사용AspectJ위빙 사용 (컴파일 타임/로드 타임)
// 해결: 별도 빈으로 분리
@Service
public class ProductCacheService {
@Cacheable("products")
public Product findById(Long id) { ... }
}
@Service
public class ProductService {
private final ProductCacheService cacheService;
public Product findWithDetails(Long id) {
Product product = cacheService.findById(id); // 프록시를 통해 호출
// ...
return product;
}
}
주의할 점
1. @Cacheable과 @CachePut을 같은 메서드에 함께 사용하면 예측 불가능한 동작이 발생한다
@Cacheable은 캐시 히트 시 메서드를 실행하지 않지만, @CachePut은 항상 메서드를 실행합니다. 둘을 동시에 붙이면 실행 여부가 모호해져 의도한 대로 동작하지 않습니다. 조회는 @Cacheable, 갱신은 @CachePut으로 메서드를 분리하세요.
2. SpEL 키 표현식에서 null 파라미터를 처리하지 않으면 NullPointerException이 발생한다
@Cacheable(key = "#request.email") 같은 SpEL 표현식에서 request가 null이거나 email 필드가 null이면 런타임에 예외가 발생합니다. 캐시 키에 사용하는 파라미터가 null이 될 수 있는지 반드시 확인하고, 필요하다면 condition으로 null 케이스를 제외해야 합니다.
3. @CacheEvict(allEntries = true)는 운영 환경에서 성능 이슈를 일으킬 수 있다
Redis 같은 분산 캐시에서 allEntries = true는 해당 캐시 이름의 모든 키를 순회하며 삭제합니다. 캐시 엔트리가 수만 건 이상이면 삭제 자체가 Redis를 블로킹하여 다른 요청에 영향을 줄 수 있습니다. 대량 데이터를 다루는 캐시에서는 개별 키 삭제나 TTL 기반 만료를 우선 고려하세요.
정리
@Cacheable은 캐시 히트 시 메서드를 실행하지 않고,@CachePut은 항상 실행 후 캐시를 갱신합니다condition은 실행 전,unless는 실행 후 평가되며,unless에서만#result를 사용할 수 있습니다@CacheEvict의allEntries와beforeInvocation옵션을 상황에 맞게 사용하세요- ** 같은 클래스 내부 호출 **에서는 프록시를 거치지 않아 캐시가 동작하지 않으므로 빈을 분리해야 합니다