Caching 전략 소개 및 사용 예제

Home

Use google guava cache

목차


캐싱 전략이란?

“캐싱 전략”은 최근 웹 서비스 환경에서 시스템 성능 향상을 위해 가장 중요한 기술입니다. 캐시는 메모리를 사용함으로 디스크 기반 데이터베이스 보다 훨씬 빠르게 데이터를 반환할 수 있고, 사용자에게 더 빠르게 서비스를 제공할 수 있습니다.

아래에는 사용 사례에 따른 여러가지 캐싱 전략이 있습니다. 적절한 캐싱 전략을 선택하여 추가 지연 시간이나 전체 이점을 보지 못하는지를 고려해야 합니다.

캐싱 전략 선택 시 고려할 점

  • 시스템 쓰기 무겁고 덜 자주 읽습니까? (예 : 시간 기반 로그)
  • 데이터를 한 번 쓰고 여러 번 읽습니까? (예 : 사용자 프로필)
  • 반환되는 데이터는 항상 고유합니까? (예 : 검색어)

추가적으로 캐싱 구현 시 고려할 점

  • 캐시 데이터의 수명

모든 데이터를 지워지지 않고 평생 캐시 저장소에 저장하는 것은 효율적이지 않습니다. 그렇기 때문에, 캐시 만료 정책을 적절하게 설정하고 오랜 시간이 지난 데이터는 캐시 저장소에서 제거될 수 있도록 운영해야 합니다.



캐싱 전략 소개

Cache Aside

/assets/images/2020-12-13-Caching/Untitled.png

캐시를 옆에 두고 필요할 때만 데이터를 캐시에 로드하는 전략입니다.

처음 사용자가 요청했을 때, 캐시 스토리지에는 아무 데이터도 없는 상황

  1. 애플리케이션은 먼저 캐시 저장소에 데이터가 있는지 조회한다. 하지만 데이터가 없다.
  2. 애플리케이션은 Contents DB 에서 데이터를 조회하고 사용자에게 제공한다.
  3. 애플리케이션은 Contents DB 에서 가져왔던 데이터를 캐시 저장소에 저장한다.

다음 사용자가 요청했을 때는 이미 캐시 저장소에 데이터가 있는 상황

  1. 애플리케이션은 먼저 캐시 저장소에 데이터가 있는지 조회한다. 캐시 저장소에 저장되어있는 데이터를 제공한다.

장점

  • 읽기가 많은 워크로드에 적합합니다.
  • 인 메모리데이터베이스인, Redis가 가장 많이 쓰이며 캐시 분리를 사용하였기 때문에 캐시 오류에 대해 탄력 적입니다. 즉 캐시 클러스터가 다운되어도 시스템 전체의 오류로 가지 않습니다.
  • 캐시를 분리하여, 데이터베이스의 모델과 다를 수 있습니다.

단점

  • 캐시에 없는 데이터인 경우, 더 오랜 시간이 걸리게 됩니다.
  • 캐시가 최신 데이터를 가지고 있는가? (동기화 문제가 중요하게 됩니다.)


Read-Through

/assets/images/2020-12-13-Caching/Untitled%201.png

Read-Through 캐시는 데이터베이스와 일렬로 배치됩니다. 캐시 미스가 발생하면 데이터베이스에서 누락 된 데이터를 로드하고 캐시를 채우고 이를 애플리케이션에 반환합니다.

캐시를 제외하고 읽기를 통한 전략 모두 데이터를 느리게 로드합니다 . 즉, 처음 읽을 때만 데이터를 로드합니다 .

Cache Aside 와의 차이점은 Application이 캐시를 채우는 역할을 하느냐 마느냐에 따라에 있습니다.

장점

  • 읽기가 많은 워크로드에 적합합니다.

단점

  • 데이터베이스의 모델과 다를 수 없습니다.
  • 데이터를 처음 요청하면 항상 캐시 누락이 발생합니다. 또한 그에 따른 패널티가 발생합니다. 해결 방법으로 개발자가 직접 쿼리를 실행하여 첫 요청 캐시 미스를 나지 않게 하는 방법을 사용하기도 합니다.


Write-Through

/assets/images/2020-12-13-Caching/Untitled%202.png

Write-Through 캐시는 Read-Through와 반대로 구성됩니다.

데이터를 데이터베이스에 작성할 때마다 캐시에 데이터를 추가하거나 업데이트합니다. 이로 인해 캐시의 데이터는 항상 최신 상태로 유지할 수 있지만, 여러가지 단점이 있습니다.

장점

  • 항상 동기화 되어 있습니다.

단점

  • 쓰지 않는 데이터도 캐시에 저장되기 때문에 리소스가 낭비됩니다.
  • 쓰기 지연 시간이 증가합니다.

여러 장점과 단점이 있지만, Write-Through 캐시와 Read-Through 캐시를 함께 사용하면 Read-Through 캐시의 모든 이점을 얻을 수 있으며 데이터 일관성 보장도 얻을 수 있습니다. (read-through / write-through)

그에 대한 예로 AWS의 DynamoDB Accelerator(DAX)가 있습니다.

/assets/images/2020-12-13-Caching/Untitled%203.png

DynamoDB Accelerator (DAX) 는 읽기 / 쓰기 캐시의 좋은 예입니다. DynamoDB 및 애플리케이션과 인라인으로 배치됩니다. DynamoDB에 대한 읽기 및 쓰기는 DAX를 통해 수행 할 수 있습니다.


Write-Around

데이터는 데이터베이스에 직접 기록되며, 읽은 데이터만 캐시에 저장됩니다.

Write-Around는 Read-Through와 결합 될 수 있으며, Cache-Aside와도 결합될 수 있습니다. 데이터가 한 번 쓰여지고, 덜 자주 읽히거나 읽지 않는 상황에서 좋은 성능을 제공합니다. 예를들어, 실시간 로그 또는 채팅방 메시지가 있습니다.


Write-Back ( Write-Behind )

/assets/images/2020-12-13-Caching/Untitled%204.png

애플리케이션은 즉시 확인하는 캐시에 먼저 데이터를 쓰고 약간의 지연 후에 데이터를 다시 데이터베이스에 씁니다.

장점

  • 쓰기가 많은 워크로드에 적합합니다.
  • Read-Through와 결합하여 가장 최근에 업데이트되고 엑세스 된 데이터를 항상 캐시에서 사용할 수 있는 혼합 워크로드에 적합합니다.
  • 데이터베이스에 대한 전체 쓰기를 줄일 수 있어, 해당 비용을 감소할 수 있습니다.

단점

  • 위와 반대의 경우 적합하지 않습니다.
  • 일부 개발자는 Cache-Aside와 Write-Back 모두 Redis를 사용하는데 가장 큰 단점은 캐시에서 오류가 발생하면 데이터를 영구 소실 하는 것입니다.



Google Guava Cache

캐시 구현에 대한 예제로 Google Guava Cache를 사용합니다.

Guava Cache란?

Google의 Guava Cache는 캐시를 쉽게 사용할 수 있도록 다양한 기능을 제공하는 오픈 소스 라이브러리입니다. 간단한 코드를 통해

  1. 캐시 크기
  2. 캐시 시간
  3. 데이터 로딩 방법
  4. 데이터 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