Use google guava cache
목차
캐싱 전략이란?
“캐싱 전략”은 최근 웹 서비스 환경에서 시스템 성능 향상을 위해 가장 중요한 기술입니다. 캐시는 메모리를 사용함으로 디스크 기반 데이터베이스 보다 훨씬 빠르게 데이터를 반환할 수 있고, 사용자에게 더 빠르게 서비스를 제공할 수 있습니다.
아래에는 사용 사례에 따른 여러가지 캐싱 전략이 있습니다. 적절한 캐싱 전략을 선택하여 추가 지연 시간이나 전체 이점을 보지 못하는지를 고려해야 합니다.
캐싱 전략 선택 시 고려할 점
- 시스템 쓰기 무겁고 덜 자주 읽습니까? (예 : 시간 기반 로그)
- 데이터를 한 번 쓰고 여러 번 읽습니까? (예 : 사용자 프로필)
- 반환되는 데이터는 항상 고유합니까? (예 : 검색어)
추가적으로 캐싱 구현 시 고려할 점
- 캐시 데이터의 수명
모든 데이터를 지워지지 않고 평생 캐시 저장소에 저장하는 것은 효율적이지 않습니다. 그렇기 때문에, 캐시 만료 정책을 적절하게 설정하고 오랜 시간이 지난 데이터는 캐시 저장소에서 제거될 수 있도록 운영해야 합니다.
캐싱 전략 소개
Cache Aside
캐시를 옆에 두고 필요할 때만 데이터를 캐시에 로드하는 전략입니다.
처음 사용자가 요청했을 때, 캐시 스토리지에는 아무 데이터도 없는 상황
- 애플리케이션은 먼저 캐시 저장소에 데이터가 있는지 조회한다. 하지만 데이터가 없다.
- 애플리케이션은 Contents DB 에서 데이터를 조회하고 사용자에게 제공한다.
- 애플리케이션은 Contents DB 에서 가져왔던 데이터를 캐시 저장소에 저장한다.
다음 사용자가 요청했을 때는 이미 캐시 저장소에 데이터가 있는 상황
- 애플리케이션은 먼저 캐시 저장소에 데이터가 있는지 조회한다. 캐시 저장소에 저장되어있는 데이터를 제공한다.
장점
- 읽기가 많은 워크로드에 적합합니다.
- 인 메모리데이터베이스인, Redis가 가장 많이 쓰이며 캐시 분리를 사용하였기 때문에 캐시 오류에 대해 탄력 적입니다. 즉 캐시 클러스터가 다운되어도 시스템 전체의 오류로 가지 않습니다.
- 캐시를 분리하여, 데이터베이스의 모델과 다를 수 있습니다.
단점
- 캐시에 없는 데이터인 경우, 더 오랜 시간이 걸리게 됩니다.
- 캐시가 최신 데이터를 가지고 있는가? (동기화 문제가 중요하게 됩니다.)
Read-Through
Read-Through 캐시는 데이터베이스와 일렬로 배치됩니다. 캐시 미스가 발생하면 데이터베이스에서 누락 된 데이터를 로드하고 캐시를 채우고 이를 애플리케이션에 반환합니다.
캐시를 제외하고 읽기를 통한 전략 모두 데이터를 느리게 로드합니다 . 즉, 처음 읽을 때만 데이터를 로드합니다 .
Cache Aside 와의 차이점은 Application이 캐시를 채우는 역할을 하느냐 마느냐에 따라에 있습니다.
장점
- 읽기가 많은 워크로드에 적합합니다.
단점
- 데이터베이스의 모델과 다를 수 없습니다.
- 데이터를 처음 요청하면 항상 캐시 누락이 발생합니다. 또한 그에 따른 패널티가 발생합니다. 해결 방법으로 개발자가 직접 쿼리를 실행하여 첫 요청 캐시 미스를 나지 않게 하는 방법을 사용하기도 합니다.
Write-Through
Write-Through 캐시는 Read-Through와 반대로 구성됩니다.
데이터를 데이터베이스에 작성할 때마다 캐시에 데이터를 추가하거나 업데이트합니다. 이로 인해 캐시의 데이터는 항상 최신 상태로 유지할 수 있지만, 여러가지 단점이 있습니다.
장점
- 항상 동기화 되어 있습니다.
단점
- 쓰지 않는 데이터도 캐시에 저장되기 때문에 리소스가 낭비됩니다.
- 쓰기 지연 시간이 증가합니다.
여러 장점과 단점이 있지만, Write-Through 캐시와 Read-Through 캐시를 함께 사용하면 Read-Through 캐시의 모든 이점을 얻을 수 있으며 데이터 일관성 보장도 얻을 수 있습니다. (read-through / write-through)
그에 대한 예로 AWS의 DynamoDB Accelerator(DAX)가 있습니다.
DynamoDB Accelerator (DAX) 는 읽기 / 쓰기 캐시의 좋은 예입니다. DynamoDB 및 애플리케이션과 인라인으로 배치됩니다. DynamoDB에 대한 읽기 및 쓰기는 DAX를 통해 수행 할 수 있습니다.
Write-Around
데이터는 데이터베이스에 직접 기록되며, 읽은 데이터만 캐시에 저장됩니다.
Write-Around는 Read-Through와 결합 될 수 있으며, Cache-Aside와도 결합될 수 있습니다. 데이터가 한 번 쓰여지고, 덜 자주 읽히거나 읽지 않는 상황에서 좋은 성능을 제공합니다. 예를들어, 실시간 로그 또는 채팅방 메시지가 있습니다.
Write-Back ( Write-Behind )
애플리케이션은 즉시 확인하는 캐시에 먼저 데이터를 쓰고 약간의 지연 후에 데이터를 다시 데이터베이스에 씁니다.
장점
- 쓰기가 많은 워크로드에 적합합니다.
- Read-Through와 결합하여 가장 최근에 업데이트되고 엑세스 된 데이터를 항상 캐시에서 사용할 수 있는 혼합 워크로드에 적합합니다.
- 데이터베이스에 대한 전체 쓰기를 줄일 수 있어, 해당 비용을 감소할 수 있습니다.
단점
- 위와 반대의 경우 적합하지 않습니다.
- 일부 개발자는 Cache-Aside와 Write-Back 모두 Redis를 사용하는데 가장 큰 단점은 캐시에서 오류가 발생하면 데이터를 영구 소실 하는 것입니다.
Google Guava Cache
캐시 구현에 대한 예제로 Google Guava Cache를 사용합니다.
Guava Cache란?
Google의 Guava Cache는 캐시를 쉽게 사용할 수 있도록 다양한 기능을 제공하는 오픈 소스 라이브러리입니다. 간단한 코드를 통해
- 캐시 크기
- 캐시 시간
- 데이터 로딩 방법
- 데이터 Refresh 방법
등을 제어할 수 있습니다.
Goolge Guava 는 Apache Commons 에서 제공하지 않는 유용한 Utility성 기능들이 상당히 많습니다.
Cache가 expire되더라도 DB 등의 요청은 한 번만 날라가고, 그 뒤에 동시에 들어온 데이터 요청은 첫번째 요청이 끝나 캐시 데이터가 다 채워진 그 결과만 받아가게 처리하여 부하를 줄여주는 역할을 할 수 있습니다.
Cache Type
Guava에서는 2가지 타입의 cache를 제공합니다.
LoadingCache
- 캐시 미스가 발생하면 자동으로 데이터를 로드한다.
- LoadingCache.get(key)을 호출하면 key에 해당하는 데이터를 반환하는데, 데이터가 없다면 먼저 데이터 로딩을 수행한다.
Cache
- 데이터를 자동으로 로드 하지 않는다.
위 2가지 타입 중 LoadingCache
를 많이 사용합니다.
동시성
캐시 인스턴스는 내부적으로 ConcurrentHashMap과 유사하게 구현되어 있고 thread-safe을 보장합니다. 동시에 여러 개의 스레드가 같은 key에 대해서 요청을 하더라도 CacheLoader의 load() 메서드는 각 key에 대해 한번만 호출됩니다. 데이터를 요청한 모든 스레드에게 호출 결과가 반환되고, 해당 값은 캐시에 저장됩니다.
Method
캐시에서 키와 관련된 값을 가져오는 메서드는 2가지가 있습니다.
get()
: 데이터를 로딩하는 중 Checked Exception이 발생할 경우 ExecutionException을 던진다. 그러므로 예외 처리 코드를 반드시 작성 해주어야 한다.getUnchecked()
: get()과 달리 CheckedException을 던지지 않는다. 그러므로 CacheLoader가 CheckedException을 던지지 않는 상황에서만 사용해야 한다. 예외가 발생하면 RuntimeException을 던진다.
Eviction
리소스 제약으로 모든 데이터를 캐시 할 수 없습니다. 그렇기 때문에 어떤 시점에 유지할 필요가 없는 데이터를 없애는 시점을 결정해야 합니다. Guava에서는 아래와 같이 3가지 방법을 제공합니다.
size-based eviction
: 캐시 사이즈의 제한을 설정하여 제거time-based eviction
: 시간 기반으로 제거reference-based eviction
: 참조 기반으로 제거
Example
SingleLoadingCache
: 위의 LoadingCache
를 활용하여 만든 예제입니다.
- 기본적으로
getUnchecked()
,time-based eviction
을 사용 - 캐시 전략으로는 기본적으로는
Read-Through
가 유지되고 있으며, 만약Write-Through
를 병행하여 사용하고 싶으면, 적절히 write시에 DB Write와 cache refresh를 사용하면 된다.
// Cache Util
package com.wnsgml972.util.cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
public class CacheUtils {
public static <T> SingleLoadingCache<T> newSingleLoadingCache(Supplier<T> supplier, Duration duration) {
return new SingleLoadingCache<>(
CacheBuilder.<Boolean, T>newBuilder()
.expireAfterWrite(duration.getSeconds(), TimeUnit.SECONDS)
.build(CacheLoader.from(supplier::get))
);
}
public static void clearCache(SingleLoadingCache... singleLoadingCaches) {
for (SingleLoadingCache singleLoadingCache : singleLoadingCaches) {
singleLoadingCache.invalidate();
singleLoadingCache.getUnchecked();
}
}
}
// SingleLoadingCache
package com.wnsgml972.util.cache;
import com.google.common.cache.LoadingCache;
import java.util.concurrent.ExecutionException;
public class SingleLoadingCache<T> {
private LoadingCache<Boolean, T> loadingCache;
SingleLoadingCache(LoadingCache<Boolean, T> loadingCache) {
this.loadingCache = loadingCache;
}
public T get() throws ExecutionException {
return loadingCache.get(true);
}
public void invalidate() {
loadingCache.invalidateAll();
}
public T getUnchecked() {
return loadingCache.getUnchecked(true);
}
}
// 사용 예제
// Entity Class
public class ExampleEntity { ... }
// 서비스 구현
class ExampleEntityService {
private final SingleLoadingCache<List<ExampleEntity>> exampleEntityCache
= CacheUtils.newSingleLoadingCache(this::selectAll, Duration.ofMinutes(10));
public void clearCache() {
CacheUtils.clearCache(exampleEntityCache);
}
public void refreshCache() {
// invalidate 후
exampleEntityCache.invalidate();
// get()이 한번 이상 호출되야 refresh 됨
exampleEntityCache.getUnchecked();
}
public List<ExampleEntity> getExampleEntities() {
return exampleEntityCache.getUnchecked();
}
private List<ExampleEntity> selectAll() { // DB Load Private
return exampleEntityRepository.findAll();
}
}
// 사용!
var exampleEntities = exampleEntityService.getExampleEntities();
exampleEntityService.refreshCache();
exampleEntityService.clearCache();
Reference
- Guava Cache
- Read-Through, Write-Through, Write-Behind, and Refresh-Ahead Caching
- Using Read-Through and Write-Through in Distributed Cache - DZone Database
- 캐시 배제 패턴 - Cloud Design Patterns
- 레디스(Redis)의 다양한 활용 사례
- Cache-Aside Pattern in Redis
- Caching Strategies and How to Choose the Right One
- DAX and DynamoDB Consistency Models