Spring-Cache?
다양한 캐시 제공자(Redis, Caffeine, EHCache 등)를 지원하는 추상화 계층이며 캐시 구현체와 독립적으로 캐시를 사용하고 관리할 수 있다. 대표적인 어노테이션으로는 @Cacheable, @CachePut, @CacheEvict, @Caching 가 있다.
@Cacheable
메서드 호출 결과를 캐시에 저장하고 이후 해당 메서드 호출 시 캐시된 결과를 반환하도록 한다. 여러 속성들이 어떤 기능을 제공하는지 알아본다.
- value: 캐시 이름을 "itemsCache" 로 지정한다.
@Cacheable(value = "itemsCache") public Item getItem(Long id) { return findItemById(id); }
- key: SpEL 을 사용하여 캐시 키를 '#id' 로 설정한다. (매개변수 Long id)
@Cacheable(value = "itemsCache", key = "#id") public Item getItemWithKey(Long id) { return findItemById(id); }
- keyGenerator: 키 생성을 위해 커스텀 키를 사용한다.
@Cacheable(value = "itemsCache", keyGenerator = "customKeyGenerator") public Item getItemWithCustomKeyGenerator(Long id) { return findItemById(id); }
- condition: 특정 조건이 'true' 일 때만 캐싱한다.
@Cacheable(value = "itemsCache", condition = "#id > 10") public Item getItemWithCondition(Long id) { return findItemById(id); }
- unless: 특정 조건이 'true' 일 때는 캐싱하지 않는다.
@Cacheable(value = "itemsCache", unless = "#result.price >= 1000") public Item getItemWithUnless(Long id) { return findItemById(id); }
- sync: 'true' 인 경우 동일한 키로 동시에 여러 쓰레드가 접근할 때 동기화한다.
@Cacheable(value = "itemsCache", key = "#id", sync = true) public Item getItemWithSync(Long id) { return findItemById(id); }
- cacheManager: 특정 캐시 매니저를 사용하도록 지정한다.
- cacheResolver: 특정 캐시 리졸버를 사용하도록 지정한다.
@CachePut
메서드의 결과를 캐싱하지만 항상 메서드를 실행한다. 업데이트가 필요한 경우 유용하다.
@CachePut
어노테이션의 속성은 @Cacheable
의 속성과 같다.@CacheEvict
캐시에서 데이터를 제거하는데 사용된다.
@CachePut
, @Cacheable
의 속성과 같으며 다른 속성들을 알아본다.- allEntries: 'true' 인 경우 캐시의 모든 항목을 제거한다.
@CacheEvict(value = "items", allEntries = true) public void removeAllItems() { /*..*/ }
- beforeInvocation: 'true' 인 경우 메서드 실행 전에 캐시를 제거한다.
@CacheEvict(value = "items", key = "#id", beforeInvocation = true) public void removeItemByIdBeforeInvocation(Long id) { /**/ }
@Caching
위의 여러 캐시 관련 어노테이션을 조합해서 사용할 수 있는 어노테이션이다.
@Caching( evict = { @CacheEvict(value = "items", key = "#item.id") }, put = { @CachePut(value = "items", key = "#item.id") } ) public Item saveItem(Item item) { return itemRepository.save(item); }
cache solution
spring-cache 를 구현하는 여러 솔루션들이 있지만 대표적인 캐시를 소개한다.
Caffeine
과 Redis
가 있다. 간략한 개요만 알아본다.특징 | Caffeine | Redis |
구조 | 로컬 캐시 | 분산 캐시 |
성능 | 매우 빠름 | 네트워크 지연으로 인해 약간의 오버헤드 존재 |
데이터 지속성 | JVM 종료 시 데이터 소멸 | 지속성 옵션(AOF, 스냅샷) 제공 |
확장성 | 제한적 (단일 JVM 내) | 높은 확장성 (클러스터링 지원) |
데이터 구조 | 간단한 키-값 저장 | 다양한 데이터 구조 (리스트, 셋, 해시 등) |
고가용성 | 지원 안 함 | 레플리케이션, 페일오버, 클러스터링 지원 |
복잡한 연산 | 제한적 | Lua 스크립트를 통한 복잡한 연산 가능 |
사용 사례 | 단일 서버 애플리케이션, 짧은 수명 데이터 | 분산 애플리케이션, 세션 관리, 실시간 분석 |
spring application 에 spring-cache 적용하기
- 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-cache'
- 설정 클래스
spring cache 는 다양한 캐싱 솔루션을 통합하여 일관된 캐싱 추상화를 제공한다. 때문에 어떤 솔루션을 사용할지 유저가 자유롭게 선택이 가능하다. 아래의 설정은
Redis
를 활용한 설정이며 어노테이션과 함께 동작한다.@EnableCaching @Configuration public class CacheConfig { @Bean public RedisCacheConfiguration defaultCacheConfig() { return RedisCacheConfiguration .defaultCacheConfig() .entryTtl(Duration.ofHours(1)) .disableCachingNullValues() .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); } @Bean public RedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) { final Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>(); return RedisCacheManager .builder(redisConnectionFactory) .cacheDefaults(defaultCacheConfig()) .withInitialCacheConfigurations(cacheConfigurations) .build(); } }
defaultCacheConfig()
메서드를 통해 RedisCacheConfiguration
의 기본 설정을 할당한다. TTL 기본 시간은 1시간이고 기본적으로 null 값을 캐싱하지 않는다.redisCacheManager
를 통해 캐싱을 관리할 매니저를 설정한다. 기본 캐시 설정을 defaultCacheConfig()
메서드로 할당하고 초기 캐시 설정을 Map<String, RedisCacheConfiguration> cacheConfigurations
으로 되어있는 설정값을 가져온다.캐시에 따라 선택적으로 TTL 및 설정을 수정할 수 있다. 아래의 코드는
hello-world
라는 캐시에 대해 TTL(5분) 을 설정하는 코드이다. Map으로 구현한 cacheConfigurations
에 추가해주면 된다.cacheConfigurations.put("hello-world", defaultCacheConfig().entryTtl(Duration.ofMinutes(10)));
Spring-Cache & AOP
위에서 설명한 Cache 관련 어노테이션을 이용해 캐싱 처리를 수행하면 된다. 캐싱 기능을 구현할 때 스프링은 AOP(Aspect-Oriented-Programming) 를 사용하여 기능을 구현한다. Spring AOP 의 프록시 매커니즘 때문인데 해당 빈의 타겟이 되는 메서드 호출을 가로채어 AOP 어드바이스를 적용한다.
일반적으로 클라이언트 코드가
.xxCacheMethod();
를 호출하게 되면 아래와 같이 AOP 가 동작하게 된다.Client -> Proxy (AOP) -> Actual Service.xxCacheMethod();
하지만
this.xxCacheMethod();
로 호출하게 되면 현재 객체를 참조하므로 프록시를 우회한다. 고로 AOP 어드바이스가 적용되지 않는다.Client -> Actual Service(this.xxCacheMethod();)
때문에 아래와 같은 코드가 있다면 우리는 캐싱 처리를 올바르게 수행할 것으로 기대하겠지만 AOP 프록시를 우회하므로 실제로 캐싱처리가 되지 않는다. 예를 들어 아래와 같은 코드가 있을 때
this.getItem();
메서드는 캐싱이 적용되지 않는다.@Service @RequiredArgsConstructor public class ExampleService { private final Repository repository; @Cacheable(value = "itemsCache", key = "#id") public Item getItem(Long id) { return repository.findItemById(id); } public void updateItem(Long id) { this.getItem(id); // 캐싱이 적용되지 않는다. /*...*/ } }
다른 클라이언트 코드가 호출하거나,
@Service @RequiredArgsConstructor public class OtherService { private final ExampleService exampleService; public void someMethod(final Long id) { exampleService.getItem(id); /*...*/ } }
혹은 이너 클래스로도 풀어낼 수 있다. 방법이 어찌됐건 AOP 프록시를 우회하지 않도록 하여 캐싱 로직을 올바르게 풀어내는 것이다.
@Service @RequiredArgsConstructor public class ExampleService { private final InnerService innerService; public Item getItem(Long id) { return innerService.findItemById(id); } public void updateItem(Long id) { innerService.getItem(id); // 캐싱이 적용된다. } @Service @RequiredArgsConstructor public static class InnerService { private final Repository repository; @Cacheable(value = "itemsCache", key = "#id") public Item getItem(Long id) { return repository.findItemById(id); } } }