특히, 유료 API나 호출 제한이 있는 API를 사용할 때는 더욱 효과적이다. 물론 데이터가 빠르게 변하지 않는 API에 대해서 캐시를 적용해야 큰 문제를 발생시키지 않는다는 점은 기억해두자.
내가 적용하고자 하는 API의 흐름 구조를 아래와 같이 변경하고자 한다.
전체 흐름
핵심 흐름
UseCase는 먼저 CacheService에 캐시 키로 데이터를 요청합니다.
캐시에 데이터가 있으면 바로 반환됩니다. (캐시 히트)
캐시에 데이터가 없으면 UseCase는 Adapter를 호출하여 외부 API에서 데이터를 가져옵니다. (캐시 미스)
외부 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() 메서드를 재정의하면 된다.