🌀
f1v3-log
  • Welcome
  • 개발
    • SecurityContext를 새로 만들어야 할까?
    • OAuth2AuthorizationRequestResolver 커스터마이징
    • 동시성 문제를 해결해보자
    • MySQL은 어떻게 ID 값을 순차적으로 넣어주는 것일까? (Feat. Auto Increment Lock)
    • 외부 API 호출에 대한 고찰
      • HTTP Clients in Spring Boot
      • I/O와 트랜잭션 분리하기
      • 처리율 제한 장치 (Rate Limiter) 도입
      • 외부 API 의존성을 줄여보자
      • 캐시 레이어를 구성해보자 (Local Cache)
    • JPA Deep Dive
      • 결제 및 정산 시스템 기능 요구사항 분석
      • 글로벌 서비스를 고려할 때, 타임존 이슈를 어떻게 처리해야 할까?
      • Spring Data JPA - ID 생성 전략과 채번은 어떻게 되는걸까?
  • 회고
    • NHN Academy 인증과정 회고
    • DND 11기 회고
  • 독서
    • Effective Java 3/E
      • Item 1. 생성자 대신 정적 팩터리 메서드를 고려하라
      • Item 2. 생성자에 매개변수가 많다면 빌더를 고려하라
      • Item 3. private 생성자나 열거 타입으로 싱글턴임을 보증하라
    • 객체지향의 사실과 오해
      • 1장. 협력하는 객체들의 공동체
      • 2장. 이상한 나라의 객체
      • 3장. 타입과 추상화
      • 4장. 역할, 책임, 협력
      • 5장. 책임과 메시지
  • Real MySQL 8.0
    • 04. 아키텍처
    • 05. 트랜잭션과 잠금
  • 생각정리
    • 기술에 매몰되지 말자.
Powered by GitBook
On this page
  • 현재 프로젝트 구조
  • 캐시 레이어 도입
  • 1. Java 기본 자료구조를 활용한 캐싱
  • 2. 로컬 캐시를 활용한 캐싱
  • 캐시 키 정규화
  • 이중 캐시 레이어 도입

Was this helpful?

  1. 개발
  2. 외부 API 호출에 대한 고찰

캐시 레이어를 구성해보자 (Local Cache)

Previous외부 API 의존성을 줄여보자NextJPA Deep Dive

Last updated 4 days ago

Was this helpful?

외부 API를 호출할 때 캐시 레이어를 도입하면 여러가지 이점이 있다.

  • 통신 비용 절감

  • 응답 시간 단축

  • API 호출 제한 회피

  • ...

특히, 유료 API나 호출 제한이 있는 API를 사용할 때는 더욱 효과적이다. 물론 데이터가 빠르게 변하지 않는 API에 대해서 캐시를 적용해야 큰 문제를 발생시키지 않는다는 점은 기억해두자.

내가 적용하고자 하는 API의 흐름 구조를 아래와 같이 변경하고자 한다.

전체 흐름

핵심 흐름

  1. UseCase는 먼저 CacheService에 캐시 키로 데이터를 요청합니다.

  2. 캐시에 데이터가 있으면 바로 반환됩니다. (캐시 히트)

  3. 캐시에 데이터가 없으면 UseCase는 Adapter를 호출하여 외부 API에서 데이터를 가져옵니다. (캐시 미스)

  4. 외부 API에서 가져온 데이터는 UseCase에 반환되고, UseCase는 이 데이터를 캐시에 저장합니다.

이와 같은 방식을 Cache-Aside Pattern 한다. 이러한 패턴을 단계별로 도입하는 방법에 대해서 하나씩 정리해보자.

현재는 가장 많이 사용되는 전략을 사용하지만, 추후에 캐시 쓰기/읽기 전략에 학습을 하고자 한다.

현재 프로젝트 구조

캐시를 도입하기 전, 전체 흐름에서 어떤 정보를 기반으로 캐시의 키가 될 것인지 선정하는 것도 중요하다.

SearchBookController

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class SearchBookController implements SearchBookApiSpec {

    private final SearchBookUseCase searchBookUseCase;

    @GetMapping("/books")
    public ApiResponse<SearchBookResponse> searchBook(
            @RequestParam String query, 
            @RequestParam(defaultValue = "1") int page) {

        return ApiResponse.success(searchBookUseCase.search(query, page));
    }
}

쿼리 파라미터로 아래의 정보를 받게 된다.

  • query: 검색어 (책 제목, 저자명)

  • page: 페이지네이션을 위한 페이지 번호

이 정보를 기반으로 캐시 키를 만들면 충분히 식별자 역할을 할 수 있을 것으로 예상된다. 예를 들어 '한강' 작가에 대한 검색어 1번 페이지 정보를 많이 요청한다면 미리 캐싱해두면 훨씬 더 빠르게 응답을 할 수 있을 것이다.

이제 스프링 프로젝트에서 캐시를 도입해보자.

캐시 레이어 도입

1. Java 기본 자료구조를 활용한 캐싱

첫 번째로 가장 간단한 방법은 Java의 자료 구조인 Map 을 사용하는 것이다. 알고리즘을 풀어본 사람이라면 얼마나 빠른 구조로 돌아가는지 알 것이다.

Map의 구현체 중 어떤 구현체를 사용할 것인지가 가장 큰 문제라고 보여진다. Application 에서 관리하는 캐시이기 때문에 적절한 캐시의 용량을 선택하는 것도 중요하다고 판단된다.

임시로 다음와 같은 캐시를 관리하는 인터페이스를 러프하게 만들어보자.

/**
 * Cache Manager interface for SearchBook.
 *
 * @author Seungjo, Jeong
 */
public interface CustomCacheManager<T> {

    void addToCache(String key, T value);

    void clear();

    T getFromCache(String key);
}

1-1. HashMap

위의 내용을 기반으로 HashMap으로 구성된 캐시를 만들어보자. 간단한 제약조건을 만들어보자.

  • 키 값은 "질의어:페이지 번호"로 구성된다.

  • Map의 크기는 10을 넘어선 안된다.

/**
 * HashMap Cache Manager.
 *
 * @author Seungjo, Jeong
 */
@Slf4j
@Component
public class HashMapCacheManager implements CustomCacheManager<SearchBookResponse> {

    private static final int MAX_CACHE_SIZE = 10;

    private final Map<String, SearchBookResponse> cache = new HashMap<>();

    @Override
    public void addToCache(String key, SearchBookResponse value) {
        if (cache.size() >= MAX_CACHE_SIZE) {

            // 캐시 저장 공간이 부족하므로 랜덤 키를 제거
            log.info("CacheManager - Cache size exceeded. Removing a random entry.");
            String randomKey = cache.keySet().iterator().next();
            cache.remove(randomKey);
            log.info("CacheManager - Removed key: {}", randomKey);
        }

        log.info("CacheManager - addToCache() : key = {}, value = {}", key, value);
        cache.put(key, value);
    }

    @Override
    public void clear() {
        cache.clear();
    }

    @Override
    public SearchBookResponse getFromCache(String key) {
        return cache.get(key);
    }
}

@Service
@RequiredArgsConstructor
public class SearchBookUseCase {

    private final BookSearchAdapter bookSearchAdapter;
    private final CustomCacheManager<SearchBookResponse> cacheManager;

    public SearchBookResponse search(String query, int page) {

        // 1. 캐시에서 검색 결과를 확인한다.
        String cacheKey = query + ":" + page;
        SearchBookResponse cachedResponse = cacheManager.getFromCache(cacheKey);
        if (cachedResponse != null) {
            // 2. 검색 결과가 있을 경우 캐시된 결과를 반환한다.
            return cachedResponse;
        }

        // 3. 캐시된 결과가 없을 경우 외부 API를 호출하여 검색 결과를 가져온다.
        SearchBookResponse response = SearchBookResponse.from(bookSearchAdapter.search(query, page));

        // 4. 가져온 검색 결과를 캐시에 저장한다.
        cacheManager.addToCache(cacheKey, response);
        return response;
    }
}

위와 같이 정말 간단하게 캐시를 구현해봤다. 하지만 이 구조에서의 문제점은 어디있을까?

  • 순서 보장을 할 수 없다. HashMap의 경우 데이터 삽입, 접근, 삭제 순서를 전혀 보장하지 않는다.

  • 랜덤으로 데이터가 교체된다. 캐시가 가득 찼을 경우 임의의 값을 제거하는 방식으로 예측할 수 없다.

이러한 문제에 대해서 고민하며 운영체제 시간에 공부를 했던 LRU 알고리즘을 지원하는 LinkedHashMap 이라는 자료 구조를 찾았다. (자바를 쓰면서 처음 사용해본다. 역시 아직 모르는게 한참 많다..)

1-2. LinkedHashMap

HashMap을 사용하여 캐시를 구현했을 때 문제점이 '순서 보장'이라는 것이었다. LinkedHashMap의 이름처럼 HashMap에서 값이 입력된 순서를 기억하는 것을 추가하기 위해 만들어진 클래스이다.

Hash table and linked list implementation of the Map interface, with predictable iteration order. 예측 가능한 반복 순서를 갖춘 Map 인터페이스의 Hash table과 linked list 구현체입니다.

또한 공식문서에서 LRU 캐시에 적합하다고 명시를 해두었다! 오라클에서 이렇게 말할 정도라면 충분히 효과적이고 강력한 캐시를 구현할 수 있다고 보인다.

간단하게 알아보는 LinkedHashMap

여기서 accessorder 옵션을 통해 가장 오래전에 접근된 엔트리부터 가장 최근에 접근된 엔트리 순서로 순회가 되도록 변경할 수 있다. 이러한 구조는 LRU(Least Recently Used) 캐시에 최적화된 구조인 것이다.

initialCapacity: 해시 테이블의 버킷 수 (default: 16)

loadFactor: 해시 맵 내부적으로 버킷 크기 조정(재해싱) 시점을 결정하는 값 (default: 0.75)

또한, put, get , putIfAbsent , getOrDefault , compute , computeIfAbsent , merge 등의 메서드를 호출하면 해당 엔트리가 접근(access)된 것으로 간주하여 가장 최근 엔트리로 이동하게 되며 이러한 구조가 가능한 것이다.

별도의 LRU 정책을 수정하고 싶다면, removeEldestEntry() 메서드를 재정의하면 된다.

이 정보들을 기반으로 LinkedHashMap 캐시 매니저를 만들어보자.

/**
 * LinkedHashMap Cache Manager.
 *
 * @author Seungjo, Jeong
 */
@Slf4j
@Component
public class LinkedHashMapCacheManager implements CustomCacheManager<SearchBookResponse> {

    private static final int MAX_CACHE_SIZE = 10;
    private final Map<String, SearchBookResponse> cache = new LinkedHashMap<>(
            MAX_CACHE_SIZE,
            0.75f,
            true
    ) {
        @Override
        protected boolean removeEldestEntry(Map.Entry<String, SearchBookResponse> eldest) {
            boolean shouldRemove = size() > MAX_CACHE_SIZE;
            if (shouldRemove) {
                log.info("CacheManager - Cache size exceeded. Removing eldest entry: key={}, value={}",
                        eldest.getKey(), eldest.getValue());
            }
            return shouldRemove;
        }
    };

    @Override
    public void addToCache(String key, SearchBookResponse value) {
        log.info("CacheManager - addToCache() : key = {}, value = {}", key, value);
        cache.put(key, value);
    }

    @Override
    public void clear() {
        cache.clear();
    }

    @Override
    public SearchBookResponse getFromCache(String key) {
        return cache.get(key);
    }
}

테스트를 위해 MAX_CACHE_SIZE를 3으로 줄이고, 정말 LRU 알고리즘을 적용해서 지우는지 테스트를 해봤다.

성능을 비교해보자.

캐시 미스의 경우 외부 API를 호출해서 통신을 주고 받는 시간이 걸리지만, 캐시 히트의 경우 JVM 메모리에서 가져오기 때문에 이렇게 큰 차이가 난다.

  • Cache Miss: 146ms (API Call 기준, POSTMAN 사용)

  • Cache Hit: 8ms (API Call 기준, POSTMAN 사용)

많이 호출되는 데이터에 대해서 미리 적재를 해두고 대비를 하는 구조로 약 18배 빠른 응답과 외부 API를 호출하지 않는 구조로 개선했다. 이러한 방식으로 Java 언어에서 제공해주는 자료구조를 통해 적절한 캐시를 적용해봤다.

장점

  • 간단한 LRU(Least Recently Used) 캐시 구현

    • accessOrder 옵션과 removeEldestEntry 메서드 오버라이딩을 통해 캐시 구현

  • 예측 가능한 순서

    • HashMap의 문제점이었던 데이터 접근/삽입 순서를 항상 보장

    • 어떤 데이터가 가장 오래된 데이터인지 파악 가능

단점

  • 메모리 사용량 증가

    • JVM 내부에 데이터를 적재하는만큼, 메모리 부담이 커질 수 있음.

    • 대용량 데이터를 적재해야 할 경우 부적합

  • 멀티 스레드 환경에서 thread-safe 하지 않다는 문제점 존재

  • TTL 정책 미지원

    • 시간 기반 만료는 별도로 구현이 필요함.

정말 간단하게 단순 자바의 자료구조만을 통해 캐시를 적용했다. 하지만 실제 운영 환경에서 위와 같이 사용할 경우 정말 효과적인 캐시라고 할 수 있을지 의문이 남는다.

  • 계속해서 사용하지 않는 데이터에 대해 남기는 것이 맞을까? (TTL, 메모리 관리 측면)

  • thread-safe 하지 않다면, 어떻게 보장할 수 있을까?

    • ConcurrentHashMap 같은 구조는 없나?

로컬 캐시를 사용해보면서 내부적으로 어떻게 처리하는지, 어떤 자료구조를 사용한건지 파악해보자.

2. 로컬 캐시를 활용한 캐싱

다양한 로컬 캐시가 존재하지만, 이번에는 Caffeine Cache를 도입해보고자 한다. Spring Boot Starter에 포함이 되어있기도하고, 어노테이션을 기반으로 쉽게 적용할 수 있다.

Caffeine 캐시를 만든 개발자분이 ConcurrentHashMap + LinkedHashMap 조합을 이미 라이브러리를 만드셨었다.

그렇다면, Thread-safe 한 구조로 Local Cache를 이용할 수 있는 것으로 보여진다.

build.gradle

implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'com.github.ben-manes.caffeine:caffeine:3.2.0'

CacheConfig

/**
 * Cache configuration class.
 *
 * @author Seungjo, Jeong
 */
@Slf4j
@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager(Caffeine caffeine) {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager("searchBook");
        cacheManager.setCaffeine(caffeine);

        return cacheManager;
    }


    /**
     * 데이터 캐시 (카카오 API 응답 캐시)
     */
    @Bean
    public Caffeine<Object, Object> caffeineConfig() {
        return Caffeine.newBuilder()
                       .recordStats()
                       .initialCapacity(10)
                       .maximumSize(100)
                       .expireAfterWrite(10, TimeUnit.MINUTES);
    }
}

  • CacheManager 를 통해 Spring에서 캐시 관련 어노테이션(@Cacheable, @CachePut , @CacheEvict 등)이 동작할 때 어떤 전략을 결정하는 역할을 한다.

  • Caffeine은 실제 캐시 동작을 담당하는 캐시 객체로, 캐시에 관련된 세부 설정을 지정하게 된다.

    • 초기 캐시 사이즈(버킷, 내부 Map): 10

    • 최대 캐시 사이즈: 100

    • TTL: 10분

이제 이 정보를 기반으로 캐시를 적용해보자. Spring에서 제공해주는 캐시를 통해서는 @Cacheable 을 사용하면 된다.

카페인 캐시는 기본적으로 Window TinyLFU라는 방식을 통해 가장 오래된 엔트리를 제거하는 eviction policy가 적용되어있다.

@Service
public class SearchBookUseCase {

    private final BookSearchAdapter bookSearchAdapter;

    public SearchBookUseCase(
            BookSearchAdapter bookSearchAdapter) {
        this.bookSearchAdapter = bookSearchAdapter;
    }

    @Cacheable(
            cacheNames = "searchBook",
            key = "#query + '_' + #page",
            unless = "#result.books == null || #result.books.isEmpty()"
    )
    public SearchBookResponse search(String query, int page) {

        return SearchBookResponse.from(bookSearchAdapter.search(query, page));
    }
}

위와 같이 어노테이션을 통해 캐시를 적용한 모습이다. 구현이 정말 간단하다! 하지만, 이 구조에서의 문제점이 있다.

  1. 의미 없는 캐시 히트가 발생할 수 있다.

  2. 모든 검색어에 대해서 캐시를 적용하는게 올바를까?

예를 들어, '쇼펜하우어의 인생 수업' 이라는 책을 검색하려고 하는 사람이 있다고 가정해보자. 과연 사람들이 띄어쓰기를 잘 적용할까?

  • 쇼펜하우어의인생수업

  • 쇼펜하우어의 인생수업

  • 쇼펜하우어의 인생 수업

  • ...

이런식으로 모두 각각 다른 질의어로 봐야하는지 의문이 들었다. 또한, '뷁', '쉙' 'ㅁㄴㅇㄹ' 등 의미없는 질의어에 대해 어떻게 처리해야 올바를까? 이러한 의문은 캐시 히트율을 올리는 것에 큰 기반이 된다고 생각한다.

이러한 이유로 질의어 정규화를 진행하고자 한다. 또한, 빈도 기반의 이중 캐시를 도입하면 더 정제된 캐시를 적용할 수 있을 것 같다. 먼저 질의어 정규화를 진행해보자.

캐시 키 정규화

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class SearchBookController implements SearchBookApiSpec {

    private static final Pattern WHITE_SPACE = Pattern.compile("\\s+");
    private static final Pattern NON_ALPHANUMERIC = Pattern.compile("[^가-힣a-z0-9]");

    private final SearchBookUseCase searchBookUseCase;

    @GetMapping("/books")
    public ApiResponse<SearchBookResponse> searchBook(
            @RequestParam String query,
            @RequestParam(defaultValue = "1") int page) {

        String normalizeQuery = normalizeQuery(query);
        return ApiResponse.success(searchBookUseCase.search(normalizeQuery, page));
    }
    
    /**
     * 정규화된 검색어
     *
     * <li>1. 공백 제거</li>
     * <li>2. 알파벳 소문자 변환</li>
     * <li>3. 특수 문자 제거</li>
     *
     * @param query 정규화할 검색어
     * @return 정규화된 검색어
     */
    private String normalizeQuery(String query) {

        String normalizedQuery = WHITE_SPACE.matcher(query).replaceAll("");
        normalizedQuery = normalizedQuery.toLowerCase();
        normalizedQuery = NON_ALPHANUMERIC.matcher(normalizedQuery).replaceAll("");

        log.info("정규화된 검색어 : {}", normalizedQuery);

        return normalizedQuery;
    }
}
  • /api/books?query=쇼펜하우어의 인생 수업?!@#$

    • 이런식으로 검색을 해도 정규화된 검색어 : 쇼펜하우어의인생수업 로 정규화 된다.

  • /api/books?query=HaRRy PottEr !!!@ and the Goblet of Fire

    • 이런 식으로 검색을 해도 정규화된 검색어 : harrypotterandthegobletoffire 로 정규화가 된다.

이러한 방식을 통해서 적절한 캐시 키를 만들어낼 수 있었다. 하지만, 여전히 우려되는 문제가 있다.

  • 검색이 된다고 무조건 캐시 메모리에 올리는 것이 맞을까?

  • 별도의 횟수 이상이 도달했을 때 메모리에 올리는 것이 좋지 않을까?

이러한 이유와 캐시 히트 측면에서 높은 히트율을 보여줄 수 없다고 생각했기에 앞에서 언급했던 이중 캐시 레이어를 도입해보고자 한다.

위와 같이 도입하고, 간단하게 테스트를 진행해봤을 때 캐시 히트율이 50%도 안나왔다..

이중 캐시 레이어 도입

간단하게 설명하자면 아래와 같이 도입하려고 한다.

  1. 1차 캐시: 각 검색어에 대한 카운트 값만 저장

  2. 2차 캐시: 실제 카카오 API 응답 데이터를 들고 있게 한다.

이런 방식으로 적절한 임계치를 설정하고, 임계치에 도달한 캐시에 대해서만 2차 캐시에 실제 응답을 저장하는 구조로 고도화 시키는게 좋다고 판단했다. 메모리에 모든 데이터를 올리는 것은 부담스러울 뿐더러, 적절한 캐시 사용이라고 보여지지 않았기 때문이다.

이런식으로 도입을 하기 위해서 Spring Cache를 사용하지 않고, 직접 구현하는 방식으로 구현해보자.

캐시의 디테일한 설정들은 실제 배포 환경에서 테스트하는 것이 좋아보인다.

따라서 캐시 미스 및 히트율을 로그로 남겨서 적절한 수치를 찾는 것이 필요하다.

캐시 설정

/**
 * Cache configuration class.
 *
 * @author Seungjo, Jeong
 */
@Slf4j
@Configuration
@EnableCaching
public class CacheConfig {

    private static final int COUNT_CACHE_EXPIRATION_MINUTES = 60;
    private static final int DATA_CACHE_EXPIRATION_MINUTES = 60;

    /**
     * 1차 캐시: 쿼리 카운트 캐시
     */
    @Bean(name = "countCache")
    public Cache<String, Integer> queryCountCache() {
        return Caffeine.newBuilder()
                .recordStats()
                .initialCapacity(10)
                .maximumSize(100)
                .expireAfterWrite(COUNT_CACHE_EXPIRATION_MINUTES, TimeUnit.MINUTES)
                .build();
    }

    /**
     * 2차 캐시: 실제 데이터 캐시 (카카오 API 응답 캐시)
     */
    @Bean(name = "dataCache")
    public Cache<String, SearchBookResponse> caffeineConfig() {
        return Caffeine.newBuilder()
                .recordStats()
                .initialCapacity(10)
                .maximumSize(30)
                .evictionListener(
                        (key, value, cause) ->
                                log.info("[Cache eviction] key {} was evicted ({}): {}", key, cause, value))
                .expireAfterWrite(DATA_CACHE_EXPIRATION_MINUTES, TimeUnit.MINUTES)
                .build();
    }
}
  • 1차 캐시

    • 검색어별 카운트만 저장하므로 메모리 부담이 적게 설정

    • 60분간 사용되지 않거나 최대 100개를 초과하면 자동으로 만료/제거

  • 2차 캐시

    • 실제 API 응답 데이터를 저장하므로 메모리 부담이 더 큼

    • 60분간 사용되지 않거나 최대 30개를 초과하면 자동으로 만료/제거

    • 데이터가 제거될 때 로그로 추적 가능

Caffeine Cache Manager

/**
 * CaffeineCacheManager class.
 *
 * @author Seungjo, Jeong
 */
@Component
@RequiredArgsConstructor
public class CaffeineCacheManager implements CustomCacheManager<SearchBookResponse> {

    private final Cache<String, Integer> countCache;
    private final Cache<String, SearchBookResponse> dataCache;

    private static final int THRESHOLD = 10;

    @Override
    public void addToCache(String key, SearchBookResponse value) {

        Integer count = countCache.asMap().compute(key, (k, v) -> v == null ? 1 : v + 1);

        if (count >= THRESHOLD) {
            dataCache.put(key, value);
        }
    }

    @Override
    public void clear() {
        countCache.invalidateAll();
        dataCache.invalidateAll();
    }

    @Override
    public Optional<SearchBookResponse> getFromCache(String key) {

        Integer count = countCache.getIfPresent(key);
        if (count == null || count < THRESHOLD) {
            return Optional.empty();
        }

        return Optional.ofNullable(dataCache.getIfPresent(key));
    }
}

여기서 임계치(Threshold)는 운영 환경에서 추후에 수정하고자 한다.

앞에서 자바의 자료구조를 사용했을 때 처럼 별도의 캐시 레이어를 통해 Service Layer에서 사용하고자 한다.

@Slf4j
@Service
public class SearchBookUseCase {

    private final BookSearchAdapter bookSearchAdapter;
    private final CustomCacheManager<SearchBookResponse> cacheManager;

    public SearchBookUseCase(
            BookSearchAdapter bookSearchAdapter,
            @Qualifier("caffeineCacheManager") CustomCacheManager<SearchBookResponse> cacheManager) {
        this.bookSearchAdapter = bookSearchAdapter;
        this.cacheManager = cacheManager;
    }

    public SearchBookResponse search(String query, int page) {

        String cacheKey = query + ":" + page;

        return cacheManager
                .getFromCache(cacheKey)
                .map(
                        response -> {
                            log.debug("[Cache hit] key: {}", cacheKey);
                            return response;
                        })
                .orElseGet(
                        () -> {
                            log.debug("[Cache miss] key: {}", cacheKey);
                            SearchBookResponse response =
                                    SearchBookResponse.from(bookSearchAdapter.search(query, page));

                            if (isCacheable(response)) {
                                cacheManager.addToCache(cacheKey, response);
                                log.debug("[Cache add] key: {}", cacheKey);
                            }
                            return response;
                        });
    }

    private boolean isCacheable(SearchBookResponse response) {
        return response.books() != null && !response.books().isEmpty();
    }
}

위와 같은 구조를 통해 캐시 히트율을 높일 수 있었다. 기존에는 50%도 안되었는데, 도입하고 나서는 거의 90% 이상으로 향상될 것으로 예측이 되며, 운영 환경에서 실제로 봐야하겠지만 높은 히트율을 보일 것으로 기대된다.

실제 배포를 적용하고 히트율이 얼마나 되는지 확인해봐야 한다.

위에서 카페인 캐시를 적용한 방식 그대로 Redis로만 변경하면 글로벌 캐시를 사용할 수 있을 것으로 보인다. 운영 환경 자체가 단일 서버이기도 하고, JVM에서 메모리를 어떻게 관리해야하는지 직접 눈으로 파악하고 글로벌 캐시를 적용해보자 한다.

이렇게 적용한 내용을 기반으로 모니터링해보고 결과를 기록해보자

좀 더 깊게 알고싶다면 을 참고해보자.

네이버 페이 블로그 글
LogoLinkedHashMap (Java Platform SE 8 )
LogoGitHub - ben-manes/caffeine: A high performance caching library for JavaGitHub
캐시 용량 초과
Oracle - LinkedHashMap
지워지는 대상이 되는 eldest 확인
eldest가 지워지고 새로운 값이 캐시에 적재된 모습
캐시 전략 흐름도