[스프링] 캐시 추상화

스프링은 캐시 추상화(cache abstraction) 기능을 제공한다. 캐시 추상화를 통해 기존 코드에 미치는 영향을 최소화하고 다양한 캐싱 라이브러리 구현체(예: Gemfire, Caffeine, EhCache 등)를 일관된 방법으로 사용할 수 있도록 도와준다. 스프링 프레임워크 4.1은 JSR-107 어노테이션과 더 많은 사용자 정의 옵션을 지원하여 캐시 추상화를 크게 확장하였다. 캐싱 추상화는 다양한 어노테이션을 통해 애플리케이션에 선언적 어노테이션 기반 캐싱을 적용할 수 있도록 도와준다. @EnableCaching 어노테이션을 메인 클래스에 적용하면 스프링은 캐시 추상화와 관련된 인프라를 자동으로 구성한다. 스프링 부트의 경우 캐시 라이브러리를 추가하지 않으면 메모리 상에서 ConcurrentMap을 사용하는 단순한 캐시 공급자(provider)를 자동으로 구성한다.

스프링의 캐싱 추상화는 기본적으로 메서드에 대해 적용된다. 기본적인 동작 방식은 특정 입력 파라미터를 키로 호출한 메서드의 반환형을 값으로 캐싱 저장소에 저장함으로써 캐싱 저장소에 값이 존재하면 캐싱된 값을 반환하여 해당 메서드 호출 횟수를 줄이는 것이다. 단, 주어진 입력에 대해 여러 번 호출 시 동일한 출력 결과를 반환하는 것이 보장되는 메서드에 대해서만 동작한다. 이러한 캐싱 동작은 비용이 높은 메서드(CPU 연산 처리 또는 네트워크를 통한 데이터 조회)를 한 번만 호출하고 값을 재사용할 수 있도록 하므로 애플리케이션의 리소스 부하를 낮추는데 도움을 준다. 캐싱 기능은 메서드 호출자에게 아무런 영향을 주지 않는다.

캐시 추상화는 용어 그대로 캐시 구현이 아닌 추상화이므로 캐싱 로직을 직접 작성하지 않도록 도와주지만 데이터 저장소(data store)를 제공하지는 않으며, 캐시 데이터를 저장하기 위해서는 실제 데이터 저장소가 필요하다. 데이터를 저장하고 읽을 수 있는 캐시 백업 저장소(backing store)에 대한 캐싱 처리는 CacheCacheManager 인터페이스에 의해 구체화된다. JSR-107을 지원하지 않는 구현체인 경우 CacheCacheManager를 직접 구현해야 한다링크. 스프링 부트에서 CacheManager 또는 CacheResolver 타입의 빈이 정의되어 있지 않은 경우 지정된 순서대로 캐시 공급자를 감지하려고 시도한다링크. 캐시 백업 저장소 없이 캐싱 처리를 하는 경우 캐싱 추상화 기능은 유지할 수 있지만 캐싱 메서드가 매번 강제로 호출된다. 이러한 구성은 테스트 용도이며 프로덕션 환경에서 권장되지 않는다.

캐싱 데이터가 저장되는 곳은 동일한 입력값에 대한 동일한 출력값을 반환하는 동작을 위해 필수적으로 키-값(key-value) 저장소이어야 한다. 적절한 키에 의해 적절한 캐싱 메서드가 호출되고 메서드의 반환값이 캐싱 저장소에 저장되어야 한다. 캐싱 라이브러리 구현체는 모두 이러한 동작에 기반한다.

스프링의 캐시 추상화는 단순히 값을 저장소에 저장하고 반환할 뿐만 아니라 캐시 데이터를 갱신(refresh)하거나 하나의 데이터 또는 모든 데이터를 저장소로부터 제거(evict)(또는 퇴거)하는 기능을 제공한다. 이는 데이터가 애플리케이션 실행 중에 주기적으로 또는 특정 시점에 변경될 수 있는 경우 유용하다. 이외에도 캐싱 연산 동기화(synchronization), 조건부 캐싱 등의 기능을 제공한다.

스프링의 캐시 추상화는 캐싱 처리 기능을 추상화하지만 구현에 대한 인터페이스만 제공하는 것은 아니며 java.util.concurrent.ConcurrentMap 기반 캐시, 젬파이어(Gemfire), 카페인(Caffeine), JSR-107 호환 캐시(예: Ehcache 3.x) 구현체와 관련된 구현을 일부 제공한다. 예를 들어, JDK 기반 캐시 구현은 org.springframework.cache.concurrent 패키지에, 카페인 캐시 구현은 org.springframework.cache.caffeine 패키지에 위치한다.

스프링의 캐시 추상화를 사용하기 위해서는 다음 두 측면을 고려해야 한다.

  • 캐시 선언: 캐싱이 필요한 메소드와 해당 정책(캐싱 키, 캐싱 조건, 캐싱명 등)을 정한다.
  • 캐시 구성: 데이터를 저장하고 읽을 수 있는 실제 캐시 백업 저장소를 구성한다. 백업 저장소 구성은 Cache, CacheManager 인터페이스 구현체 구성을 포함한다.


멀티 스레드(또는 프로세스) 환경에서 캐싱 처리

스프링의 캐싱 추상화는 멀티 스레드 또는 멀티 프로세스 환경에 대한 특별한 처리를 하지 않으며, 이러한 기능은 캐시 구현체에 의해 처리된다. 이러한 환경에서는 요구사항에 맞는 적절한 캐시 구현체를 구성해야 한다. 그렇지 않으면 캐시 데이터의 일관성이 깨지는 문제가 발생하게 된다.

여러 프로세스(애플리케이션)가 동일한 캐시 데이터의 복사본을 사용하는 경우, 하나의 프로세스가 데이터를 변경하면 다른 프로세스는 이를 감지하고 데이터 변경 처리를 수행하는 전파(propagation) 메커니즘이 필요할 수 있다.

멀티 스레드 환경에서는 여러 스레드가 동일한 캐싱 메서드를 호출함으로써 동일한 데이터를 동시적으로(concurrently) 조회하거나 갱신, 제거하려는 시도를 할 수 있다. 이 경우 적절한 스레드 동기화 및 잠금(lock) 과정이 없다면 여러 스레드는 서로 다른 캐시 데이터를 사용하게 될 수 있다. 예를 들어, 하나의 스레드가 캐시 데이터를 갱신 또는 제거하는 동안 다른 스레드는 갱신 또는 제거 전의 오래된 데이터를 조회하는 경우가 발생할 수 있다. 애플리케이션의 동시성에 의해 캐시 데이터의 일관성이 깨지는 문제를 해결하기 위해서는 캐싱 처리를 수행하는 스레드 간 적절한 동기화 처리가 필요하다.


캐시 갱신 및 비우기 스케줄링

스프링의 캐시 추상화 기능이 구현체의 일부 기능을 지원하기는 하지만 제한적이다. 캐시 데이터의 주기적인 갱신 및 비우기 등의 기능은 구현체를 통해 적용해야 한다.


선언적 어노테이션 기반 캐싱 처리

캐싱 추상화와 관련된 어노테이션은 다음과 같다.

  • @Cacheable: 캐시 채우기(population)를 트리거한다.
  • @CacheEvict: 캐시 비우기(eviction)를 트리거한다.
  • @CachePut: 메서드 실행을 방해하지 않고 캐시를 업데이트한다.
  • @Caching: 메서드에 적용할 여러 캐싱 연산을 재그룹화한다.
  • @CacheConfig: 클래스 수준에서 몇 가지 일반적인 캐시 관련 설정을 공유한다.


@Cacheable 어노테이션은 캐싱 메서드 첫 호출 시 메서드 반환값을 캐시 저장소에 저장하며 이후 동일한 파라미터로 캐싱 메서드를 다시 호출하면 원본값이 아닌 캐시를 반환한다. 캐싱 메서드가 처음 호출될 때만 캐시가 저장되며 이후 호출에서는 캐시를 반환한다. 반면 @CachePut 어노테이션은 캐싱 메서드의 첫 호출 뿐만 아니라 이후 호출에 대해서도 메서드 반환값을 캐시 저장소에 저장한다. @CachePut 어노테이션은 주로 캐시 데이터를 갱신하기 위해 사용한다.


캐시 추상화의 캐싱 처리 동작 방식

스프링의 캐시 추상화는 Cache, CacheManager, CacheResolver 인터페이스 및 그 구현체를 기반으로 한다.

각각의 역할은 다음과 같다.

  • Cache: 공통된 캐싱 처리 동작을 정의하는 인터페이스이다. 캐싱 데이터를 조회, 저장, 제거하는 메서드 정의를 제공한다. Cache 구현체로는 CaffeineCache, ConcurrentMapCache, JCacheCache 등이 있다.
  • CacheManager: 캐시 관리 역할을 하는 SPI이며 Cache 구현체를 반환하는 역할을 수행한다. CacheManager 구현체는 캐싱 처리와 관련된 다양한 구성 메서드를 제공한다. CacheManager 구현체로는 ConcurrentMapCacheManager, CaffeineCacheManager, JCacheCacheManager 등이 있다.
  • CacheResolver: 캐싱 처리 과정에서 인터셉트되는 캐싱 메서드 호출 시, 캐싱 처리를 위해 사용할 Cache 구현체를 결정하는 역할을 수행한다. CacheResolver를 사용자 정의하면 메서드 별로 서로 다른 Cache 구현체를 사용하도록 함으로써 캐싱 처리 동작을 다르게 적용할 수 있다.


캐싱 처리와 관련된 동작은 Cache를 통해 수행하며, CacheManager는 캐시명을 인자로 받는 getCache() 메서드를 통해 Cache를 반환한다. CacheResolver는 생성자의 인자로 CacheManager를 받으며, 내부적으로 resolveCaches() 메서드에서 CacheManager를 통해 Cache를 전달 받은 후 Collection<Cache>에 추가하고 이를 반환한다. 별도의 구성을 하지 않을 경우 스프링은 기본적으로 구성된 CacheManagerCacheResolverSimpleCacheManagerSimpleCacheResolver를 사용하여 Cache를 구성한다.

Cache 구성을 사용자 정의할 수도 있다. CacheResolverresolveCaches() 메서드를 재정의함으로써 메서드 별로 서로 다른 CacheManager를 구성하여 메서드 별로 서로 다른 캐싱 처리를 수행할 수 있으며, 단순히 @Cacheable 어노테이션의 cacheManagercacheResolver 파라미터를 사용하여 구현체를 직접 지정할 수도 있다. 이때,cacheManagercacheResolver 파라미터는 상호 배타적이므로 두 개의 파라미터를 모두 지정할 경우 사용자 정의한 CacheManager 구현이 CacheResolver 구현에 의해 무시되므로 예외가 발생하게 된다. 이는 예상치 못한 결과일 수 있으므로 주의해야 한다.


조건부 캐싱


null 캐싱

Cache 인터페이스를 통한 캐싱 처리 구현의 일반적인 사용을 위해 캐싱 메서드가 널을 반환할 수 있도록 함으로써 널 값의 저장을 허용하는 것이 좋다. 널 값이 값이 없음을 표현하는 경우 비즈니스 로직 상 의미 있는 값이므로 캐싱 데이터로 널 값을 저장하는 것이 반드시 필요할 수 있다.

@CacheableOptional도 지원한다. 메서드 반환형인 Optional의 값이 있으면(isPresent()true) 캐시에 저장되고, 값이 없으면(isEmpty()true) 캐시에 널 값이 저장된다. 따라서 자바 언어의 경우 메서드가 널을 반환하거나 Optipnal.empty()를 반환하는 경우 캐시 저장소에 널 값이 저장된다.

캐시 저장소에 널 값을 저장하지 않도록 하기 위해서는 다음 방법 중 하나를 사용하면 된다.

  • @Cacheable 어노테이션의 unless 파라미터의 값으로 "#result == null" 지정
  • CacheManager 구현체가 제공하는 관련 메서드 사용
    • CaffeineCacheManagersetAllowNullValues()


서킷 브레이커 패턴과 캐싱


코틀린의 중단 함수와 캐싱


참고

Comments