우이삭 프로젝트에 캐시를 도입하기로 했다.
로컬 캐시인 Caffeine Cache를 적용한 이유는 다음과 같다.
1. Redis는 비용이 비싸다. Caffeine Cache는 비용이 발생하지 않는다.
2. 현재 프로젝트는 단일 서버 구조이다.
Java의 캐시 라이브러리이며 성능이 좋고 유연하며 간편한 API를 제공하는 In-memory 캐싱 라이브러리이다.
처음 Cache에 대해서 공부를 하다보면 Spring cache에 대해서 공부하게 되고, 다음으로 Caffeine Cache 또는 Ehcache 등을 공부하게된다.
이 과정에서 Spring Cache와 Caffeine Cache의 관계가 도대체 무엇인지 궁금증이 들었었다.
공부 결과 한 단어로 정리하면 '추상화와 구현체의 관계'라고 정리할 수 있을 것 같다.(맞나요..?😅)
위 처럼 정리한 이유를 서술하겠다.
Spring Cache와 Caffeine Cache에 대해서 간략하게 설명해보았다.
@Configuration
@Profile("!test")
@EnableCaching
@Slf4j
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(
Caffeine.newBuilder()
.expireAfterAccess(10, TimeUnit.MINUTES) //첫 번재 접근 후 10분 경과 후 제거
.initialCapacity(200) //초기 크기 설정
.softValues() // 값 객체에 대한 부드러움 참조: 메모리가 부족할 때만 GC가 일어남. GC가 수집 대상으로 판단하더라도 GC가 일어나지 않음
.maximumSize(1000) // 최대 크기 설정 (개수임)
.recordStats() // 캐시 지표 기록
.removalListener((key ,value, cause) -> log.debug("key: {}, value: {}가 제거 되었습니다. cause: {}", key, value, cause))
);
return cacheManager;
}
}
Caffeine Cache를 적용한 주된 이유 중 하나가 Evict정책을 지정해줄 수 있기 때문이다.
Baeldung에는 Caffeine의 eviction policy를 위와 같이 설명하고 있다.
얼마나 eviction policy가 중요하고 최적화되어있음을 알 수 있다.
Caffeine Cache는 일반적으로 LRU정책을 기반으로 동작한다.
최근에 가장 적게 사용된 데이터를 삭제하는 정책이다.
따라서, 캐시 삭제가 일어날때 "맨 처음 추가된" 데이터가 아니라 "가장 최근에 가장 적게 사용된" 데이터가 삭제된다고 생각하면 된다.
여러가지 Eviction policy가 있는데 순서대로 설명해보겠다.
설정해준 캐시의 크기를 넘어서면 삭제가 발생한다.
크기의 기준도 count, weigh 두가지가 있다.
cache.get("B");
cache.cleanUp();
assertEquals(1, cache.estimatedSize());
Baeldung에는 위의 예시코드와 함께 다음과 같은 설명을 하고 있다.
위 설명이 정확히 와닿지 않아 찾아보았다.
비동기적 실행
특정 작업이 바로 완료되지 않고 백그라운드에서 진행된다는 의미이다. Caffeine Cache에서는 캐시 정리 작업이 메인 프로그램의 실행 흐름과 독립적으로 실행된다. 이는 프로그램이 캐시 정리를 기다리지 않고(기다리는것이 동기적 실행) 다른 작업을 계속 할 수 있게 해준다.
LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
.maximumWeight(10)
.weigher((k,v) -> 5)
.build(k -> DataObject.get("Data for " + k));
assertEquals(0, cache.estimatedSize());
cache.get("A");
assertEquals(1, cache.estimatedSize());
cache.get("B");
assertEquals(2, cache.estimatedSize());
cache.get("C");
cache.cleanUp();
assertEquals(2, cache.estimatedSize());
위는 Baeldung에 있는 예시이다.
"A","B"가 캐시에 저장되었을때 까지는 캐시의 크기가 2인것을 알 수 있다. "C"가 저장되니 캐시의 크기가 3이 아니라 그대로 2이다. 이는 하나의 캐시 데이터가 삭제되었음을 의미한다.
왜 이렇게 되는지 살펴보겠다.
위 예시에서 weigher메서드는 Caffeine Cache에서 weigh를 결정하는데 사용된다. 이는 maximumWeight()와 함께 사용된다.
weigh의 의미는 개발자가 정의하기 나름이며 크기나 중요도 등을 나타내는 추상적인 개념일 수 있다.
weigher메서드는 캐시에 저장되는 각 데이터에 대해 weigh를 계산하는 함수를 정의한다.
정의된 함수는 캐시 키(k)와 캐시 값(v) 두개의 매개변수를 받는다. 그리고 각 데이터의 무게를 정수로 반환해야 한다.
.weigher((k, v) -> k.length() + v.toString().length())
이 처럼 문자열 길이에 따라 weigh를 계산하는 람다식을 줄 수도 있다.
마지막으로 접근(읽거나 쓰기)된 시점 이후로 설정해준 시간이 지나면 데이터가 삭제된다.
LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
.expireAfterAccess(5, TimeUnit.MINUTES)
.build(k -> DataObject.get("Data for " + k));
마지막으로 쓰여진 시점 이후로 설정해준 시간이 지나면 데이터가 삭제된다.
cache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.SECONDS)
.weakKeys()
.weakValues()
.build(k -> DataObject.get("Data for " + k));
.weakKeys()
.weakValues()가 뭐지?
weakKeys()와 weakValues()는 Caffeine Cache의 설정 옵션으로, 캐시의 키(key)와 값(value)을 각각 약한 참조(weak reference)로 저장하는 것을 의미한다. 이 설정들은 Java의 가비지 컬렉션 방식과 관련이 있으며, 메모리 관리에 특히 중요하다.
약한 참조란 무엇인가?
약한 참조는 일반적인 참조(강한 참조)와 다르게, 객체에 대한 참조가 있더라도 가비지 컬렉터(GC)가 그 객체를 회수할 수 있게 한다. 즉, 객체가 약한 참조만으로 참조되고 있을 때, 그 객체는 GC에 의해 수집될 수 있다.
weakKeys()
작동 방식: weakKeys()를 사용하면 캐시는 키를 약한 참조로 저장한다. 이는 Java의 WeakReference를 사용하여 구현된다.
가비지 컬렉션: 약한 참조로 저장된 키는 가비지 컬렉터(GC)가 메모리를 회수할 수 있는 대상이 된다. 다시 말해, 해당 키에 대한 강한 참조(strong reference)가 더 이상 존재하지 않으면, GC는 이 키를 회수할 수 있다.
용도: 이 옵션은 메모리 사용량을 줄이고, 메모리 누수를 방지하는 데 유용하다. 특히, 동적으로 생성되는 많은 수의 키를 가진 캐시에 적합하다.
weakValues()
작동 방식: weakValues()를 사용하면 캐시는 값을 약한 참조로 저장합니다. 이 역시 Java의 WeakReference를 사용한다.
가비지 컬렉션: 약한 참조로 저장된 값은 해당 값에 대한 다른 강한 참조가 없을 경우 GC에 의해 회수될 수 있다.
용도: 이 옵션은 캐시에 저장된 객체가 더 이상 필요하지 않을 때 자동으로 메모리를 회수할 수 있게 하여, 메모리 관리를 개선하는 데 도움이 된다.
LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.SECONDS)
.weakKeys()
.weakValues()
.build(k -> DataObject.get("Data for " + k));
cache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.SECONDS)
.softValues()
.build(k -> DataObject.get("Data for " + k));
softValues()는 또 뭐야?
소프트 참조된 객체는 메모리가 충분할 때는 GC에 의해 회수되지 않는다. 하지만 시스템이 메모리 부족 상태에 빠졌을 때, GC는 소프트 참조된 객체를 회수할 수 있다.
약한 참조는 참조된 객체가 언제든지 GC에 의해 회수될 수 있다. 소프트 참조는 메모리 압박 상황에서만 회수된다. 따라서 소프트 참조는 약한 참조보다 객체가 메모리에 머무는 시간이 길다.
결론적으로 소프트 참조는 메모리 사용량과 성능 사이의 균형을 잘 맞추고자 할 때 사용될 수 있다. 메모리 부족 상황에서 자동으로 메모리를 확보할 수 있고, 가능한 한 캐시 데이터를 오래 유지하고자 할 대 적합하다.