Spring 캐시 추상화
by Gunju Ko
Spring 캐시 추상화
스프링은 빈의 메소드에 캐시 서비스를 적용할 수 있는 기능을 제공한다. 캐시 서비스는 AOP를 이용해 메소드 실행 과정에 투명하게 적용된다. 스프링이 제공하는 캐시 추상화 기술을 사용하면 장점은 아래와 같다.
- 빈 코드에 캐시 API를 넣지 않아도 된다.
- 캐시 구현 기술에 종속되지 않는다. 즉 환경이 바뀌거나 적용할 기술을 변경해서 캐시 서비스의 종류가 달라지더라도 코드에 영향을 주진 않는다.
캐시 특징
- 캐시의 목적은 성능 향상이다.
- 캐시는 반복적으로 동일한 결과가 돌아오는 작업에만 이용할 수 있다.
- 매번 다른 결과를 돌려줘야 하는 작업에는 캐시를 적용해봤자 성능이 오히려 떨어진다.
- 캐시에 저장해둔 내용이 바뀌는 상황을 잘 파악해야한다.
- 캐시의 내용이 유효하지 않은 시점이 되면 캐시에서 해당 내용을 제거해주는 작업이 필요하다.
- 캐시는 여러 위치에 적용 가능하다.
- DB 조회와 관련된 캐시라면 데이터 액세스 기술에 적용할 수 있음
- JPA나 하이버네이트 같은 ORM은 자체적으로 캐시 서비스 기능을 제공
- DB 내부적으로 제공하는 캐시 기능을 활용
- 웹 서버 또는 전용 캐시 장비가 제공하는 웹 캐시 기능을 이용해 특정 요청의 HTML 결과를 통째로 저장해줬다가 사용할 수도 있음
- 빈의 메소드 결과를 캐시에 저장해두는 것이 적절한 경우 스프링이 제공하는 캐시 추상화 기능 이용
애노테이션을 이용한 캐시 속성 부여
스프링의 캐시 서비스 추상화는 AOP를 이용한다.
- 어드바이스 : 스프링이 제공
- 빈과 메소드 선정 : @Cacheable 애노테이션을 사용
@Cacheable
- 캐시 서비스는 보통 메소드 단위로 지정 (클래스나 인터페이스 레벨에 캐시를 지정하는것도 가능)
- 캐시에 저장할 내용 : 메소드의 리턴값
- 캐시 키 : 메소드 파라미터
- 캐시에 오브젝트가 저장될 때 키 정보도 함께 저장된다.
- 캐시에 저장되는 정보를 구분하기 위해 캐쉬 이름을 사용
- 캐시 어드바이스는 캐시에서 파라미터와 같은 키 값으로 저장된 오브젝트가 있으면 메소드를 실행하지 않고 캐시에 저장된 오브젝트를 돌려준다.
- 없으면 메소드를 실행한 뒤에 결과 값을 캐시에 추가한다.
- AOP는 메소드의 실행 전과 후에 부가적인 작업을 추가하기도 하지만, 아예 메소드의 실행 여부를 결정할 수도 있음
- 메소드의 파라미터가 없는 경우는 캐시 서비스의 기본 키 값 생성 구현 방식에 의해 0이라는 키를 지정한다.
- 메소드의 파라미터 값이 여러 개인 경우
- 디폴트 : 모든 파라미터의 hashCode() 값을 조합해서 키로 만든다.
- @Cacheable 애노테이션의 key 엘리먼트를 이용해 키 값으로 사용할 파라미터를 지정해줄 수 있다. key 엘리먼트는 SpEL을 이용해 키 값을 지정한다. SpEL을 이용해 파라미터의 특정 프로퍼티 값을 키로 사용할 수도 있다.
// 특정 파라미터를 키로 사용
@Cacheable(value="product", key="#productNo")
Product bestProduct(String productNo, User user, Date dateTime) {
}
// 파라미터의 특정 프로퍼티 값을 키로 사용
@Cacheable(value="product", key="#condition.productNo")
Product bestProduct(SearchCondition condition) {
}
- condition 엘리먼트를 이용해 파라미터 값이 특정 조건을 만족하는 경우에만 캐시를 적용할 수도 있다. condition 엘리먼트에 SpEL 형식으로 조건을 넣어주면 된다.
// 사용자의 타입이 관리자인 경우에만 캐시를 적용 (user 파라미터의 type 프로퍼티가 ADMIN인 경우에만 캐시 적용 대상이 된다.
@Cacheable(value="user", condition="#user.type == 'ADMIN'")
public User findUser(User user) {
...
}
@CacheEvict
- 캐시는 적절한 시점에 제거돼야 한다.
- 캐시는 메소드를 실행했을 때와 동일한 결과가 보장되는 동안에만 사용돼야하고 메소드 실행 결과와 캐시 값이 달라지는 순간 제거돼야 한다.
- 캐시를 제가하는 2가지 방법
- 일정한 주기로 캐시를 제거하는 것
- 캐시에 저장한 한 값이 변경되는 상황을 알 수 있는 경우 이를 이용해 캐시를 제거할 수 있다.
- 캐시 제거에 사용될 메소드에 @CacheEvict 애노테이션을 붙이면 된다.
// bestProduct 캐시의 내용을 제거한다
@CacheEvict(value="bestProduct")
public void refreshBestProducts() {
// ...
}
- @CacheEvict는 기본적으로 메소드의 키 값에 해당하는 캐시만 제거한다.
// productNo와 같은 키값을 가진 캐시를 제거한다.
@CacheEvict(value="product", key="#product.productNo")
public void updateProduct(Product product) {
// ...
}
- 캐시에 저장된 값을 모두 제거할 필요가 있다면 allEntries 엘리먼트를 true로 지정해주면 된다.
@CachePut
- 캐시에 값을 저장하는 용도로만 사용한다.
- 메소드의 실행 결과를 캐시에 저장하지만, 저장된 캐시의 내용을 사용하진 않는다. 즉 항상 메소드를 실행한다.
- 한 번에 캐시에 많은 정보를 저장해두는 작업이나, 다른 사용자가 참고할 정보를 생성하는 용도로만 사용되는 메소드에 이용할 수 있다.
애노테이션을 이용한 캐시 기능 설정
- @Cacheable과 @CacheEvict 등을 사용하려면, @Configuration 클래스에 @EnableCaching 애노테이션을 추가해주기만 하면 된다.
캐시 매니저
스프링은 캐시 기술의 종류와 상관없이 추상화된 스프링 캐시 API를 이용할 수 있게 해주는 서비스 추상화를 제공한다.
- 캐시 추상화에서는 적용할 캐시 기술을 지원하는 캐시 매니저를 빈으로 등록한다.
- 캐시 추상화 API인 캐시 매니저는 CacheManager 인터페이스를 구현해서 만든다. 스프링은 아래와 같은 CacheManager 구현 클래스를 제공한다.
- ConcurrentMapCacheManager : CocurrentMapCache 클래스를 캐시로 사용하는 캐시 매니저다. ConcurrentMapCache는 ConcurrentHashMap을 이용해 캐시 기능을 구현한 간단한 캐시다.
- SimpleCacheManager : 기본적으로 제공하는 캐시가 없다. 따라서 프로퍼티를 이용해서 사용할 캐시를 직접 등록해줘야한다. 스프링 Cache 인터페이스를 구현해서 캐시 클래스를 직접 만드는 경우 테스트에서 사용하기에 적당하다.
- EhCacheCacheManager : EhCache를 지원하는 캐시매니저다.
- CompositeCacheManager : 하나 이상의 캐시 매니저를 사용하도록 지원해주는 혼합 캐시 매니저다. 여러 개의 캐시 기술이나 캐시 서비스를 동시에 사용해야 할 때 이용할 수 있다. cacheManagers 프로퍼티에 적용할 캐시 매니저 빈을 모두 등록해주면 된다.
- NoOpCacheManager : 캐시가 지원되지 않는 환경에서 동작할 때 캐시 관련 설정을 제거하지 않아도 에러가 나지 않게 해준다.
Caffeine Cache
Caffeine Cache는 고성능 캐시 라이브러리이다. 캐시에서 어떤 오브젝트를 제거할 지는 eviction policy에 의해 결정된다. 따라서 eviction policy는 캐시의 히트율에 직접적으로 영향을 준다. 또한 eviction policy는 캐시 라이브러리의 가장 중요한 특징 중 하나이다. Caffeine Cache는 Window TinyLfu eviction policy를 사용하며, 대부분의 경우 높은 히트율을 제공한다.
Caffeine Cache는 Size-based Eviction, Time-Based Eviction, Reference-Based Eviction을 제공한다. Caffeine Cache 사용법에 대한 자세한 내용은 Baeldung - Introduction to Caffeine 을 참고하길 바란다.
Caffeine Cache는 일정한 주기로 데이터를 Refresh하는 기능도 제공한다. Refresh는 비동기적으로 진행된다. 캐시에 있는 데이터를 Refresh하는 동안 캐시에 접근하면 캐시는 이전 데이터를 반환한다.
Caffeine#recordStats() 메소드를 통해 통계 수집 기능을 사용할 수도 있다. Cache#stats() 메소드는 CacheStats 객체를 리턴한다. CacheStats 객체를 통해 히트율, eviction count, 새로운 값을 로딩하는데 소요된 평균 시간 등을 구할 수 있다. 이러한 통계는 캐시를 튜닝하는데 큰 도움이 된다. 또한 성능이 중요한 애플리케이션은 이러한 통계를 지속적으로 모니터링해야 한다.
Removal
- eviction : Policy로 인한 제거
- invalidation : 콜러에 의한 수동 제거
- removal : eviction 또는 invalidation으로 인한 캐시 데이터 삭제
Cache의 데이터를 언제든지 명시적으로 제거할 수 있다.
// individual key
cache.invalidate(key)
// bulk keys
cache.invalidateAll(keys)
// all keys
cache.invalidateAll()
Removal Listeners
Cache<Key, Graph> graphs = Caffeine.newBuilder()
.removalListener((Key key, Graph graph, RemovalCause cause) ->
System.out.printf("Key %s was removed (%s)%n", key, cause))
.build();
캐시에 있는 데이터가 제거될 때마다 실행될 콜백 메소드를 Caffeine#removalListener 메소드를 통해 등록할 수 있다. RemovalListener 메소드는 Executor를 사용해서 비동기로 실행된다. 디폴트 Executor는 ForJoinPool#commonPool() 이다. 만약 커스텀한 Executor를 사용하고 싶다면, Caffeine#executor 메소드로 Executor를 등록해주면 된다. 만약 콜백 메소드가 동기로 실행되길 원한다면 CacheWriter를 사용해야한다.
CacheWriter
CacheWriter를 사용하면 write-through 또는 write-back 캐쉬를 구현할 수 있다.
- write-through : CacheWriter 작업이 동기로 실행이되며, writer가 성공적으로 완료된 경우에만 캐시가 업데이트 된다. 이는 캐시와 리소스가 독립적으로 업데이트 될 때 발생할 수 있는 race condition을 방지한다.
- write-back : CacheWriter 작업이 비동기로 실행되며 CacheWriter 작업의 성공 유무와 관계없이 캐시가 업데이트 된다. 이는 Write 처리량을 향상시키기 위함이다. 대신에 데이터 불일치 문제가 발생할 수 있다. (예를 들어 캐시는 업데이터가 성공했지만 외부 리소스는 업데이트에 실패한 경우) write-back의 경우 외부 리소스에 쓰기 작업을 batch로 해서 쓰기 작업을 최소화할 수 있다.
그 외에도 멀티레벨 캐시, Synchronous Listener 등을 구현할 때 CacheWriter를 사용할 수 있다.
더 자세한 내용은 공식 문서 - Writer를 참고하길 바란다.
출처 및 참고 사이트
- 토비의 스프링
- Caffeine Cache