부제: 어떻게 하면 일을 조금이라도 안할까?
회사에서 AWS를 사용하면서 EC2, RDS에 많은 요청이 오는것이 걱정되었다
인스턴스를 켜둔 시간만큼 돈도 내고, 요청에 따라서도 과금이 되는데 어떻게 하면 비용을 줄일까?
결국 캐시를 생각하게 되었다.
API를 근 2년간 여러개 만들어봤지만, 캐시를 사용해볼 기회는 없었다.
프로젝트 요구사항을 검토해봐도 딱히 cache를 적용할 영역이 없었고, 데이터가 준 실시간으로 변경되었지만
이번 프로젝트는 하루에 1번~5번 꼴로 데이터가 변경되서 캐시를 설정하면 상당히 유용하리라 판단했다.
개요
1.
스프링부트에 적용할 캐시 종류
2.
caffeine 을 선택한 이유
3.
spring boot에 cache 설정하기
4.
API에 cache 적용하기
5.
API마다 다르게 cache 적용하기
그 중에서 나름 관심있는 3가지만 언급하자면 아래와 같다
1.
EhCache 2.X
•
우리팀 다른 프로젝트에선 EhCache를 많이 사용한다. 따라서 회사에 레퍼런스도 있고, 매뉴얼도 있다. Spring boot 에서는 ehcache.xml 이라는 classpath에 넣어두면 알아서 인식할 정도로 많이 사용한다.
2.
Redis
•
spring 뿐만 아니라 django, nodeJs 등 다양한 Framework에서 많이 사용한다.
•
in-memory에 자료구조, DB처럼 사용하기도 하고, 캐시용도, message broker 용도등 다양하게 사용한다.
•
규모가 큰 서비스에서는 Redis 서버를 별도로 두기도 하고, 그 만큼 용도가 다양하다 (하지만 나는 잘 모른다)
•
이러한 특징으로 봤을때 API의 결과만 cache 해두면 되는데 over spec이 아닐까? 해서 패스했다.
3.
Caffeine
4.
사실 caffeine 이 잘쓰이는지는 모르겠다
5.
근데 설정이 좀 쉬운편이고 properties나 yml 등으로 쉽게 설정할 수 있다.
caffeine 을 선택한 이유
redis는 오버스펙이라 판단했고, EhCache는 회사에 레퍼런스도 많은데 굳이 공부해서 Caffeine 캐시를 쓴 이유는 무엇인가?
부제에 slider에 나와있지만, 어떻게하면 일을 안할까? 라고 고민하다보니…
Caffeine is a Java 8 rewrite of Guava’s cache that supersedes support for Guava. If Caffeine is present, a CaffeineCacheManager (provided by the spring-boot-starter-cache “Starter”) is auto-configured. Caches can be created on startup by setting the spring.cache.cache-names property and can be customized by one of the following (in the indicated order):
와.. 그럼 이거 써야 하는거 아니야? 설정이 되어있다잖아 ㅠㅠ 내가 코드 한 줄이라도 덜 짤 수 있다잖아?
아.. 물론 회사분들이 내 블로그를 보실 수도 있으니깐.. 조금 있어보이는 이야기를 하자면…
1.
maven repo에 있어서 (다른것도 있음)
2.
size-based eviction: 사이즈를 최초에 설정해두면 딱 그만큼만 저장해둔다. 넘어가면 evict(내보내다) 해버린다
3.
time-based eviction: 정해진 시간만큼 caching 한다.
•
read based: cache를 마지막으로 읽은 시간부터 특정 시간
•
write based: cache에 write한 시점부터 특정시간
4.
Weak reference를 통해 GC를 통해 삭제할 수 있다.
•
그래서 뭐 어쩌라는건지 모르겠지만 performance가 좋다고 wiki에 나와있다.
5.
캐시 호출에 대해 statistic를 둘 수 있다.
•
회사에 log 모니터링 툴이 있다.
•
캐시 도달률, 요청률 등을 실시간으로 모니터링 하면 너무 간지 터지지 않을까?
말이 긴게 싫다. Do not talk, show me your code!
package com.yevgnenll.artist;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@EnabledCaching
@SpringBootApplication
public class ArtistApplication {
public static void main(String[] args) {
SpringApplication.run(ArtistApplication.class, args);
}
}
Java
복사
여기서 중요한건 @EnableCaching 이다.
@SpringBootApplication 이 있는 Spring boot 설정 파일에 추가해줘야한다.
그러면 이 spring boot 는 cache 기능을 사용하겠다고 설정하는 것이다.
현재 구현한 API의 요구사항은 다음과 같다고 가정하자
1.
예술가 리스트를 제공하는 API 30분마다 갱신된다.
2.
예술가 1명에 대한 데이터를 제공하는 API는 한번 DB에 저장되면 수정되지 않는다.
이러한 경우 어떻게 API 에 캐시를 제공할 수 있을까?
@RestController
@RequestMapping(path = "/artists")
public class ArtistController {
@Autowired
private ArtistService artistService;
@Cacheable(cacheNames = "artists")
@GetMapping(path = "/")
public Page<Artist> artistList(Pageable pageable) {
return artistService.artistList(pageable);
}
@GetMapping(path = {"/{name}/", "/{name}"})
public Artist artistByName(@PathVariable String name) {
return artistService.getArtist(name);
}
}
Java
복사
@Cacheable annotaion을 사용하면 해당 method를 캐싱하는것이다.
cacheNames 라는 속성에 String 값을 입력하면, 입력 된 String 값이 key 로 되어 데이터를 캐싱한다.
현재는 별도의 캐싱을 정하지 않았기 때문에 Spring simple cache를 사용하는데 ConcurrentHashMap 으로 만들었다고 한다.
그리고, API에 parameter가 존재한다면 key + parameter 형식으로 ConcurrentHashMap에 저장된다.
이것을 위해서 다음 규칙을 정해줘야 한다.
spring.cache.cache-names=artists
Markdown
복사
application.properties 에 cache 이름을 정의한다. (스프링 2.0.2 기준에서는 정의하지 않아도 빌드가 된다.)
하지만, properties에 캐시 이름을 하나라도 정의했다면 그 이후부터 추가되는 캐시에 대해선 빌드가 되지 않을것이다.
우리가 가정한 요구사항은 2개 API 를 캐싱하는 것이니 다음과 같이 배열 형태로 정의한다.
@RestController
@RequestMapping(path = "/artists")
public class ArtistController {
@Autowired
private ArtistService artistService;
@Cacheable(cacheNames = "artists")
@GetMapping(path = "/")
public Page<Artist> artistList(Pageable pageable) {
return artistService.artistList(pageable);
}
@Cacheable(cacheNames = "artistInfo")
@GetMapping(path = {"/{name}/", "/{name}"})
public Artist artistByName(@PathVariable String name) {
return artistService.getArtist(name);
}
}
Java
복사
spring.cache.cache-names=artist,artistInfo
일단 캐싱 자체는 완료했다. 사실 이 상태 만으로도 캐싱이 되지만 우리는 Caffeine 캐시를 사용할 것이니 pom.xml 혹은 gradle에
다음과 같은 코드를 추가해준다.
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>2.6.2</version>
</dependency>
XML
복사
// https://mvnrepository.com/artifact/com.github.ben-manes.caffeine/caffeine
compile group: 'com.github.ben-manes.caffeine', name: 'caffeine', version: '2.6.2'
Groovy
복사
만약에 Spring boot를 사용중이라면, version 정보는 누락해도 좋다.
spring boot 문서에 이러한 내용이 있다. cli로 스프링부트를 추가한 경우 list에 있는 version이 자동으로 들어간다.
3.x.x 대 프로젝트를 내가 맡게되면 4.x.x로 올리는 경우도 있고, xml로 configuration이 정의되어있다면
java configuration으로 변경하거나 아예 스프링부트를 추가하기도 한다.
이러한 경우가 아니면 그냥 선택한 스프링부트를 사용하고 해당 버젼에 스프링부트에서 선택한 버젼을 사용한다.
지금은 카페인을 설명하는 포스팅이니 버젼에 대한 다양한 의견은 다음에 논의하겠다.
다시 요구사항으로 돌아오자
1.
예술가 리스트를 제공하는 API 30분마다 갱신된다. -> artists
2.
예술가 1명에 대한 데이터를 제공하는 API는 한번 DB에 저장되면 수정되지 않는다. -> artistInfo
1번 API는 30분마다 갱신된다면, 캐싱되고 5분마다 evict 하면 어떨까?
2번 API는 한번 DB에 저장되고 수정되지 않는다면, 24시간 캐싱해도 괜찮지 않을까?
만약 24시간 캐싱된다면 캐싱되는 데이터의 갯수는 몇개로 하면 좋을까?
물론 프로젝트에 insert 하는 예술가 DB의 갯수는 한정되어 있지만, 데이터 사이언티스트들이 생각하는 가정을 보면
데이터는 무한하다 라는 가정을 가지고 생각하니, 우리도 데이터가 무한하다고 고려하자
즉 내가 원하는 구현은 API마다 캐싱되는 시간과, 용량이 달라야 한다는 것이다.
@Getter
public enum CacheType {
ARTISTS("artists", 5 * 60, 10000),
ARTIST_INFO("artistInfo", 24 * 60 * 60, 10000);
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;
}
Java
복사
두 가지의 캐시에 대한 enum을 정의하였고 생성자로 각각 캐시 이름(@Cacheable에서 정의한 이름)과
expired 시간, 캐시 길이를 결정했다.
stack over flow나 여러 블로그들을 보니 enum을 사용해 캐시를 구분하는 것을 상당히 추천하길래 이렇게 사용했다.
그러면 이제 cacheManager를 Bean으로 등록해서 설정을 완료해보자
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
SimpleCacheManager cacheManager = new SimpleCacheManager();
List<CaffeineCache> caches = Arrays.stream(CacheType.values())
.map(cache -> new CaffeineCache(cache.getCacheName(), Caffeine.newBuilder().recordStats()
.expireAfterWrite(cache.getExpiredAfterWrite(), TimeUnit.SECONDS)
.maximumSize(cache.getMaximumSize())
.build()
)
)
.collect(Collectors.toList());
cacheManager.setCaches(caches);
return cacheManager;
}
}
Java
복사
지금까지의 작업은 요구사항
1.
예술가 리스트를 제공하는 API 30분마다 갱신된다. -> artists
2.
예술가 1명에 대한 데이터를 제공하는 API는 한번 DB에 저장되면 수정되지 않는다. -> artistInfo
를 만족하는 캐싱을 하였다. 하지만 여기서 개선지점이 있다. 30분 마다 갱신이 된다는 조건있는데
갱신되기 1초 전에 캐싱이 되었고 DB에 갱신이 된다면 30분동안 DB와 서빙하는 API의 데이터는 동기화되지 않을 것이다.
캐싱에 대해선 이게 피곤한 부분인거 같다. 실무에서는 사실 이것보다 더 까다로운 요구사항들이 많다.
그건 다음 포스팅에 공유하도록 한다.(다음에 하겠다 —> 안하겠다)
참고자료
예시코드 저장소