Caffeine Cache

SexyWoong·2023년 12월 2일
0

spring

목록 보기
3/11

우이삭 프로젝트에 캐시를 도입하기로 했다.

로컬 캐시인 Caffeine Cache를 적용한 이유는 다음과 같다.
1. Redis는 비용이 비싸다. Caffeine Cache는 비용이 발생하지 않는다.
2. 현재 프로젝트는 단일 서버 구조이다.

Caffeine Cache

Java의 캐시 라이브러리이며 성능이 좋고 유연하며 간편한 API를 제공하는 In-memory 캐싱 라이브러리이다.

특징

1. 고성능

  • 매우 빠른 캐시 라이브러리로 알려져있다.
  • 동시성과 관련하여 최적화되어 있다.

2. 다양한 캐싱 정책 지원

  • 사용자가 캐시의 최대 크기, 만료 시간(접근 후 만료, 쓰기 후 만료 등), 참조 기반의 정리 등을 설정할 수 있다.

3. 비동기 지원

  • 비동기 계산과 로딩을 지원하며, 미래에 결과가 나올 값을 캐싱하는 것이 가능하다. 이는 높은 성능을 유지하면서도 리소스를 효율적으로 사용할 수 있게 해준다.

4. 통계 및 모니터링

  • 캐시의 성능과 상태를 모니터링 할 수 있는 통계 기능을 제공한다. 이를 통해 캐시의 효율성과 동작을 파악하고 최적화할 수 있다.

Spring Cache와 Caffeine Cache의 관계

처음 Cache에 대해서 공부를 하다보면 Spring cache에 대해서 공부하게 되고, 다음으로 Caffeine Cache 또는 Ehcache 등을 공부하게된다.
이 과정에서 Spring Cache와 Caffeine Cache의 관계가 도대체 무엇인지 궁금증이 들었었다.

공부 결과 한 단어로 정리하면 '추상화와 구현체의 관계'라고 정리할 수 있을 것 같다.(맞나요..?😅)
위 처럼 정리한 이유를 서술하겠다.

Spring Cache

  • Spring Cache는 Caffeine Cache, Ehcache, Redis등 다양한 캐시 저장소와 함께 사용될 수 있도록 설게되었다.
  • Spring Cache는 @Cacheable, @CacheEvict, @CachePut 등의 어노테이션을 활용하여 메서드 수준에서 캐싱을 쉽게 구현할 수 있도록 해준다.
    • 캐시 저장소에 의존하지 않고 캐시 로직을 구현할 수 있고, 캐시 저장소가 변경되더라도 코드 수정이 최소화된다.
  • 캐시의 성능과 만료 정책, 최대 크기 등의 캐시 구성은 캐시 저장소에 따라 달라진다.

Caffeine Cache

  • 구체적인 캐시 구현 라이브러리이고, In-memory 캐싱에 특화되어 있다.
  • CaffeineCacheManager를 통해 Spring Cache와 통합하여 사용할 수 있다.
  • 만료 시간, 최대 크기, 통계 및 모니터링 등 세밀한 캐시 관리 기능을 제공한다.

Spring Cache와 Caffeine Cache에 대해서 간략하게 설명해보았다.

CaffeineCacheManager

@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정책을 지정해줄 수 있기 때문이다.

Eviction

Baeldung에는 Caffeine의 eviction policy를 위와 같이 설명하고 있다.
얼마나 eviction policy가 중요하고 최적화되어있음을 알 수 있다.

Least Recently Used(LRU) 정책

Caffeine Cache는 일반적으로 LRU정책을 기반으로 동작한다.
최근에 가장 적게 사용된 데이터를 삭제하는 정책이다.
따라서, 캐시 삭제가 일어날때 "맨 처음 추가된" 데이터가 아니라 "가장 최근에 가장 적게 사용된" 데이터가 삭제된다고 생각하면 된다.

여러가지 Eviction policy가 있는데 순서대로 설명해보겠다.

Sized-Based Eviction

설정해준 캐시의 크기를 넘어서면 삭제가 발생한다.
크기의 기준도 count, weigh 두가지가 있다.

Count

  • Cache의 최대 갯수는 maximumSize(갯수)로 지정해줄 수 있고 위 설정에서는 1000으로 설정해준것을 확인할 수 있다.
  • 처음 캐시가 초기화되면 count는 0이다.
  • maximumSize()에 설정해준 갯수보다 캐시가 많아지면 삭제 정책에 따라(일반적으로 LRU) 캐시가 삭제된다.
cache.get("B");
cache.cleanUp();

assertEquals(1, cache.estimatedSize());

Baeldung에는 위의 예시코드와 함께 다음과 같은 설명을 하고 있다.

위 설명이 정확히 와닿지 않아 찾아보았다.

  • 캐시의 크기를 확인하기 전에 'cleanUp()'메서드를 호출하는 것은 캐시의 정리가 비동기적으로 실행되기 때문이다.

비동기적 실행
특정 작업이 바로 완료되지 않고 백그라운드에서 진행된다는 의미이다. Caffeine Cache에서는 캐시 정리 작업이 메인 프로그램의 실행 흐름과 독립적으로 실행된다. 이는 프로그램이 캐시 정리를 기다리지 않고(기다리는것이 동기적 실행) 다른 작업을 계속 할 수 있게 해준다.

  • cleanUp()메서드는 Caffeine Cache에서 비동기적으로 진행 중인 캐시 정리 작업을 완료하기 위해 호출된다. 즉, 이 메서드를 호출하면 캐시 정리 작업이 완료될 때까지 기다린다.
  • 캐시의 정확한 크기를 알고 싶을 때 이 메서드를 호출하는 이유는 비동기적으로 진행되는 정리 작업으로 인해 캐시의 크기가 실시간으로 바뀔 수 있기 때문이다. cleanUp()메서드를 호출하면 정리 작업이 완료되고, 그 후에 캐시의 크기를 확인하면 정확한 캐시의 크기를 얻을 수 있다.

Weigh

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) -> 5)는 모든 항목의 무게를 5로 정의하고 있다.
  • maximumWeight(10)은 캐시가 저장할 수 있는 총 무게를 10으로 제한한다. 따라서, 이 캐시는 최대 2개의 항목(각각 무게가 5)을 저장할 수 있다.
.weigher((k, v) -> k.length() + v.toString().length())

이 처럼 문자열 길이에 따라 weigh를 계산하는 람다식을 줄 수도 있다.

Time-Based Eviction

Expire after access

마지막으로 접근(읽거나 쓰기)된 시점 이후로 설정해준 시간이 지나면 데이터가 삭제된다.

LoadingCache<String, DataObject> cache = Caffeine.newBuilder()
  .expireAfterAccess(5, TimeUnit.MINUTES)
  .build(k -> DataObject.get("Data for " + k));

Expire after write

마지막으로 쓰여진 시점 이후로 설정해준 시간이 지나면 데이터가 삭제된다.

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에 의해 회수될 수 있다.

용도: 이 옵션은 캐시에 저장된 객체가 더 이상 필요하지 않을 때 자동으로 메모리를 회수할 수 있게 하여, 메모리 관리를 개선하는 데 도움이 된다.

Reference-Based Eviction

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()는 또 뭐야?

Soft Referene

소프트 참조된 객체는 메모리가 충분할 때는 GC에 의해 회수되지 않는다. 하지만 시스템이 메모리 부족 상태에 빠졌을 때, GC는 소프트 참조된 객체를 회수할 수 있다.

약한 참조와의 차이

약한 참조는 참조된 객체가 언제든지 GC에 의해 회수될 수 있다. 소프트 참조는 메모리 압박 상황에서만 회수된다. 따라서 소프트 참조는 약한 참조보다 객체가 메모리에 머무는 시간이 길다.

결론적으로 소프트 참조는 메모리 사용량과 성능 사이의 균형을 잘 맞추고자 할 때 사용될 수 있다. 메모리 부족 상황에서 자동으로 메모리를 확보할 수 있고, 가능한 한 캐시 데이터를 오래 유지하고자 할 대 적합하다.

profile
함께 있고 싶은 사람, 함께 일하고 싶은 개발자

0개의 댓글