개요
프로젝트에서 특정 기간 동안의 국가별 사용자 데이터에 대한 통계를 대시보드로 제공하려고 한다. 이 경우에 꽤 볼륨이 큰 쿼리를 사용하게 되는데, 일정 시간을 기준으로 하는 데이터이기 때문에 매번 사용자가 조회할 때마다 데이터를 전달하는 것보다 고정된 데이터를 주기적으로 업데이트만하고 이를 캐시로 제공하면 좋겠다고 생각했다.
이를 위해 Redis를 얹는 것보다, 로컬 캐싱만으로도 충분히 해결할 수 있는 볼륨이어서 Spring Cache, 그리고 로컬 캐싱에 특화된 캐싱 라이브러리인 Caffeine을 이용하고자 한다.
Spring Cache?
Spring에서 단순한 추상화(어노테이션)를 통해 캐시를 쉽게 사용할 수 있도록 지원하는 라이브러리다.
선언적인 방식(어노테이션, 무엇을 할지만 적어놓는다)을 통해 메서드의 실행 결과를 캐시에 저장하고, 같은 메서드 호출 시 캐시에 저장된 결과를 반환한다.
Caffeine?
스프링 공식 라이브러리는 아니지만, Github star수 14.1K(2023.08.14.), Java 8 이후로 Spring Boot에서도 auto-configuration을 지원할 정도의 신뢰도 높은 캐시 라이브러리다.
Caffeine이 제공하는 기능은 다음과 같다고 한다(Github피셜).
•
캐시에 항목 자동 로딩(선택적으로 비동기식)
•
빈도 및 최근도(recency)에 따라 최대치를 초과할 경우 크기 기반 제거(size-based-eviction)
•
마지막 접근 또는 마지막 쓰기 이후로 측정된 시간 기반 항목 만료
•
항목에 대한 첫 번째 요청이 발생할 때 비동기적으로 새로 고침
•
외 등등…
기존의 Spring Cache만으로는 별도의 Config을 설정하지 않는 이상 ConcurrentMap으로만 In-Memory 캐시를 관리하게 되는데, 이 때 특정 기준(대체로 쓰이고 난 후의 시간)에 따른 만료 등의 구현등이 어렵다.
하지만, Caffeine의 경우 라이브러리의 예쁜 추상화 덕분에 간편하게 위 기능들을 사용할 수 있다고 한다.
설정 구현
의존성 설정하기
dependencies {
// ...
// Cache
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation "com.github.ben-manes.caffeine:caffeine:3.1.8"
// ...
}
Java
복사
spring-boot-starter-cache가 있어야 CaffeineCache를 사용할 수 있다.
CaffeineCache를 생성할 때, Caffeine 라이브러리를 이용해서 Cache를 build, 적용한다.
어플리케이션에 캐싱 활성화하기 - @EnableCaching
Spring Cache 기능을 활성화하는 어노테이션이다.
이 어노테이션을 Spring Application의 설정 클래스에 추가함으로서 Spring의 캐싱 지원을 활성화 할 수 있다.
이를 통해, Spring에서 @Cacheable, @CachePut, @CacheEvict 등과 같은 캐싱 관련 어노테이션을 인식하고 적절하게 처리할 수 있다.
이 어노테이션이 없으면, 캐싱 어노테이션이 아무런 효과가 없다!!
한편, @EnableCaching은 단지 캐싱을 활성화 시킬 뿐이지 캐시 저장소에 대한 지정을 하지 않는다.
→ 실제 캐시 저장소를 정의하려면, 추가적인 설정이 필요한데, 일반적으로 Caffeine 또는 EhCache와 같은 캐시 프로바이더와 함께 사용된다.
@SpringBootApplication
@EnableCaching // <-- 어노테이션 추가로 캐시 기능을 활성화한다.
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}
Java
복사
캐시 관리 및 설정 - CacheManager, CacheProvider
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
SimpleCacheManager simpleCacheManager = new SimpleCacheManager();
simpleCacheManager.setCaches(/* ${Collection<? extends Cache> caches} */);
return simpleCacheManager;
}
Java
복사
•
Configuration을 통해 CacheManager에 대한 구현체(SimpleCacheManager)를 빈으로 등록하면 CacheManaging을 해준다.
•
이 때, 우리가 원하는, 생성한 Cache의 Collection을 set 해주면 사용할 수 있게 된다.
따라서, 우리가 원하는 의도대로 구성된 Cache들을 만들어야 한다.
Cache(org.springframework.cache)?
Enum을 통한 캐시 정보의 분리
현재는 한가지만 필요하지만, 캐싱해야 할 데이터들이 추가가 되는 경우에 대비하여 Enum으로 관리하면 편할 것이라고 생각했고, 대부분의 레퍼런스에서 이렇게 사용하고 있었다.
@Getter
public enum CacheType {
MAN_DU_RUT_SAM("manDuRutSam", 60 * 60 * 24 * 7, 1);
private final String cacheName;
private final int secsToExpireAfterWrite;
private final int entryMaxSize;
CacheType(String cacheName, int secsToExpireAfterWrite, int entryMaxSize) {
this.cacheName = cacheName;
this.secsToExpireAfterWrite = secsToExpireAfterWrite;
this.entryMaxSize = entryMaxSize;
}
}
Java
복사
CacheConfig
위에서 적었던 사항들(캐시 관리 및 설정)을 반영하면 다음과 같이 작성해볼 수 있다.
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
List<CaffeineCache> caches = Arrays.stream(CacheType.values())
.map(cacheType ->
new CaffeineCache(
cacheType.getCacheName(), // 주의! .name()은 enum의 이름으로 반환된다.
Caffeine.newBuilder()
.recordStats()
.expireAfterWrite(cacheType.getSecsToExpireAfterWrite(), TimeUnit.SECONDS)
.maximumSize(cacheType.getEntryMaxSize())
.build()))
.toList();
SimpleCacheManager cacheManager = new SimpleCacheManager();
cacheManager.setCaches(caches);
return cacheManager;
}
}
Java
복사
CaffeineCache는 Cache를 상속한다.
위에서 작성한 CacheType 열거형 인스턴스들을 values()로 전체 배열로 가져온 후에, 해당 인스턴스들을 순회하면서 설정해놓은 프로퍼티(name, secsToExpireAfterWrite, maximumSize)를 이용하여 List<CaffeineCache>로 매핑한다.
CacheManager는 Collection<? extends Cache>를 매개변수로 하는 set 메서드를 가지므로, 위에서 생성한 List<CaffeineCache>를 set한다. 이렇게 캐시가 세팅된 CacheManager를 bean으로 등록하면, CacheType 인스턴스가 갖는 CacheName을 이용해 추상화된 in-memory 캐싱을 사용할 수 있다.
적용 및 테스트
@Cacheable
@Cacheable이 붙은 메소드가 호출되면 Spring은 그 결과를 캐시에 저장한다.
같은 메소드 호출이 재발생하면, Spring은 메소드를 실행하지 않고 캐시에 저장된 결과를 반환한다.
@Cacheable이 갖는 멤버와 그 기능에 대해 알아보자.
@Cacheable("books")
//@Cacheable(value={"books", "isbns"})
public Book findBook(ISBN isbn) {
// expensive lookup
}
Java
복사
•
value = cacheName or cacheNames
캐시 이름을 지정하는 데 사용한다. 둘 다 같은 기능을 하는데, 배열로도 지정이 가능하다.
@Cacheable(value="books", key="#isbn")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)
Java
복사
•
key
메소드의 매개변수를 통해서도 캐시 키를 형성할 수 있다.
key 속성은 캐시에 저장될 데이터의 고유 식별자를 지정하는 데 사용되는데, 키를 지정하지 않으면 Spring은 메소드 매개변수를 기반으로 자동으로 키를 생성한다.
위 예시에서는 ISBN isbn 매개변수 별로 캐싱이 진행될 것이고, books라는 CacheName을 가진 캐시의 maximumSize까지 캐싱이 진행될 것이다.
@Cacheable(value="books", condition="#includeUsed == false")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)
Java
복사
•
condition
캐시를 저장하거나 조회하는 조건을 SpEL 표현식으로 지정할 수 있다.
우리의 케이스는 별도의 key로 식별하는 것이 아닌 모두가 동일하게 조회하는 통계 데이터이므로 key가 별도로 필요하지 않다.
테스트
@RestController
@RequestMapping("/cache")
public class CacheTestController {
@GetMapping
@Cacheable(value = "manDuRutSam")
public CacheTestDto get() {
CacheTestDto data = new CacheTestDto("name", 10);
System.out.println("데이터를 만들었삼~");
return data;
}
@AllArgsConstructor
@Getter
private class CacheTestDto {
private String name;
private int age;
}
}
Java
복사
한참 위에서 작성했던 CacheType 중 MANDURUTSAM의 cacheName은 manDuRutSam이었다. @Cacheable 어노테이션을 통해 해당 cacheName을 지정하면 캐싱이 진행되는 것을 알 수 있다.
몇번을 요청해도 문구는 한번만 출력된다.
정리
Caffeine과 Spring Cache의 추상화덕에 매우 간단하고 빠르게 캐싱을 구현할 수 있었다.
별도의 스케줄링을 구현하지 않고도 자연스럽게 캐싱을 통한 업데이트 또한 가능할 것 같다는 생각이 든다. 캐시와 관련한 통계치도 별도로 저장할 수 있는 것으로 찾아봤는데, 이후에 유용하게 사용할 수도 있을 것 같다.
자주 변하지 않는 데이터들을 WAS 자체에서 내부적으로 관리하고, DB의 부하를 줄일 수 있다는 점에서 캐싱은 안 짚고 넘어갈 수 없는 관리 기법인 것 같다.
참고자료
@sichoi님의 Diary 캐싱 관련 PR