사이드 프로젝트에 캐시를 적용하며 이것저것 알아보는 시리즈
1. [Spring] 캐시란? + Spring Boot 내장 캐시 알아보기
2. [Cache/Spring] EhCache vs Cache2k vs Caffeine 캐시 비교하기
3. [Cache/Spring] 내 어플리케이션의 캐시 히트율 알아보기
캐시를 왜 쓰나요?
본격적으로 EhCache와 Caffeine을 비교하기에 앞서, 캐시가 무엇이고 왜 쓰는지 먼저 알아보자. 위키피디아는 캐시를 다음과 같이 정의하고 있다.
데이터를 저장하여 향후 데이터에 대한 요청을 더 빠르게 처리할 수 있도록 하는 하드웨어 또는 소프트웨어 구성 요소.
캐시에 저장된 데이터는 이전 계산의 결과이거나 다른 곳에 저장된 데이터의 복사본일 수 있다.
ref) https://en.wikipedia.org/wiki/Cache_(computing)
쉽게 말해, 자주 사용하는 데이터를 가까운 어딘가에 저장해두었다가, 필요할 때 빠르게 꺼내보기 위한 기술이다.
생소하게 느껴질 수도 있지만, 사실 이미 OS나 웹(네트워크) 등 많은 분야에서 다양한 방식으로 사용되고 있다. Java의 CAS나 volatile 키워드를 찾아보면 OS까지 내려갈 필요도 없이 자바 언어에서도 이미 캐시가 적용되고 있음을 알 수 있다.
웹 어플리케이션의 서버라면 데이터 조회 성능을 개선하기 위해 캐시를 사용하는 일이 많은 것 같다.
- e.g., 스프링에서 MySQL의 데이터를 꺼내오려면 DB 서버와의 커넥션과 디스크 I/O가 필요하다.
- 하지만 메모리에 데이터를 캐시 해두면, 그 다음 요청부턴 DB에 접근할 필요 없이 메모리 영역에서 요청을 해결할 수 있어 성능이 개선된다.
메모리가 훨씬 빠른데 왜 대부분의 DBMS가 디스크에서 관리되는지 궁금하다면 여기와 여기를 참고하자.
Local 캐시 vs Global 캐시
OS나 네트워크가 아닌, 어플리케이션에서 캐시를 적용하려면 로컬 캐시와 글로벌 캐시에 대해 알아야 한다. 이름 그대로 로컬 영역에 있는 캐시인지, 시스템 전체에서 공유되는 별도의 외부 서버 캐시인지를 의미한다. 개인적으로 느낀 장단점은 아래와 같다.
로컬 캐시 | 글로벌 캐시 | |
정의 | 로컬 컴퓨터 내의 메모리(또는 디스크)를 이용하는 캐시 | 캐시를 목적으로 세팅된 컴퓨터를 이용하는 캐시 (=캐시 서버) |
장점 | - 동일한 서버의 리소스를 사용하기에 속도가 아주 빠르다. - 별도의 외부 의존성 없이도 구현할 수 있다. |
- 별도의 캐시 서버를 사용하므로, 보다 유연하다. - 클러스터링을 활용하여 데이터를 분산 저장할 수 있다. - 서버가 여러 대로 늘어나도, 동일한 캐시 서버를 바라보기 때문에 비교적 관리가 용이하다. |
단점 | - 서버가 여러 대일 경우 공유가 어렵고 동기화가 필요하다. - 캐시 데이터 양이 늘어날 수록, 인스턴스 메모리 스펙업이 필요하다. |
- 캐시 서버의 설정과 관리가 필요하다. - 설정하는 과정에서 비용이 발생할 수 있다. (인메모리 디비는 비쌈!) - 외부 캐시 서버와의 네트워크 연결이 필요하므로, 로컬 캐시보다는 느리다. |
헷갈린다면 git 개념과 연관지어 이해하는 걸 추천한다. 내 작업 공간(local)과 원격 공간(remote:: global)의 차이를 떠올리면 더 쉽게 다가올 것 같다.
Spring Boot 내장 캐시와 캐시 라이브러리
굳이 라이브러리나 인메모리 DB까지 갈 필요 없이, Java 언어로 직접 구현하거나, Spring에서 제공해주는 내장 캐시를 사용할 수도 있다.
💁♀️ Java로 직접 구현한다면?
static 변수를 활용할 수 있다. 만약 Thread-safe해야 하고, 데이터를 변경할 필요가 없다면 final을 이용해 불변으로 만들어주면 된다.
public enum ImageExtension {
JPG("jpg"),
JPEG("jpeg"),
PNG("png"),
;
private final String value;
private static final Map<String, ImageExtension> cachedImageExtension =
Arrays.stream(ImageExtension.values())
.collect(
Collectors.toMap(extension -> extension.value, extension -> extension));
}
위 코드는 처음 인스턴스가 생성될 때 Enum name과 value를 Map으로 맵핑해준다. Enum의 values()를 이용해 직접 원하는 값을 찾으는 것은 비용이 비싼 편이라, enum이 싱글톤이라는 점을 활용해 캐싱해준 예시이다.
💁♀️ Spring Boot 내장 캐시를 사용한다면?
스프링을 사용한다면, 어플리케이션 서버 내의 메모리를 사용해 쉽게 캐시 기능을 사용할 수 있다. 사용 방법 및 예제는 더보기 확인!
1. Spring Boot에 dependency 추가
// gradle (Kotlin)
implementation("org.springframework.boot:spring-boot-starter-cache")
2. Main class에 캐시 어노테이션 추가
@EnableCaching
@SpringBootApplication
public class MainApplication {
public static void main(String[] args) {
SpringApplication.run(MainApplication.class, args);
}
}
3. 캐시 관련 설정 추가 -> 아래 코드는 TTL을 직접 구현한 예시입니다.
@EnableCaching
@Configuration
public class CacheConfig {
public static final String AUTHENTICATE = "authenticate";
@Bean
public CacheManager cacheManager() {
SimpleCacheManager cacheManager = new SimpleCacheManager();
cacheManager.setCaches(Set.of(
new ExpireConcurrentMapCache(AUTHENTICATE, 300L)
));
return cacheManager;
}
@Scheduled(cron = "0 0 0 * * *")
private void evict() {
ExpireConcurrentMapCache cache = (ExpireConcurrentMapCache) cacheManager().getCache(AUTHENTICATE);
if (cache != null) {
cache.evictAllExpired();
}
}
}
4. 필요한 데이터에 캐시 적용
// 어노테이션을 사용해 선언형으로 구현한 예시
@Cacheable("member")
public List<Member> findMembers() {
return memberRepository.findAll();
}
// 직접 캐시 인스턴스를 사용해 명령형으로 구현한 예시
public String checkMemberAuthenticationCode(String code) {
Cache cache = getCodeCache();
Long memberId = cache.get(code, Long.class);
if (memberId != null) {
cache.evict(code);
// 생략
} else {
throw new CodeNotFoundException();
}
}
private Cache getCodeCache() {
return cacheManager.getCache(CacheConfig.AUTHENTICATE);
}
위 예제처럼, Spring은 CacheManager를 이용해 여러 종류의 캐시를 손쉽게 사용할 수 있다. 마치 TransactionManager가 서비스에서 사용 중인 DBMS가 MySQL이든 PostegreSQL이든 상관 없이 동일하게 동작하는 것처럼!
여기에 Spring의 3대 요소 중 PSA (Portable Service Abstraction)와 AOP (Aspect Oriented Programmin)가 사용되었는데, 만약 이 개념이 궁금하다면 아래 링크를 참고하자.
Spring CacheManager 종류
CacheManager는 Interface로, 우리는 캐시 구현체에 따라 CacheManager 구현체를 선택하면 된다. CacheManager의 구현체에 대한 상세한 내용은 공식 JavaDoc에서 확인할 수 있다. 본 포스팅에선 외부 infrastructrue 없이 사용할 수 있는 구현체만 간단하게 비교해봤다.
이름 | 설명 |
SimpleCacheManager | 주어진 캐시 컬렉션에 대해 작동하는 CacheManager. 캐시로 사용할 컬렉션을 개발자가 직접 선택할 수 있다. (Set, Map 등) 공급자 (=Provider)가 없다면 ConcurrentHashMap이 자동으로 사용된다. 빈 등록 없이 직접 사용할 경우, AbstractCacheManager.initializeCaches()를 호출해야 한다. |
ConcurrentMapCacheManager | ConcurrentMap을 이용하여 캐시를 관리하는 CacheManager. 런타임에 캐시가 동적으로 생성되는 것이 아니라, 캐시 영역을 미리 정의해둘 수 있다. CompletableFuture를 이용해 비동기 작업을 지원한다. 단, 캐시 구성 옵션을 별도로 제공하지 않으니, 고급 로컬 캐싱이 필요하면 CaffeineCacheManager 또는 JCacheCacheManager를 사용하기를 권장한다. |
(더보기에 첨부한 예시는 TTL을 직접 구현하기 위해 ConcurrentMap을 상속받은 커스텀 맵 클래스를 사용했고, 여러개의 cache가 존재할 수 있어 Set 컬렉션을 사용해야 했기에 SimpleCacheManager를 사용했다.)
Java에서 변수를 활용해 직접 캐싱을 구현하려면 put, evict 등의 로직을 직접 하나하나 구현해야 하므로 비즈니스 로직에 캐시에 필요한 불필요한 코드가 섞이게 된다. 그것도 캐시가 사용되는 컴포넌트마다! 이런 중복된 작업들을 AOP를 활용해 추상화한 것이 Spring CacheManager이다.
그럼 그냥 스프링 내장 캐시 이용하면 편할 것 같은데요? 👀
그러면 좋겠지마는 안타깝게도, 컬렉션을 활용한 스프링 내장 캐시는 문제가 좀 있다. 일단 제일 큰 문제는 제공되는 캐시 옵션이 적다는 것이다. 이건 JavaDoc 공식 문서에도 기재되어 있다.
Spring의 Cache, CacheManager는 단순히 캐시 사용을 추상화한 것이지, 실제 캐시 저장소를 포함하진 않는다. 어노테이션을 이용해 캐시 등록, 수정 등은 제공해줘도, eviction, expire같은 세부 캐시 정책은 CacheManager 구현체가 처리해주어야 한다.
즉, SimpleCacheManager이나 ConcurrentMapCacheManager를 사용한다면, 내부 캐시 저장소는 단순 자바 컬렉션이므로 TTL 같은 정책은 존재하지 않는다. 따라서 개발자가 필요한 정책들을 직접 구현해주어야 한다.
🤔 엥 뭐 설정 굳이 필요한가요? 어차피 로컬 캐시라서 어플리케이션 재가동되면 reset될텐데, 그냥 쓰면 안되나요?
그래도 되지만 권장하진 않는다. 이유는 1) 최신 데이터인지 확인해주어야 하고 2) 사용되지 않는 캐시가 계속 살아있다면 불필요하게 리소스가 낭비되기 때문이다.
캐시는 결국 원본 저장소에 직접 접근하는게 아니라 만들어둔 데이터 사본을 사용하는 기술이다. 따라서 캐시된 후 시간이 오래 지나면 원본 데이터와 차이가 생길 수도 있다. React 개발자와 협업해본 경험이 있다면, React Query cache에 의해 서버에서 내려주는 최신 응답값이 씹혀 변경사항이 화면에 반영되지 않는 이슈를 겪어본 적이 있을 것 같다. 캐시를 제때 refresh 해주지 않으면 곤란한 상황이 벌어질 수도 있다.
이는 @CacheEvict 등을 사용해 어찌저찌 처리하더라도, 메모리 이슈가 아직 남아있다. 히트율이 높은 캐시라면 몰라, 잘 참조되지도 않는데 메모리 공간을 가득 잡아먹고 있다면 정작 필요한 상황에 OOM이 발생할 수 있다. 메모리는 비싼 자원이고, 프로그램 세계에서 자주 사용되는 공간이라는 점을 기억하자.
💁♀️ Java 캐시 라이브러리는 무엇이 있나요?
스프링 3.x 기준 CacheManager가 제공하는 캐시 Provider은 다음과 같다.
여기서 Hazelcast, Infinispan, Couchbase, Redis는 NoSQL 데이터베이스로 분류되고, EhCache, Caffeine, Cache2k는 DBMS가 아니라 단순 캐시 라이브러리로 분류된다. (아니라면 댓글 남겨주세요!)
EhCache, Caffeine, Cache2k 중에선 EhCache가 제일 메이저했으나, 최근엔 Caffeine이 급부상한다고 알고 있다 👀 각각의 라이브러리는 다음 포스팅에서 비교할 예정!
레퍼런스
https://docs.spring.io/spring-boot/reference/io/caching.html