Front Cache?

ARCUS와 같이 별도의 서버에서 캐싱을 수행하는 리모트 형태의 캐시 솔루션은 여러 애플리케이션에서 캐싱된 데이터를 서로 공유할 수 있는 장점이 있습니다. 하지만 리모트 캐시는 일시적인 많은 요청으로 인한 장비의 리소스 부족이나 대량의 네트워크 트래픽의 발생에 따라 데이터 응답 시간이 민감하게 영향을 받습니다. 이러한 문제는 클러스터를 확장하거나, 장비의 사양을 올려 해결할 수 있지만, 운영 비용이 증가하는 부담이 있습니다. 상대적으로 비용이 들지 않으면서 소프트웨어적으로 해결하는 방법이 있습니다. 바로 리모트 캐시 앞단에서 애플리케이션 장비의 로컬 메모리를 사용하여 데이터를 캐싱하는 방법입니다. 이를 프론트 캐시(Front Cache)라고 하며, 로컬 메모리에 캐싱한다고 하여 로컬 캐시(Local Cache)라고도 부릅니다. 로컬 메모리에 데이터를 캐싱해두는 것이 네트워크를 통하여 외부 서버에 캐싱하는 것보다 훨씬 빠릅니다. 네트워크 트래픽에 영향을 전혀 받지 않기 때문에, 이벤트 및 공지사항이나 최근 인기 뉴스와 같은 트래픽이 폭주하는 데이터에 프론트 캐시를 적용한다면 애플리케이션의 빠른 응답성과 높은 처리량을 확보할 수 있습니다.

이를 위해 Spring Framework의 Spring Cache를 ARCUS에 맞추어 구현한 ARCUS Spring 라이브러리의 신규 버전(1.13.3)에서 프론트 캐시를 위한 인터페이스를 추가하였습니다. 이번 포스팅에서는 ARCUS Spring에서 제공되는 프론트 캐시 기능의 동작 구조와 사용 방법 그리고 사용시 주의사항에 대해 공유드리겠습니다.

FrontCache 동작 구조

ARCUS Spring에서 제공되는 프론트 캐시는 ARCUS 앞단에서 캐싱을 수행합니다. 동작 구조를 캐시 요청 타입별로 정리하면 아래와 같습니다.

캐시 데이터 조회 동작

  1. 애플리케이션에서 캐시 데이터 조회를 위해 Spring Cache 인터페이스의 get API를 호출합니다.
  2. 프론트 캐시에서 데이터가 존재하는지 확인합니다. 데이터가 존재한다면 조회된 데이터를 리턴합니다.
  3. ARCUS 캐시에서 데이터가 존재하는지 확인합니다. 데이터가 존재한다면 프론트 캐시에 데이터를 저장하고 조회된 데이터를 리턴합니다.

캐시 데이터 저장 동작

  1. 애플리케이션에서 캐시 데이터 저장을 위해 Spring Cache의 put API를 호출합니다.
  2. ARCUS 캐시에 데이터 저장 요청을 수행합니다.
  3. a) (Option 1) ARCUS의 저장 요청이 성공했을 때만 프론트 캐시에 데이터를 저장합니다.
    b) (Option 2) ARCUS의 저장 요청 결과에 상관없이 프론트 캐시에 데이터를 저장합니다.

캐시 데이터 제거 동작

  1. 애플리케이션에서 캐시 데이터 제거를 위해 Spring Cache의 evict/clear API를 호출합니다.
  2. ARCUS 캐시에 데이터 제거 요청을 수행합니다.
  3. a) (Option 1) ARCUS의 제거 요청이 성공했을 때만 프론트 캐시에서 데이터를 제거합니다.
    b) (Option 2) ARCUS의 제거 요청 결과에 상관없이 프론트 캐시에서 데이터를 제거합니다.

캐시 데이터의 저장, 제거에서 Option 1 동작이 디폴트 동작이며, 이는 forceFrontCaching 설정으로 변경하실 수 있습니다. Option 2 동작은 네트워크 이슈로 ARCUS 캐시에 일시적으로 접근이 불가한 경우에 프론트 캐시 기능을 유지할 수 있습니다. 변경될 수 있는 데이터라면 Option 1을, 변경되지 않는 성격의 데이터라면 Option 2를 선택하여 사용하시는 것이 좋습니다.

사용 방법

Spring Cache 인터페이스를 ARCUS에 맞게 구현한 ArcusCache 클래스는 프론트 캐싱을 위해 아래의 추상화된 인터페이스를 의존합니다. 개발자가 아래의 인터페이스를 직접 구현하여 다른 리모트 캐시 혹은 로컬 캐시를 ARCUS의 프론트 캐시로서 사용될 수 있도록 유연성을 제공합니다.

public interface ArcusFrontCache {

  Object get(String key);
  void set(String key, Object value, int expireTime);
  void delete(String key);
  void clear();
}

하지만 개발자가 직접 해당 인터페이스를 구현하지 않아도, ARCUS Spring에서 기본으로 제공되는 DefaultArcusFrontCache 클래스를 사용하실 수 있습니다. 이 클래스는 데이터를 애플리케이션의 로컬 메모리에 캐싱하며, 내부적으로 EhCache 라이브러리를 사용합니다. 이 클래스를 활용하여, Spring 기반의 애플리케이션에서 ARCUS 캐시의 앞단에 프론트 캐싱을 수행할 수 있도록 적용해보겠습니다.

먼저 DefaultArcusFrontCache 클래스를 생성하고, ArcusCache 클래스에 의존성을 설정해줍니다. 테스트를 위해 프론트 캐시의 TTL(TimeToLive)은 ARCUS 캐시의 TTL 보다 20초 짧은 10초로 설정하겠습니다.

public class ArcusCacheConfiguration {

  @Bean
  public ArcusCache testCache() {
      ArcusCache arcusCache = new ArcusCache();
      arcusCache.setName("test");
      arcusCache.setServiceId("TEST-");
      arcusCache.setPrefix("TEST");
      arcusCache.setTimeoutMilliSeconds(800);
      arcusCache.setArcusClient(arcusClient());
      // ARCUS 캐시의 아이템 TTL 설정
      arcusCache.setExpireSeconds(30);
      // 프론트 캐시 인스턴스 설정. 
      arcusCache.setArcusFrontCache(testArcusFrontCache());
      // 프론트 캐시의 아이템 TTL 설정.
      arcusCache.setFrontExpireSeconds(10);
      // ARCUS의 저장/제거 요청이 실패하여도, 
      // 프론트 캐시에 저장/제거 요청을 수행하도록 설정.
      arcusCache.setForceFrontCaching(true);
      return arcusCache;
  }

  @Bean
  public ArcusFrontCache testArcusFrontCache() {
    return new DefaultArcusFrontCache(
      // 캐시 이름, 이름은 인스턴스마다 고유해야합니다.
      "test", /* name */
      // 캐시 아이템의 최대 저장 개수.
      // 이를 초과하면 LRU에 의해 기존 아이템은 제거된다.
      10000, /* maxEntries */
      // 프론트 캐시에서 아이템 인스턴스를 가져올 때 
      // 아이템의 레퍼런스만 가져올 지, 복사해서 가져올 지에 대한 설정.
      // false로 설정하면 아이템의 레퍼런스를 가져온다.
      false, /* copyOnRead */
      // 프론트 캐시에서 아이템 인스턴스를 저장할 때
      // 아이템의 레퍼런스만 저장할 지, 복사해서 저장할 지에 대한 설정
      // false로 설정하면 아이템의 레퍼런스를 저장한다.
      false /* copyOnWrite */
    );
  }

  @Bean
  public ArcusClientPool arcusClient() {
    ArcusClientFactoryBean arcusClientFactoryBean = new ArcusClientFactoryBean();
    arcusClientFactoryBean.setUrl("1.2.3.4:1234");
    arcusClientFactoryBean.setServiceCode("test");
    arcusClientFactoryBean.setPoolSize(8);
    return arcusClientFactoryBean.getObject();
  }
}

방금 생성했던 ArcusCache 인스턴스를 CacheManager에 전달합니다.

@EnableCaching
@Configuration
public class CacheConfiguration implements CachingConfigurer {

  @Autowired
  private ArcusCache testCache;

  @Bean
  @Override
  public CacheManager cacheManager() {
    SimpleCacheManager arcusCacheManager =
        new SimpleCacheManager();
    arcusCacheManager.setCaches(
      List.of(testCache)
    );
    return arcusCacheManager;
  }

  @Override
  public KeyGenerator keyGenerator() {
    return new StringKeyGenerator();
  }

  @Override
  public CacheResolver cacheResolver() {
    return null;
  }

  @Override
  public CacheErrorHandler errorHandler() {
    return null;
  }
}

이제 프론트 캐시 설정이 끝났습니다. 캐시를 적용하고 싶은 서비스에 @Cacheable 어노테이션을 적용하여 프론트 캐시가 잘 적용되고 있는지 확인해보겠습니다.

@Service
public class ProductService {

  @Autowired
  private ProductRepository productRepository;

  @Cacheable(value = "test", key="#product.id")
  public Product get(Product product) {
    return productRepository.select(productDto.getId());
  }
}

캐싱이 적용된 서비스 요청을 매번 수행하면 ArcusCache 클래스에서 캐싱을 하기 위해 아래의 로그가 출력될 것입니다. 출력된 로그 메시지의 의미를 하나씩 살펴보겠습니다.

(1): 처음에는 프론트 캐시와 ARCUS 캐시에 모두 데이터가 존재하지 않기 때문에, ARCUS 캐시와 프론트 캐시에 데이터를 저장합니다.

(2): 프론트 캐시에서 데이터를 가져옵니다. 방금 전 설정했던 프론트 캐시의 TTL은 10초이므로, 프론트 캐시의 데이터가 만료되기 전까지 ARCUS에서 데이터를 조회하지 않습니다.

(3): 프론트 캐시에서 데이터를 저장하고 10초가 지나 만료된 시점입니다. 프론트 캐시의 조회가 실패하여 ARCUS 캐시에서 데이터를 가져오고, 가져온 데이터를 프론트 캐시에 저장합니다.

(4): (2)번 과정과 똑같습니다.

DEBUG 21-07-16 17:42:05 [ArcusCache:448] - getting value by key: TEST-PRODUCT:1266
DEBUG 21-07-16 17:42:05 [ArcusCache:480] - trying to put key: TEST-PRODUCT:1266, value: com.jam2in.arcus.Product ... (1) ARCUS, 프론트 캐시에 저장
DEBUG 21-07-16 17:42:07 [ArcusCache:448] - getting value by key: TEST-PRODUCT:1266 
DEBUG 21-07-16 17:42:07 [ArcusCache:454] - front cache hit for TEST-PRODUCT:1266 ... (2) 프론트 캐시에서 데이터를 조회하여 리턴
DEBUG 21-07-16 17:42:10 [ArcusCache:448] - getting value by key: TEST-PRODUCT:1266
DEBUG 21-07-16 17:42:10 [ArcusCache:454] - front cache hit for TEST-PRODUCT:1266 ... (2)
DEBUG 21-07-16 17:42:15 [ArcusCache:448] - getting value by key: TEST-PRODUCT:1266
DEBUG 21-07-16 17:42:15 [ArcusCache:454] - front cache hit for TEST-PRODUCT:1266 ... (2)
DEBUG 21-07-16 17:42:16 [ArcusCache:448] - getting value by key: TEST-PRODUCT:1266
DEBUG 21-07-16 17:42:16 [ArcusCache:470] - arcus cache hit for TEST-PRODUCT:1266 ... (3) 프론트 캐시의 TTL 만료로 ARCUS에서 데이터를 조회하여 리턴
DEBUG 21-07-16 17:42:17 [ArcusCache:448] - getting value by key: TEST-PRODUCT:1266
DEBUG 21-07-16 17:42:17 [ArcusCache:454] - front cache hit for TEST-PRODUCT:1266 ... (4) 프론트 캐시에서 데이터를 조회하여 리턴
DEBUG 21-07-16 17:42:18 [ArcusCache:448] - getting value by key: TEST-PRODUCT:1266
DEBUG 21-07-16 17:42:18 [ArcusCache:454] - front cache hit for TEST-PRODUCT:1266 ... (4)

위의 예에서는 단일 프론트 캐시를 생성하여 동작하는 예를 보여주었지만, 1개가 아닌 N개의 프론트 캐시를 구성하는 것도 가능합니다. 예를 들어 아래와 같이 상품과 이벤트 서비스가 존재하고, Prefix 설정을 위해 서비스 타입마다 다른 ARCUS 캐시 인스턴스 둔 경우, 각 서비스에 해당하는 프론트 캐시를 할당하여 사용할 수 있습니다.

@Bean
public ArcusCache productCache() {
    ArcusCache arcusCache = new ArcusCache();
    arcusCache.setName("product");
    ... (생략) ...
    arcusCache.setArcusFrontCache(productFrontCache()); // product 프론트 캐시 사용
    return arcusCache;
}

@Bean
public ArcusFrontCache productFrontCache() { // product 프론트 캐시 생성
  return new DefaultArcusFrontCache("productFront", 20000, false, false);
}

@Bean
public ArcusCache eventCache() {
    ArcusCache arcusCache = new ArcusCache();
    arcusCache.setName("event");
    arcusCache.setPrefix("EVENT");
    ... (생략) ...
    arcusCache.setArcusFrontCache(eventFrontCache()); // event 프론트 캐시 사용
    return arcusCache;
}

@Bean
public ArcusFrontCache eventFrontCache() { // event 프론트 캐시 생성
  return new DefaultArcusFrontCache("eventFront", 10000, false, false);
}

참고로 위에서 생성한 프론트 캐시 인스턴스들은 각 인스턴스마다 별도의 해시 테이블을 보유하고 있기 때문에 캐시 데이터를 서로 공유하지 않습니다. 여러 ARCUS 캐시 인스턴스에서 프론트 캐시의 데이터를 공유하고 싶다면, 아래처럼 하나의 프론트 캐시 인스턴스를 생성하고, 여러 ARCUS 캐시 인스턴스에게 할당해주면 됩니다.

@Bean
public ArcusCache productCache() {
    ArcusCache arcusCache = new ArcusCache();
    arcusCache.setName("product");
    ... (생략) ...
    // share 프론트 캐시 사용     
    arcusCache.setArcusFrontCache(sharedFrontCache()); 
    return arcusCache;
}

@Bean
public ArcusCache eventCache() {
    ArcusCache arcusCache = new ArcusCache();
    arcusCache.setName("event");
    arcusCache.setPrefix("EVENT");
    ... (생략) ...
    // share 프론트 캐시 사용     
    arcusCache.setArcusFrontCache(sharedFrontCache()); 
    return arcusCache;
}

@Bean
  // shared 프론트 캐시 사용
  public ArcusFrontCache sharedFrontCache() { 
  return new DefaultArcusFrontCache("shared", 50000, false, false);
}

주의 사항

로컬 메모리에 캐시 데이터를 저장하는 방식의 프론트 캐시는 성능적으로 리모트 캐시보다 빠르지만, 모든 케이스에서 사용할 수 있는 것은 아닙니다. 프론트 캐시를 사용함으로써 고려해야 하는 아래의 문제들이 존재합니다.

많은 메모리 사용

프론트 캐시는 똑같은 데이터를 모든 애플리케이션의 메모리에 저장하므로, 전반적으로 많은 메모리 사용 공간을 요구하는 단점이 있습니다. 가용 메모리 공간이 부족한 상황에서 많은 양의 데이터를 로컬 메모리에 저장할 경우, 만료되어 더 이상 참조되지 않는 캐시 데이터(인스턴스)들이 많아짐에 따라 JVM의 Full Garbage Collection을 자주 일으켜 오히려 애플리케이션의 성능을 떨어뜨립니다. 따라서 애플리케이션 장비의 메모리 사용량을 고려하여 프론트 캐시에 저장해둘 수 있는 데이터의 최대 크기 및 개수를 산정해야 합니다. ARCUS Spring의 DefaultArcusFrontCache 클래스를 사용한다면 maxEntries 속성으로 저장 가능한 최대 데이터 개수를 설정할 수 있습니다.

데이터 불일치

여러 애플리케이션들이 캐시 데이터를 공유하지 못하는 문제로, 애플리케이션 간의 데이터 불일치 현상이 발생할 수 있습니다. 예를 들어 특정 애플리케이션에 데이터 변경 요청을 수행하면, 다른 애플리케이션의 프론트 캐시에는 변경된 데이터가 반영되지 않습니다. 이 때문에 다중 서버 환경의 요청에서 일관되지 않은 데이터가 응답되는 문제가 있습니다. 따라서 아래의 방법으로 데이터 불일치를 해결합니다.

  • 프론트 캐시의 TTL을 짧게 설정합니다. 많은 데이터가 요청되는 핫(Hot) 데이터의 경우 만료 시간이 짧아도 성능 효과가 큽니다.
  • 애플리케이션 앞단의 로드 밸런서에 Sticky Session을 설정하여 세션에 따라 처음 요청을 처리한 서버로 요청을 전달합니다.
  • 변경이 거의 없는 데이터에 한해서만 프론트 캐시를 적용합니다.

이중 캐싱 (ARCUS Java Client를 사용할 경우)

ARCUS의 Java Client에도 내부적으로 프론트 캐시 기능이 존재합니다. 하지만 프론트 캐시의 TTL과 최대 데이터 개수 및 기타 속성들을 모든 캐시 대상마다 공유해야 하는 제약 사항이 존재합니다. 이러한 제약 사항이 문제가 안된다면 ARCUS Java Client의 프론트 캐시 기능만 사용하여도 충분합니다. 만약 다양한 속성의 프론트 캐시가 필요하여 ARCUS Spring의 프론트 캐시를 사용한다면, 이중 캐싱을 방지하기 위하여 ARCUS Java Client의 프론트 캐시 기능은 비활성화되어야 합니다. ARCUS Java Client의 프론트 캐시 기능은 디폴트로 비활성화되어 있습니다. 만약 ARCUS Java Client의 프론트 캐시 기능에서 ARCUS Spring의 프론트 캐시 기능으로 전환하는 경우라면, 아래와 같이 명시적으로 ARCUS Java Client의 프론트 캐시 기능을 비활성화하여야 합니다.

ConnectionFactoryBuilder factory = new ConnectionFactoryBuilder();
// 0으로 설정하여 프론트 캐시 비활성화, 기본값은 0이다.
factory.setMaxFrontCacheElements(0); 
ArcusClient client = new ArcusClient(SERVICE_CODE, factory);

마치며

지금까지 ARCUS Spring에서 제공되는 프론트 캐시 기능을 알아보았습니다. 프론트 캐시를 사용함으로써 발생되는 제약 사항들을 인지하고 안전하게 사용한다면, 단독으로 ARCUS 캐시를 사용하는 것보다 더 많은 애플리케이션의 요청 처리 성능 향상을 경험하실 수 있습니다. 하지만 프론트 캐시를 적용할 수 있는 캐시 대상은 한정되어있어, 더 많은 캐시 대상 영역에 적용하기 위해선 여러 애플리케이션에 존재하는 프론트 캐시와 ARCUS 캐시간의 데이터의 불일치를 제거하기 위한 동기화 기능이 추가되어야 합니다. ARCUS Spring 프로젝트는 현재까지 꾸준히 개선되고 있으며, 프론트 캐시와 관련한 개선 사항 및 추가 기능이 진행될 때마다 다음 포스팅에서 공유하도록 하겠습니다. 감사합니다.

0개의 댓글