참고자료
Caffeine Cache를 활용하여 홈 API 응답시간 개선
왜 홈 API에 캐시를 적용했는가?
사용자들의 빈번한 CS로 홈 화면의 로딩이 느리게 된다는 점을 제보받았다.
클라이언트와 서버 측 모두 개선할 여지가 있는 부분이기 때문에 이를 개선해보고자 했다.
캐싱된 데이터가 가장 최신의 데이터라는 것을 판별할 수 있는 방법은?
서버에서 캐시가 업데이트될 때마다 캐시의 버전을 업데이트하고 클라이언트는 요청할 때 버전을 함께 전달하도록 한다.
서버는 현재 버전과 클라이언트가 전달한 버전을 비교하여 최신인지 확인하여 데이터의 최신화 여부를 판별하면 될 것 같다.
왜 로컬 캐시 중 Caffeine Cache를 적용했는지?
- 캐시 전략에 우선순위를 부여 가능하다.
- 캐시 히트율이 매우 높은 알고리즘인
Window TinyLFU
를 제공한다.
Caffeine 캐싱 데이터 저장 방식
- JVM Heap 메모리 내에 저장된다.
Caffeine 캐싱 전략
Caffeine의 캐싱 전략을 살펴보자
Main Cache
가 존재하며 전체 용량의 99%를 가지고 있다. (Segmented LRU)Main Cache
의 20%를 차지하는Probation Cache
가 존재한다.- 새로운 데이터가
Probataion
영역에 저장되어야 할 때,Probation
영역의 데이터와 LRU로 비교하여Evict
를 수행한다.Probation
영역에 일정 횟수 이상 접근되면Protected Cache
로 옮겨진다.(LFU)
- 새로운 데이터가
Main Cache
의 80%를 차지하는Protected Cache
가 존재하며 이 공간의 데이터는 제거되지 않는다.Protected Cache
영역이 꽉차면 오래된 데이터는 쫓겨난다.(LRU)- 이 때 쫓겨난 데이터는
TinyLFU
알고리즘에 의해 제거되거나Main Cache
의Probation Cache
영역으로 옮겨진다.
Window Cache
가 존재하여 전체 용량의 1%를 가지고 있다.- 새로운 데이터가 쓰일 때 가장 먼저 이 공간에 쓰인다.
Window Cache
가 꽉차있다면LRU
로 비교하여 기존 데이터를 쫓아낸다. (제거되는 게 아님)- 이 쫓아내진 데이터는
Tiny LFU
알고리즘으로Probation Cache
와 비교하여Probation Cache
에 저장되거나 승격하지 못하면 제거된다.
- 이 쫓아내진 데이터는
여기서 사용되는 TinyLFU
의 정책을 살펴보기 전 우선 용어 정리는 다음과 같다.
Window Cache
,Main Cache내의 Protected Cache
로부터 제거되는 데이터는Candidate
이다.Main Cache 내의 Probation Cache
에서 제거되는 데이터는Victim
이다.
TinyLFU
의 정책은 아래와 같다.
Candidate Cache
접근 횟수 >Victim Cahce
접근 횟수 ->Victim 제거
Candidate Cache
접근 횟수 <Victim Cahce
접근 횟수 &&Candidate
접근 횟수가 5회 미만 ->Candidate 제거
- 둘 중 하나
랜덤하게 제거
정리하자면 Window TinyLFU
알고리즘은
Window Cache
에 있는 새로운 데이터가Probation
영역으로 승격시킬 지 제거할 지 판단할 때 사용하는 알고리즘이다.- 승격하지 못하면 제거된다.
Protected Cache
의 용량이 꽉 찼을 때Probation
영역에서 승격될 대상과 비교할 때 사용하는 알고리즘이다.- 여기서 기존
Protected Cache
의 데이터는 제거되거나Probation
영역으로 강등된다.
- 여기서 기존
실제 코드로 적용해보기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@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;
}
}
위와 같은 CacheManager를 Bean으로 등록해줬다.
또한 어떤 데이터를 캐싱할지를 정의하기 위해 아래 CacheType을 정의했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Getter
public enum CacheType {
LECTURE("lecture", 10 * 60, 1000);
CacheType(String cacheName, int expiredAfterWrite, int maximumSize) {
this.cacheName = cacheName;
this.expiredAfterWrite = expiredAfterWrite;
this.maximumSize = maximumSize;
}
private final String cacheName;
private final Integer expiredAfterWrite;
private final Integer maximumSize;
}
위 두 가지 요소들로 캐시를 사용하기 위한 설정을 해줬다면 실제 컨트롤러 부분에서 캐시를 적용시켜야한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Cacheable(cacheNames = "lecture") // Cache 적용
@CacheStatics // 캐시 Miss/Hit 통계를 위한 Aspect 애노테이션
@ApiLogger(option = "lecture")
@GetMapping("/all")
public ResponseEntity<LectureAndCountResponseForm> findAllLectureApi(
@RequestParam(required = false) String option,
@RequestParam(required = false) Integer page,
@RequestParam(required = false) String majorType
) {
LectureFindOption findOption = new LectureFindOption(option, page, majorType);
LectureAndCountResponseForm response = lectureService.readAllLecture(findOption);
return ResponseEntity.ok(response);
}
Local Cache - Ehcache vs Caffeine 성능 비교
위 벤치마크 결과를 본다면 압도적으로 카페인 캐시가 성능이 좋게 나온다.
성능이 좋다고 무조건 쓰는게 좋은 것은 아니므로 기존 환경을 잘 고려하여 선택하면 될 것 같다.
성능 개선 측정은 어떻게 했는지?
포스트맨으로 측정했다. 최근에 JMeter, nGrinder등 성능 측정, 모니터링 툴을 알게되어 이러한 도구들을 쓸 수도 있을 것 같다.
캐시가 효과가 있는지는 어떻게 아는지?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component
@RequiredArgsConstructor
@Slf4j
public class CacheStaticsLogger {
private final CacheManager cacheManager;
public void getCachesStats(String cacheKeys) {
CaffeineCache cache = (CaffeineCache) cacheManager.getCache(cacheKeys);
CacheStats stats = Objects.requireNonNull(cache).getNativeCache().stats();
log.info("Cache hit count: " + stats.hitCount());
log.info("Cache miss count: " + stats.missCount());
}
}
위와 같은 CacheStats 객체를 통해 캐시 히트와 캐시 미스의 대한 지표를 얻을 수 있다.
이 객체를 활용, 로그를 모니터링을 함으로써 캐시가 히트됐을 때의 응답속도를 측정하면 근거가 될 것으로 본다.
캐시의 TTL은 어떻게 지정할까?
캐시 히트율을 통계로 추출하여 캐시 TTL을 조정해가며 적절한 타협점을 찾는게 좋을 것 같다.
캐시 TTL을 짧게 가져갔을 때와 길게 가져갔을 때의 장/단점은 어떤게 있을까?
TTL을 짧게 가져간다면
- 캐싱된 데이터가 가장 최근에 업데이트된 데이터에 빠르게 반영될 수 있는 장점이 있다.
- 하지만 자주 변하지 않는 데이터라면 캐시를 교체하는 리소스가 빈번하게 발생할 수 있다는 단점이 있다.
TTL을 길게 가져간다면
- 캐시를 교체하는 주기가 길어지기 때문에 캐싱된 데이터를 변경없이 오랫동안 제공할 수 있는 장점이 있다.
- 하지만 캐시가 교체되어야할 시점에 적절하게 교체되지 못하여 변경 전의 데이터를 장기간 제공하는 상황이 발생할 수 있다는 단점이 있따.
해당 캐시가 가장 최신의 데이터임을 판별하는 방법은?
캐시에 메타데이터
를 넣으면 어떨까?
캐시 데이터에 해당 캐시의 버전을 표기할 수 있는 메타데이터를 통해 클라이언트와 버전 싱크를 맞추면 해결할 수 있을 것으로 생각한다.
클라이언트는 응답 받은 데이터의 캐시 정보에 대한 메타데이터를 확인하고 이전에 받은 데이터의 버전과 일치한다면 최신 버전으로 간주하고
- 만약 버전이 변경되었다면 새로운 캐시를 받아 반영하는 방식을 적용한다면 될 것 같다.
캐시 신선도를 활용하기 위한 eTag 활용하기
위에서 언급한 방법을 실제 코드로 적용한 바는 아래와 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
public class ETagConfig {
@Bean
public ShallowEtagHeaderFilter shallowEtagHeaderFilter() {
FilterRegistrationBean<ShallowEtagHeaderFilter> filterFilterRegistrationBean = new FilterRegistrationBean<>(
new ShallowEtagHeaderFilter()
);
filterFilterRegistrationBean.addUrlPatterns("/lecture/all");
filterFilterRegistrationBean.setName("etagFilter");
return new ShallowEtagHeaderFilter();
}
}
위와 같이 ETag를 추가하면, 응답에 ETag
라는 헤더 네임을 가진 MD5 Payload
가 생성된다.
이 정보를 바탕으로 클라이언트가 if-None-Match
라는 이전에 응답 받았던 요청 헤더에 MD5 Payload
를 넣고 요청하게되면 캐시의 변동사항이 있는지 확인할 수 있게 된다.
이전에 받은 캐시 내용을 그대로 사용해도 된다면 HTTP Status 304을 내리게 되는데 클라이언트는 이 상태 코드를 보고 데이터를 사용하면 된다.