캐싱 전략

Spring Cache 추상화

Spring Cache는 캐싱 구현체(Caffeine, Redis, EhCache 등)를 교체해도 비즈니스 코드를 바꾸지 않아도 되는 추상화 레이어다.

@SpringBootApplication
@EnableCaching  // 캐싱 활성화
public class MyApplication { }

@Cacheable

@Service
public class ProductService {
 
    // 캐시 히트 시 메서드 실행 안 함
    @Cacheable(value = "products", key = "#id")
    public ProductResponse findById(Long id) {
        return productRepository.findById(id)
            .map(ProductResponse::from)
            .orElseThrow(() -> new ResourceNotFoundException("상품을 찾을 수 없습니다"));
    }
 
    // 복합 키
    @Cacheable(value = "products", key = "#category + '_' + #page")
    public List<ProductResponse> findByCategory(String category, int page) {
        return productRepository.findByCategory(category, PageRequest.of(page, 20))
            .stream().map(ProductResponse::from).toList();
    }
 
    // 조건부 캐싱
    @Cacheable(
        value = "products",
        key = "#id",
        condition = "#id > 0",          // 조건 만족 시에만 캐시
        unless = "#result == null"       // 결과가 null이면 캐시 안 함
    )
    public ProductResponse findByIdConditional(Long id) {
        return productRepository.findById(id).map(ProductResponse::from).orElse(null);
    }
}

@CachePut

항상 메서드를 실행하고 결과를 캐시에 저장한다. 데이터 수정 후 캐시 갱신에 사용한다.

@CachePut(value = "products", key = "#result.id")
public ProductResponse updateProduct(Long id, UpdateProductRequest request) {
    Product product = productRepository.findById(id)
        .orElseThrow(() -> new ResourceNotFoundException("상품을 찾을 수 없습니다"));
    product.update(request);
    return ProductResponse.from(productRepository.save(product));
}

@CacheEvict

캐시를 무효화한다.

// 특정 키 삭제
@CacheEvict(value = "products", key = "#id")
public void deleteProduct(Long id) {
    productRepository.deleteById(id);
}
 
// 전체 캐시 삭제
@CacheEvict(value = "products", allEntries = true)
public void clearProductCache() { }
 
// 메서드 실행 후 삭제 (beforeInvocation = false 기본)
@CacheEvict(value = "products", key = "#id", beforeInvocation = false)
public void updateStock(Long id, int quantity) {
    productRepository.updateStock(id, quantity);
}

@Caching — 여러 어노테이션 조합

@Caching(
    put = { @CachePut(value = "products", key = "#result.id") },
    evict = { @CacheEvict(value = "productList", allEntries = true) }
)
public ProductResponse createProduct(CreateProductRequest request) {
    Product product = Product.of(request);
    return ProductResponse.from(productRepository.save(product));
}

커스텀 KeyGenerator

@Component("customKeyGenerator")
public class CustomKeyGenerator implements KeyGenerator {
 
    @Override
    public Object generate(Object target, Method method, Object... params) {
        return target.getClass().getSimpleName() + "_"
            + method.getName() + "_"
            + Arrays.stream(params).map(Object::toString).collect(Collectors.joining("_"));
    }
}
 
// 사용
@Cacheable(value = "reports", keyGenerator = "customKeyGenerator")
public Report generateReport(Long userId, LocalDate from, LocalDate to) { ... }

Caffeine 캐시 (로컬 인메모리)

단일 서버 환경에서 가장 빠른 캐시. JVM 메모리에 저장하므로 서버 재시작 시 초기화된다.

// build.gradle.kts
implementation("com.github.ben-manes.caffeine:caffeine")
@Configuration
@EnableCaching
public class CacheConfig {
 
    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(
            Caffeine.newBuilder()
                .maximumSize(1000)                   // 최대 항목 수
                .expireAfterWrite(10, TimeUnit.MINUTES) // 쓰기 후 10분
                .expireAfterAccess(5, TimeUnit.MINUTES) // 마지막 접근 후 5분
                .recordStats()                       // 통계 수집
        );
        return manager;
    }
}

캐시별 다른 설정

@Bean
public CacheManager cacheManager() {
    SimpleCacheManager manager = new SimpleCacheManager();
    manager.setCaches(List.of(
        buildCache("products", 1000, Duration.ofMinutes(10)),
        buildCache("categories", 100, Duration.ofHours(1)),
        buildCache("userSessions", 10000, Duration.ofMinutes(30))
    ));
    return manager;
}
 
private CaffeineCache buildCache(String name, int size, Duration ttl) {
    return new CaffeineCache(name,
        Caffeine.newBuilder()
            .maximumSize(size)
            .expireAfterWrite(ttl)
            .recordStats()
            .build()
    );
}

Caffeine 알고리즘

Caffeine은 W-TinyLFU 알고리즘을 사용한다. LRU(Least Recently Used)보다 히트율이 높다.

  • LRU: 최근 접근 시간 기준으로 제거
  • LFU: 접근 빈도 기준으로 제거
  • W-TinyLFU: 빈도 + 최근성을 조합, 새로운 아이템에 우호적

Redis 캐시 (분산 캐시)

여러 서버에서 캐시를 공유할 때 사용한다.

@Configuration
@EnableCaching
public class RedisCacheConfig {
 
    @Bean
    public CacheManager cacheManager(RedisConnectionFactory factory) {
        // 기본 설정
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .serializeKeysWith(
                RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
            )
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(
                    new GenericJackson2JsonRedisSerializer()
                )
            )
            .disableCachingNullValues(); // null은 캐시하지 않음
 
        // 캐시별 다른 TTL
        Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
            "products", defaultConfig.entryTtl(Duration.ofMinutes(30)),
            "categories", defaultConfig.entryTtl(Duration.ofHours(6)),
            "userSessions", defaultConfig.entryTtl(Duration.ofMinutes(60))
        );
 
        return RedisCacheManager.builder(factory)
            .cacheDefaults(defaultConfig)
            .withInitialCacheConfigurations(cacheConfigs)
            .build();
    }
}

직렬화 주의사항

기본 Java 직렬화는 사용하지 말 것. 클래스 구조가 바뀌면 역직렬화 오류가 발생한다.

// ❌ 기본 직렬화 (위험)
new JdkSerializationRedisSerializer()
 
// ✅ JSON 직렬화 (권장)
new GenericJackson2JsonRedisSerializer()
 
// ✅ 타입 정보 포함 JSON
new Jackson2JsonRedisSerializer<>(MyDto.class)

로컬 캐시 vs 분산 캐시 선택 기준

항목Caffeine (로컬)Redis (분산)
서버 수단일 서버다중 서버
속도매우 빠름 (ns)빠름 (ms)
서버 재시작캐시 소멸유지
일관성서버별 독립공유 (일관성 보장)
용량JVM 힙 제한별도 메모리 서버
운영 복잡도낮음Redis 서버 필요

실무 팁

캐시 스탬피드(Stampede): 캐시 만료 직후 다수 요청이 동시에 DB를 조회하는 현상. @Cacheable은 기본적으로 이를 보호하지 않는다. Caffeine의 refreshAfterWrite 설정으로 완화할 수 있다.

엔티티 직접 캐싱 금지: JPA 엔티티를 캐시하면 영속성 컨텍스트 밖에서 사용될 때 LazyInitializationException이 발생한다. 반드시 DTO로 변환 후 캐싱하자.

캐시 키 충돌 방지: Redis를 쓸 때 여러 애플리케이션이 같은 Redis를 공유한다면 spring.cache.redis.key-prefix=myapp: 설정으로 네임스페이스를 분리하자.