Java 환경에서 기본 패턴의 캐시 적용을 돕는 ARCUS 공통 모듈

잼투인·2021년 6월 28일
7

ARCUS 공통 모듈

목록 보기
1/2
post-thumbnail

캐시를 처음 적용해보는 개발자라면, 애플리케이션에 캐시를 어떻게 적용할 지에 대한 방향을 제대로 못 잡을 수 있습니다. 애플리케이션에 캐시를 적용할 수 있는 패턴은 매우 다양합니다. 그 중 가장 일반적으로 사용되는 Demand-fill 패턴에 대해 알아보고, 이 방식을 애플리케이션에 그대로 적용했을 때 발생할 수 있는 문제들을 알아보겠습니다. 마지막으로 이러한 문제들을 해결하기 위한 방법으로 사용되는 Spring AOP에 대해 간략히 알아보고, Spring AOP를 활용한 Java 라이브러리 ARCUS 공통 모듈에서 제공되는 기능을 소개하고자 합니다.

Demand-fill

Demand-fill 캐싱 방식은 애플리케이션에서 데이터 조회 요청이 들어올 때, 먼저 메인 데이터베이스 저장소가 아닌 캐시 저장소에 접근하여 데이터를 가져오는 방식입니다. 캐시 저장소에 데이터가 존재하면 데이터를 가져와서 반환하고, 존재하지 않는다면 메인 데이터베이스 저장소에서 데이터를 불러와 캐시 저장소에 저장한 후 데이터를 반환합니다. 서버에서 요청이 들어올 때 캐싱을 해두는 방식으로, 주로 조회 성격의 요청에서 이 방식이 사용됩니다.

function fetch(key, ttl) {
  data = cache.read(key);
  if (!data) {
    data = database.read(key);
    cache.write(key, data, expire);
  }
  return data;
}

Demand-fill 방식을 설명하기 위해 상품 정보를 조회하는 Java의 Spring Framework 애플리케이션을 통해 예로 들어보겠습니다.

@Service
class ProductService {
  private ProductDatabase database;
  ...
  // 상품 조회
  public Product get(long id) {
  // ID를 통해 데이터베이스에서 상품 데이터를 불러옴.  
  return database.get(id); 
  }
}

ProductService 클래스에서 상품 조회를 위한 get API가 존재하며, 조회하고 싶은 상품에 대한 ID를 ProductService 클래스의 get API에 전달하면 데이터베이스를 통해 상품 정보 데이터를 가져옵니다. 여기서 ARCUS Client가 제공하는 API의 기본 사용법을 참고하여 Demand-fill 방식으로 상품 정보를 캐싱해보겠습니다.

@Service
class ProductService {
  private ProductDatabase database;
  private ArcusClient arcusClient;
  ...
  // 상품 조회.
  public Product get(long id) {
    // 특정(id) 상품에 대한 캐시 Key 생성 
    String cacheKey = "product:" + id;
    // ARCUS 캐시에 아이템을 조회하는 Get operation에 대한 비동기 요청.
    Future<Object> getFuture = client.asyncGet(cacheKey);
    try {
      // ARCUS 캐시로부터 요청 결과를 700ms 동안 기다림.
      Product product = 
        (Product) future.get(700, TimeUnit.MILLISECONDS);
      if (product != null) {
        // 캐시 히트(Hit)시, 캐싱된 데이터를 반환.
        return product;
      }
    } catch (Exception e) {
      // 요청 타임아웃, 캐시 서버의 중단, 에러 발생 시 Get operation 요청을 취소.
      future.cancel(true);
    }
    // 캐시 미스(Miss)시, 데이터베이스에서 조회.
    Product product = database.get(id);
    // 데이터베이스에서 조회된 상품을 ARCUS 캐시에 
    // 자동 만료시간(expire time) 60초로 저장.
    client.set(cacheKey, 60, product);
    return product;  
  }
}

한 줄 크기의 상품 조회 코드가 Demand-fill 방식의 캐싱을 적용한 후 많은 수의 코드가 추가되었습니다. 실제 애플리케이션의 데이터 조회 코드는 이처럼 간단하지 않고 더욱 복잡합니다. 따라서 캐시 로직의 추가로 인하여 변경 코드에 대한 코드 복잡도와 테스트 비용이 증가되며, 적용할 캐시 대상들이 많아지는 만큼 중복되는 코드가 많아질 것입니다. 결국 조회 성능을 위해 캐시를 적용하려다 핵심 비즈니스 로직에 집중할 수 없는 유지 보수하기 어려운 코드로 바뀌어 되려 개발 비용이 증가하는 상황이 벌어질 것입니다. 어떻게 하면 기존 코드를 변경하지 않고, 간단한 방법으로 Demand-fill 방식의 캐시 로직을 대상 API에 적용할 수 있을 지에 대한 고민이 필요합니다.

Spring AOP 기반의 캐싱

Spring AOP(Aspect Orient Programming)는 관점 지향 프로그래밍으로, 모든 코드의 공통 로직을 다른 코드로 분리하여 애플리케이션의 핵심 비즈니스 로직에 집중할 수 있게 해줍니다. 일반적으로 Spring Framework 애플리케이션에서 데이터베이스의 트랜잭션을 위해 사용되는 @Transactional Annotation이 AOP 개념을 활용한 것입니다. 흔히 데이터베이스의 여러 변경 작업들을 수행하는 로직이 있다면, 이 작업들을 원자적으로 수행하기 위해 해당 API 위에 @Transactional을 부여합니다. 한 계좌에서 다른 계좌로 송금을 하는 서비스의 코드를 예로 들어보겠습니다.

@Transactional
public void transferMoney(long from, long to, long amount) {
  accountDatabase.decreaseAmount(from, amount);
  accountDatabase.increaseAmount(to, amount);
}

transferMoney API를 호출 할 때 from 계좌에서 금액이 데이터베이스에서 차감(decreaseAmount)된 이후, to 계좌의 금액을 데이터베이스에서 증가(increaseAmount)하는 부분에서 오류가 발생된다면, from 계좌의 금액은 이전으로 상태로 롤백이 될 것입니다. 하지만 데이터베이스에서 오류가 발생될 때 어디에도 계좌의 금액을 롤백하는 코드를 찾아볼 수 없습니다. 사실 transferMoney의 일부 코드는 @Transactional에 의해 숨겨져있습니다. 실제 코드와는 똑같진 않겠지만, 숨겨진 코드를 표현해본다면 아래와 같습니다.

public void transferMoney(long from, long to, long amount) {
  try {
    // 트랜잭션의 시작
    transactionManager.begin();
    
    accountDatabase.decreaseAmount(from, amount);
    accountDatabase.increaseAmount(to, amount);
    
    // 송금 성공 시 데이터베이스에 반영
    transactionManager.commit();
  } catch (Exception e) {
    // 송금 실패 시 이전 상태로 롤백
    transactionManager.rollback();
  }
}

트랜잭션을 위해 숨겨진 코드들은 다른 모듈로부터 분리되어있으며, 분리된 코드는 @Transactional이 부여된 API(메소드)에 삽입이 됩니다. Spring AOP에서 대상 API에 코드를 삽입하는 방법은 대표적으로 두 가지가 존재합니다. 컴파일 타임에 대상 클래스의 바이트코드에 코드를 삽입하는 방법과, 런타임에 대상 클래스의 Proxy를 생성하는 방법이 있습니다. 이에 대해 설명하려면 내용이 길어지므로, 자세히 알고싶으시다면 Spring AOP 공식 문서를 참고해주세요.

Demand-fill 방식의 캐시 로직도 @Transactional과 마찬가지로 다른 모듈로 분리할 수 있습니다. 그렇다면 캐시를 적용할 대상에 삽입될 코드를 어디에서 정의할 수 있을까요? Spring AOP에서는 서비스의 공통 로직들을 클래스 형태로 모듈화할 수 있도록 @Aspect Annotation을 제공합니다. @Aspect로 부여된 클래스에서 대상 API의 실행 전/후, 예외가 발생될 때 실행 되어야 할 코드를 작성 할 수 있습니다.

@Component
@Aspect
class ArcusCacheAspect {

  /* 
     @Pointcut: 공통 로직을 적용할 대상 설정. (Annotation, 
     패키지, 클래스, 메소드, 파라미터명을 사용하여 설정한다.) 
 
     Service 클래스의 메소드에 @ArcusCache 어노테이션이 
     부여되어있으며, 리턴타입이 존재하는 메소드에 공통 로직을 적용.
  */
  
  @Pointcut("@annotation(ArcusCache) 
    && execution(public !void *Service(..))")
  public void pointcut() {
  }
  
  /* 
    @Around: 대상 API의 호출 전, 호출 후의 코드를 수행할 수 있는 Annotation.

    아래 메소드는 공통 로직이 수행되는 코드이며, joinPoint는 대상 API의
    Signature(클래스,메소드, 파라미터, Annotation 정보)를 가지고 있음. 
  */
  
  @Around("pointcut()")
  public Object around(final ProceedingJoinPoint joinPoint) 
    throws Throwable {
    // 대상 API 호출 전
    System.out.println("before");
    // 대상 API 호출
    Object object = joinPoint.proceed();
    // 대상 API 호출 후
    System.out.println("after");
    // 대상 API의 데이터를 반환
    return object;
  }
}

앞에서 예로 설명드린 Demand-fill 캐싱 방식이 적용된 상품 조회 코드의 캐시 로직을 @Aspect를 이용하여 분리해보겠습니다.

@Component
@Aspect
class ArcusCacheAspect {
  @Pointcut("@annotation(ArcusCache)")
  public void pointcut() {
  }
  @Around("pointcut()")
  public Object around(final ProceedingJoinPoint joinPoint) 
    throws Throwable {
    // 대상 API의 파라미터를 통해 캐시 Key 생성.
    String cacheKey = createArcusKeyFromJoinPoint(joinPoint);
    // 대상 API의 @ArcusCache Annotation 정보를 통해서 Expire Time을 얻음.
    int expireTime = getExpireTimeFromJoinPoint(joinPoint);
    // ARCUS 캐시에 아이템을 조회하는 Get operation에 대한 비동기 요청.
    Future<Object> getFuture = client.asyncGet(cacheKey);
    try {
      // ARCUS 캐시로부터 요청 결과를 700ms 동안 기다림.
      Object object = getFuture.get(700, TimeUnit.MILLISECONDS);
      if (object != null) {
        // 캐시 히트(Hit)시, 캐싱된 데이터를 반환.
        // 대상 API의 실제 코드(joinPoint.proceed())는 수행되지 않는다. 
        return object;
      }
    } catch (Exception e) {
      // 요청 타임아웃, 캐시 서버의 중단, 에러 발생 시 Get operation 요청을 취소.
      getFuture.cancel(true);
    }
    // 캐시 미스(Miss)시, 대상 API의 코드를 수행.
    Object object = joinPoint.proceed();
    // 데이터베이스에서 조회된 Object를 ARCUS 캐시에 저장.
    client.set(cacheKey, 60, object);
    // 데이터 반환
    return object;
  }
  ...
}

이제 캐시를 적용하고 싶은 대상 API에 @ArcusCache Annotation을 부여하면, 대상 API 호출 시 ArcusCacheAspect 클래스의 around가 호출되고, Demand-fill 방식으로 캐싱이 이루어지게 됩니다.

Java 환경에서 기본 패턴의 캐시 적용을 돕는 ARCUS 공통 모듈

ARCUS 공통 모듈은 Demand-fill 방식의 캐시 로직을 수행하는 Aspect 클래스를 모듈화하여 제공합니다. 따라서 코드 수정 없이 간단한 방식으로 대상 API에 캐시를 적용할 수 있습니다. ARCUS 공통 모듈을 사용하면 기존 캐시 클라이언트에서 제공되는 API를 직접 사용하는 방식과 비교해 볼 때 다음의 장점을 지닙니다.

  • 캐시 로직을 다른 모듈로 분리되어 개발자는 핵심 비즈니스 로직에 집중할 수 있음.
  • 기존 코드를 변경하지 않아도 되므로 개발 비용을 줄일 수 있음.
  • 캐시 클라이언트 API 사용 방법에 대해 숙지하지 않아도 됨.

ARCUS 공통 모듈에서 제공되는 Demand-fill 캐싱 방식은 두 가지가 있습니다. 하나는 캐시 대상 API에 Annotation을 부여하는 방식이며, 다른 하나는 Property 파일에 캐시 대상 API 정보를 명시하는 방식입니다.

Annotation 기반의 캐싱

처음에 예로 설명드린 상품 조회 get API에서 @ArcusCache를 부여하면 간단하게 캐시 적용이 가능합니다. 상품 조회시 캐시 히트(Hit)가 발생하면, get API의 내부 코드를 수행하지 않고 캐시 저장소에서 불러온 데이터를 그대로 반환할 것입니다. 만약 캐시 서버의 장애로 캐시 아이템을 불러올 수 없는 상황이 발생한다면, get API의 내부 코드를 수행하도록 구현되어있기 때문에, 캐시 서버의 장애로 인한 서비스 동작에 전혀 영향을 주지 않습니다.

@Service
class ProductService {
  private ProductDatabase database;
  
  ...
  
  // id를 통한 상품 조회
  @ArcusCache(prefix = "PRODUCT", 
              expireTime = 60, 
              operationTimeout = 700)
  public Product get(@ArcusCacheKey long id) {
    // 캐시 히트(Hit)가 발생하면 아래의 코드는 수행되지 않음.
    return database.get(id); 
  }
  // object를 통한 상품 조회
  @ArcusCache
  public Product get(
    // product의 모든 필드를 캐시 key parameter로 사용
    @ArcusCacheKeyParameter("*") Product product) {
    // 캐시 히트(Hit)가 발생하면 아래의 코드는 수행되지 않음.
    return database.get(producet.getId()); 
  }
}

Spring Cache를 많이 사용해보셨다면 @Cacheable Annotation과 비슷하다는 것을 눈치채셨을겁니다. ARCUS Spring에서도 Spring Cache를 지원하기 위해 Cache 구현체를 제공하고 있습니다. @ArcusCache와는 달리 캐시 속성(Prefix, Expire Time, OperationTimeout)들을 설정하려면, 각 캐시 속성을 갖고 있는 Spring Cache 인스턴스를 생성하여 Spring Cache Annotation의 cacheNames 속성에 지정해야 합니다.

@Service
class ProductService {
  
  private ProductDatabase database;
  
  ...
  
  // 상품 조회
  @Cacheable(cacheNames="product_60_ttl_cache", key = "#id")
  public Product get(long id) {
    return database.get(id);
  }
}
@Configuration
class CacheConfiguration extends CachingConfigurerSupport {
  
  ...
  
  @Bean
  public Map<String, ArcusCacheConfiguration> initialCacheConfig() {
    Map<String, ArcusCacheConfiguration> initialCacheConfig 
      = new HashMap<>();
    initialCacheConfig.put(
      "product_60_ttl_cache", 
      product60TTLCache());
    return initialCacheConfig;
  }
  @Bean
  public ArcusCacheConfiguration product60TTLCache() {
    ArcusCacheConfiguration cacheConfig = 
      new ArcusCacheConfiguration();
    cacheConfig.setPrefix("PRODUCT");
    cacheConfig.setExpireSeconds(60);
    cacheConfig.setTimeoutMilliSeconds(700);
    return cacheConfig;
  }
}

Spring Cache는 캐시 추상화에 중점을 두어 설계되었으므로 벤더에서 제공되는 특수한 속성(ARCUS의 경우 Prefix)들을 설정하는 것이 번거롭습니다. 또한 결과에 영향을 주는 캐시 Key 파라미터를 개발자가 직접 설정해야합니다.

ARCUS 공통 모듈에서 제공되는 @ArcusCache Annotation은 각 대상 API에 따른 캐시 속성을 유연하게 지정할 수 있고, 캐시 Key 파라미터를 자동으로 설정하여 캐시 Key를 생성하는 기능이 포함되어 있어, 이를 고민하지 않아도 됩니다. 그럼에도 불구하고 코드 변경 없이 캐시 구현체를 바꿀 수 있기를 원한다면 ARCUS Spring을 사용하는 것이 좋은 선택지가 될 수 있습니다.

Property 파일 기반의 캐싱

Annotation 기반의 캐싱을 적용한다면, 캐시가 적용된 API 항목들을 한 눈으로 정리하여 확인하기가 어렵습니다. 또한 Annotation을 부여하기 위해서 코드 수정이 필요하여 프로젝트를 다시 빌드하고 배포해야 합니다. 이를 위해 ARCUS 공통 모듈에서는 캐시 대상 API들을 별도의 파일에 명시하여 캐시를 적용할 수 있는 방법을 제공하고 있습니다. 프로젝트에 arcusCacheItems.json 파일을 생성하고, 캐시 대상 API(패키지 + 클래스 + 메소드명)와 캐시 속성들을 json 포맷으로 작성하기만 하면 됩니다.

// arcusCacheItems.json
[
  {
    "target": "com.service.ProductService.get",
    "prefix": "PRODUCT",
    "keyParams": ["id"],
    "expireTime": 60
  },
  {
    "target": "com.service.UserService.get",
    "prefix": "USER",
    "keyParams": ["user.id"],
    "expireTime": 120
  }
]

ARCUS 공통 모듈에는 arcusCacheItems.json와 같은 캐시 대상들의 정보들을 관리하는 API를 제공합니다. 여기서 조금 더 응용해본다면, arcusCacheItems.json 파일을 사용하지 않고, 외부 저장소로부터 캐시 대상 정의 목록들을 불러온 후 ARCUS 공통 모듈에서 제공되는 캐시 대상 관리 API를 사용하여 대상들을 추가하고, 제거하는 것이 가능합니다. 그렇다면 런타임에 캐시 대상들을 변경할 수가 있어 애플리케이션을 재배포하지 않아도 되는 장점이 있습니다.

class CacheItemManager {
  private ArcusCacheItemManager arcusCacheItemManager;
  private Database database;
  ...
  public void updateArcusCacheItems() {
    arcusCacheItemManager.update(getArcusCacheItems());
  }
  public List<ArcusCacheItem> getArcusCacheItems() {
    return database.getArcusCacheItems();
  }
}

마치며

지금까지 애플리케이션에서 일반적으로 사용되는 캐시 적용 방법과 ARCUS 공통 모듈에서 제공되는 기능들을 알아보았습니다. 이미 캐시를 많이 적용해본 분들이라면 진부한 내용일 수 있겠으나, 실제로 고객사의 ARCUS 구축 프로젝트를 진행해보면 캐시를 모르는 분들이 있었고, 캐시가 무엇인지는 알고 있지만 어떻게 애플리케이션에서 적용할 지 갈피를 못잡는 분들도 있었습니다. 이외에도 다른 캐시 솔루션을 많이 접해봤지만, ARCUS 클라이언트 사용 방법을 제대로 몰라서 질문을 하시는 분들도 계셨습니다. 그런 분들을 위해서 응용에서 ARCUS를 조금 더 쉽게 사용할 수 있도록 ARCUS 공통 모듈을 만들게되었고, 앞으로 애플리케이션에서 더욱 쉽고 빠르게 ARCUS 캐시를 적용할 수 있도록 아래의 기능 추가와 최적화 작업을 진행할 예정입니다.

  • 캐시 아이템 제거, 저장 기능 추가: 데이터베이스의 변경 요청이 발생하면, 캐시 아이템을 갱신하거나 제거하기 위한 Annotation 추가 (예: Spring Cache의 @CacheEvict, @CachePut).

  • 캐시 Stampede 방지를 위한 최적화 작업: 캐시 아이템의 만료시, 데이터베이스와 캐시 서버로 요청이 몰리는 현상 해결.

  • 캐시 대상 API 변경 관리: 웹 페이지에서 캐시 대상들을 관리하고, 캐시 대상 변경시 애플리케이션의 재배포 없이 즉각 반영.

  • 캐시 아이템 동기화 기능: 데이터베이스의 데이터 변경 발생시, 데이터베이스의 데이터와 캐시 아이템과의 연관 관계를 추적하여 캐시 아이템을 동기화하는 기능.

  • Collection 지원: Key-Value 이외에 B+Tree, Map, List, Set 자료 구조의 Wrapper API 또는 Annotation 제공.

1개의 댓글

comment-user-thumbnail
2021년 7월 4일

좋은 글 감사합니다 :)

답글 달기