- Published on
- •👁️
백엔드 캐싱 I - Spring Cache
- Authors

- Name
- River
상세 설명
선언적 캐싱의 핵심 개념
명령형 캐싱
public Product findProductById(Long id) { String cacheKey = "product:" + id; Product cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { return cached; } Product product = productRepository.findById(id); redisTemplate.opsForValue().set(cacheKey, product, Duration.ofMinutes(10)); return product; }- 명령형 캐싱 ⇒ 직접 구현
- Cache Hit/Miss 상황을 직접 구현한다.
- Redis에서 사전 설정한 Key로 값을 찾고 있다면 그것을 가져와서 반환
- Redis에 캐싱된 객체가없다면 DB 조회 후 반환
선언적 캐싱
@Cacheable(value = "products", key = "#id") public Product findProductById(Long id) { return productRepository.findById(id); }- Spring Cache 이용
@Cacheable등
- 비즈니스 로직엔 비즈니스 로직만 집중할 수 있다.
- 선언적 캐싱의 핵심은 캐시 프록시이다.
- Spring Cache 이용
@EnableCaching을 통한 캐시 기능 활성화
@SpringBootApplication
@EnableCaching
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}
@EnableCaching- AOP 프록시 생성을 지시하는 애노테이션
SpringBoot 메인 클래스에
@EnableCaching이 적용되면,Spring은 캐싱 애노테이션이 있는 클래스의 Bean을 프록시(Proxy) 객체로 감싸서 생성한다.
// Bean 초기화 ProductService originalInstance = new ProductService(); ProductServiceProxy proxyInstance = new ProductServiceProxy(originalInstance); // 프록시 생성 container.registerBean("productService", proxyInstance);캐싱 애노테이션은 메서드 레벨 애노테이션이다.
프록시 내부 구조
public class ProductServiceProxy extends ProductService { private ProductService target; // 원본 객체 public ProductServiceProxy(ProductService target) { this.target = target; } @Override public Product getProduct(Long id) { if (cacheHit) return cachedValue; return target.getProduct(id); } @Override public Product someMethod(Long id) { return target.someMethod(id); } }즉, 프록시 객체가 적용되면
@Autowired를 통해 주입 받는 것은 프록시 객체이다.@RestController public class ProductController { @Autowired private ProductService productService; // ← 프록시 객체가 대신 들어감 public Product getProduct(Long id) { return productService.getProduct(id); } }
AOP 기반 캐시 프록시 동작 원리
- 실제 동작 순서
클라이언트 ⇒
ProductService.findProductById(1L)호출Spring Proxy ⇒ 캐시에서
products::1키 조회Cache Hit or Miss
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ProductServiceProxy ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ @Cacheable 애노테이션 감지 캐시 확인 → Hit? 반환 : 원본 호출 ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ ↓ (Cache Miss 시) ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ProductService.getProduct() ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛- Cache Hit ⇒ 즉시 캐시 데이터 반환 (DB 조회 안함)
- Cache Miss ⇒ 원본 메서드 실행 ⇒ 결과를 캐시에 저장 후 반환
캐시 프록시의 제한사항 ⭐
@Service
public class ProductService {
@Cacheable("products")
public Product getProduct(Long id) {
return this.getExpensiveProduct(id); // ✅
}
public Product directCall(Long id) {
return this.getProduct(id); // ❌
}
@Cacheable("products")
private Product privateMethod(Long id) { // ❌
return productRepository.findById(id);
}
}
private 메서드는 프록시 ❌
같은 클래스 내부 호출은 캐시 ❌
- 내부 호출은 프록시를 우회하기 때문에 애노테이션이 무시된다.
무시되는 이유
directCall()에서getProduct()를 호출할 때는 this가 원본 객체이다. 프록시 객체가 아니다.- 프록시 객체의 경우, 프록시 객체가 원본 객체를 호출 하기 전에 중간 작업을 하도록 오버라이딩이 되어 있지만, 원본 객체를 바로 호출하면 우회되는 것이다.
당연히 static 메서드 또한 선언적 캐싱을 활용할 수 없다. 프록시 작업 자체가 인스턴스 기반이다.
상세 설명
@Cacheable 필수 파라미터
@Cacheable(
value = "products",
cacheNames = "products"
)
public Product findById(Long id) { ... }
value- 캐시 저장소 이름
- cacheNames와 동일한 기능이지만, 더 간결해서 자주 사용
value = {"cache1", "cache2"}
cacheNames- value와 동일한 기능, 둘 중 하나만 쓰면 된다.
@Cacheable 키 관련 파라미터
key 지정 (SpEL 사용)
@Cacheable(value = "products", key = "#id") public Product findById(Long id) { ... }key 생략 시 - 자동 키 생성 (모든 파라미터 조합)
@Cacheable(value = "products") public List<Product> findProducts(Long categoryId, String status, int limit) { ... }key = "1,ACTIVE,10"형태로 자동 생성
복합 키 생성
@Cacheable(value = "products", key = "#categoryId + ':' + #status + ':' + #limit") public List<Product> findProducts(Long categoryId, String status, int limit) { ... }keyGenerator 사용
@Cacheable(value = "products", keyGenerator = "customKeyGenerator") public Product complexMethod(Object... params) { ... }KeyGenerator 구현체 작성
@Component("customKeyGenerator") public class CustomKeyGenerator implements KeyGenerator { @Override public Object generate(Object target, Method method, Object... params) { StringBuilder keyBuilder = new StringBuilder(); keyBuilder.append(target.getClass().getSimpleName()); keyBuilder.append(":"); keyBuilder.append(method.getName()); for (Object param : params) { keyBuilder.append(":"); keyBuilder.append(param.toString()); } return keyBuilder.toString(); } }- 이름은 원하는 이름으로 작성
- 복잡한 키가 필요할 때만 keyGenerator 사용
Spring의 기본 키 생성 규칙 (key 생략 시)
파라미터를
:로 연결public Product findProduct(Long id, String category)- 생성되는 키 :
"1:electronics"
- 생성되는 키 :
파라미터 없을 때
public Product findProduct()- 생성되는 키 :
"SimpleKey.EMPTY"
- 생성되는 키 :
단일 파라미터
public Product findProduct(Long id)- 생성되는 키 :
"1"
- 생성되는 키 :
@Cacheable 고급 파라미터
@Cacheable(
value = "products",
key = "#id",
condition = "#id > 0", // 사전 조건 (메서드 실행 전 검사)
unless = "#result == null", // 사후 조건 (결과값 검사 후 캐시 저장 여부)
sync = true, // 동시 요청 동기화 (캐시 스탬피드 방지)
cacheManager = "redisCacheManager", // 특정 캐시 매니저 지정
cacheResolver = "customCacheResolver" // 동적 캐시 선택
)
public Product findById(Long id) { ... }
@Cacheable에는 기본 파라미터 외에도 다양한 고급 파라미터들이 있다간단하게 메서드 실행 전에 검사하여 캐싱 조회/저장을 안 하도록 하거나
메서드 실행 후 원하지 않는 값은 캐싱 저장을 안 하도록 할 수 있다.
실무 사용 예시
단순 ID 조회
@Service @Slf4j public class ProductService { @Cacheable(value = "products", key = "#id") public Product findById(Long id) { log.debug("DB 조회 - Product ID: {}", id); return productRepository.findById(id) .orElseThrow(() -> new ProductNotFoundException(id)); } }복합 키 조회 (카테고리별 상품 목록)
@Service @Slf4j public class ProductService { @Cacheable(value = "productsByCategory", key = "#category + ':' + #page + ':' + #size") public Page<Product> findByCategory(String category, int page, int size) { log.debug("DB 조회 - Category: {}, Page: {}, Size: {}", category, page, size); Pageable pageable = PageRequest.of(page, size); return productRepository.findByCategory(category, pageable); } }사용자별 추천 상품 (세션 기반)
@Service @Slf4j public class ProductService { @Cacheable(value = "userRecommendations", key = "#userId + ':' + #limit") public List<Product> getRecommendations(Long userId, int limit) { log.debug("추천 알고리즘 실행 - User: {}, Limit: {}", userId, limit); return recommendationService.calculateRecommendations(userId, limit); } }
상세 설명
Stale Data 문제와 데이터 일관성
@Cacheable(value = "products", key = "#id")
public Product findById(Long id) {
return productRepository.findById(id);
}
public Product updatePrice(Long id, BigDecimal newPrice) {
Product product = productRepository.findById(id);
product.setPrice(newPrice); // DB 업데이트
return product;
}
- Stale Data
- 오래된 데이터(Stale Data) 문제는 캐시를 사용할 때 가장 흔한 문제이다.
- 문제점
- DB는 새롭게 업데이트 되었지만, 캐시에서는 여전히 캐싱된 값을 반환
- 원인
@Cacheable은 캐시가 있으면 메서드(DB조회)를 실행하지 않는다.
- 해결책
- 데이터 변경 시 캐시도 함께 갱신하거나 삭제해야 한다.
@CachePut - 캐시 갱신
@Service
@Transactional(readOnly = true)
@Slf4j
public class ProductService {
// 1. 업데이트 (흔하지 않음)
@Transactional
@CachePut(value = "products", key = "#id")
public Product updatePrice(Long id, BigDecimal newPrice) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
product.setPrice(newPrice);
log.info("가격 업데이트 - ID: {}, 새 가격: {}", id, newPrice);
return product;
}
// 2. 생성 (더 흔하지 않음)
@Transactional
@CachePut(value = "products", key = "#result.id")
public Product createProduct(ProductCreateDto dto) {
Product product = Product.builder()
.name(dto.getName())
.price(dto.getPrice())
.build();
return productRepository.save(product);
}
}
@CachePut은 메서드를 항상 실행하고, 그 반환 값으로 캐시를 Update한다.다만, 삽입, 수정, 삭제 시
@CacheEvict로 처리하여 재조회 시 캐시되도록 하는 것이 보통이고@CachePut은 잘 쓰이지 않는다.- 왜냐하면 이렇게 하는 것이 안정적이고 단순하기 때문이다.
@CachePut의 특징
- 항상 메서드 실행 (캐시 존재 여부와 무관)
- 메서드 반환값으로 캐시 갱신
- 즉, 기존 캐시 데이터 덮어쓰기
⭐ (중요!) 응답 DTO 처리와 캐싱 ⭐
문제 상황
일반적으로 DTO 캐싱보다 Entity 캐싱이 일반적이다.
DTO를 캐싱하는 경우, 모든 DTO마다 캐시 메서드를 만들어야 하기 때문이다.
앞서 설명했지만 Spring Cache는 프록시 패턴으로 작동한다. 따라서 같은 Service 클래스 내부에서 Entity 캐싱 메서드를 이용할 수 없다.
즉, Entity 캐싱을 하는 경우 DTO 변환을 클래스 외부에서 해야 한다.
해결 방법
나중에 더 자세한 내용으로 심화 학습 예정
1️⃣ Controller에서 DTO 변환
- 소규모 프로젝트
- 이상적으로는 Service Layer에서 DTO 변환을 하는 것이 맞지만, 캐싱을 활용하기 위해 Controller에서 DTO 변환을 하는 경우도 많다.
- Entity 캐싱으로 재사용성을 확보할 수 있는 가장 쉬운 방법이다.
2️⃣ Facade 패턴
- 중간 규모 프로젝트
Controller와 Service Layer 사이의 중간 계층인 Facade Layer를 생성한다.
Facade Layer는 Controller가 여러 서비스를 활용하는 경우 아주 유용하다.
Controller는 오직 Facade Layer만 사용하고, Facade Layer에서 여러 Service를 조합하여 최종 결과를 제공한다.
즉, Controller 복잡도 감소라는 장점이 있다.
이 경우 Entity 캐싱을 활용하기 위해 각 Entity 서비스에 캐싱을 걸고, Facade Layer는 조합만 담당한다.
3️⃣ CQRS 패턴
- 대규모 프로젝트
- CQRS 패턴은 Service Layer를 읽기 작업(Query) 서비스와 쓰기 작업(Command) 서비스로 분할하는 패턴이다.
- 읽기 성능과 쓰기 성능의 요구사항이 다른 경우 유용하지만 구현 복잡도가 많이 증가한다.
- 이벤트를 활용하여 쓰기 작업 시 읽기 작업도 업데이트 하도록 하는 형태이다. 즉, 데이터 동기화가 아주 중요하다.
- 또한 CQRS 패턴에서도 2가지로 나뉜다
- 같은 DB, 다른 모델
- 읽기용 DB와 쓰기용 DB 분할
@CacheEvict - 캐시 삭제
@CacheEvict의 주요 속성
key: 특정 키만 삭제allEntries = true: 해당 캐시의 모든 데이터 삭제beforeInvocation: 메서드 실행 전/후 삭제 시점 제어
실무 사용 패턴
단일 데이터 삭제
// 하드 삭제 @CacheEvict(value = "products", key = "#id") public void deleteProduct(Long id) { productRepository.deleteById(id); } // 소프트 삭제 @CacheEvict(value = "products", key = "#id") public void deactivateProduct(Long id) { Product product = productRepository.findById(id); product.setActive(false); }데이터 수정 시 캐시 무효화
// 단순 수정 @CacheEvict(value = "products", key = "#id") public Product updatePrice(Long id, BigDecimal newPrice) { Product product = productRepository.findById(id); product.setPrice(newPrice); return product; } // 연관 캐시 함께 삭제 @CacheEvict(value = {"products", "productsByCategory"}, key = "#id") public Product updateCategory(Long id, String newCategory) { Product product = productRepository.findById(id); product.setCategory(newCategory); return product; }신규 생성 시 목록 캐시 무효화
@CacheEvict(value = {"productsByCategory", "popularProducts"}, allEntries = true) public Product createProduct(ProductCreateDto dto) { Product product = Product.builder() .name(dto.getName()) .category(dto.getCategory()) .build(); return productRepository.save(product); }대량 작업 시 전체 캐시 초기화
@CacheEvict(value = "products", allEntries = true) public void bulkUpdateProducts(List<ProductUpdateDto> updates) { productRepository.bulkUpdate(updates); }
beforeInvocation과 트랜잭션 동기화
@Service
@Transactional
public class ProductService {
// beforeInvocation = false (기본값): 메서드 성공 후 캐시 제거
@CacheEvict(value = "products", key = "#id")
public void deleteProduct(Long id) {
productRepository.deleteById(id);
// 만약 여기서 예외 발생하면 롤백되고 캐시도 제거되지 않음 ✅
}
// beforeInvocation = true: 메서드 실행 전 캐시 제거
@CacheEvict(value = "products", key = "#id", beforeInvocation = true)
public void forceDeleteFromCache(Long id) {
productRepository.deleteById(id);
// 예외가 발생해도 캐시는 이미 제거됨 ⚠️
}
}
beforeInvocation속성으로 캐시 작업 시점을 제어할 수 있다.beforeInvocation 선택 기준
false(default)- 트랜잭션 안전성 우선
- 메서드 성공 시에만 캐시 제거
true- 캐시 일관성 우선
- 메서드 실패해도 캐시 제거
실무 CUD 작업별 캐시 전략
- 핵심 원칙
- 단일 아이템 변경 ⇒ 해당 키만 삭제
- 목록에 영향 ⇒ 목록 캐시 전체 삭제
- 복잡한 변경 ⇒ 관련된 모든 캐시 삭제
1️⃣ CREATE (생성)
// 목록 캐시들 무효화 (새 데이터가 추가되므로)
@CacheEvict(value = "productsByCategory", allEntries = true)
public Product createProduct(ProductCreateDto dto) { ... }
2️⃣ UPDATE (수정)
// 해당 아이템 캐시만 삭제 (다음 조회시 최신 데이터)
@CacheEvict(value = "products", key = "#id")
public Product updatePrice(Long id, BigDecimal newPrice) { ... }
3️⃣ DELETE (삭제)
// 해당 아이템 + 관련 목록 캐시 삭제
@CacheEvict(value = {"products", "productsByCategory"}, key = "#id")
public void deleteProduct(Long id) { ... }
상세 설명
@Caching 애노테이션의 필요성
// ❌ 컴파일 에러 - Java 허용 ❌
@CacheEvict(value = "products", key = "#id")
@CacheEvict(value = "productsByCategory", allEntries = true)
public void deleteProduct(Long id) { ... }
// ✅ @Caching으로 해결
@Caching(evict = {
@CacheEvict(value = "products", key = "#id"),
@CacheEvict(value = "productsByCategory", allEntries = true)
})
public void deleteProduct(Long id) { ... }
Java는 같은 타입의 애노테이션을 하나의 메서드에 중복으로 선언하는 것을 허용하지 않는다.
- Java 8+에서는
@Repeatable로 중복 사용 가능한 경우도 있지만, 캐시 애노테이션은 적용되지 않는다. - 따라서 이를
@Caching애노테이션으로 해결한다.
- Java 8+에서는
@Caching vs 단순 배열 비교
같은 키로 여러 캐시
@CacheEvict(value = {"products", "items"}, key = "#id") public void simpleDelete(Long id) { ... }- 이런 경우는 단순 배열을 사용할 수 있다.
서로 다른 키/옵션
@Caching(evict = { @CacheEvict(value = "products", key = "#id"), @CacheEvict(value = "productsByCategory", allEntries = true) }) public void complexDelete(Long id) { ... }- @Caching 필요하다.
실무 @Caching 사용 패턴
다중 @CacheEvict - 가장 흔한 패턴
@Service public class ProductService { // 상품 삭제 - 개별 상품과 관련 목록 캐시 모두 무효화 @Caching(evict = { @CacheEvict(value = "products", key = "#id"), @CacheEvict(value = "productsByCategory", allEntries = true), @CacheEvict(value = "userRecommendations", allEntries = true) }) public void deleteProduct(Long id) { productRepository.deleteById(id); } }@Service public class UserService { // 각각 별도 캐시로 관리 @Cacheable(value = "usersByUsername", key = "#username") public User findByUsername(String username) { return userRepository.findByUsername(username); } @Cacheable(value = "usersByEmail", key = "#email") public User findByEmail(String email) { return userRepository.findByEmail(email); } // 사용자 삭제 시 관련 캐시들 모두 무효화 @Caching(evict = { @CacheEvict(value = "usersByUsername", allEntries = true), @CacheEvict(value = "usersByEmail", allEntries = true), @CacheEvict(value = "userProfiles", allEntries = true) }) public void deleteUser(Long id) { userRepository.deleteById(id); } }@CachePut + @CacheEvict 조합 - 거의 사용 안 함
@Service public class ProductService { @Caching( put = @CachePut(value = "products", key = "#result.id"), evict = { @CacheEvict(value = "productsByCategory", allEntries = true), @CacheEvict(value = "popularProducts", allEntries = true) } ) public Product updateProduct(Product product) { return productRepository.save(product); } }상품 업데이트 ⇒ 개별 캐시 갱신 + 목록 캐시 무효화
실무에서는
@CachePut대신@CacheEvict만 사용하는 경우가 많다.- 갱신 로직이 예측하기 어렵고, 캐시와 DB 불일치 가능성이 있다.
실무 패턴
@Caching(evict = { @CacheEvict(value = "products", key = "#product.id"), @CacheEvict(value = "productsByCategory", allEntries = true), @CacheEvict(value = "popularProducts", allEntries = true) }) public Product updateProduct(Product product) { return productRepository.save(product); }- 캐시 삭제만 한다. ⇒ 이후
@Cacheable로 저장한다.
- 캐시 삭제만 한다. ⇒ 이후
복합적인 캐시 전략 - 실무에서 자주 사용
@Service public class OrderService { @Caching(evict = { @CacheEvict(value = "userOrders", key = "#order.userId"), @CacheEvict(value = "productStock", key = "#order.productId"), @CacheEvict(value = "dailyStats", allEntries = true) }) public Order completeOrder(Order order) { return orderRepository.save(order); } }- 주문 완료 ⇒ 다양한 캐시에 영향
기본 SpEL 표현식
- SpEL(Spring Expression Language) 기본 문법
메서드 파라미터 접근
@Cacheable(value = "products", key = "#id") public Product findById(Long id) { ... }객체 속성 접근
@Cacheable(value = "users", key = "#user.username") public User findByUser(User user) { ... }메서드 결과 접근
@CachePut(value = "products", key = "#result.id") public Product createProduct(ProductDto dto) { ... } @Cacheable(value = "products", key = "#id", unless = "#result == null") public Product findById(Long id) { return productRepository.findById(id).orElse(null); }- 리턴 결과인 Product를
#result로 받는다.
- 리턴 결과인 Product를
복합 키 생성
@Cacheable(value = "products", key = "#category + ':' + #status") public List<Product> findProducts(String category, String status) { ... }Root 객체 사용
@Cacheable( value = "methodCache", key = "#root.methodName + ':' + #id" // "findProduct:123" ) public Product findProduct(Long id) { ... } @Cacheable( value = "targetCache", key = "#root.target.class.simpleName + ':' + #id" // "ProductService:123" ) public Product getProduct(Long id) { ... }- Root 객체란?
- SpEL이 제공하는 기본 컨텍스트 객체
#root.methodName- 메서드명#root.target- 현재 객체 인스턴스#root.targetClass- 현재 클래스#root.args- 메서드 파라미터 배열
- Root 객체란?
정적 메서드 사용
@Cacheable( value = "dailyCache", key = "T(java.time.LocalDate).now() + ':' + #category" // "2025-07-19:electronics" ) public List<Product> getTodayProducts(String category) { ... } @Cacheable( value = "hashCache", key = "T(java.util.Objects).hash(#user.name, #user.email)" ) public UserProfile getUserProfile(User user) { ... }- 시간 기반 캐시 키
T(java.time.LocalDate).now()
- 유틸 메서드 활용한 캐시 키
T(java.util.Objects).hash(#user.name, #user.email)
- 시간 기반 캐시 키
실무 SpEL 패턴
@Service
public class ProductService {
// 사용자별 + 카테고리별 캐시
@Cacheable(value = "userProducts", key = "#userId + ':' + #category")
public List<Product> getUserProducts(Long userId, String category) { ... }
// 페이징 정보 포함
@Cacheable(value = "productPages",
key = "#category + ':' + #page + ':' + #size")
public Page<Product> getProductPage(String category, int page, int size) { ... }
// 조건부 키 생성
@Cacheable(value = "products",
key = "#user.isVip ? 'vip:' + #category : 'normal:' + #category")
public List<Product> getProducts(User user, String category) { ... }
// 메서드 결과 기반 캐시 키
@CachePut(value = "users", key = "#result.username")
public User updateUserProfile(UserUpdateDto dto) {
User user = userRepository.findById(dto.getId());
user.updateProfile(dto);
return userRepository.save(user);
}
}
- 캐시 키를 삼항 연산자로 조건에 따라 다르게 생성할 수 있다.
복합 캐시 전략
@Service
public class ProductService {
// 상품 생성 - 여러 목록 캐시 무효화
@Caching(evict = {
@CacheEvict(value = "productsByCategory", allEntries = true),
@CacheEvict(value = "popularProducts", allEntries = true),
@CacheEvict(value = "newArrivals", allEntries = true),
@CacheEvict(value = "searchResults", allEntries = true)
})
public Product createProduct(ProductCreateDto dto) {
return productRepository.save(Product.from(dto));
}
// 카테고리 변경 - 이전/새 카테고리 모두 영향
@Caching(evict = {
@CacheEvict(value = "products", key = "#id"),
@CacheEvict(value = "productsByCategory", allEntries = true),
@CacheEvict(value = "categoryStats", allEntries = true)
})
public Product changeCategory(Long id, String newCategory) {
Product product = productRepository.findById(id);
product.setCategory(newCategory);
return productRepository.save(product);
}
}
데이터 변경이 여러 캐시에 영향을 미치는 경우 체계적으로 관리해야 한다.
- 새로운 데이터 생성, 카테고리 변경 등
과도한 캐시 무효화는 성능에 좋지 않다.
캐시 무효화 범위 결정 원칙
- 개별 아이템 변경 : 해당 키만 삭제
- 목록에 영향을 주는 변경 : 관련 목록 캐시 전체 삭제 (
allEntries = true) - 통계/집계에 영향 : 모든 통계 캐시 삭제
- 검색 결과에 영향 : 검색 관련 캐시 전체 삭제
⭐ 예시 : 주문 시스템의 복합 캐시 전략 ⭐
사용자 주문 목록 캐시
@Cacheable(value = "userOrders", key = "#userId") public List<Order> getUserOrders(Long userId) { return orderRepository.findByUserIdOrderByCreatedAtDesc(userId); }List<Order>객체가 저장된다.List<Order> = [ Order(id=1, productName="iPhone 15", price=1200000, status="COMPLETED"), Order(id=2, productName="MacBook", price=2500000, status="SHIPPED"), ... ]
상품 재고 캐시
@Cacheable(value = "productStock", key = "#productId") public ProductStock getProductStock(Long productId) { return productRepository.findStockByProductId(productId); }ProductStock객체가 저장된다.ProductStock( productId=456, productName="iPhone 15 Pro", currentStock=100, reservedStock=5, availableStock=95 )
일일 통계 캐시
@Cacheable(value = "dailyStats", key = "T(java.time.LocalDate).now()") public DailyStats getTodayStats() { return statsService.calculateDailyStats(LocalDate.now()); }DailyStats객체가 저장된다.DailyStats( date="2025-07-19", totalOrders=1547, totalRevenue=89750000, topSellingProduct="iPhone 15", avgOrderValue=58000 )
주문 완료 시 복합 캐시 무효화
@Caching(evict = { @CacheEvict(value = "userOrders", key = "#order.userId"), @CacheEvict(value = "productStock", key = "#order.productId"), @CacheEvict(value = "dailyStats", allEntries = true) }) public Order completeOrder(Order order) { return orderRepository.save(order); }- 사용자가
"iPhone 15"를 주문userOrders:123삭제 (userId가 123인 사용자의 주문 목록)productStock:456삭제dailyStats전체 삭제
- 사용자가
상세 설명
기본 CacheManager 종류와 특징
ConcurrentMapCacheManager (기본)
@Configuration @EnableCaching public class CacheConfig { // Bean 등록하지 않으면 자동으로 ConcurrentMapCacheManager 생성 }특징
- Spring이 자동 생성하는 기본 캐시
ConcurrentHashMap기반 JVM 메모리 사용- TTL 없음 (만료 시간 설정 불가)
- 크기 제한 없음 (메모리 누수 가능성)
- 서버 재시작 시 모든 캐시 손실
운영 환경 비추천 이유
- 메모리 누수로 인한 OutOfMemoryError 위험
- TTL 없어서 오래된 데이터 계속 유지
- 서버별 독립적이라 데이터 불일치 가능
SimpleCacheManager
@Bean public CacheManager simpleCacheManager() { SimpleCacheManager manager = new SimpleCacheManager(); manager.setCaches(Arrays.asList( new ConcurrentMapCache("products"), new ConcurrentMapCache("users") )); return manager; }- 특징
- 수동으로 캐시 이름을 미리 정의
- ConcurrentMapCache와 동일한 제약 사항
- 테스트 환경에서만 사용
- 특징
기본 캐시 매니저 우선 순위
cacheManager 생략 시 기본 캐시 매니저 사용
Spring이 cacheManager를 선택하는 순서는 아래와 같다.
@Primary 애노테이션
@Bean("fastCache") @Primary public CacheManager caffeineCacheManager() { ... } @Bean("persistentCache") public CacheManager redisCacheManager() { ... }@Primary가 붙은 것이 기본 캐시 매니저가 된다.
"cacheManager"라는 이름의 Bean
@Bean("cacheManager") public CacheManager defaultCache() { ... } @Bean("otherCache") public CacheManager anotherCache() { ... }- Bean 이름이 "cacheManager"인 것을 자동 선택
CacheManager Bean이 하나만 있는 경우
@Bean public CacheManager onlyOne() { ... }- 다른 CacheManager Bean이 없으면 자동 선택
- 선택을 안 했는데 여러 개면 오류 발생 (
NoUniqueBeanDefinitionException)
자동 생성
// 해당 의존성이 있으면 자동 생성 // spring-boot-starter-data-redis → RedisCacheManager // caffeine → CaffeineCacheManager // 없으면 → ConcurrentMapCacheManager- 만약
@EnableCaching후 캐시 Bean을 등록하지 않은 경우- Classpath에 관련 의존성이 있다면 그것을 자동 생성한다.
- 즉, Redis 의존성 같은 것이 있다면 자동 등록해준다.
- 다만, 이것도 없다면 ConcurrentMapCacheManager를 생성한다.
- 만약
운영환경에서 피해야 할 CacheManager
❌ ConcurrentMapCacheManager
@Bean public CacheManager badChoice() { return new ConcurrentMapCacheManager(); }- 운영 환경에서는 하면 안 된다.
- 문제점
- 메모리 누수로 서버 다운 위험
- TTL 없어서 오래된 데이터 누적
- 서버별 독립적이라 데이터 불일치
✅ 운영 환경 권장 CacheManager
// Caffeine - 로컬 캐시 @Bean public CacheManager caffeineCacheManager() { CaffeineCacheManager manager = new CaffeineCacheManager(); manager.setCaffeine(Caffeine.newBuilder() .expireAfterWrite(Duration.ofMinutes(10)) // TTL 설정 .maximumSize(1000)); // 크기 제한 return manager; } // Redis - 분산 캐시 @Bean public CacheManager redisCacheManager(RedisConnectionFactory factory) { RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofHours(1)); // TTL 설정 return RedisCacheManager.builder(factory) .cacheDefaults(config) .build(); }
멀티 CacheManager 활용 패턴
@Service
public class ProductService {
// 빠른 로컬 캐시 - 자주 조회되는 데이터
@Cacheable(value = "hotProducts", cacheManager = "caffeineCacheManager")
public Product getHotProduct(Long id) {
return productRepository.findById(id);
}
// 분산 캐시 - 서버 간 공유 필요한 데이터
@Cacheable(value = "products", cacheManager = "redisCacheManager")
public Product getProduct(Long id) {
return productRepository.findById(id);
}
}
- 선택 기준
- 빠른 조회 + 서버별 독립적 ⇒ Caffeine
- 데이터 공유 + 영속성 필요 ⇒ Redis
- 메모리 절약 + 단순한 구조 ⇒ 기본
캐시 키 네이밍 컨벤션 ⭐
@Cacheable(value = "products", key = "'product:' + #id") // product:123
@Cacheable(value = "users", key = "'user:' + #email") // user:john@example.com
@Cacheable(value = "orders", key = "'order:' + #userId + ':' + #status") // order:456:PENDING
- 키는 같은 네임스페이스(
value) 안에서도 데이터 종류를 구분하는 역할이다. - 일관된 패턴은 관리 효율을 증대시킨다.
- 권장 패턴
{도메인}:{식별자}:{추가정보}
네이밍 컨벤션이 중요한 이유
캐시 충돌 방지
잘못된 경우
@Cacheable(value = "cache", key = "#id") public User getUser(Long id) { ... } @Cacheable(value = "cache", key = "#id") public Product getProduct(Long id) { ... }같은 ID로 User와 Product가 서로 덮어써질 수 있다.
key를 직접 지정하자
@Cacheable(value = "cache", key = "'user:' + #id") // user:123 public User getUser(Long id) { ... } @Cacheable(value = "cache", key = "'product:' + #id") // product:123 public Product getProduct(Long id) { ... }
캐시 관리 용이성
redis> KEYS user:* // 사용자 관련 캐시만 조회 redis> KEYS product:* // 상품 관련 캐시만 조회 redis> DEL order:123:* // 특정 주문의 모든 캐시 삭제- Redis CLI에서 패턴별 관리 가능
협업 시 일관성
@Cacheable(value = "users", key = "'user:profile:' + #userId") @Cacheable(value = "products", key = "'product:detail:' + #productId") @Cacheable(value = "orders", key = "'order:summary:' + #orderId + ':' + #status")- 팀 표준 컨벤션으로 코드 가독성 향상
실무 캐시 네임스페이스 분리 전략 ⭐
도메인별 분리
// 사용자 도메인 @Cacheable(value = "users", key = "'profile:' + #userId") @Cacheable(value = "users", key = "'preferences:' + #userId") // 상품 도메인 @Cacheable(value = "products", key = "'detail:' + #productId") @Cacheable(value = "products", key = "'stock:' + #productId") // 주문 도메인 @Cacheable(value = "orders", key = "'summary:' + #orderId")데이터 생명주기(TTL)별 분리
// 단기 (1분) @Cacheable(value = "realtime", key = "'stock:' + #productId") // 중기 (1시간) @Cacheable(value = "hourly", key = "'stats:' + #date") // 장기 (1일) @Cacheable(value = "daily", key = "'report:' + #date")접근 권한별 분리
// 공개 데이터 @Cacheable(value = "public", key = "'product:' + #id") // 사용자별 데이터 @Cacheable(value = "userPrivate", key = "#userId + ':cart'") // 관리자 데이터 @Cacheable(value = "admin", key = "'dashboard:' + #type")