2022. 5. 24. 23:59ㆍSpring
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' 카테고리의 다른 글
Spring Batch, 제대로 이해하기 (1) - 개념이해 (0) | 2022.06.18 |
---|---|
XML Unmarshalling, Xstream 어렵지 않게 사용하기 (0) | 2022.06.12 |
Spring, IP Filtering 어렵지 않게 제작하기 (0) | 2022.05.01 |
Spring Security, 제대로 이해하기 - FilterChain (18) | 2022.04.14 |
Caffeine Cache, 어렵지 않게 사용하기 1 (0) | 2022.04.04 |
Backend Software Engineer
𝐒𝐮𝐧 · 𝙂𝙮𝙚𝙤𝙣𝙜𝙨𝙪𝙣 𝙋𝙖𝙧𝙠