Spring Boot - Cache!

Spring Cache

spring-boot-starter 라이브러리에 기본적으로 사용할 수 있는 캐싱 설정을 제공한다.
하지만 아래 설명할 EhCache, Redis 와 같은 추가 라이브러리를 사용하려면 spring-boot-starter-cache 를 추가해줘야 한다.

implementation 'org.springframework.boot:spring-boot-starter-cache'

Spring Cache 기본 설정에선 @EnableCaching 어노테이션을 사용하면 CacheConfigurations.SimpleCacheConfiguration 에서 ConcurrentMapCacheManager 을 Bean 으로 등록한다.

ConcurrentMapCacheManager 기본 구성을 사용할 것이라면 @EnableCaching 만 적용하면 된다.

// 2중 ConcurrentMap 형태
public class ConcurrentMapCacheManager implements CacheManager, BeanClassLoaderAware {
    // <cacheName, ConcurrentMapCache>
    private final ConcurrentMap<String, Cache> cacheMap = new ConcurrentHashMap<>(16);
    ...
}

public class ConcurrentMapCache extends AbstractValueAdaptingCache {
    private final String name; // cacheName
    // <key, data value>
    private final ConcurrentMap<Object, Object> store;
}

Map 을 늘리는것을 방지하려면 아래와 같이 Key 값을 지정하여 Bean 생성.

@EnableCaching
@Configuration
public class CacheConfig {
    @Bean(name = "localCacheManager")
    public CacheManager localCacheManager() {
        ConcurrentMapCacheManager cacheManager = new ConcurrentMapCacheManager("customerCache");
        return cacheManager;
    }
}

캐시값 제어는 아래 어노테이션을 통해 수행할 수 있다.

  • @Cacheable
  • @CacheEvict
  • @CachePut

CacheManager 구현체마다 세부 구현이 조금씩 다른데 ConcurrentMapCacheManager 기준으로 설명할 예정

@Cacheable

캐시에 값이 있다면 바로 값을 조회해서 반환하고
값이 없다면 메서드 내부코드를 실행후 캐시에 저장하고 반환한다.

@Slf4j
@Service
@RequiredArgsConstructor
public class CustomerService {

    @Cacheable(cacheNames = "customerCache", key = "#input", cacheManager = "localCacheManager")
    public String getTest(String input) throws InterruptedException {
        Thread.sleep(5000);
        // input을 이용한 계산 또는 데이터 로딩 등의 작업 수행
        return "hello world";
    }

    @Cacheable(cacheNames = "customerCache")
    public List<Customer> findAll() throws InterruptedException {
        Thread.sleep(5000);
        List<Customer> result = new ArrayList<>();
        for (int i = 0; i < random.nextInt(10); i++) {
            result.add(CustomerGenerator.random());
        }
        return result;
    }

    @Cacheable(cacheNames = "customerCache")
    public List<Customer> findAll(List<String> ids) throws InterruptedException {
        Thread.sleep(5000);
        List<Customer> result = new ArrayList<>();
        for (String id : ids) {
            result.add(CustomerGenerator.random(id));
        }
        return result;
    }
}

@Cacheable 설정으로 아래 속성을 자주 사용한다.

  • cacheNames(value): 필수값, 데이터가 저장되어 있는 공간을 찾아가기 위한 캐시 저장공간 네이밍값.
  • key: keyName, SpEL 로 지정
  • cacheManager: CacheManager 빈 이름

cacheNames → key 형식의 level 형태의 키구조를 생성한다(2중 Map 구조).
cacheNames 을 통해 데이터를 구조적으로 그룹화 하고 관리하는 것이 중요하다.

key 속성을 별도로 지정하지 않았다면 SimpleKey 클래스를 사용해 메서드 파라미터를 기반으로 키가 구성된다.
key 를 설정하면 아래와 같이 문자열로 키값을 지정할 수 있다.

@Cacheable(cacheNames = "userDetailCache", key = "'userId-' + #user.id", condition = "#user.role == 'ADMIN'", cacheManager = "myCacheManager")
public UserDetail getUserDetail(User user) {
}

만약 cacheNames 를 배열로 지정할 경우 두개의 <String, Cache> 가 저장된다.
그리고 최초 검색되는 캐시값을 반환한다.

@Cacheable(cacheNames = {"userDetailCache", "test"}, key = "'userId-' + #user.id")
public UserDetail getUserDetail(User user) {
}

이외 기타 설정들

  • condition: 요청 파라미터에 의한 캐시 저장 조건, SpEL 로 지정
  • unless: 반환값에 의한 캐시 저장 조건, SpEL 로 지정
  • keyGenerator: KeyGenerator 인터페이스 구현체, 커스텀하게 key 를 생성하고 싶다면 재정의해서 지정
  • cacheResolver: CacheResolver 인터페이스 구현체, 커스텀하게 value 를 생성하고 싶다면 재정의해서 지정

[condition, unless] 속성은 아래와 같이 지정 가능

@Cacheable(value="posts", condition="#postId>10")
public Post findById(Integer postId) { ... }

@Cacheable(value="posts", condition="#title.length > 20")
public Post findByTitle(String title) { ... }

@Cacheable(value="titles", unless="#result.length() < 50")
public String getTitle(Post post) { ... }

@CacheEvict

메서드가 호출완료 된 후 @CacheEvict 에 적용되는 캐시를 삭제한다.

//캐시 삭제
@CacheEvict(value = "customerCache")
public void refresh() {
    log.info("cache clear");
}

//캐시 삭제 - 키값 사용
@CacheEvict(value = "customerCache", key = "#id")
public void refresh(String id) {
    log.info("cache clear");
}

대부분 속성은 @Cacheable 과 동일하고 beforeInvocation 속성이 추가되었다.

  • cacheNames
  • key
  • cacheManager
  • condition
  • keyGenerator
  • cacheResolver
  • beforeInvocation
    메서드가 호출되기 전에 제거가 발생해야 하는지 여부, default false
    true 지정시 메서드 도중 예외가 발생한다 해도 이미 삭제가 되어있다.

@CachePut

@CachePut 은 캐시 업데이트를 위한 어노테이션으로
메서드가 반드시 호출되고, 반환값을 캐시에 저장한다.

// 캐시 업데이트
@CachePut(value = "customerCache", key = "#id", cacheManager = "redisCacheManager")
public Customer update(String id) throws InterruptedException {
    Thread.sleep(5000);
    Customer customer = CustomerGenerator.random(id);
    return customer;
}

사용 속성은 아래와 같다.

  • cacheNames
  • key
  • cacheManager
  • condition
  • unless
  • keyGenerator
  • cacheResolver

EhCache CacheManager

http://ehcache.org/

대부분의 경우 캐시를 다룰 때 ConcurrentMapCacheManager 를 사용하지 않음.
다음과 같은 장점때문에 EhCache 를 로컬레벨의 캐시 라이브러리로 자주 사용한다.

  • 메모리, 디스크 기반 저장 가능
  • TTL, TTI 기능 지원
  • JMX 모니터링 지원
  • LFU, LRU 등 캐시 전략 지원

TTI(Time To Idle) 는 생존은 위한 이전 사용시간을 뜻함

Spring Cache 와 EhCache 모두 JCache 구현체를 지원함으로
EhCache 와 JCache 의 integration 방법을 사용해야한다.

https://www.ehcache.org/documentation/3.10/107.html

implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'org.ehcache:ehcache:3.10.8'

@Primary
@Bean(name = "ehCacheManager")
public CacheManager cacheManager() {
    CachingProvider provider = Caching.getCachingProvider();
    EhcacheCachingProvider ehcacheProvider = (EhcacheCachingProvider) provider;
    DefaultConfiguration defaultConfiguration = new DefaultConfiguration(ehcacheProvider.getDefaultClassLoader(),
            new DefaultPersistenceConfiguration(new File("cache/directory")));
    javax.cache.CacheManager cacheManager = ehcacheProvider.getCacheManager(ehcacheProvider.getDefaultURI(), defaultConfiguration);

    CacheConfiguration configuration = CacheConfigurationBuilder.newCacheConfigurationBuilder(
            Object.class, // key type
            Object.class, // value type
            ResourcePoolsBuilder.newResourcePoolsBuilder()
                    .heap(1000, EntryUnit.ENTRIES)
                    .offheap(10, MemoryUnit.MB)
                    .disk(1, MemoryUnit.GB)
                    .build())
            .withExpiry(ExpiryPolicyBuilder.timeToLiveExpiration(Duration.ofSeconds(60)))
            .withDefaultDiskStoreThreadPool()
            .build();
    cacheManager.createCache("customerCache",
            Eh107Configuration.fromEhcacheCacheConfiguration(configuration));
    return new JCacheCacheManager(cacheManager);
}

Redis CacheManager

redisCacheManager 로 사용 가능.
[cacheNames, key] 속성이 통합되어 문자열로 변경되어 key 값으로 사용한다.

implementation 'org.springframework.boot:spring-boot-starter-data-redis'
@Configuration
@EnableCaching
public class RedisConfig extends CachingConfigurerSupport {

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        LettuceConnectionFactory connectionFactory = new LettuceConnectionFactory("localhost", 6379);
        connectionFactory.start();
        return connectionFactory;
    }

    @Bean(name = "redisCacheManager")
    public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(RedisSerializer.json()))
                .entryTtl(Duration.ofMinutes(3L));
        return RedisCacheManager.RedisCacheManagerBuilder
                .fromConnectionFactory(redisConnectionFactory)
                .cacheDefaults(redisCacheConfiguration)
                .build();
    }
}
@Slf4j
@Service
@RequiredArgsConstructor
public class CustomerService {

    // key 이름: customerCache::SimpleKey []
    @Cacheable(cacheNames = "customerCache", cacheManager = "redisCacheManager")
    public List<Customer> findAll() throws InterruptedException {
        Thread.sleep(5000);
        List<Customer> result = new ArrayList<>();
        for (int i = 0; i < random.nextInt(10); i++) {
            result.add(CustomerGenerator.random());
        }
        return result;
    }

    // key 이름: customerCache::1,2,3,4,5
    @Cacheable(cacheNames = "customerCache", cacheManager = "redisCacheManager")
    public List<Customer> findAll(List<String> ids) throws InterruptedException {
        Thread.sleep(5000);
        List<Customer> result = new ArrayList<>();
        for (String id : ids) {
            result.add(CustomerGenerator.random(id));
        }
        return result;
    }
}

{cacheNames}::{param eky} 형태로 key,
Java 클래스 표현식으로 출력된 문자열이 value 로 저장된 것을 확인할 수 있다.

127.0.0.1:6379> keys *
# 1) "customerCache::SimpleKey []"
# 2) "customerCache::1,2,3,4,5"

127.0.0.1:6379> type "customerCache::SimpleKey []"
# string

127.0.0.1:6379> get "customerCache::SimpleKey []"
# "[\"java.util.ArrayList\",[{\"@class\":\"com.example.redis.model.Customer\",\"id\":\"a0a96847-5042-499a-aabf-787d35242fa1\",\"nickName\":\"npssdlhfrm\",\"customerType\":\"BRONZE\",\"createTime\":[\"java.util.Date\",1686642104620]},{\"@class\":\"com.example.redis.model.Customer\",\"id\":\"e9e102f0-9ab7-402c-9d7f-e9933137cd93\",\"nickName\":\"blcovuitlm\",\"customerType\":\"GOLD\",\"createTime\":[\"java.util.Date\",1686642104620]},{\"@class\":\"com.example.redis.model.Customer\",\"id\":\"aa42d2e7-7628-452b-b1b4-7410d767446d\",\"nickName\":\"zpvctjvkoa\",\"customerType\":\"SILVER\",\"createTime\":[\"java.util.Date\",1686642104620]},{\"@class\":\"com.example.redis.model.Customer\",\"id\":\"83d52f67-002b-43da-9cc2-abf7c9715405\",\"nickName\":\"lwffwuygud\",\"customerType\":\"GOLD\",\"createTime\":[\"java.util.Date\",1686642104620]},{\"@class\":\"com.example.redis.model.Customer\",\"id\":\"160f50df-e2a1-455e-a1a7-291de969aada\",\"nickName\":\"mrtybmfxnc\",\"customerType\":\"DIAMOND\",\"createTime\":[\"java.util.Date\",1686642104620]},{\"@class\":\"com.example.redis.model.Customer\",\"id\":\"77c3d17a-c731-424c-b2c6-397d8b2ac0d3\",\"nickName\":\"dtpankciwp\",\"customerType\":\"SILVER\",\"createTime\":[\"java.util.Date\",1686642104620]}]]"
@Cacheable(cacheNames = {"customerCache", "test"}, key = "'customer-' + #id", cacheManager = "redisCacheManager")
public Customer findById(String id) throws InterruptedException {
    Thread.sleep(5000);
    return CustomerGenerator.random(id);
}
127.0.0.1:6379> keys *
# 1) "test::customer-1"
# 2) "customerCache::customer-1"

데모코드

https://github.com/Kouzie/spring-boot-demo/tree/main/cache-demo

카테고리:

업데이트: