Guava Cache 제대로 사용하기

내 이 세상 도처에서 쉴 곳을 찾아보았으나, 마침내 찾아낸, 컴퓨터가 있는 구석방보다 나은 곳은 없더라.

Guava Cache 제대로 사용하기

Google Guava Cache는 캐시를 쉽게 사용할 수 있도록 다양한 기능을 제공한다. 간단한 코드로 캐시 크기, 캐시 시간, 데이터 로딩 방법, 데이터 리프레시 방법 등을 제어할 수 있다. 회사에서도 성능 향상을 위해 Guava 캐시가 널리 사용하고 있는데, 최근 캐시 관련 코드를 보다가 이상한 점을 발견했다.

Guava 캐시는 CacheBuilder를 이용해 쉽게 만들 수 있다. LoadingCache를 사용하면 CacheLoaderloadloadAll 메서드를 오버라이드해 데이터 로딩 방법을 지정할 수 있다. 그런데 회사 코드에서 캐시를 사용하는 부분은 거의 대부분 다음과 같은 패턴으로 작성되어 있었다. 여기서는 Product 객체를 캐시하며, 원천 데이터는 productService로부터 가져온다고 가정하자.

private Cache<Key, Product> productCache = CacheBuilder.newBuilder()
    .maximumSize(MAX_SIZE)
    .expireAfterWrite(DURATION, TimeUnit.MINUTES)
    .build();

...

public Product getProduct(Key key) {
  reloadIfNecessary(Lists.newArrayList(key));
  return productCache.getIfPresent(key);
}

public List<Product> getProducts(List<Key> keys) {
  reloadIfNecessary(keys);
  List<Product> products = Lists.newArrayList();
  for (Key key : keys) {
    Product p = productCache.getIfPresent(key);
    if (p != null) {
      products.add(p);
    }
  }
  return products;
}

Product 객체를 얻기 전에 항상 reloadIfNecessary를 호출한다. reloadIfNecessary는 다음과 같이 구현되어 있다.

private void reloadIfNecessary(List<Key> keys) {
  try {
    List<Product> toReload = Lists.newArrayList();
    for (Key key : keys) {
      if (productCache.getIfPresent(key) == null) {
        toReload.add(key);
      }
    }
    if (CollectionUtils.isNotEmpty(toReload)) {
      List<Product> products = productService.findByIds(toReload);
      productCache.putAll(Maps.uniqueIndex(products, Product.keyFunc));
    }
  } catch (Exception e) {
    log.error("Reload product-cache exception : " + e.getMessage());
  }
}

reloadIfNecessary 메서드가 하는 일은, 요청한 키 목록에 대한 아이템이 캐시에 있는지 살펴보고 없는 키에 대해서는 다른 서비스를 통해 아이템을 구해 캐시에 채워 넣는 것이다.

왜 이렇게 했을까? 캐시에 없는 데이터를 캐시로 로드하는 기능은 LoadingCache에 있는 기능이다. 이와 동일한 코드가 LoadingCache에 이미 있으므로 위 코드는 불필요하다. 단지 캐시를 생성할 때 CacheLoader를 만들어 넣어주면 된다.

private LoadingCache<Key, Product> productCache = CacheBuilder.newBuilder()
    .maximumSize(MAX_SIZE)
    .expireAfterWrite(DURATION, TimeUnit.MINUTES)
    .build(new CacheLoader<Key, Product>() {
      @Override public Product load(Key key) {
        return productService.findById(key);
      }

      @Override public Map<Key, Product> loadAll(Iterable<? extends Key> keys) {
        List<Product> products = productService.findByIds(keys);
        return Maps.uniqueIndex(products, Product.keyFunc);
      }
   });

LoadingCache에서 get 또는 getAll 메서드를 통해 아이템을 얻으려 할 때 해당 아이템이 없으면 load 또는 loadAll 메서드가 호출되어 캐이에 데이터를 채울 것이다. loadAll은 리턴 타입이 Map<Key, Product>인데 productService.findByIds의 리턴 타입은 List<Product>기 때문에 변환을 위한 코드가 추가되었다. Product.keyFuncProduct로부터 Key를 얻는 함수(Guava의 Function)다.

이제 getProductgetProducts도 다음과 같이 수정할 수 있다.

public Product getProduct(Key key) {
  try {
    return productCache.getUnchecked(key);
  } catch (ExecuteException x) {
    return null;
  }
}

public Map<Key, Product> getProducts(Iterable<Key> keys) throws ExecuteException {
  try {
    return Map<Key, Product> m = productCache.getAll(keys);
  } catch (ExecuteException x) {
    return productCache.getAllPresent(keys);
  }
}

코드가 훨씬 단순해 졌다. 길이만 짧아진게 아니라 이해하기도 쉬워졌다. 수정 전의 코드는 길이도 길었을뿐 아니라 쓸데없는 잡음으로 이해하기도 어려웠다.

참고로, getProducts의 파라미터 타입을 List<Key>에서 Iterable<Key>로, 리턴 타입은 List<Product>에서 Map<Key, Product>로 바꾸었다. 파라미터 타입을 바꾼 이유는 LoadingCache.getAll 메서드의 파라미터 타입과 맞추기 위해서다. getAll 메서드가 Iterable<Key>을 파라미터로 받을 수 있는데 getProducts의 파라미터를 List<Key>로 제한할 필요는 없어 보인다. 물론 getProducts를 호출할 때 List<Key>를 전달하는 것은 가능하다.

또한 파라미터로 여러 개의 Key를 전달했는데 Product의 목록만 리턴하는 것 보다는 Key-Product 맵으로 리턴하는 것이 더 합당해 보인다. 상황에 따라 이렇게 마음대로 인터페이스를 바꾸는 게 불가능할 수도 있다. 인터페이스를 바꾸는 게 좋을지 유지하는게 좋을지 판단은 각자의 몫이다.

결론

라이브러리를 사용하기로 했다면 최소한 해당 라이브러리의 사용 방법과 API를 미리 확인해야 한다. 제대로 확인하지 않고 라이브러리에 이미 있는 기능을 다시 구현하는 것은 바보 같은 짓이다. 코드만 길어지는 게 아니라 불필요한 잡음을 추가해 코드의 가독성을 떨어뜨리고 유지보수를 어렵게 만든다.