Caffeine Cache, 어렵지 않게 사용하기 2

2022. 5. 24. 23:59Spring

Caffeine Cache에 대해 알아보고 사용법에 대해 알아보는 것이 해당 포스팅의 목표입니다.

 

본 편은 이전 포스팅인 Caffeine Cache, 제대로 사용하기에 이은 두 번째 포스팅입니다.

전반적인 소개글인 이론이전 포스팅에서 다루며,

본 편에서는 실제로 사용하는 방법인, 응용에 대해 다룹니다.

 

추가로, Spring Cache에 대한 기본적인 이해를 하고 있다는 전제하에 작성한 내용입니다.

Spring Cache, 제대로 사용하기도 이어진 포스팅이니 참고하시길 바랍니다.

 

이번 포스팅에서는 로컬 캐시 중 성능에 유리한 Caffeine Cache를 다뤄보겠습니다.

본 포스팅의 큰 그림은 세팅하는 방법부터 어떻게 사용하는지에 대해 다룹니다.

 

 

 

Dependency

기본적으로 의존성을 추가해주겠습니다.

먼저 아래의 두 의존성을 추가해줍니다.

 

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

 

Spring은 Cache 추상화가 이루어져 있는데요.

즉, 캐시 서비스 구현 기술에 종속되지 않도록 추상화 서비스를 제공하기 때문에

CaffeineCahce나 EhCache, redis 등 캐싱 종류에 의존하지 않고 추상화된 인터페이스로 캐싱을 적용 할 수 있습니다.

 

그래서 캐시를 변경하고자 할 때, 이 의존성과 캐시 매니저 등의 몇 가지 설정을 바꾸고 

꽤 같은 형식으로 개발을 이어나갈 수 있습니다.

 

 

 

Detailed Setting

지난 포스팅에서는 간단한 설정으로 마무리했는데요.

이번에는 각 캐시마다의 설정 값을 다르게 주고 싶을 때의 예시를 가져왔습니다.

 

@EnableCaching
@Configuration
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        SimpleCacheManager cacheManager = new SimpleCacheManager();

        List<CaffeineCache> caches = Arrays.stream(CacheType.values())
            .map(cache -> new CaffeineCache(
                cache.getName(),
                Caffeine.newBuilder()
                    .expireAfterWrite(cache.getExpireAfterWrite(), TimeUnit.SECONDS)
                    .maximumSize(cache.getMaximumSize())
                    .recordStats()
                    .build()
            ))
            .collect(Collectors.toList());

        cacheManager.setCaches(caches);
        return cacheManager;
    }
}

 

Caffeine Cache의 Cache Manager를 생성하며 캐시 관련 설정을 해줍니다.

어떤 설정들이 있는지 지난 포스팅에서도 언급했지만 다시 한 번 다루겠습니다.

 

 

 

📌 Creating Parameters

 

initialCapacity: 내부 해시 테이블의 최소한의 크기를 설정합니다.

✔ maximumSize: 캐시에 포함할 수 있는 최대 엔트리 수를 지정합니다.

✔ maximumWeight: 캐시에 포함할 수 있는 엔트리의 최대 무게를 지정합니다.

✔ expireAfterAccess: 캐시가 생성된 후, 해당 값이 가장 최근에 대체되거나 마지막으로 읽은 후 특정 기간이 경과하면 캐시에서 자동으로 제거되도록 지정합니다.

✔ expireAfterWrite: 항목이 생성된 후 또는 해당 값을 가장 최근에 바뀐 후 특정 기간이 지나면 각 항목이 캐시에서 자동으로 제거되도록 지정합니다.

✔ refreshAfterWrite: 캐시가 생성되거나 마지막으로 업데이트된 후 지정된 시간 간격으로 캐시를 새로 고칩니다.

✔ weakKeys: 키를 weak reference로 지정합니다. (GC에서 회수됨)

✔ weakValues: Value를 weak reference로 지정합니다. (GC에서 회수됨)

✔ softValues: Value를 soft reference로 지정합니다. (메모리가 가득 찼을 때 GC에서 회수됨)

✔ recordStats: 캐시에 대한 Statics를 적용합니다.

 

⚠️ expireAfterWrite 와 expireAfterAccess 가 함께 지정된 경우, expireAfterWrite가 우선순위로 적용됩니다.

⚠️ maximumSize와 maximumWeight는 함께 지정될 수 없습니다.

 

 

📌 Enum: Cache Type

제가 찾아보았을 때에는 캐시 타입을 enum으로 관리하는 코드가 가장 깔끔하다고 생각되었습니다.

그래서 아래와 같이 enum으로 설정해주었습니다 😉

 

public enum CacheType {

    USERS("users"),
    VIEWS("views");
   
    private String name;
    private int expireAfterWrite;
    private int maximumSize;

    CacheType(String name) {
        this.name = name;
        this.expireAfterWrite = ConstConfig.DEFAULT_TTL_SEC;
        this.maximumSize = ConstConfig.DEFAULT_MAX_SIZE;
    }

    static class ConstConfig {
        static final int DEFAULT_TTL_SEC = 3000;
        static final int DEFAULT_MAX_SIZE = 10000;
    }

}

 

위와 같이 정의해두시면 추후 캐시를 사용할 때 정적 스트링을 하드코딩하지 않고 아래와 같이 유지보수에 유용하게 사용할 수 있어요.

 

@Cacheable(cacheName = CacheType.USER.name()

 

 

Application

이제 어떻게 응용할 수 있을지 알아보도록 합시다.

 

참고로, 저는 테스트를 할 때 아래와 같이 데이터를 넣어서 캐시를 확인했습니다.

더 좋은 방법이 있다면 알려주세요 ,,ㅎㅎ

 

    cacheManager.getCache(CacheType.USERS.getName()).putIfAbsent("selectUserByName_test1", new UserVO("test1", "test1@email.com", "qwerty"));
    cacheManager.getCache(CacheType.USERS.getName()).putIfAbsent("selectUserByName_test2", new UserVO("test2", "test2@email.com", "qwerty"));
    cacheManager.getCache(CacheType.USERS.getName()).putIfAbsent("selectUserByName_test3", new UserVO("test3", "test3@email.com", "qwerty"));
    cacheManager.getCache(CacheType.VIEWS.getName()).putIfAbsent("views_user1", 2);
    cacheManager.getCache(CacheType.VIEWS.getName()).putIfAbsent("views_user2", 5);
    cacheManager.getCache(CacheType.VIEWS.getName()).putIfAbsent("views_user4", 12);
    cacheManager.getCache(CacheType.VIEWS.getName()).putIfAbsent("views_user4", 12);
    cacheManager.getCache(CacheType.VIEWS.getName()).putIfAbsent("views_user6", 11);
    cacheManager.getCache(CacheType.VIEWS.getName()).putIfAbsent("views_user6", 11);
    cacheManager.getCache(CacheType.VIEWS.getName()).putIfAbsent("views_user6", 11);
    cacheManager.getCache(CacheType.VIEWS.getName()).putIfAbsent("views_user6", 11);

 

users, views라는 cache에 각각의 데이터들을 넣어보고 실행했습니다.

users에는 각각 다른 세 개의 키 값에 다른 데이터를 넣었고,

views에는 views_userX 라는 키 값에 동일한 값이 들어가는 것까지 넣어봤어요.

 

여러개의 캐시에 다양한 키 값들이 생성되는 것을 확인하기 위해서 위와 같이 테스트했습니다.

 

 

📌  All Cache Name

어떤 캐시가 등록되어있는지 확인하고 싶을 수 있습니다.

모든 캐시를 조회하고자 할 때에는 아래와 같이 cacheNames를 사용할 수 있습니다.

 

public class CacheTests {
    @Autowired
    CacheManager cacheManager;  // Bean 주입

    @Test
    public void getAllCaches() {
    
      for (String cacheName : cacheManager.getCacheNames()) {
        log.info(cacheName);
      }
      
      // or functional
      cacheManager.getCacheNames().forEach(log::info);
    }
}

 

log로 찍히는 데이터는 아래와 같습니다.

 

 

output

users
views

 

 

 

 

📌 All keys & Values

조회한 cacheName으로 그 값을 가져옵니다.

 

public class CacheTests {
    @Autowired
    CacheManager cacheManager;  // Bean 주입

    @Test
    public void getAllKeyAndValue() {
    
        for (String cacheName : cacheManager.getCacheNames()) {
            Cache cache = ((CaffeineCache) cacheManager.getCache(cacheName)).getNativeCache();

            for (Object key: cache.asMap().keySet()) {
                Object value = cache.getIfPresent(key);
                log.info("key: {} - value: {}", key, value.toString());
            }
        }
        
        // or functional
        cacheManager.getCacheNames()
            .stream()
                .map(cacheName -> ((CaffeineCache) cacheManager.getCache(cacheName)).getNativeCache())
                .forEach(cache -> cache.asMap().keySet().forEach(key -> {
                    log.info("key: {} - value: {}", key, cache.getIfPresent(key).toString());
                 }));
    }
}

 

for-each loop를 사용할 수도 있고, stream을 사용할 수 있습니다.

위의 결과로는 아래와 같은 값을 확인할 수 있습니다.

 

Output

cacheName: users
  key: selectUserByName_test2 - value: UserVO(name=test2, email=test2@email.com, password=qwerty)
  key: selectUserByName_test3 - value: UserVO(name=test3, email=test3@email.com, password=qwerty)
  key: selectUserByName_test1 - value: UserVO(name=test1, email=test1@email.com, password=qwerty)
cacheName: views
  key: views_user1 - value: 2
  key: views_user2 - value: 5
  key: views_user4 - value: 12
  key: views_user6 - value: 11

 

모든 캐시에 모든 키, 그리고 모든 데이터가 잘 출력된 것을 확인할 수 있습니다.

 

 

 

 

📌 Statics

캐시를 사용할 때 중요한 건 적중률이 얼마나 되느냐라고 생각해요.

hit 율을 직접 확인해봐야 잘 동작하는지 확인 할 수 있겠죠.

 

이럴 때에는 Statics를 사용할 수 있습니다.

 

cache.stats() 를 통해 CacheStatics 객체를 받아올 수 있어요.

 

 

public class CacheTests {
  @Autowired
  CacheManager cacheManager;

  @Test
  public void getCachesStats() {
    for (String cacheName : cacheManager.getCacheNames()) {
      Cache cache = ((CaffeineCache) cacheManager.getCache(cacheName)).getNativeCache();
      CacheStats stats = cache.stats();
      log.info("cache '{}' - stats : {}", cacheName, stats.toString());
    }
  }
}

 

저는 evictionCount까지 테스트를 해보고 싶어서 cache의 maxSize를 1로 두고 테스트를 진행했습니다.

참고로, evictionCount는 캐시 정책에 의해 eviction이 될 때에만 카운팅됩니다.

 

public enum CacheType {
    // ...
    VIEWS("views", 10, 1);
}

 

위와 같이 views의 maxWeight를 1로 지정해주고, 실행해보면 아래와 같이 나옵니다.

 

cache 'users' - stats : CacheStats{hitCount=0, missCount=3, loadSuccessCount=3, loadFailureCount=0, totalLoadTime=7775, evictionCount=0, evictionWeight=0}
cache 'views' - stats : CacheStats{hitCount=4, missCount=4, loadSuccessCount=4, loadFailureCount=0, totalLoadTime=2291, evictionCount=3, evictionWeight=3}

 

users는 캐싱된 데이터를 불러올 일이 없어서 missCount만 3번 발생했고,

views는 views_user4 에서 1번, views_user6에서 3번 hit해서 hitCount가 4회인 것을 확인할 수 있습니다.

또, 3회의 eviction이 발생해서 evictionCount가 3회인 것을 확인할 수 있습니다.

views_user1 -> views_user2 -> views_user4 -> views_user6 으로 3회인 것을 확인할 수 있습니다.

 

 

이 부분은 직접 확인해보면 확실하게 와닿을 것 같아 리스너를 달아 확인해 보았습니다.

 

Caffeine.newBuilder()
		.expireAfterWrite(cache.getExpireAfterWrite(), TimeUnit.SECONDS)
		.maximumSize(cache.getMaximumSize())
		.evictionListener((Object key, Object value, RemovalCause cause) ->
			log.info("Key {} was evicted ({}): {}", key, cause, value))
		.recordStats()
		.build());

 

위와 같이 evictionListener를 달았습니다.

리스너에 대한 내용과 Eviction, Removal 등에 대한 차이에 대한 내용은

이 전 포스팅에서 다뤘으니 따로 자세한 내용은 생략하도록 하겠습니다.

 

이에 대한 로그는 아래와 같이 나옵니다.

 

 Key views_user2 was evicted (SIZE): 5
 Key views_user1 was evicted (SIZE): 2
 Key views_user4 was evicted (SIZE): 12

 

 

 

 

📌 Remove All Cache

public class CacheTests {
    @Autowired
    CacheManager cacheManager;

    @Test
    public void removeAllCaches() {
        for (String cacheName : cacheManager.getCacheNames()) {
            cacheManager.getCache(cacheName).clear();
        }
    }
}

 

위의 코드를 실행하면 아래와 같은 결과를 확인할 수 있습니다.

 

 

Output

-- before -- 
cacheName: users
  key: selectUserByName_test2 - value: UserVO(name=test2, email=test2@email.com, password=qwerty)
  key: selectUserByName_test3 - value: UserVO(name=test3, email=test3@email.com, password=qwerty)
  key: selectUserByName_test1 - value: UserVO(name=test1, email=test1@email.com, password=qwerty)
cacheName: views
  key: views_user1 - value: 2
  key: views_user2 - value: 5
  key: views_user4 - value: 12
  key: views_user6 - value: 11
-- after -- 
cacheName: users
cacheName: views

 

 

📌 Remove Cache by Name

특정 캐시만을 삭제할 수도 있습니다.

 

public class CacheTests {
    @Autowired
    CacheManager cacheManager;

    @Test
    public void removeTargetCache() {
        String targetCacheName = "views";

        ((CaffeineCache)cacheManager.getCache(targetCacheName)).clear();
    }
}

 

Output

-- before -- 
cacheName: views
  key: views_user1 - value: 2
  key: views_user2 - value: 5
  key: views_user4 - value: 12
  key: views_user6 - value: 11
-- after -- 
cacheName: views

 

 

 

📌 Remove Cache by Key

 

등록된 키를 사용해서 삭제할 수도 있습니다.

 

public class CacheTests {
    @Autowired
    CacheManager cacheManager;

    @Test
    public void removeTargetKey() {
        String targetCacheName = "views";
        String targetCacheKey = "views_user4";

        ((CaffeineCache)cacheManager.getCache(targetCacheName)).evict(targetCacheKey);
    }
}

 

 

Output

-- before -- 
cacheName: views
  key: views_user1 - value: 2
  key: views_user2 - value: 5
  key: views_user4 - value: 12
  key: views_user6 - value: 11
-- after -- 
cacheName: views
  key: views_user1 - value: 2
  key: views_user2 - value: 5
  key: views_user6 - value: 11

 

 

 

 

그럼 지금까지 Spring Caffeine Cache의 실제 사용법을  다뤘습니다.

오타나 잘못된 내용을 댓글로 남겨주세요!

감사합니다 ☺️ 

 

 

 

📌 Spring Cache Series

✔ Spring Cache, 제대로 사용하기

✔ Caffeine Cache, 제대로 사용하기 1