[자바/스프링/웹플럭스] 캐싱

웹플럭스에서는 스프링이 제공하는 캐싱 추상화 기능을 단순히 적용하더라도 올바르게 동작하지 않으며 그 원인은 리액티브 프로그래밍의 특성과 관련이 있다. 웹플럭스의 리액터가 제공하는 MonoFlux(이하 데이터 스트림 객체)는 데이터를 포함하고 있는 래퍼 객체이며, 데이터 스트림 객체에 데이터가 담기고 특정 연산이 수행되기 위해서는 구독(subscription)이라는 과정이 반드시 필요하다. 스프링의 캐싱 추상화 기능이 제공하는 어노테이션 중 @Cacheable은 메서드 반환 타입이 데이터 스트림 객체인 경우 해당 래퍼 객체를 캐시하기 때문에 캐싱을 의도했던 데이터에 대해서는 캐시 미스(cache miss)가 발생하게 된다. 따라서 데이터 스트림 객체의 실제 데이터를 캐싱하기 위해서는 다른 방법이 필요하다. 중요한 것은 리액티브 프로그래밍에서 사용하는 리액티브 스트림 구현체가 스프링의 캐싱 추상화 기능과 얼마나 통합되어 있는가이다.

리액티브 스트림 구현체 중 하나인 프로젝트 리액터(Project Reactor)는 데이터 스트림 객체의 캐싱을 위해 CacheMono를 애드온으로 제공하였지만, 성능 상 이점이 없고 다른 문제가 발생될 수 있다는 이유로 3.6.0 버전부터 제거되었으며, 현재 캐싱 메커니즘 관련 구현이 없는 상태이다. 대신 리액터의 데이터 스트림 객체인 MonoFluxcache() 메서드를 사용하여 자체적으로 제공하는 내장 캐싱 메커니즘을 사용할 수 있다. 이 경우 래퍼 객체 자체에 내장된 캐시를 사용하여 결과값에 대한 참조를 만들고 이를 캐싱한다. 이 방법은 원본 데이터인 캐싱 메서드의 반환값이 아닌 캐싱된 값을 다시 캐싱하는 것이므로 두 캐시에 대한 TTL 값 설정에 유의해야 한다. 이러한 이유로 MonoFlux의 내장 캐시 TTL은 @Cacheable의 TTL 보다 길어야 한다.

@Cacheable("item")
public Mono<Item> getItem(String id) {
  return repository.findById(id).cache();
}


사실 스프링의 캐싱 추상화 기능을 리액티브 스트림 구현체와 통합하여 코드를 구현하지 않는다면 데이터 스트림 객체의 실제 데이터에 대한 캐싱 구현 방법은 비교적 간단하다. 먼저 캐시 저장소로부터 데이터를 조회한 후 데이터가 있다면 해당 값으로 데이터 스트림 객체를 생성하고, 데이터가 없다면 데이터베이스로부터 조회하거나 데이터를 계산 및 생성한 후 캐시 저장소에 저장한다. 캐싱할 데이터를 생성하고 캐시 저장소에 저장하는 것을 캐시 채우기(cache population)라고 표현한다. 이때 주의할 점은 캐시 저장소에서 캐싱할 데이터를 저장하고 캐싱된 데이터를 조회하는 작업을 리액터 연산으로 수행하는 메서드는 스레드를 블로킹하지 않아야 한다는 것이다. 캐싱할 데이터를 조회하여 데이터 스트림 객체로 반환하는 작업, 캐시 저장소에 데이터 스트림 객체의 값을 캐싱할 데이터로 저장하는 작업, 캐싱된 데이터를 조회하는 작업, 세 가지 작업이 필요하며 웹플럭스에서는 이러한 모든 작업이 비동기, 논블로킹 방식으로 연결되도록 구현하는 것이 권장된다.

캐싱할 데이터를 리액티브 방식으로 조회한다고 하더라도 캐싱 라이브러리가 리액티브 지원을 하지 않으면 스레드가 블로킹되므로 성능 상 이점을 누릴 수 없다. 단, 캐싱 라이브러리가 Future 인터페이스를 지원하면 Future 구현체를 대상으로 리액터의 toFuture()fromFuture() 메서드를 사용하여 양방향으로 타입 변환이 가능하므로 리액티브 방식으로 문제 없이 캐싱 레이어 구현이 가능하다.

캐싱할 데이터를 조회하여 캐시 저장소에 저장하는 작업이 서버 애플리케이션과 데이터베이스에 오버헤드를 유발시킨다먼 캐시 스탬피드(cache stampede) 또는 캐시 미스 스톰(cache miss storm) 문제가 발생할 수 있다. 이를 막기 위해 동기적 캐시 조회 대신 비동기적 캐시 조회가 필요하다.

자바 프로그래밍에서는 Future 인터페이스를 사용하여 비동기 및 동시성 구현이 가능하므로 캐싱할 데이터 조회 -> 캐싱할 데이터 저장 -> 캐싱된 데이터 조회 과정에서 데이터 스트림 객체 -> Future 인터페이스 구현체 -> 데이터 스트림 객체 간 변환이 필요하다. Future 인터페이스 기반의 비동기적 캐시 조회를 지원하는 라이브러리를 사용하면 Future 구현체를 대상으로 리액터의 toFuture()fromFuture() 메서드를 사용함으로써 비동기 및 논블로킹 방식으로 비동기적 캐시 조회가 가능해진다. 자바 8부터 도입된, Future 구현체인 CompletableFuture는 비동기 프로그래밍을 위해 도입되었지만 항상 논블로킹 방식으로 동작하는 것은 아니며, 구현 방식에 따라 스레드를 블로킹할 수도, 그렇지 않을 수도 있다. 예를 들어, 작업 수행이 완료될 때까지 기다리는 CompletableFutureget() 메서드는 스레드를 블로킹한다.

카페인(Caffeine) 라이브러리를 사용하는 경우 비동기적으로 캐시 데이터를 생성(예: 데이터베이스 조회 및 추가 연산 처리)하고, 비동기적으로 캐시 데이터를 조회할 수 있다. 비동기 캐시 매핑 구현체인 AsyncLoadingCache를 구성할 때 buildAsync() 메서드의 인자로 AsyncCacheLoader 인터페이스 구현체를 전달한다. AsyncCacheLoaderAsyncLoadingCache를 채우기 위해 키를 기반으로 비동기적으로 값을 계산하고 조회하는 역할을 한다. 물론 동기적 조회도 가능하다.

private final AsyncLoadingCache<String, Mono<Item>> cache;


캐싱할 데이터 조회 메서드가 데이터 스트림 객체를 반환하는 경우, 반환값에 대해 block() 메서드를 호출하여 결과값을 블로킹 방식으로 조회함으로써 메인 스레드를 블로킹하는 구현은 피해야 한다. 블로킹 호출은 리액티브 프로그래밍의 원칙에 반한다. 대신 논블로킹 방식으로 데이터 스트림 객체로부터 값을 얻기 위해 subscribe() 메서드를 호출한다. 데이터 스트림 객체에 대한 구독이 먼저 수행되어야 데이터가 담기고 연산 처리가 수행되기 때문에 구독 과정을 통해 데이터 스트림 객체를 생성한다.

AsyncCacheLoaderasyncLoad() 메서드는 load() 메서드와 달리 Executor를 인자를 추가적으로 전달 받으며, Executor는 캐시 데이터의 계산을 수행하고 결과값을 CompletableFuture에 담아 반환한다. 따라서 CompletableFuture를 데이터 스트림 객체로 변환함으로써 리액티브 프로그래밍 구현 시 캐시 조회 결과값을 받아 처리할 수 있다. 작업 수행이 완료될 때까지 기다리는 CompletableFutureget() 메서드는 작업이 완료되어 결과값이 준비될 때까지 기다리는 블로킹 호출이기 때문에 이를 직접 호출하여 값을 꺼낸 후 다시 데이터 스트림 객체에 담는 것보다는 fromFuture()를 사용하여 논블로킹 방식으로 타입을 변환하는 것이 좋다. fromFuture() 메서드의 경우 데이터 스트림에 대한 구독이 취소되면 취소 시그널이 전파되어 Future 작업도 취소된다.

데이터 스트림 객체가 직렬화 가능하지 않은 경우 캐싱 기능이 정상적으로 동작하지 않기도 한다.

헤이즐캐스트(Hazelcast) 라이브러리의 get()은 블로킹


참고

Comments