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) 관련 문서
📌 캐시에 대한 이해
2022.08.28 - [🌱 Spring Framework] - Cache#2 캐시에 대한 이해
📌 스프링의 캐시 추상화 (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( .. 설정 ..) 에서
- 설정을 다 생략했는데 등록된 CacheResovler가 없으면 기본 CacheManager를 사용합니다.
2. 설정을 다 생략했는데 등록된 CacheResolver가 있다면, CacheResolver가 동작합니다.
- 특정 CacheManager를 사용하고 싶다면, @Cacheable(cacheManager="...")로 명시해주면 됩니다.
- 특정 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에 직접 코드로 넣어도 되지만, 아래와 같은 설정값으로 따로 관리하는게 좋다.
// 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 // 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등을 사용하여 적절하게 처리하자
'개발공부 > Java(JPA)' 카테고리의 다른 글
Cache#2 캐시에 대한 이해 (1) | 2025.02.24 |
---|---|
[Java] Stream() 과 parallelStream() 차이점 (0) | 2025.02.18 |
[JPA] JPA save() 반복 호출로 DB 커넥션 과부하 해결 (0) | 2025.02.18 |
[JPA] for문으로 save() 할 경우 생기는 이슈 및 처리 (0) | 2025.02.18 |
[Java] Map 과 HashMap의 차이점! (0) | 2025.02.17 |