용로그
article thumbnail

들어가며


집사의 고민 서비스의 기능 개발이 거의 마무리되면서 기존 코드 또는 기능의 퀄리티를 올리기 위한 작업 중 캐시를 적용하자는 의견이 나왔습니다. 이번 글에서는 집사의 고민 팀에서 사용하는 캐시와 분석한 내용을 공유하려고 합니다.

 

캐시 동작 과정

캐시는 웹사이트, 브라우저, 앱이 더 빠르게 로드되도록 돕기 위해 데이터를 임시로 저장해 놓은 공간입니다. 요청한 데이터를 캐시에서 찾을 수 있으면 Cache Hit, 찾을 수 없다면 Cache Miss가 발생합니다.

글로벌 캐시 vs 로컬 캐시


글로벌 캐시(Global Cache)

서버가 여러대로 분산되어 있는 분산 환경에서 적합한 캐시 전략입니다. 당연하게도 별도의 캐시 서버를 두고 공유하여 사용하기 때문에 서버 간 데이터 공유가 쉽다는 장점이 있습니다. 반면, 로컬캐시가 아니기 때문에 캐시 저장소와 통신하기 위한 네트워크 비용이 듭니다. 대표적으로 Redis 또는 Memcached 등이 있습니다.

 

로컬 캐시(Local Cache)

각 서버마다 내장 캐시를 사용하는 전략입니다. 로컬 서버의 리소스(Memory, Disk)를 사용하여 캐싱처리를 합니다. 각 서버 내에 존재하는 캐시를 사용하기 때문에 네트워크 통신 비용이 필요하지 않아 속도가 빠릅니다. 하지만 다른 서버의 캐시를 참조하기 어려우며, 캐시 서버와 단절(disconnection)될 확률이 존재합니다. 대표적으로 EhCache, Guava Cache, Caffeine Cache 등을 사용합니다.

 

로컬 캐시를 선택한 이유

  1. 캐시 관리가 비교적 쉽습니다. 캐시에 존재하는 데이터와 데이터베이스에 존재하는 데이터가 일치하지 않을 경우 캐시를 동기화/삭제시켜주는 작업이 필요합니다. 이때 분산 서버 + 로컬 캐시를 사용할 경우 메시지 전파(Message Propagation)를 통해 다른 서버들의 캐시 처리도 해줘야 하기 때문에 매우 까다로운 상황이 발생할 수 있습니다. 하지만, 집고 서비스는 여러 대의 분산 서버로 이루어지지 않은 상태이므로 여러 서버의 캐시 무효화가 필요하지 않습니다.
  2. 더 빠르게 처리할 수 있습니다. 캐시를 공유하여 관리할 이유가 없기 때문에 네트워크 통신이 필요없는 로컬 캐시(Local Cache)를 사용하는게 좋다고 생각합니다.
  3. 부가적인 인프라 비용이 발생합니다. 글로벌 캐시를 사용하게 되면 인프라 구축에 대한 비용도 무시할 수 없습니다. 만약 캐시 공유가 필요하지 않다면 인메모리로 처리하는 게 적절하다고 생각합니다.

분산 환경에서 글로벌 캐시를 사용한다고 했을 때는 로컬 캐시를 글로벌 캐시의 부하를 줄여주기 위해 사용하기도 한다고 합니다.

로컬 캐시 살펴보기


로컬 캐시는 아래와 같이 굉장히 다양한 종류가 있지만, 대표적인 3가지 로컬 캐시만 살펴보겠습니다.

  • EhCache
  • GuavaCache
  • CaffeineCache
  • Concurrentmap
  • SimpleCache
  • CompositeCache
  • JCache

EhCache

EhCache는 Spring에서 간단하게 사용할 수 있는 Java 기반 오픈 소스 캐시 라이브러리입니다. Redis나 Memcached 같이 데몬을 가지는 캐시 엔진과는 달리 Spring 내부에서 동작하며 캐싱 처리를 합니다.

 

특징

EhCache는 버전간의 차이가 큽니다. 특히 3 버전에서는 많은 새로운 기능이 공개되었는데 대표적으로 아래와 같습니다.

  • Java 제네릭을 활용하고 캐시 상호 작용을 단순화 하는 API 개선
  • javax.cache API와 완벽 호환
  • 오프힙(offheap) 전용 캐시를 포함한 오프힙 스토리지 기능
  • 즉시 사용 가능한 Spring 캐싱 및 Hibernate

저장공간

EhCache의 저장 공간은 크게 4가지로 나눌 수 있습니다.

Storage layer

  • On-Heap Store : Java의 On-Heap 메모리를 활용하여 캐시 항목을 저장할 수 있습니다. 이 계층은 Java 애플리케이션과 동일한 힙 메모리를 사용하며, 모두 JVM GC가 처리합니다. 다만, JVM이 활용하는 힙(Heap) 공간이 많을수록 GC로 인해 애플리케이션 성능에 더 많은 영향을 받습니다. 매우 빠르지만 크기가 가장 작다는 단점이 있습니다.
  • Off-Heap Store : 사용 가능한 메모리에 따라 크기가 제한됩니다. GC가 적용되지 않으며, 데이터를 저장하고 다시 접근할 때 JVM 힙으로 데이터를 이동시켜야 하기 때문에 On-Heap Store 보다는 느립니다.
  • Disk Store : 디스크(File System)을 활용하여 캐시를 저장합니다. 당연하게도 디스크에 저장하는 만큼 저장 공간은 많지만, RAM 기반 저장소보다 훨씬 느립니다. 디스크 스토리지를 사용하는 모든 애플리케이션의 경우 처리량을 최적화하기 위해 빠른 전용 디스크를 사용하는 것이 좋습니다.
  • Clustered Store : 이 데이터 저장소는 원격 서버의 캐시입니다. 원격 서버에는 선택적으로 향상된 고가용성(HA)을 제공하는 장애 조치 서버가 있을 수 있습니다. 클러스터링 된 스토리지에는 네트워크 대기 시간은 물론 클라이언트/서버 일관성 설정과 같은 요인으로 인해 성능 차이가 발생하기 때문에 본질적으로 Off-Heap Store 보다는 느립니다.

여기서 놀랐던 점은 로컬 캐시가 분산 캐시를 지원한다는 점이였습니다. 분산 캐시의 목적은 애플리케이션이 여러 개의 노드에 올라가 있는 상황에서 한 노드의 캐시에 변화가 생기면 나머지 노드에도 변경 사항을 적용하여 같은 상태로 유지하는 것입니다. (이걸 IMDG라고 해야 할까요..?)

 

애플리케이션 클러스터링 구조

Terracotta Server는 각 캐시 노드들의 허브(Hub) 역할을 하는 분산 캐시 서버입니다. EhCache와 Terracotta Server를 결합하여 캐시 노드들 간의 변경 내용을 공유하고 동기화합니다.

 

캐시의 동기화로 인한 부하는 Terracotta 분산 서버에 위임하여 처리합니다. 이를 통해 가용성과 확장성을 가진 클러스터링 된 캐시 형태를 구현합니다.

 

하지만 클러스터링된 환경에서 캐시를 적용하고 싶다면 인프라 비용을 잘 고려해서 글로벌 캐시를 적용하는 것도 괜찮다고 생각합니다. 그 이유는 Terracotta 서버와도 네트워크 비용이 들기 때문입니다.

 

Guava 캐시


Guava Cache는 Google에서 개발한 오픈 소스 라이브러리입니다. 간단한 코드를 통해 키시 크기, 캐시 시간, 데이터 로딩 방법, 데이터 Refresh 방법 등을 제어할 수 있습니다. 또한 Google Guava는 유용한 utility성 기능들이 많습니다.

 

특징

Guava는 대표적으로 2가지의 캐시의 타입이 존재합니다.

  • Cache : 캐시 미스가 발생해도 데이터를 자동으로 로드하지 않습니다.
  • LoadingCache : 캐시 누락이 발생하면 자동으로 데이터를 로드합니다.

필요한 값이 없을 때 자동으로 데이터를 로딩하는 LoadingCache를 더 많이 사용합니다.

 

동시성

내부적으로 ConcurrentHashMap과 유사하게 구현되어 있으며, thread-safe를 보장합니다. 동시에 여러 개의 스레드가 같은 key에 대해서 요청하더라도 CacheLoader의 load() 메서드는 각 key에 대해 한 번만 호출합니다. 데이터를 요청한 모든 스레드에게 호출 결과가 반환되고, 해당 값은 캐시에 저장됩니다.

 

캐시 제거 정책

리소스 제약으로 모든 데이터를 캐시 할 수는 없기 때문에 유지할 필요가 없는 데이터를 삭제할 시점을 결정해야 합니다. Guava에서는 아래와 같이 3가지 방법을 제공합니다.

  • size-based eviction : 캐시 사이즈의 제한을 설정하여 제거
  • time-based eviction : 시간 기반으로 제거
  • reference-based eviction : 참조 기반으로 제거

size-based eviction

캐시가 maximumSize 크기 이상으로 커지면 최근 또는 자주 사용하지 않는 데이터를 제거하려고 시도합니다. 또는 다른 캐시 데이터에 다른 가중치가 있는 경우 weigher가 있는 가중치 함수와 maximumWeight가 있는 최대 캐시 가중치를 지정할 수 있습니다.

time-based eviction

현재 캐시에 저장되어 있는 각각의 엔트리에 대해 아래의 기준 시간 이후에 지정한 시간이 지나면 자동으로 캐시에서 삭제합니다.

  • expireAfterAccess(long, TimeUnit)
    • 엔트리가 처음으로 생성된 시간
    • 가장 최근에 엔트리의 값이 바뀐 시간
    • 가장 마지막으로 접근했던 시간
  • expireAfterWrite(long, TimeUnit)
    • 엔트리가 처음으로 생성된 시간
    • 가장 최근에 엔트리의 값이 바뀐 시간

reference-based eviction

Guava를 사용하면 항목의 GC를 허용하거나 키 또는 값에 weakKeys를 사용하거나 값에 대한 softKeys를 사용하여 캐시를 설정할 수 있습니다.

  • weakKeyhs(), weakValues() : 값이나 키가 weakReference로 감싸집니다. reference가 없어지고 weakReference만 남아있으면 GC가 동작합니다.
  • softKeys(), softValues() : 값이나 키가 sofytReference로 감싸집니다. JVM의 메모리가 부족한 경우 GC가 동작합니다.

Caffeine 캐시

Caffein 캐시의 깃허브에서는 Caffein 캐시가 최적의 성능에 가까운 고성능 캐싱 라이브러리(high performance, near optimal caching library)라고 소개하고 있습니다. Caffein 캐시는 다음과 같이 구성됩니다.

특징

Caffeine 캐시는 EhCache를 비롯해 다른 캐시 방식보다도 높은 성능을 보여주며 데이터 처리량 대비 초당 작업에서 높은 처리 속도를 보여줍니다. 이는 Caffeine 캐시가 우수한 캐시 제거 전략을 사용하기 때문입니다.

 

read 100%에서 성능 비교

캐시 제거 방식

Ehcache의 제거 방식은 LRU(Least Recent Used), LFU(Least Frequency Used), FIFO(First In First Out) 3가지 알고리즘을 가지고 있습니다.

 

반면, Caffeine 캐시는 거의 최적의 적중률을 제공하는 Window TinyLFU 방식을 사용합니다. 캐시 내부 알고리즘은 LFU와 LRU의 장점을 통합하여 빈번히 호출되고 최근에 생성된 캐시 데이터들은 가능한 캐시에 유지하는 방식을 사용합니다.

 

결론적으로 집고의 로컬 캐시 라이브러리는 다른 로컬 캐시에 비해 매우 빠른 성능을 보여주는 Caffein Cache로 결정했습니다. 

 

로컬 캐시 적용하기


우선 build.gradle에 의존성을 추가해 줍니다.

 

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-cache'
    implementation 'com.github.ben-manes.caffeine:caffeine'
}

 

다음으로 CacheConfig를 만들어줘야 하는데, 어노테이션을 Application에 붙여도 되지만 다른 설정들과 섞이지 않게 분리하는 게 깔끔하겠죠?

 

@EnableCaching
@Configuration
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        SimpleCacheManager cacheManager = new SimpleCacheManager();
        cacheManager.setCaches(caches());
        return cacheManager;
    }

    private List<CaffeineCache> caches() {
        return Arrays.stream(CacheType.values())
                .map(cacheType -> new CaffeineCache(cacheType.getName(), cache(cacheType)))
                .toList();
    }

    private Cache<Object, Object> cache(CacheType cacheType) {
        return Caffeine.newBuilder()
                .maximumSize(cacheType.getMaxSize())
                .expireAfterWrite(cacheType.getExpireTime(), TimeUnit.SECONDS)
                .build();
    }

}

@Getter
@AllArgsConstructor
public enum CacheType {

    BREEDS("breeds", 1, 10),
    ;

    private final String name;
    private final int maxSize;
    private final long expireTime;

}

 

여기서 CacheType은 enum 타입인데, 캐시의 설정을 관리합니다. 해당 파일은 캐시 이름, 캐시 만료 시간, 저장 가능한 개수 등을 설정합니다.

 

public interface BreedRepository extends JpaRepository<Breed, Long> {

    @Cacheable(cacheNames = "breeds")
    default List<Breed> findAllBreeds() {
        return findAll();
    }
}

 

마지막으로 위와 같이 @Cacheable 어노테이션을 통해 캐싱을 사용합니다.

결과


breed 테이블을 전체 조회 하는 쿼리를 테스트해보았습니다.

 

결과

278ms에서 20ms로 응답시간이 확연하게 줄어든 걸 볼 수 있습니다.

 

쿼리 카운터

기존 집고 서비스에 적용되어 있던 쿼리카운터로 쿼리 개수를 확인해 본 결과 캐시가 잘 적용되었다는 것을 알 수 있습니다.

 

마치며


캐시를 도입하기 위한 코드를 작성하는 시간보다 여러 가지 비교해 보는 시간이 훨씬 많았던 것 같네요 ㅎㅎ; 처음에 캐싱이라고 하면 당연하게 레디스 써야지!라는 생각을 했는데, 실제로 학습 후 적용을 해보니 로컬 캐시 전략도 매우 좋아 보입니다. 물론 상황에 따라 다른 캐시 전략을 써야 합니다.

 

쿼리를 아무리 개선한다고 한들 캐시가 있고 없고의 차이는 뛰어넘을 수 없는 벽과 같은 존재 같다고 느껴지네요. 무작정 쿼리 개선만 하던 제가 반성되네요. 앞으로는 우발적 복잡성을 고려하면서 가성비가 좋은 것부터 적용해 보는 학습 방법도 가져봐야겠습니다. 읽어주셔서 감사합니다.

profile

용로그

@용로그

벨덩보단 용덩 github.com/wonyongChoi05