본문 바로가기
Spring Boot

Spring에서 Caffeine 캐시 완벽 가이드

by pswamazing 2025. 12. 5.

안녕하세요! 오늘은 Spring에서 Caffeine 캐시를 사용하는 방법을 알아보겠습니다. Caffeine은 Java 8 기반의 고성능 캐싱 라이브러리로, Spring Cache 추상화와 완벽하게 통합됩니다!


1. Caffeine이란?

Caffeine은 Google의 Guava 캐시를 기반으로 만들어진 고성능 Java 캐싱 라이브러리입니다. Spring Boot 2.x부터는 기본 캐시 구현체로 Caffeine을 권장하고 있습니다.

Caffeine의 장점

뛰어난 성능 - 읽기/쓰기 속도가 매우 빠르며, 멀티스레드 환경에서도 안정적입니다.

다양한 만료 정책 - 시간 기반, 크기 기반, 참조 기반 등 다양한 만료 정책을 지원합니다.

자동 로딩 - CacheLoader를 통해 캐시 미스 시 자동으로 데이터를 로드할 수 있습니다.

비동기 지원 - AsyncCache를 통해 비동기 캐싱을 지원합니다.

통계 기능 - 캐시 히트율, 미스율 등의 통계 정보를 제공합니다.


2. Maven 의존성 추가

먼저 pom.xml에 Caffeine 라이브러리를 추가합니다.

 
<!-- Caffeine Cache -->
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.1.8</version>
</dependency>

<!-- Spring Cache 추상화 (Spring Boot Starter에 포함) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

버전 선택 가이드

Caffeine 3.x는 Java 11 이상이 필요합니다. Java 8을 사용한다면 Caffeine 2.9.x 버전을 사용하세요.

<!-- Java 8용 -->
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>2.9.3</version>
</dependency>

3. Caffeine 캐시 설정하기

Spring에서 Caffeine을 사용하는 방법은 크게 두 가지입니다. Configuration 클래스를 통한 Bean 등록 방식과 application.properties를 통한 설정 방식입니다.

방법 1: Java Config로 Bean 등록

가장 세밀하게 제어할 수 있는 방법입니다.

CacheConfig.java

package com.example.config;

import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.TimeUnit;

@Configuration
@EnableCaching
public class CacheConfig {
    
    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager();
        cacheManager.setCaffeine(caffeineCacheBuilder());
        return cacheManager;
    }
    
    Caffeine<Object, Object> caffeineCacheBuilder() {
        return Caffeine.newBuilder()
                .initialCapacity(100)           // 초기 캐시 공간
                .maximumSize(500)               // 최대 캐시 개수
                .expireAfterWrite(10, TimeUnit.MINUTES)  // 쓰기 후 10분 후 만료
                .recordStats();                 // 통계 기능 활성화
    }
}

 

방법 2: application.properties 설정

간단한 설정은 properties 파일로도 가능합니다.

# 캐시 타입 지정
spring.cache.type=caffeine

# Caffeine 설정 (spec 형식)
spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=10m

# 캐시 이름 지정 (선택사항)
spring.cache.cache-names=users,products,orders

Caffeine Spec 문법

# 최대 개수 500개
maximumSize=500

# 쓰기 후 10분 후 만료
expireAfterWrite=10m

# 접근 후 5분 후 만료
expireAfterAccess=5m

# 초기 용량 100
initialCapacity=100

# 약한 참조 키 사용
weakKeys

# 약한 참조 값 사용
weakValues

# 소프트 참조 값 사용
softValues

# 통계 기록
recordStats

4. 캐시 이름별 개별 설정

각 캐시마다 다른 설정을 적용하고 싶을 때는 다음과 같이 합니다.

@Configuration
@EnableCaching
public class CacheConfig {
    
    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager();
        
        // users 캐시: 1시간 유지, 최대 1000개
        cacheManager.registerCustomCache("users",
            Caffeine.newBuilder()
                .expireAfterWrite(1, TimeUnit.HOURS)
                .maximumSize(1000)
                .build());
        
        // products 캐시: 30분 유지, 최대 500개
        cacheManager.registerCustomCache("products",
            Caffeine.newBuilder()
                .expireAfterWrite(30, TimeUnit.MINUTES)
                .maximumSize(500)
                .build());
        
        // sessions 캐시: 접근 후 10분 유지
        cacheManager.registerCustomCache("sessions",
            Caffeine.newBuilder()
                .expireAfterAccess(10, TimeUnit.MINUTES)
                .maximumSize(100)
                .build());
        
        return cacheManager;
    }
}

5. 캐시 어노테이션 사용법

이제 실제로 캐시를 사용해봅시다. Spring의 캐시 어노테이션을 활용하면 매우 간단합니다.

@Cacheable - 캐시 조회

메서드 결과를 캐시에 저장하고, 다음 호출부터는 캐시된 값을 반환합니다.

@Service
public class UserService {
    
    @Autowired
    private UserRepository userRepository;
    
    @Cacheable(value = "users", key = "#userId")
    public User getUserById(Long userId) {
        System.out.println("DB 조회: " + userId);
        return userRepository.findById(userId)
                .orElseThrow(() -> new RuntimeException("User not found"));
    }
    
    @Cacheable(value = "users", key = "#email")
    public User getUserByEmail(String email) {
        System.out.println("DB 조회: " + email);
        return userRepository.findByEmail(email)
                .orElseThrow(() -> new RuntimeException("User not found"));
    }
}

@CachePut - 캐시 갱신

항상 메서드를 실행하고, 결과를 캐시에 업데이트합니다.

@Service
public class UserService {
    
    @CachePut(value = "users", key = "#user.id")
    public User updateUser(User user) {
        System.out.println("사용자 업데이트: " + user.getId());
        return userRepository.save(user);
    }
}

@CacheEvict - 캐시 삭제

캐시에서 데이터를 삭제합니다.

@Service
public class UserService {
    
    @CacheEvict(value = "users", key = "#userId")
    public void deleteUser(Long userId) {
        System.out.println("사용자 삭제: " + userId);
        userRepository.deleteById(userId);
    }
    
    @CacheEvict(value = "users", allEntries = true)
    public void deleteAllUsers() {
        System.out.println("모든 사용자 캐시 삭제");
        userRepository.deleteAll();
    }
}

@Caching - 여러 캐시 작업 조합

@Service
public class UserService {
    
    @Caching(
        evict = {
            @CacheEvict(value = "users", key = "#user.id"),
            @CacheEvict(value = "users", key = "#user.email")
        },
        put = {
            @CachePut(value = "users", key = "#user.id")
        }
    )
    public User updateUserEmail(User user) {
        return userRepository.save(user);
    }
}

6. 조건부 캐싱

특정 조건에서만 캐시를 적용할 수 있습니다.

@Service
public class ProductService {
    
    // 결과가 null이 아닐 때만 캐시
    @Cacheable(value = "products", key = "#productId", unless = "#result == null")
    public Product getProduct(Long productId) {
        return productRepository.findById(productId).orElse(null);
    }
    
    // 가격이 1000 이상인 상품만 캐시
    @Cacheable(value = "products", key = "#productId", condition = "#result.price >= 1000")
    public Product getExpensiveProduct(Long productId) {
        return productRepository.findById(productId).orElse(null);
    }
    
    // 활성 상품만 캐시
    @Cacheable(value = "products", key = "#productId", unless = "#result.status != 'ACTIVE'")
    public Product getActiveProduct(Long productId) {
        return productRepository.findById(productId).orElse(null);
    }
}

7. 복합 키 사용하기

여러 파라미터를 조합하여 캐시 키를 만들 수 있습니다.

@Service
public class OrderService {
    
    // 방법 1: SpEL로 직접 조합
    @Cacheable(value = "orders", key = "#userId + '_' + #status")
    public List<Order> getOrdersByUserAndStatus(Long userId, String status) {
        return orderRepository.findByUserIdAndStatus(userId, status);
    }
    
    // 방법 2: 객체 전체를 키로 사용
    @Cacheable(value = "orders", key = "#searchDto")
    public List<Order> searchOrders(OrderSearchDto searchDto) {
        return orderRepository.search(searchDto);
    }
    
    // 방법 3: 커스텀 키 생성기 사용
    @Cacheable(value = "orders", keyGenerator = "customKeyGenerator")
    public List<Order> getOrders(Long userId, String status, LocalDate from, LocalDate to) {
        return orderRepository.findOrders(userId, status, from, to);
    }
}

커스텀 키 생성기

@Component
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("_"));
    }
}

8. 캐시 통계 모니터링

Caffeine의 통계 기능을 활용하여 캐시 성능을 모니터링할 수 있습니다.

@RestController
@RequestMapping("/cache")
public class CacheController {
    
    @Autowired
    private CacheManager cacheManager;
    
    @GetMapping("/stats/{cacheName}")
    public Map<String, Object> getCacheStats(@PathVariable String cacheName) {
        CaffeineCacheManager caffeineCacheManager = (CaffeineCacheManager) cacheManager;
        CaffeineCache cache = (CaffeineCache) caffeineCacheManager.getCache(cacheName);
        
        if (cache != null) {
            com.github.benmanes.caffeine.cache.Cache<Object, Object> nativeCache = 
                cache.getNativeCache();
            
            CacheStats stats = nativeCache.stats();
            
            Map<String, Object> statsMap = new HashMap<>();
            statsMap.put("hitCount", stats.hitCount());
            statsMap.put("missCount", stats.missCount());
            statsMap.put("hitRate", stats.hitRate());
            statsMap.put("evictionCount", stats.evictionCount());
            statsMap.put("size", nativeCache.estimatedSize());
            
            return statsMap;
        }
        
        return Collections.emptyMap();
    }
    
    @DeleteMapping("/clear/{cacheName}")
    public String clearCache(@PathVariable String cacheName) {
        Cache cache = cacheManager.getCache(cacheName);
        if (cache != null) {
            cache.clear();
            return "캐시 초기화 완료: " + cacheName;
        }
        return "캐시를 찾을 수 없음: " + cacheName;
    }
}

9. 수동으로 캐시 사용하기

어노테이션 없이 직접 캐시를 제어할 수도 있습니다.

@Service
public class ManualCacheService {
    
    @Autowired
    private CacheManager cacheManager;
    
    public User getUserWithManualCache(Long userId) {
        Cache cache = cacheManager.getCache("users");
        
        // 캐시에서 조회
        User cachedUser = cache.get(userId, User.class);
        if (cachedUser != null) {
            System.out.println("캐시 히트!");
            return cachedUser;
        }
        
        // 캐시 미스 - DB 조회
        System.out.println("캐시 미스 - DB 조회");
        User user = userRepository.findById(userId).orElse(null);
        
        // 캐시에 저장
        if (user != null) {
            cache.put(userId, user);
        }
        
        return user;
    }
    
    public void evictUser(Long userId) {
        Cache cache = cacheManager.getCache("users");
        cache.evict(userId);
    }
    
    public void clearAllUsers() {
        Cache cache = cacheManager.getCache("users");
        cache.clear();
    }
}

10. 실전 예제: 상품 조회 서비스

실제 프로젝트에서 활용할 수 있는 완전한 예제입니다.

Product 엔티티

@Entity
@Table(name = "products")
public class Product implements Serializable {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    private BigDecimal price;
    private String category;
    private Integer stock;
    
    // getter, setter 생략
}

ProductService

@Service
@Slf4j
public class ProductService {
    
    @Autowired
    private ProductRepository productRepository;
    
    @Cacheable(value = "products", key = "#productId")
    public Product getProduct(Long productId) {
        log.info("DB에서 상품 조회: {}", productId);
        return productRepository.findById(productId)
                .orElseThrow(() -> new RuntimeException("상품을 찾을 수 없습니다"));
    }
    
    @Cacheable(value = "productsByCategory", key = "#category")
    public List<Product> getProductsByCategory(String category) {
        log.info("DB에서 카테고리 상품 조회: {}", category);
        return productRepository.findByCategory(category);
    }
    
    @CachePut(value = "products", key = "#product.id")
    public Product updateProduct(Product product) {
        log.info("상품 업데이트 및 캐시 갱신: {}", product.getId());
        return productRepository.save(product);
    }
    
    @Caching(evict = {
        @CacheEvict(value = "products", key = "#productId"),
        @CacheEvict(value = "productsByCategory", allEntries = true)
    })
    public void deleteProduct(Long productId) {
        log.info("상품 삭제 및 캐시 제거: {}", productId);
        productRepository.deleteById(productId);
    }
    
    @CacheEvict(value = "products", allEntries = true)
    @Scheduled(cron = "0 0 3 * * ?")  // 매일 새벽 3시
    public void clearAllCaches() {
        log.info("모든 상품 캐시 초기화");
    }
}

ProductController

@RestController
@RequestMapping("/api/products")
public class ProductController {
    
    @Autowired
    private ProductService productService;
    
    @GetMapping("/{id}")
    public ResponseEntity<Product> getProduct(@PathVariable Long id) {
        Product product = productService.getProduct(id);
        return ResponseEntity.ok(product);
    }
    
    @GetMapping("/category/{category}")
    public ResponseEntity<List<Product>> getProductsByCategory(@PathVariable String category) {
        List<Product> products = productService.getProductsByCategory(category);
        return ResponseEntity.ok(products);
    }
    
    @PutMapping("/{id}")
    public ResponseEntity<Product> updateProduct(@PathVariable Long id, @RequestBody Product product) {
        product.setId(id);
        Product updated = productService.updateProduct(product);
        return ResponseEntity.ok(updated);
    }
    
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        productService.deleteProduct(id);
        return ResponseEntity.noContent().build();
    }
}

11. 캐시 사용 시 주의사항

Serializable 구현

캐시에 저장되는 객체는 Serializable을 구현해야 합니다.

public class User implements Serializable {
    private static final long serialVersionUID = 1L;
    // 필드 선언
}

적절한 만료 시간 설정

데이터 특성에 따라 적절한 만료 시간을 설정하세요.

  • 자주 변경되는 데이터: 짧은 만료 시간 (1~5분)
  • 가끔 변경되는 데이터: 중간 만료 시간 (10~30분)
  • 거의 변경되지 않는 데이터: 긴 만료 시간 (1시간 이상)

메모리 관리

maximumSize를 적절히 설정하여 메모리 부족을 방지하세요.

Caffeine.newBuilder()
    .maximumSize(1000)  // 최대 1000개까지만 저장
    .build();

분산 환경 고려

Caffeine은 로컬 캐시입니다. 여러 서버 환경에서는 Redis 같은 분산 캐시를 고려하세요.


마무리

이제 Spring에서 Caffeine 캐시를 자유롭게 사용할 수 있게 되었습니다!

정리하면:

1단계 - Maven 의존성 추가 (caffeine, spring-boot-starter-cache)

2단계 - CacheConfig 클래스에 @EnableCaching과 CacheManager Bean 등록

3단계 - 서비스 메서드에 @Cacheable, @CachePut, @CacheEvict 어노테이션 적용

4단계 - 필요시 캐시 통계 모니터링 및 수동 제어

댓글