스프링 캐시에 대해서 고민해보자

스프링 프레임워크 기반의 애플리케이션은 캐시 추상화를 제공하여 캐시 관련 모듈에 대한 의존성을 추가하고 @EnableCaching과 같은 어노테이션으로 선언적 캐시를 간단하게 적용할 수 있습니다. 스프링을 다루는 많은 개발자들이 일부 비즈니스 로직의 성능 향상을 위해서 자주 사용되겠지만 생각보다 도입이 간단한 점으로 인해 여러가지 부분들을 신경쓰지 않고 사용하고 있을 가능성이 높다고 생각합니다. 개인적인 경험을 토대로 스프링 캐시를 활용하는 경우에 어떤 부분들을 고민하면 좋은지 다루어보고자 합니다.

캐시 프로바이더와 다양한 캐시 전략을 검토해야

applicaiton.yml
spring.cache: type: caffeine caffeine.spec: maximumSize=1000,expireAfterAccess=PT5M cache-names: category, book

단일 인스턴스로 운용되는 애플리케이션에서도 레디스와 같은 외부 메모리 저장소를 의존하기도 하는데 스케일 아웃이 되지 않을 가능성이 높다면 차라리 Caffeine 과 같은 로컬 캐시 프로바이더를 선택하고 프로바이더마다 적용할 수 있는 다양한 캐시 전략을 검토하는 것이 좋습니다. Caffeine을 도입하더라도 기본 예제에서 소개되는 CaffeineSpec을 개발 시 뿐만 아니라 운영환경에서 그대로 사용할 가능성도 높습니다. 또한, 캐시마다 효율적인 전략이 다양하므로 스프링 부트 자동 설정에 의한 단일 전략보다는 캐시 별 전략을 별도로 사용하도록 직접 구성하는게 좋을 것 같습니다.

@Cacheable 키 구성을 확인해야

applicaiton.yml
logging.level.org.springframework.cache: trace

기본적으로 CacheInterceptor에 대한 로그 레벨을 TRACE로 지정해두지 않기 때문에 @Cacheable에 정의한 키에 대해서 검토하지 않고 캐시되는지 여부만 확인하여 잘못된 키로 캐시되어도 무시되고 있을 가능성이 높습니다. 또한, 주로 키를 구성할 때 #root.methodName 만 사용해버리면 키 중복 발생이 높아지고 ClassCastException이 발생할 수 있는데 다음과 같이 항상 서비스 클래스 이름도 키 구성에 포함하는 것을 추천합니다. 또한, 키 정의 시 사용해야할 파라미터 수가 많다면 나열하기 보다는 KeyGenerator 인터페이스 구현체를 만들어서 사용하는 것이 더 간단합니다.

@Cacheable(cacheNames = "[cache-name]", key = "T(String).join(':', #root.targetClass.simpleName, #root.methodName)")

캐시에 대한 키 정의 시 중복에 대한 부분은 런타임 예외로 발생하기 때문에 사전에 인지하기 어려운 부분이기 때문에 주의해야할 부분입니다.

@CacheEvict 에서 전체 삭제를 해야할까

@CacheEvict(cacheNames="[cache-name]", allEntries = true)

우리가 캐시되어진 일부 데이터가 갱신되어야할 때 @CacheEvict와 함께 allEntries 속성을 사용하여 동일한 캐시 이름을 가진 모든 데이터를 삭제하게 됩니다. 개발자 입장에서 간단하고 편리해보이지만 캐시 이름에 대해 저장되는 키 패턴이 다양해지는 경우에 특정 키 패턴으로 구성된 일부 데이터만 갱신하면 되지만 모든 데이터가 삭제되어 GC 부하 뿐만 아니라 나머지 데이터에 대한 DB 부하도 다시 발생한다는 점을 생각해보게 됩니다. @CacheEvict 에서 키 패턴에 의해 삭제하는 방법을 지원하지 않기 때문에 직접 별도로 AOP로 구현해야합니다.

자체 호출에 의한 캐시가 적용되지 않음

인텔리제이 IDE 에서 자체 호출에 대한 코드가 발생하는 경우 친절하게 @Cacheable 자기 호출(실질적으로 타깃 객체 내의 메서드가 타깃 객체 내의 다른 메서드를 호출) 입니다. 캐시 어노테이션이 런타임 시에 무시됩니다라는 문구로 경고해주고 있습니다. 이와 같이 스프링 캐시 어노테이션에 의한 선언적 캐시는 스프링 AOP를 통해 프록시로 동작하므로 자체 호출(Self Invocation)에 의해서는 적용되지 않습니다. 따라서, 자체 호출로 남지 않도록 코드 리뷰를 수행해야하고 Self-Injection 또는 자체 호출이 되지 않는 구조로 리팩토링 해야합니다.

애플리케이션 캐시 부하 모니터링

CaffeineCache cache = (CaffeineCache) cacheManager.getCache("sample");
log.info("sample - {}", cache.getNativeCache().stats());

서비스 회사의 개발 조직처럼 애플리케이션 모니터링을 지속적으로 수행하지 않는 개발자 입장에서는 선언적 캐시 적용으로 인해 발생할 수 있는 애플리케이션 부하를 검토하지 않고 자연스레 무시될 가능성이 높습니다. 애플리케이션에 다양한 기능을 추가하면서 캐시를 도입하다보면 서로 다른 캐시로 인해 애플리케이션에서 정의한 캐시 제한량을 넘어서거나 캐시가 무의미하게 저장되고 삭제되는 것이 반복될 수 있습니다. 만약, Caffiene 캐시를 도입한다면 recordStats 옵션을 사용하고 캐시에 대한 통계를 주기적으로 모니터링할 수 있는 로그를 남기는게 좋습니다.

마지막으로, 애플리케이션에서 로컬 캐시로 충분한대도 불구하고 무작정 레디스라는 외부 캐시 저장소 기술에 의존하고 있는지 다시한번 생각해보도록 합시다.