Spring Cache Abstraction 심화

Spring Cache Abstraction이란?

Spring의 Cache Abstraction은 캐시 구현체(Redis, Caffeine, EhCache 등)에 독립적인 선언적 캐싱 레이어를 제공합니다. @Cacheable, @CacheEvict, @CachePut 어노테이션만으로 메서드 결과를 캐싱하고, 캐시 구현체를 바꿔도 비즈니스 코드를 수정할 필요가 없습니다. Spring AOP 기반으로 동작하며, 프록시를 통해 메서드 호출을 인터셉트합니다.

기본 설정과 캐시 매니저

@EnableCaching으로 캐싱을 활성화하고, CacheManager 빈으로 캐시 구현체를 설정합니다.

@Configuration
@EnableCaching
public class CacheConfig {

    // Caffeine 캐시 매니저 (로컬 캐시)
    @Bean
    public CacheManager caffeineCacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(Duration.ofMinutes(10))
            .recordStats()  // 히트율 통계
        );
        return manager;
    }

    // Redis 캐시 매니저 (분산 캐시)
    @Bean
    public RedisCacheManager redisCacheManager(
            RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration
            .defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(30))
            .serializeKeysWith(
                SerializationPair.fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(
                SerializationPair.fromSerializer(
                    new GenericJackson2JsonRedisSerializer()));

        // 캐시별 TTL 개별 설정
        Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
            "users", defaultConfig.entryTtl(Duration.ofHours(1)),
            "products", defaultConfig.entryTtl(Duration.ofMinutes(15)),
            "config", defaultConfig.entryTtl(Duration.ofHours(24))
        );

        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(defaultConfig)
            .withInitialCacheConfigurations(cacheConfigs)
            .transactionAware()  // 트랜잭션과 동기화
            .build();
    }
}

@Cacheable: 조회 캐싱

@Cacheable은 메서드 결과를 캐시에 저장하고, 동일 파라미터로 재호출 시 캐시에서 반환합니다. 메서드가 실행되지 않으므로 부작용이 없는 조회 메서드에 사용합니다.

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    // 기본: 파라미터가 캐시 키
    @Cacheable("users")
    public User findById(Long id) {
        return userRepository.findById(id).orElseThrow();
    }

    // 커스텀 키: SpEL로 키 생성
    @Cacheable(value = "users", key = "#email")
    public User findByEmail(String email) {
        return userRepository.findByEmail(email).orElseThrow();
    }

    // 복합 키: 여러 파라미터 조합
    @Cacheable(value = "user-search", key = "#department + '-' + #role")
    public List<User> findByDepartmentAndRole(String department, String role) {
        return userRepository.findByDepartmentAndRole(department, role);
    }

    // 조건부 캐싱: condition (캐시 저장 조건)
    @Cacheable(value = "users", condition = "#id > 0")
    public User findByIdSafe(Long id) {
        return userRepository.findById(id).orElseThrow();
    }

    // unless: 결과 기반 캐시 제외
    @Cacheable(value = "users", unless = "#result == null")
    public User findByIdOrNull(Long id) {
        return userRepository.findById(id).orElse(null);
    }

    // unless: 특정 조건의 결과 캐싱 제외
    @Cacheable(value = "users", unless = "#result.status == 'INACTIVE'")
    public User findActive(Long id) {
        return userRepository.findById(id).orElseThrow();
    }
}

@CacheEvict: 캐시 무효화

데이터가 변경되면 캐시를 무효화해야 합니다. @CacheEvict는 특정 키 또는 전체 캐시를 삭제합니다.

@Service
public class UserService {

    // 특정 키 삭제
    @CacheEvict(value = "users", key = "#user.id")
    public User updateUser(User user) {
        return userRepository.save(user);
    }

    // 캐시 전체 삭제
    @CacheEvict(value = "users", allEntries = true)
    public void deleteAll() {
        userRepository.deleteAll();
    }

    // beforeInvocation: 메서드 실행 전에 캐시 삭제
    // (메서드가 예외를 던져도 캐시는 이미 삭제됨)
    @CacheEvict(value = "users", key = "#id", beforeInvocation = true)
    public void deleteUser(Long id) {
        userRepository.deleteById(id);  // 예외 발생해도 캐시는 삭제됨
    }

    // 여러 캐시 동시 무효화
    @Caching(evict = {
        @CacheEvict(value = "users", key = "#user.id"),
        @CacheEvict(value = "user-search", allEntries = true),
        @CacheEvict(value = "user-stats", allEntries = true),
    })
    public User updateUserProfile(User user) {
        return userRepository.save(user);
    }
}

@CachePut: 캐시 갱신

@CachePut은 메서드를 항상 실행하고 결과를 캐시에 저장합니다. @Cacheable과 달리 캐시가 있어도 메서드가 실행됩니다. 데이터 변경 후 캐시를 최신 상태로 유지할 때 사용합니다.

@Service
public class ProductService {

    // 저장 후 캐시도 갱신
    @CachePut(value = "products", key = "#result.id")
    public Product createProduct(ProductDto dto) {
        Product product = Product.from(dto);
        return productRepository.save(product);
    }

    // 업데이트 후 캐시 갱신
    @CachePut(value = "products", key = "#id")
    public Product updateProduct(Long id, ProductDto dto) {
        Product product = productRepository.findById(id).orElseThrow();
        product.update(dto);
        return productRepository.save(product);
    }

    // @Cacheable + @CachePut 조합은 피하세요!
    // 두 어노테이션의 동작이 충돌합니다
}

@Caching: 복합 캐시 연산

하나의 메서드에서 여러 캐시 연산을 조합할 때 @Caching을 사용합니다.

@Service
public class OrderService {

    @Caching(
        put = {
            @CachePut(value = "orders", key = "#result.id"),
        },
        evict = {
            @CacheEvict(value = "order-list", allEntries = true),
            @CacheEvict(value = "user-orders", key = "#dto.userId"),
            @CacheEvict(value = "order-stats", allEntries = true),
        }
    )
    public Order createOrder(OrderDto dto) {
        Order order = Order.from(dto);
        return orderRepository.save(order);
    }
}

커스텀 KeyGenerator

복잡한 키 생성 로직이 반복되면 커스텀 KeyGenerator를 빈으로 등록합니다.

@Component("tenantKeyGenerator")
public class TenantKeyGenerator implements KeyGenerator {

    @Override
    public Object generate(Object target, Method method, Object... params) {
        // 현재 테넌트 + 메서드명 + 파라미터 조합
        String tenant = TenantContext.getCurrentTenant();
        String methodName = method.getName();
        String paramKey = Arrays.stream(params)
            .map(String::valueOf)
            .collect(Collectors.joining("-"));
        return tenant + ":" + methodName + ":" + paramKey;
    }
}

// 사용
@Cacheable(value = "products", keyGenerator = "tenantKeyGenerator")
public List<Product> findByCategory(String category) {
    return productRepository.findByCategory(category);
}
// 캐시 키: "tenantA:findByCategory:electronics"

멀티 레벨 캐시: Caffeine + Redis

로컬 캐시(Caffeine)로 초고속 조회를 제공하고, 분산 캐시(Redis)로 다중 인스턴스 간 일관성을 유지하는 2단계 캐시 구조입니다.

@Component
public class MultiLevelCacheManager implements CacheManager {

    private final CaffeineCacheManager l1Cache;
    private final RedisCacheManager l2Cache;

    public MultiLevelCacheManager(
            CaffeineCacheManager l1Cache,
            RedisCacheManager l2Cache) {
        this.l1Cache = l1Cache;
        this.l2Cache = l2Cache;
    }

    @Override
    public Cache getCache(String name) {
        return new MultiLevelCache(
            l1Cache.getCache(name),
            l2Cache.getCache(name)
        );
    }

    @Override
    public Collection<String> getCacheNames() {
        Set<String> names = new HashSet<>();
        names.addAll(l1Cache.getCacheNames());
        names.addAll(l2Cache.getCacheNames());
        return names;
    }
}

// L1 → L2 순서로 조회, L1 미스 시 L2 조회 후 L1에 저장
public class MultiLevelCache implements Cache {

    private final Cache l1;
    private final Cache l2;

    @Override
    public ValueWrapper get(Object key) {
        // L1 조회
        ValueWrapper value = l1.get(key);
        if (value != null) return value;

        // L1 미스 → L2 조회
        value = l2.get(key);
        if (value != null) {
            l1.put(key, value.get());  // L1에 채움
        }
        return value;
    }

    @Override
    public void put(Object key, Object value) {
        l1.put(key, value);
        l2.put(key, value);
    }

    @Override
    public void evict(Object key) {
        l1.evict(key);
        l2.evict(key);
    }
    // ... 나머지 메서드 구현
}

캐시 모니터링과 통계

캐시 히트율을 Micrometer로 수집하여 캐시 효율을 모니터링합니다.

// Caffeine + Micrometer 통합
@Bean
public CacheManager caffeineCacheManager(MeterRegistry registry) {
    CaffeineCacheManager manager = new CaffeineCacheManager("users", "products");
    manager.setCaffeine(Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(Duration.ofMinutes(10))
        .recordStats()  // 통계 활성화 필수
    );

    // Micrometer에 캐시 메트릭 등록
    manager.getCacheNames().forEach(name -> {
        Cache cache = manager.getCache(name);
        if (cache != null) {
            CaffeineCacheMetrics.monitor(registry,
                (com.github.benmanes.caffeine.cache.Cache<?, ?>)
                    cache.getNativeCache(),
                name);
        }
    });

    return manager;
}

// Prometheus 메트릭 예시:
// cache_gets_total{cache="users",result="hit"} 9500
// cache_gets_total{cache="users",result="miss"} 500
// cache_evictions_total{cache="users"} 200
// cache_size{cache="users"} 8500

운영 베스트 프랙티스

  • self-invocation 주의: 같은 클래스 내부 호출에서는 AOP 프록시가 적용되지 않아 캐시가 동작하지 않습니다
  • null 캐싱 전략: unless = "#result == null"로 null 캐싱을 방지하거나, cache penetration 방어를 위해 의도적으로 null을 캐싱하세요
  • TTL 설계: 데이터 변경 빈도에 따라 적절한 TTL을 설정하고, 캐시별 TTL을 개별 관리하세요
  • 캐시 키 충돌: 캐시 이름(value)을 명확히 지정하고, 키가 겹치지 않도록 네이밍 규칙을 수립하세요
  • 트랜잭션 동기화: transactionAware()로 트랜잭션 커밋 후에만 캐시가 갱신되도록 설정하세요
  • 히트율 모니터링: Micrometer로 캐시 히트율을 추적하고, 70% 미만이면 TTL/사이즈를 재검토하세요
위로 스크롤
WordPress Appliance - Powered by TurnKey Linux