캐시 레이어를 구성해보자
Last updated
Was this helpful?
Last updated
Was this helpful?
외부 API를 호출할 때 캐시 레이어를 도입하면 여러가지 이점이 있다.
통신 비용 절감
응답 시간 단축
API 호출 제한 회피
...
특히, 유료 API나 호출 제한이 있는 API를 사용할 때는 더욱 효과적이다. 물론 데이터가 빠르게 변하지 않는 API에 대해서 캐시를 적용해야 큰 문제를 발생시키지 않는다는 점은 기억해두자.
내가 적용하고자 하는 API의 흐름 구조를 아래와 같이 변경하고자 한다.
UseCase는 먼저 CacheService에 캐시 키로 데이터를 요청합니다.
캐시에 데이터가 있으면 바로 반환됩니다. (캐시 히트)
캐시에 데이터가 없으면 UseCase는 Adapter를 호출하여 외부 API에서 데이터를 가져옵니다. (캐시 미스)
외부 API에서 가져온 데이터는 UseCase에 반환되고, UseCase는 이 데이터를 캐시에 저장합니다.
이와 같은 방식을 Cache-Aside Pattern 한다. 이러한 패턴을 단계별로 도입하는 방법에 대해서 하나씩 정리해보자.
현재는 가장 많이 사용되는 전략을 사용하지만, 추후에 캐시 쓰기/읽기 전략에 학습을 하고자 한다.
캐시를 도입하기 전, 전체 흐름에서 어떤 정보를 기반으로 캐시의 키가 될 것인지 선정하는 것도 중요하다.
SearchBookController
쿼리 파라미터로 아래의 정보를 받게 된다.
query: 검색어 (책 제목, 저자명)
page: 페이지네이션을 위한 페이지 번호
이 정보를 기반으로 캐시 키를 만들면 충분히 식별자 역할을 할 수 있을 것으로 예상된다. 예를 들어 '한강' 작가에 대한 검색어 1번 페이지 정보를 많이 요청한다면 미리 캐싱해두면 훨씬 더 빠르게 응답을 할 수 있을 것이다.
이제 스프링 프로젝트에서 캐시를 도입해보자.
첫 번째로 가장 간단한 방법은 Java의 자료 구조인 Map
을 사용하는 것이다. 알고리즘을 풀어본 사람이라면 얼마나 빠른 구조로 돌아가는지 알 것이다.
Map의 구현체 중 어떤 구현체를 사용할 것인지가 가장 큰 문제라고 보여진다. Application 에서 관리하는 캐시이기 때문에 적절한 캐시의 용량을 선택하는 것도 중요하다고 판단된다.
임시로 다음와 같은 캐시를 관리하는 인터페이스를 러프하게 만들어보자.
위의 내용을 기반으로 HashMap으로 구성된 캐시를 만들어보자. 간단한 제약조건을 만들어보자.
키 값은 "질의어:페이지 번호"로 구성된다.
Map의 크기는 10을 넘어선 안된다.
위와 같이 정말 간단하게 캐시를 구현해봤다. 하지만 이 구조에서의 문제점은 어디있을까?
순서 보장을 할 수 없다. HashMap의 경우 데이터 삽입, 접근, 삭제 순서를 전혀 보장하지 않는다.
랜덤으로 데이터가 교체된다. 캐시가 가득 찼을 경우 임의의 값을 제거하는 방식으로 예측할 수 없다.
이러한 문제에 대해서 고민하며 운영체제 시간에 공부를 했던 LRU 알고리즘을 지원하는 LinkedHashMap
이라는 자료 구조를 찾았다. (자바를 쓰면서 처음 사용해본다. 역시 아직 모르는게 한참 많다..)
HashMap을 사용하여 캐시를 구현했을 때 문제점이 '순서 보장'이라는 것이었다. LinkedHashMap의 이름처럼 HashMap에서 값이 입력된 순서를 기억하는 것을 추가하기 위해 만들어진 클래스이다.
Hash table and linked list implementation of the Map interface, with predictable iteration order. 예측 가능한 반복 순서를 갖춘 Map 인터페이스의 Hash table과 linked list 구현체입니다.
또한 공식문서에서 LRU 캐시에 적합하다고 명시를 해두었다! 오라클에서 이렇게 말할 정도라면 충분히 효과적이고 강력한 캐시를 구현할 수 있다고 보인다.
여기서 accessorder
옵션을 통해 가장 오래전에 접근된 엔트리부터 가장 최근에 접근된 엔트리 순서로 순회가 되도록 변경할 수 있다. 이러한 구조는 LRU(Least Recently Used) 캐시에 최적화된 구조인 것이다.
initialCapacity: 해시 테이블의 버킷 수 (default: 16)
loadFactor: 해시 맵 내부적으로 버킷 크기 조정(재해싱) 시점을 결정하는 값 (default: 0.75)
또한, put
, get
, putIfAbsent
, getOrDefault
, compute
, computeIfAbsent
, merge
등의 메서드를 호출하면 해당 엔트리가 접근(access)된 것으로 간주하여 가장 최근 엔트리로 이동하게 되며 이러한 구조가 가능한 것이다.
별도의 LRU 정책을 수정하고 싶다면, removeEldestEntry()
메서드를 재정의하면 된다.
이 정보들을 기반으로 LinkedHashMap 캐시 매니저를 만들어보자.
테스트를 위해 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 같은 구조는 없나?
로컬 캐시를 사용해보면서 내부적으로 어떻게 처리하는지, 어떤 자료구조를 사용한건지 파악해보자.
Caffeine cache ...
Caffeine 캐시를 만든 개발자분이 ConcurrentHashMap + LinkedHashMap 조합을 이미 라이브러리를 만드셨었다..
그렇다면, Thread-safe 한 구조로 Local Cache를 이용할 수 있는 것일까?
Redis ...