본문 바로가기

개발공부/Java(JPA)

Cache#1 스프링의 캐시 추상화(@Cacheable)

Cache#1

스프링의 캐시 추상화(@Cacheable)

!https://blog.kakaocdn.net/dn/08rWR/btrKJ6gJT9H/OexB6JlmrYBLJYcepmUv3K/img.png

스프링 프레임워크는 다른 서비스와의 통합 & 기능 추상화를 제공해준다. (RedisTemplate, RestTemplate, MailSender....)

2012년 스프링 3.1버전부터 캐시 추상화(Cache Abstraction)을 제공해주며, 이는 2014년 스프링 4.1에서 여러가지가 개선되었다.

스프링 프레임워크의 다른 서비스와의 통합(Integration) 관련 문서

Integration As a lightweight container, Spring is often considered an EJB replacement. We do believe that for many, if not most, applications and use cases, Spring, as a container, combined with its rich supporting functionality in the area of transactions, ORM and JD docs.spring.io

📌 캐시에 대한 이해

2022.08.28 - [🌱 Spring Framework] - Cache#2 캐시에 대한 이해

Cache#2 캐시에 대한 이해 📌 Cache Cache는 컴퓨터 과학에서, 데이나 값을 미리 복사해놓는 임시 저장소를 가르킨다. 한글로는 고속완충기억기..지만 아무도 그렇게 안부른다 성능향상을 위해 결과를 캐싱(저장)하여 값을 jiwondev.tistory.com


📌 스프링의 캐시 추상화 (Spring Cache Abstraction)

어노테이션 기반으로 캐시를 추상화하여 사용할 수 있다. 이는 Spring Data 의 @Transcational 과 굉장히 유사하다.

AOP 기반으로 메서드의 결과를 캐싱하여 사용할 수 있다. (* 캐싱된 경우, 메서드를 실행하지 않고 바로 결과를 반환한다.)

@Cacheable("bestSeller") // cacheName: bestSeller, cachekey : BookNo로, 메서드 결과를 캐싱한다.public Book getBestSeller(String bookNo) {   return result}

스프링의 캐시 추상화에 사용되는 객체들은 org.springframework.cache 패키지에서 찾아볼 수 있다.

org.springframework.cache.Cacheorg.springframework.cache.CacheManager

!https://blog.kakaocdn.net/dn/chKyLT/btrKHcPOXYH/5npU0pspiATkRzE7akC2n1/img.png

스프링 4.1부터는 자바 표준인 JSR-107 어노테이션도 지원합니다.


📌 스프링 프레임워크에 캐시 구현체(저장소)는 없다.

Cache, CacheManager는 캐시 사용을 추상화하는거지, 실제 캐시 저장소는 포함하지 않는다.

캐시에 대한 세부설정(TTL, 정책)이나 멀티 스레드 동기화등은 캐시 저장소 구현체가 처리해야한다. 이는 공식문서에도 강조된 내용이다.

@Cacheable 같은 어노테이션들은 스프링 빈으로 등록되어있는 CacheManager 인터페이스를 이용하여 캐시를 적용시킨다.

이를 코드로 직접 구현해도 되지만, 스프링부트에서 아래와 같은 캐시 구현체들을 CacheManager로 쉽게 만들 수 있게 제공해준다.

// 스프링부트를 이용하여 현업에서 자주 사용하는 캐시 구현체들은 대부분 쉽게 사용할 수 있다.implementation 'org.springframework.boot:spring-boot-starter-cache'// 스프링 application 내부 캐시implementation 'org.springframework.boot:spring-boot-starter-data-redis'// redisimplementation 'com.github.ben-manes.caffeine:caffeine'// caffenie

!https://blog.kakaocdn.net/dn/cwtWXi/btrKGw843pN/Hhobpr9NlnWFiY6Gc8XrM1/img.png

해당 캐시구현체는 스프링 부트에서 설정 연동이나 아예 spring-boot-starter-...를 추가적으로 제공한다

해당 캐시 구현체들은 따로 설정해주지 않아도, 설정파일(yml) 기반으로 캐시 설정이 가능하다.

!https://blog.kakaocdn.net/dn/clG2lw/btrKHVGKnQM/fFxhBTCUGSMwFUn2ZhL1kK/img.png

자세한건 공식문서를 참고하자.


📌 스프링의 캐시 추상화 적용방법 (@EnableCaching)

간단하다. @Configuration 클래스에 @EnableCaching 를 추가하고, CacheManager 인터페이스를 구현하는 빈을 등록하면 된다.

한번 더 언급하는데, 스프링의 캐시 추상화 어노테이션들은 CacheManager 를 통해 캐시를 사용한다.

CacheManager 의 설정 방법은 캐시 구현체마다 다를 수 있다. 이는 글 하단에 다시 설명하니, 일단은 가볍게 보고 넘어가자

  @Configuration  @EnableCaching  class RedisCacheConfig {    // 스프링에서 캐시 추상화는 CacheManager 객체를 통해 작동한다. 해당 빈을 등록해주자    @Bean    fun userCacheManager(objectMapper: ObjectMapper, redisProperties: RedisProperties): CacheManager {      // RedisClient 구현체를 Lettuce 로 변경 (스프링-부트-레디스의 기본 구현체 : Jedis )      val redisConnectionFactory = LettuceConnectionFactory(redisProperties.host, redisProperties.port)      // Redis 캐시설정      val redisConfiguration = RedisCacheConfiguration.defaultCacheConfig()        .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer ()))        .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer ()))        .disableCachingNullValues()        .entryTtl(Duration.ofHours(5L));      // 스프링에서 사용할 CacheManager 빈 생성 (spring-boot-starter-data-redis)      return RedisCacheManager.RedisCacheManagerBuilder        .fromConnectionFactory(redisConnectionFactory)        .cacheDefaults(redisConfiguration)        .build()    }  }

물론 CacheManager 인터페이스를 직접 구현해도 되지만, 스프링에서는 이미 아래와 같이 다앙한 구현체를 제공하고 있다.

  • ConcurrentMapCacheManager : 간단하게 ConcurrentHashMap을 사용하는 CocurrentMapCache 를 사용한다.
  • SimpleCacheManager : 프로퍼티를 이용해서 사용할 캐시를 직접 등록해주는 방법(기본적으로 제공하는 캐시가 없다) 이는 스프링 Cache 인터페이스를 구현해서 캐시 클래스를 직접 만드는 경우 테스트에서 사용하기에 적당하다.
  • CompositeCacheManager : 하나 이상의 캐시 매니저를 사용하도록 지원해주는 혼합 캐시 매니저다.여러 캐시 저장소를 동시에 사용해야 할 때 쓴다. cacheManagers 프로퍼티에 적용할 캐시 매니저 빈을 모두 등록해주면 된다.
  • NoOpCacheManager : 캐시를 사용하지 않는다. 캐시가 지원되지 않는 환경에서 동작할 때 캐시 관련 설정을 제거하지 않아도 에러가 나지 않게 해준다.

그 외 많이 사용하는 캐시저장소의 구현체가 이미 존재한다.

  • JCacheCacheManager: 자바 표준 JSR107 기반으로 동작하는 구현체를 위한 캐시매니저
  • RedisCacheManager : Redis 지원하는 캐시매니저
  • EhCacheCacheManager : EhCache 지원하는 캐시매니저
  • CaffeineCacheManager: Caffeine 지원하는 캐시매니저
  • ... 생략

📌 캐시 사용방법

스프링 캐시는 기본적으로 메서드 단위의 AOP로 구현되어 있다.

메서드에 아래 어노테이션들을 사용하여 메서드 반환값을 캐싱할 수 있다. 캐싱된 경우 메서드는 아예 실행되지 않는다는 점을 유의하자.

🧨 @Transcational과 동일하게, @Cacheable이 걸려있는 클래스 내부 메서드를 호출(Self-invocation)하면 AOP가 동작하지 않는다.

!https://blog.kakaocdn.net/dn/Ag0mI/btrKGOBKjQa/TBYLJFc24qswPwF8dAkvAk/img.png

SPEL 문법 ( 공식문서 )

조건에 따라 캐시를 다르게 동작하게 하거나, 캐시 key를 지정하고 싶다면 SPEL 문법을 사용하여 설정할 수 있다.

스프링 캐시는 기본적으로 메서드의 파라메타를 캐시 key 로 사용한다. ( #값, #객체명.변수명, #객체명.메서드()) 로 사용하면 된다.

// 특정 파라미터를 키로 사용@Cacheable(value="product", key="#productNo")Product bestProduct(String productNo, User user, Date dateTime) {}// 파라미터의 특정 프로퍼티 값을 키로 사용@Cacheable(value="product", key="#condition.productNo")Product bestProduct(SearchCondition condition) {}// 사용자의 타입이 관리자인 경우에만 캐시를 적용 (user.type 프로퍼티가 "ADMIN"인 경우에만 캐시 적용)@Cacheable(value="user", condition="#user.type == 'ADMIN'")public User findUser(User user) {	...}

만약 반환값이나 메서드의 이름등 다른 값을 사용하고 싶다면 Spring Cache전용 SPEL 문법을 사용하면 된다.

!https://blog.kakaocdn.net/dn/rzV0b/btrKHaK9oW7/7MkD3kLfTfNket7p6ahQkk/img.png

예를 들어 (메서드 반환 객체의 id 필드)를 캐시 키로 사용하고싶다면, key="#result.id" 로 작성하면 된다. 자세한건 공식문서를 참고하자

@Cacheable

(조건에 따라) 메서드 결과값을 캐시로 저장한다.

여러 개의 캐시 이름을 붙일 수 있으며, 최소 하나 이상의 cache hit이 존재한다면, 메서드를 실행하지 않고 해당 캐시 값을 바로 반환한다.

@Cacheable(cacheNames="books", key="#isbn")public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)@Cacheable(cacheNames="books", key="#isbn.rawNumber")public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)
// unless = "..SPEL.." 의 결과가 true이면 캐싱하지 않는다.@Cacheable(value="spittleCache" unless="#result.message.contains('NoCache')")Spittle findOne(long id);// condition = ".." 결과가 true 이면 캐싱한다.// 물론 unless 와 condition을 둘 다 적어도 된다.@Cacheable(value="spittleCache" unless="#result.message.contains('NoCache')" condition="#id >= 10")Spittle findOne(long id);// 참고로 코틀린처럼 ?. null-safety 문법도 제공해준다 ㅋ@Cacheable(cacheNames="book", condition="#name.length() < 32", unless="#result?.hardback")public Optional<Book> findBook(String name)

@Cacheable(value="..") 는 @Cacheable(cacheName="..") 과 같은 값이다.

캐시를 탈 조건은 condition, 캐시를 타지 않을 조건은 unless 에 적으면 된다. 필요하다면 둘 다 적어도 된다.

!https://blog.kakaocdn.net/dn/dD2EGy/btrKK9qUFCk/TgDorLG2xtFDwQGKXnz4a1/img.png

cacheable 설정 값

🧨 주의 - condition은 메서드를 호출하기 전에, unless는 메서드를 호출한 뒤에 평가한다. (공식문서)

@Cacheable(condition="..")은 메서드를 호출하기 전에 SPEL을 실행해서 true인지 확인한다.

즉 메서드의 파라메타가 아닌, 메서드의 반환값 (#result) 등의 조건은 사용할 수 없다. 캐시 조건이 정상적으로 동작하지 않는다.

반환값을 캐시 키로 사용하고 싶은 경우, unless 조건만을 사용해야한다.

꿀팁을 하나 주자면 CacheableResult 같은 전용 객체를 하나 만들어서 사용하면 cache hit 여부도 알 수 있고, SPEL 조건도 깔끔해진다.

// 내가 만든 Cache용 객체class CacheableResult<T>(  val data: T,  val isFailed: Boolean = false // 기본 값을 isFailed=false 로 설정)
/* 메서드 실행 후를 평가하고 싶다면 unless 를 사용해야한다. 이 때 이런식으로 구성하면 코드 가독성이 좋아진다. */@Cacheable(value = ["myCacheName"], unless = "#result.isFailed()")fun getData(categoryId: String): CacheableResult<List<CategoryRes>> {    return CacheableResult(    	data = data,        isFailed = true // 이렇게 캐시 여부를 명시적으로 지정할 수 있다.    )}

@CacheEvict, @CachePut

  • @CacheEvict : (조건에 따라) 캐시를 삭제한다. 참고로 Evict은 쫓아내다, 퇴거하다는 의미의 단어이다.

!https://blog.kakaocdn.net/dn/4Ivfe/btrKGpoF3gm/sMbjYWWgBivLOqeRtwUZk0/img.png

beforeInvocation, allEntries는 삭제(CacheEvict)에만 존재하는 설정이다.

// bestProduct 캐시의 내용을 제거한다@CacheEvict(value="bestProduct")public void refreshBestProducts() {	// ...}// productNo와 같은 키값을 가진 캐시를 제거한다.@CacheEvict(value="product", key="#product.productNo")public void updateProduct(Product product) {	// ...}// 그냥 통째로 삭제한다.@CacheEvict(value = "bestSeller", allEntires = true)public void clearBestSeller() {}
  • @CachePut : 메서드 실행을 방해하지 않고, 캐시를 업데이트한다. (메서드는 언제나 실행된다.)
// 특정 캐시를 삭제하고 싶다면 @CacheEvict 을 사용하자.@CacheEvict(value = ["product"], key = "#id")fun updateProduct(long id, dto: UpdateProductDto) {  val product: Product = repository.findById(id)  product.update(    name = dto.name,    price = dto.price,    owner = dto.owner  )}// 메서드 결과가 캐시가 되었음에도 메서드를 항상 실행시키고 싶다면, @CachePut을 사용하자@CachePut(cacheNames = ["exampleStore"], key = "#cacheData.value", condition = "#cacheData.value.length() > 5")fun updateCacheData(cacheData:CacheData){  return cacheData}

@CacheConfig, @Caching

  • @CacheConfig : 클래스 단위로 캐시설정을 할 때 사용한다. (클래스 내의 모든 메서드에 설정이 적용된다.)

!https://blog.kakaocdn.net/dn/sznMS/btrKHZJlogO/JJbMVkS7HU3PiwVonSxta0/img.png

  • @Caching : 여러 개의 캐싱 동작(@Cacheable, @CacheEvict, @CachePut)을 하나로 묶을 때 사용한다.
@CacheConfig(cacheNames= "books", cacheManager="redisCacheManager")public class CustomerDataService {    // 메서드에 따로 적지않아도, @CacheConfig에 적은 내용이 포함된다.    @Cacheable // == @Cacheable(cacheName="books", cacheManager="redisCacheManager")    public String getAddress(Customer customer) {...}}
@Caching(evict = {  @CacheEvict("addresses"),  @CacheEvict(value="directory", key="#customer.name") })public String getAddress(Customer customer) { /* ... */ }

💁‍♂️ @Cacheable(key="..")는 생략해도 사용이 가능하긴 합니다.

추천하는 방법은 아니지만, 특정 필드를 key로 사용하지 않고, 그냥 생략해서 사용할 수 있다.

// 깔-끔@Cacheable("books")public Book findBook(ISBN isbn) { ... }

이 경우 스프링의 SimpleKeyGenerator에 의해 아래와 같이 Cache Key를 생성한다.

SimpleKeyGenreator, SimpleKey 소스코드

더보기

  • 메서드 파라메타가 없는 경우 : SimpleKey.empty()
  • 메서드 파라메타가 1개인 경우 : 해당 파라메타 값(그대로 박음, SimpleKey로 감싸지 않는다)
  • 메서드 파라메타가 여러개인 경우 : SimpleKey(..파라메타들..) (* 모든 파라메타의 hashcode 값을 이용해 하나의 복합키로 만듦)

추천하는 방법은 아니지만 이는 KeyGenerator 인터페이스를 직접 구현하고 빈으로 등록해서 이를 변경할 수 있다.

KeyGenerator 구현 코드

더보기

  • 스프링에서 캐시 키를 비교할 때, hashcode()만 사용하고 equals()를 사용하지 않습니다.

자바의 hashcode는 32비트 메모리 주소기반으로 생성됩니다. 즉 64비트 운영체제 사용 시 아주 낮은 확률로 해시충돌이 발생가능합니다. 해당 이슈(SPR-10237) 때문에 스프링 4.0에서는 DefaultKeyGenerator 를 제거하고 SimpleKeyGenerator 로 변경하였습니다.

!https://blog.kakaocdn.net/dn/MtSR8/btrKIx6IiXW/UY01nV7jySOf7vmtSwSbTk/img.png

SimpleKey(...)에 할당된 파라메타가 여러개인 경우, SimpleKey.readObject 를 사용해서 hashcode를 모든 파라메타의 복합키로 재정의하도록 구현되어있다.

💁‍♂️ 어노테이션을 커스텀해서 사용할 수도 있습니다.

이는 원래 스프링에서 제공하는 기능이긴 합니다.

@Retention(RetentionPolicy.RUNTIME)@Target({ElementType.METHOD})@Cacheable(cacheNames="books", key="#isbn")public @interface SlowService {}
@SlowService // == @Cacheable(cacheNames="books", key="#isbn")public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)

📌 CacheManager 가 여러개 일 때 (캐시 저장소를 여러 개를 쓸 때)

@EnableCaching 으로 스프링 캐시를 활성화 시키면, CacheManager 타입의 빈을 조회하여 기본 매니저로 등록합니다.이 때 등록된 CacheManager 빈 타입이 여러 개라면 캐시 활성화 도중 에러가 발생합니다. ( NoUniqueBeanDefinitionException )

@Configuration@EnableCachingpublic class CacheConfig extends CachingConfigurerSupport {    @Override    @Bean // ex) EHCacheManager    public CacheManager cacheManager() { ... CacheManager A }    @Bean // ex) RedisCacheManager    public CacheManager bCacheManager() { ... CacheManager B }}

해결책1. @Primary

추천하는 방법은 아니지만, 뭐 스프링 컨테이너의 @Primary를 사용하면 해결되긴 합니다.

@Configuration@EnableCachingpublic class MultipleCacheManagerConfig {    @Bean    @Primary    public CacheManager cacheManager() {        CaffeineCacheManager cacheManager = new CaffeineCacheManager("customers", "orders");        cacheManager.setCaffeine(Caffeine.newBuilder()          .initialCapacity(200)          .maximumSize(500)          .weakKeys()          .recordStats());        return cacheManager;    }    @Bean    public CacheManager alternateCacheManager() {        return new ConcurrentMapCacheManager("customerOrders", "orderprice");    }}

이제 기본 캐시매니저는 @Primary 가 사용되고, 필요할 때 아래와 같이 캐시매니저 빈 이름을 명시해주면 됩니다.

@Cacheable(cacheNames = "customers") // @Primary CacheManager 사용됨public Customer getCustomerDetail(Integer customerId) {    return customerDetailRepository.getCustomerDetail(customerId);}// 다른 캐시매니저 사용됨@Cacheable(cacheNames = "customerOrders", cacheManager = "alternateCacheManager")public List<Order> getCustomerOrders(Integer customerId) {    return customerDetailRepository.getCustomerOrders(customerId);}

해결책2(추천). CachingConfigurerSupport

스프링 @Configuration에 해당 Configurer 객체를 상속받아서 구현하면 됩니다. 이는 해결책 1과 동일하게 사용 가능합니다.@Primary 가 없는데도 CacheManager 여러 개 등록시 에러가 뜨지 않는 이유 -> CacheResolver가 생성되었기 때문

더보기

!https://blog.kakaocdn.net/dn/zocqk/btrKHxMVcwu/jaAY35fYFJy0aynnwpQElk/img.png

기본으로 사용할 객체들을 지정할 수 있습니다.

@Configuration@EnableCachingpublic class MultipleCacheManagerConfig extends CachingConfigurerSupport {    // CachingConfigurerSupport.cacheManager()를 상속했음 ㅋ @Primary 생략가능    @Override    @Bean    public CacheManager cacheManager() {        CaffeineCacheManager cacheManager = new CaffeineCacheManager("customers", "orders");        cacheManager.setCaffeine(Caffeine.newBuilder()          .initialCapacity(200)          .maximumSize(500)          .weakKeys()          .recordStats());        return cacheManager;    }    @Bean    public CacheManager alternateCacheManager() {        return new ConcurrentMapCacheManager("customerOrders", "orderprice");    }}

해결책3. CompositeCacheManager 사용하기

스프링 CacheManager 구현체중에는, 아예 캐시매니저를 여러 개 등록할 수 있는 구현체도 있습니다.

@Beanpublic CacheManager cacheManager(net.sf.ehcache.CacheManager cm, javax.cache.CacheManager jcm) {  CompositeCacheManager cacheManager = new CompositeCacheManager();  List<CacheManager> managers = new ArrayList<CacheManager>();  managers.add(new JCacheCacheManager(jcm));  managers.add(new EhCacheCacheManager(cm))  managers.add(new RedisCacheManager(redisTemplate()));  cacheManager.setCacheManagers(managers); // 개별 캐시 매니저 추가  return cacheManager;}

이는 권장하는 방법은 아닙니다만, 알아두면 통합테스트 할때 유용합니다.

예를 들어 메서드에서 요청한 캐시매니저가 등록되지 않았다면 NoOpCacheManager가 실행되도록 만들 수 있습니다.

@TestConfigurationpublic class TestConfig {  @Autowired  private CacheManager jdkCache; // 예시  @Autowired  private CacheManager guavaCache; // 예시  @Bean  @Primary  public CacheManager cacheManager() {    CompositeCacheManager manager = new CompositeCacheManager(jdkCache, guavaCache);    // CompositeCacheManager에 포함된 CacheManager 외    // 모든 캐시 요청을 NoOpCache로 보냄 (여기에선 jdkCache, guavaCache 외의 모든 캐시)    // NoOpCache는 캐시가 없는 것과 동일하게 동작    manager.setFallbackToNoOpCache(true);    return manager;  }}

해결책4(추천). CacheResolver 사용하기

스프링 4.1 부터는 @Cacheable에 아무런 설정 값을 적지 않더라도 캐시매니저를 알아서 찾아줍니다.

이는 cacheManager=".." 값이 없다면 기본으로 등록된 SimpleCacheResolver가 동작하여 CacheManager를 찾아주기 때문인데, 우리는 이를 커스텀해서 사용할 수 있습니다.

예를 들어 메서드 이름에 따라 다른 캐시가 동작하도록 하고 싶다면, 아래와 같이 구현할 수 있습니다.

public class MultipleCacheResolver implements CacheResolver {    private final CacheManager simpleCacheManager;    private final CacheManager caffeineCacheManager;    private static final String ORDER_CACHE = "orders";    private static final String ORDER_PRICE_CACHE = "orderprice";    public MultipleCacheResolver(CacheManager simpleCacheManager,CacheManager caffeineCacheManager) {        this.simpleCacheManager = simpleCacheManager;        this.caffeineCacheManager=caffeineCacheManager;    }    // resolveCaches 메서드를 재정의하면 된다.    @Override    public Collection<? extends Cache> resolveCaches(CacheOperationInvocationContext<?> context) {        Collection<Cache> caches = new ArrayList<Cache>();        // 해당 코드는 context 파라메타를 이용해 해당 메서드 이름을 받아와서 비교한다.        if ("getOrderDetail".equals(context.getMethod().getName())) {            caches.add(caffeineCacheManager.getCache(ORDER_CACHE));        } else {            caches.add(simpleCacheManager.getCache(ORDER_PRICE_CACHE));        }        return caches;    }}

그리고 이를 해결책3과 동일하게 기본 CacheResolver로 등록해주면 됩니다.

CacheResolver가 등록 된 경우, @Cacheable에 설정 값이 없을 때 CacheResolver를 거쳐서 캐시 매니저를 찾게됩니다.

@Configuration@EnableCachingpublic class MultipleCacheManagerConfig extends CachingConfigurerSupport {    @Override    @Bean    public CacheManager cacheManager() {        CaffeineCacheManager cacheManager = new CaffeineCacheManager("customers", "orders");        cacheManager.setCaffeine(Caffeine.newBuilder()          .initialCapacity(200)          .maximumSize(500)          .weakKeys()          .recordStats());        return cacheManager;    }    @Bean    public CacheManager alternateCacheManager() {        return new ConcurrentMapCacheManager("customerOrders", "orderprice");    }    // 이제 CacheManager를 명시하지 않으면 해당 CacheResolver가 동작하여 찾아준다.    @Override    @Bean    public CacheResolver cacheResolver() {        return new MultipleCacheResolver(alternateCacheManager(), cacheManager());    }}

@Cacheable( .. 설정 ..) 에서

  1. 설정을 다 생략했는데 등록된 CacheResovler가 없으면 기본 CacheManager를 사용합니다.

2. 설정을 다 생략했는데 등록된 CacheResolver가 있다면, CacheResolver가 동작합니다.

  1. 특정 CacheManager를 사용하고 싶다면, @Cacheable(cacheManager="...")로 명시해주면 됩니다.
  2. 특정 CacheResolver를 사용하고 싶다면, @Cacheable(cacheResolver="...")로 명시해주면 됩니다.
@Cacheable(cacheNames = "orders", cacheResolver = "cacheResolver")public Order getOrderDetail(Integer orderId) {    return orderDetailRepository.getOrderDetail(orderId);}@Cacheable(cacheNames = "orderprice", cacheResolver = "cacheResolver")public double getOrderPrice(Integer orderId) {    return orderDetailRepository.getOrderPrice(orderId);}

여기서 문제는 @Cacheable( cacheManager="..", cacheResovler="..") 이렇게 둘 다 적었을 때 무엇이 동작할지 모른다는건데,

이 경우에는 캐시 초기화(@EnableCaching) 시점에 스프링 컨테이너가 예외를 띄워줍니다. 둘 중 하나만 적어야합니다.

친절하게 해당 어노테이션이 사용된 위치와, 이렇게 설정을 둘 다 적으면 안된다고 예외 메시지로 알려줍니다.

!https://blog.kakaocdn.net/dn/XU0EM/btrKF6JvaHe/kKC0pWBi3j7NXpavsDrRI0/img.png

스프링 빈 생성시점에 예외가 발생하며, IllegalStateException 에서 해당 어노테이션이 쓰인 메서드 위치까지 알려준다 ㅎ


📌 Redis

레디스는 Spring-Data-Redis 프로젝트가 별도로 존재해서, 스프링에서 설정을 쉽게 할 수 있다.

implementation 'org.springframework.boot:spring-boot-starter-data-redis'// 스프링5 webflux 기반에서 사용하는 경우 추가 (아직 spring-data-redis에 통합되지 않음)implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive'

Spring-Data-Redis에서는  Redis Client로 Lettuce와 Jedis를 제공해준다.

2022년 기준 설정의 편의성이나, 성능, 업데이트 모든 것이 Lettuce가 더 좋기 때문에, 이를 사용하는 것을 권장한다 (관련 글)

RedisTemplate

레디스를 사용하기 위해선 RedisTemplate 객체를 빈으로 등록해주어야한다.

각종 설정값들은 RedisTemplate에 직접 코드로 넣어도 되지만, 아래와 같은 설정값으로 따로 관리하는게 좋다.

Spring Boot Data 관련 프로퍼티 정리글

// application.ymlspring:  redis:    host: localhost    port: 6379

spring.redis.database 0 커넥션 팩토리에 사용되는 데이터베이스 인덱스

spring.redis.host localhost 레디스 서버 호스트
spring.redis.password   레디스 서버 로그인 패스워드
spring.redis.pool.max-active 8 pool에 할당될 수 있는 커넥션 최대수 (음수로 하면 무제한)
spring.redis.pool.max-idle 8 pool의 "idle" 커넥션 최대수 (음수로 하면 무제한)
spring.redis.pool.max-wait -1 pool이 바닥났을 때 예외발생 전에 커넥션 할당 차단의 최대 시간 (단위: 밀리세컨드, 음수는 무제한 차단)
spring.redis.pool.min-idle 0 풀에서 관리하는 idle 커넥션의 최소 수 대상 (양수일 때만 유효)
spring.redis.port 6379 레디스 서버 포트
spring.redis.sentinel.master   레디스 서버 이름
spring.redis.sentinel.nodes   호스트:포트 쌍 목록 (콤마로 구분)
spring.redis.timeout 0 커넥션 타임아웃 (단위: 밀리세컨드)

RedisProperties 객체를 사용하는데, 이는 별건 아니고 application.yml 설정 값을 불러오는 편의 객체이다.

// org.springframework.boot.autoconfigure.data.redis.RedisProperties@ConfigurationProperties(prefix="spring.redis")public class RedisProperties {...}// 물론 RedisProperties 객체를 사용하지 않아도 설정파일은 읽을 수 있다.@Value("${spring.redis.host}")private String host;@Value("${spring.redis.port}")private int port;

이 설정 값을 이용하여 RedisTemplate 빈을 구성해주면 된다.

@Configurationpublic class RedisConfig {    private final RedisProperties redisProperties;    @Bean // Redis와 Connection을 생성하는 객체를 정의한다. 여기에서는 Lettuce Redis Client 사용    public RedisConnectionFactory redisConnectionFactory(){        return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort());    }    @Bean // 스프링에서 제공하는 RedisTemplate을 정의한다. 위에서 정의한 ConnectionFactory를 사용한다    public RedisTemplate<String, Object> redisTemplate(){        RedisTemplate<String,Object> redisTemplate = new RedisTemplate<>();        redisTemplate.setConnectionFactory(redisConnectionFactory());        //redisTemplate.setKeySerializer(new StringRedisSerializer());    //key serializer        //redisTemplate.setValueSerializer(new StringRedisSerializer());  //value serializer        //redisTemplate.setHashKeySerializer(new StringRedisSerializer());        //redisTemplate.setHashValueSerializer(new StringRedisSerializer());        // 위의 설정들을 한번에 설정하고 싶다면 DefaultSerializer 를 등록해주면 된다        //redisTemplate.setDefaultSerializer(new StringRedisSerializer());        return redisTemplate;    }}

Redis Serializer 커스텀

참고로 Redis는 Serializer를 별도로 등록하지 않는 경우, key-value를 둘 다 Byte[] 으로 저장해버린다.

RedisTemplate의 기본값은 JdkSerializationRedisSerializer 이다.- 이는 자바의 Serializable 객체를 구현해야만 직렬화가 가능하다.

  • 기본값을 사용하는 경우 자바가 아닌 다른 언어에서는 Redis 저장소의 값을 읽을 수 없다쓸모 없는 자바의 기본 Object class의 정보까지 Redis에 저장해서 비효율적이다.

그래서 보통 아래 3가지 Serializer를 많이 사용한다.

1. GenericJackson2JsonRedisSerializer

별도의 클래스 타입을 지정해주지 않아도, 객체를 Json 으로 직렬화해준다.다만 @class 에 Class 정의 경로를 박아버려서, 데이터를 꺼내올 때 같은 디렉토리에 해당 DTO가 반드시 존재해야하는 단점이 있다.

!https://blog.kakaocdn.net/dn/LAolZ/btrKHw1zg1l/zonK6nufK9e2KDta9wOENk/img.png

com.ssg.api.item.domain.Item 경로를 박아버린다... 이런..

2. Jackson2JsonRedisSerializer

특정 클래스 타입을 명시해줘야한다. 그 대신 @class 정보를 데이터에 포함하지 않는다.

다만 클래스 타입별로 Serializer를 별도로 생성해줘야하는데, cacheDto 가지는 API들이 각자 고유한 RedisTemplate을 따로 들고있어야한다.. ㅠ

redisTemplate.setHashValueSerializer(new Jackson2JsonRedisSerializer(value.getClass()));

3. StringRedisSerializer + ObjectMapperSpringBoot-Redis에 포함된 StringRedisSerializer는 그냥 모든 key-value를 문자열로 저장한다.

그리고 값(value)중에 문자열로 변환이 불가능한 객체를 저장할 땐, 아래와 같이 ObjectMapper를 커스텀하여 사용한다.

@Configurationclass RedisConfig {  @Bean  fun redisConnectionFactory(redisProperties: RedisProperties): RedisConnectionFactory {    return LettuceConnectionFactory(redisProperties.host, redisProperties.port)  }  @Bean  fun redisObjectMapper(): ObjectMapper = ObjectMapper()    .registerModule(kotlinModule())    .registerModule(JavaTimeModule())    .activateDefaultTyping(      BasicPolymorphicTypeValidator.builder().allowIfBaseType(Any::class.java).build(),      ObjectMapper.DefaultTyping.EVERYTHING    )  @Bean  fun redisTemplate(redisConnectionFactory: RedisConnectionFactory): RedisTemplate<String, Any?> {    val redisTemplate = RedisTemplate<String, Any?>()    redisTemplate.setConnectionFactory(redisConnectionFactory)    redisTemplate.keySerializer = StringRedisSerializer()    // GenericJackson2JsonRedisSerializer에 ObjectMapper를 커스텀    redisTemplate.valueSerializer = GenericJackson2JsonRedisSerializer(redisObjectMapper())    return redisTemplate  }}

Redis 사용하기 1 - RedisTemplate 직접 사용

참고로 레디스는 단순 스트링 뿐 아니라, 여러가지 자료구조를 제공해주는데 이는 redisTemplate의 아래 메서드로 사용할 수 있다.

!https://blog.kakaocdn.net/dn/Vj3aD/btrKHdnESOz/L74ROBNxlt8GS0KArhKTq1/img.png

// 대충 이렇게 사용하면 된다.object CacheUtil {  fun setValues(    redisTemplate: RedisTemplate<String, Any?>,    prefix: String,    key: String,    data: String,    timeout: Long,    timeUnit: TimeUnit  ) {    redisTemplate.opsForValue().set("$prefix::$key", data, timeout, timeUnit)  }  fun getValuesOrNull(redisTemplate: RedisTemplate<String, Any?>, prefix: String, key: String): Any? {    return redisTemplate.opsForValue()["$prefix::$key"]  }  fun deleteValues(redisTemplate: RedisTemplate<String, Any?>, prefix: String, key: String) {    redisTemplate.delete("$prefix::$key")  }  fun getRedisTtl(redisTemplate: RedisTemplate<String, Any?>, prefix: String, key: String): Long? {    return redisTemplate.getExpire("$prefix::$key")  }  fun setRedisTtl(redisTemplate: RedisTemplate<String, Any?>, prefix: String, key: String, timeout: Long) {    redisTemplate.expire("$prefix::$key", Duration.ofSeconds(timeout))  }}

Redis 사용하기2 - RedisRepositories (비추천)

권장하는 방법은 아니지만, Redis를 마치 JPA처럼 사용할 수 있다. 참고로 내부적으로 RedisTemplate을 사용하는건 동일하다.

@Configuration@EnableRedisRepositories // 추가public class RedisConfig {    /*.. 이하 동일 .. */}
@RedisHash(value = "refreshToken")public class RefreshToken {    @Id // org.springframework.data.annotation.Id    private String userId;    private String refreshToken;    @TimeToLive    private long expiredTime;}
// Spring.Data의 CrudRepository를 상속받은 Repository 인터페이스를 정의한다.public interface RefreshTokenRedisRepository extends CrudRepository<RefreshToken, String> {}// 마치 JPA 처럼 사용할 수 있다.@Servicepublic class AdminUserRedisService {    private final RefreshTokenRedisRepository refreshTokenRedisRepository;    @Override    public RefreshToken save(RefreshToken adminUserToken) {        return refreshTokenRedisRepository.save(adminUserToken);    }    @Override    public RefreshToken findById(String userId) {        return refreshTokenRedisRepository.findById(userId).get();    }}
@SpringBootTestclass AdminUserRedisServiceTest {    @Autowired    private AdminUserRedisService adminUserRedisService;    @DisplayName("token redis 저장 success")    @Test    void tokenSave(){        //given        RefreshToken token = RefreshToken.builder()                .userId("test")                .refreshToken("test_token")                .expiredTime(60)                .build();        //when        RefreshToken refreshToken = adminUserRedisService.save(token);        //then        RefreshToken findToken = adminUserRedisService.findById(token.getUserId());        assertEquals(refreshToken.getRefreshToken(), findToken.getRefreshToken());    }}

Redis 사용하기3 - 스프링 캐시 추상화 CacheManager

RedisTemplate을 생성하는 과정은 위와 동일하다. 보통 관리하기 편하게 RedisConfig를 별도로 분리해서 지정하곤 한다.

팁을 하나 주자면, @ConditionalOnProperty 어노테이션을 이용해 yml 설정에 redis 정보가 있을때만 등록하게 할 수 있다.

스프링부트의 조건부 Configuration

@Configuration // spring.cache.type: redis 일 때 해당 설정을 등록한다, 만약 설정값이 아예 없는 경우 생성하지 않는다.@ConditionalOnProperty(name = ["spring.cache.type"], havingValue = "redis", matchIfMissing = false)class RedisConfig {  @Bean  fun redisConnectionFactory(redisProperties: RedisProperties): RedisConnectionFactory {    return LettuceConnectionFactory(redisProperties.host, redisProperties.port)  }  @Bean // 참고로 그냥 String으로 저장할거면, objectMapper를 커스텀할 필요는 없다 ㅎ  fun redisObjectMapper(): ObjectMapper = ObjectMapper()    .registerModule(kotlinModule())    .registerModule(JavaTimeModule())    .activateDefaultTyping(      BasicPolymorphicTypeValidator.builder().allowIfBaseType(Any::class.java).build(),      ObjectMapper.DefaultTyping.EVERYTHING    )  @Bean  fun redisTemplate(redisConnectionFactory: RedisConnectionFactory): RedisTemplate<String, Any?> {    val redisTemplate = RedisTemplate<String, Any?>()    redisTemplate.setConnectionFactory(redisConnectionFactory)    redisTemplate.keySerializer = StringRedisSerializer()    // GenericJackson2JsonRedisSerializer에 ObjectMapper를 커스텀    redisTemplate.valueSerializer = GenericJackson2JsonRedisSerializer(redisObjectMapper())    return redisTemplate  }}

이렇게 생성한 Redis 객체들을 이용하여 CacheManager를 등록해주면 된다.

  @Bean  // RedisCacheManager 빈  public CacheManager cacheManager(RedisTemplate redisTemplate) {      return new RedisCacheManager(redisTemplate); // Redis템플릿의 인스턴스를 생성자에 전달하여, 생성}

참고로 CacheManager만 사용할 경우, RedisTemplate를 꼭 만들 필요는 없다. 아래와 같이 빌더로 RedisTemplate 의 설정과 비슷하게 만들 수 있다. (RedisCacheConfiguration)

@Configuration@EnableCachingclass CacheConfig {    // spring.cache.type: redis 가 아니라면, 생성하지 않는다. (아예 값이 없어도 생성하지 않는다)    @ConditionalOnProperty(name = ["spring.cache.type"], havingValue = "redis", matchIfMissing = false)    @Bean    fun cacheManager(       redisConnectionFactory: RedisConnectionFactory,       objectMapper:ObjectMapper    ): CacheManager {      val redisConfiguration:RedisCacheConfiguration = RedisCacheConfiguration      // 해당 캐시매니저가 사용할 기본 설정      .defaultCacheConfig()        // 1. 캐시 유효기간(Time to live) 설정        .entryTtl(Duration.ofSeconds(3600))        // 2. 캐시 이름에 prefix:: 를 자동으로 붙여줌.        .computePrefixWith(CacheKeyPrefix.simple())        // 3. key 직렬화에 StringRedisSerializer 사용        .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()))        // 4. value 직렬화에 GenericJackson2JsonRedisSerializer(커스텀한 ObjectMapper) 사용        .serializeValuesWith(          RedisSerializationContext.SerializationPair.fromSerializer(            GenericJackson2JsonRedisSerializer(objectMapper)          )        )      // 앞에서 생성한 redisConnectionFactory 와 여기서 만든 redisConfiguration을 등록함      return RedisCacheManager.RedisCacheManagerBuilder        .fromConnectionFactory(redisConnectionFactory)        .cacheDefaults(redisConfiguration)        .build()    }}

📌 Caffeine

Caffeine은 사용법이 단순한 로컬 캐시(메모리 캐시)의 하나로, 자바의 ConcurrentMap 보다 속도가 빠른걸로 유명하다.

!https://blog.kakaocdn.net/dn/ci3TgW/btrKF1OXxd3/z2FC4V7HBPamluO7Xx5sok/img.png

참고로 EHCache도 자바에서 많이 사용하는 로컬 캐시중 하나이다. 서버 분산캐시, 비동기, 디스크저장등 기능은 많으나 성능은 카페인이 우세하다.( https://www.ehcache.org/documentation/3.1/clustered-cache.html )

레디스 설정을 봤으면 느꼈겠지만 캐시 구현체 설정이 귀찮은거지 CacheManager 설정은 별거없다 ㅎ

// 스프링 로컬 cache를 위한 설정implementation 'org.springframework.boot:spring-boot-starter-cache'// caffeineimplementation 'com.github.ben-manes.caffeine:caffeine'
@EnableCaching@Configurationpublic class CacheConfig {    @Bean // 카페인 객체를 생성한다. 물론 꼭 빈으로 등록할 필요는 없다.    public Caffeine caffeineConfig() {        return Caffeine                .newBuilder()                 .recordStats() // 캐시 통계 기록                 .expireAfterWrite(60, TimeUnit.MINUTES) // .expireAfterWrite(java.time.Duration.ofMinutes(60L))                 .maximumSize(5)    }    @Bean    public CacheManager cacheManager(Caffeine caffeine) {        CaffeineCacheManager cacheManager = new CaffeineCacheManager();        cacheManager.setCaffeine(caffeine);        return cacheManager;    }}

✔ initialCapacity: 내부 해시 테이블의 최소한의 크기를 설정합니다.

✔ maximumSize: 캐시에 포함할 수 있는 최대 엔트리 수를 지정합니다.

✔ maximumWeight: 캐시에 포함할 수 있는 엔트리의 최대 크기를 지정합니다. (* maximumSize와 함께 지정할 수 없다, 둘 중 하나만)

✔ expireAfterAccess: (캐시가 생성된 후 or 대체되거나 마지막으로 읽은 후) 특정 기간이 경과하면 캐시에서 자동으로 제거

✔ expireAfterWrite: (항목이 생성된 후 or 바뀐 후) 특정 기간이 지나면 각 항목이 캐시에서 자동으로 제거

✔ refreshAfterWrite: 캐시가 생성되거나 마지막으로 업데이트된 후 지정된 시간 간격으로 캐시를 새로고침(갱신) 합니다.

✔ weakKeys: 키를 weak reference로 지정합니다. (GC에서 회수됨)

✔ weakValues: Value를 weak reference로 지정합니다. (GC에서 회수됨)

✔ softValues: Value를 soft reference로 지정합니다. (메모리가 가득 찼을 때 GC에서 회수됨)

✔ recordStats: 캐시에 대한 Statics를 적용합니다.

참고로 recordStats()는 아래와 같은 캐시 통계 객체를 생성한다. 이는 Cache.stats() 메서드로 확인할 수 있다.

// com.github.benmanes.caffeine.cache.Cache 인터페이스Cache.stats() // CacheStats 를 반환한다.
public final class CacheStats {    // ...    private final long hitCount;    private final long missCount;    private final long loadSuccessCount;    private final long loadFailureCount;    private final long totalLoadTime;    private final long evictionCount;    private final long evictionWeight;    // ...    public double hitRate() {/* ... */}    public long evictionCount() {/* ... */}    public double averageLoadPenalty() {/* ... */}    // ...}

캐시 설정 꿀팁1 - ENUM 이용하기

매번 설정값들을 Configuration에서 숫자로 박는 건, 유지보수나 관리 측면에서 좋지 못하다.

아래와 같이 설정값들을 가지고 있는 CacheType 객체를 만들고, 이를 Enum으로 들고있자.

@Getterpublic enum CacheType {  ARTISTS("artists", 5 * 60, 10000), // cachename: artists  ARTIST_INFO("artistInfo", 24 * 60 * 60, 10000); // cachename: artistInfo  CacheType(String cacheName, int expiredAfterWrite, int maximumSize) {    this.cacheName = cacheName;    this.expiredAfterWrite = expiredAfterWrite;    this.maximumSize = maximumSize;  }  private String cacheName;  private int expiredAfterWrite;  private int maximumSize;}

캐시 설정 꿀팁2- 설정(yml) 읽어서 이용하기

한걸음 더 나아가서, 설정값들을 자바 객체가 아닌 스프링 프로퍼티로 일괄적으로 관리하여 읽어오면 더 깔끔하다.

my:  project:    cache-manager:      type: REDIS      caches:        - cacheName: accessToken          expireAfterWrite: 1800        - cacheName: loginOtp          expireAfterWrite: 300      type: CAFFEINE      caches:        - cacheName: test          expireAfterWrite: 180          maximumSize: 5        - cacheName: channels          expireAfterWrite: 180          maximumSize: 1        - cacheName: categoryContents          expireAfterWrite: 1800          maximumSize: 30        - cacheName: newContentIds          expireAfterWrite: 1800          maximumSize: 1

이후 이 설정을 쉽게 읽을 수 있게 스프링 프로퍼티 객체를 따로 만든다.

// yml 프로퍼티를 객체로 읽어오는 어노태이션@ConstructorBinding@ConfigurationProperties(prefix = "my.project.cache-manager") // 프로퍼티 값 불러오기data class MyCacheProperties(  val type: String,  val caches: List<ArBookCache>? = null) {  data class MyCache(    val cacheName: String,    val expireAfterWrite: Long,    val maximumSize: Long? = null  )}

아래와 같이 프로퍼티를 이용하여 실제 설정 값을 코드가 몰라도, 등록할 수 있게 추상화한다.

@Configuration@EnableCaching // 주의 - ConfigurationProperties를 사용할땐, Enable 어노테이션을 추가해야 생성된다.@EnableConfigurationProperties(MyCacheProperties::class)class CachingConfig() {    @Bean    fun cacheManager(myCacheProperties: MyCacheProperties): CacheManager{        val cacheProperties:List<MyCacheProperties.MyCache> = myCacheProperties.caches         ?: return NoOpCacheManager() // yml 설정값이 없는 경우, 캐시를 적용하지 않도록 만듦        if(cacheProperties.type == "CAFFEINE"){           val caches:List<CaffeineCache> = cacheProperties.map{             CaffeineCache( it.cacheName, // it == MyCacheProperties.MyCache                            Caffeine.newBuilder()                            .expireAfterWrite(it.expireAfterWrite.seconds.toJavaDuration())                            .maximumSize(property.maximumSize ?: 1)                            .build()                            )        }        /*.. 이하 CacheManager 생성부분 생략 ..*/    }}

위 코드는 예제일 뿐이고, 위에서 살짝 언급한 @CondtionalOnProperty 같은스프링부트의 조건부 빈 등록 과 함께 사용하면, yml 설정만으로 동작을 변경하도록 추상화 할 수 있다.

만들기 나름이다. 만약 캐시매니저가 여러개라면 위에서 언급한 CacheConfigurerSupport, CacheResolver등을 사용하여 적절하게 처리하자