Home 수위키 - Caffeine Cache를 활용하여 홈 API 응답시간 개선 과정
Post
Cancel

수위키 - Caffeine Cache를 활용하여 홈 API 응답시간 개선 과정

참고자료

CaffeineCache-1

CaffeineCache-2

eTag


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 CacheProbation 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을 내리게 되는데 클라이언트는 이 상태 코드를 보고 데이터를 사용하면 된다.

This post is licensed under CC BY 4.0 by the author.

수위키 - 운영 중인 프로덕션의 테스트 환경 개선에 관하여 (+테스트 대역)

Kafka 정확히 한 번 전송/읽기 톺아보기 및 적용