들어가며
이번 포스팅은 게시글 조회에서, 게시글이 10개, 100개일때와 수천 개 이상이 되는 순간을 고민했을때의 대용량 데이터의 조회 API(게시글 상세, 전체 목록 등) 을 위해서 Redis를 활용한 캐싱을 적용하며, 고민했던 내용을 담아보려한다
캐싱을 적용해야하는 이유는 무엇일까 ?
서론에서도 짧게 작성했었지만, 게시글 상세 조회나 전체 목록 조회는 로직 자체는 단순할지 모르지만, 많은 사용자들이 짧은 시간 안에 반복적으로 호출하게 된다면 ? 그 게시글이 1,000개, 10,000개가 된다면 ? 과 같은 고민을 하게된다면 이는 DB에 부하가 크게 가게되어, 무리를 줄것이라고 생각했다. 이럴 때 캐싱을 적용하면, 이미 조회된 결과를 메모리나 외부 캐시에서 즉시 꺼내 쓰기 때문에 평균 응답 속도가 눈에 띄게 떨어지는걸 확인할 수 있었다


캐싱 적용시 주의할점
캐싱을 적용하게 되면 JVM 힙에 로컬 캐시를 너무 많이 저장하면 GC 지연이 발생할 수 있다는 문제점이 있다. 좀 더 쉽게 설명하자면, 메모리에 캐시된 객체가 많아지면 JVM이 사용 중인 메모리 내역을 스캔하고 정리해야 할 데이터가 급격히 늘어나서, 잠깐 멈추는 현상이 자주 일어날 수 있다. 예를 들어서 게시글 목록 1,000페이지 분량의 데이터를 모두 힙에 담아두면 메모리 청소 주기마다 수십 밀리초에서 수백 밀리초까지 응답이 멈추는 현상이 나타날 수 있다 😬
JVM 관련에 대해서 좀 더 자세히 알고싶다면, 아래 링크에서 static, final, static final의 메모리 영역 관련을 참고해주면 좋을거같다
https://huncozyboy.tistory.com/27
final와 static, 그리고 static final의 차이점 (JAVA)
staticstatic 키워드를 가진 멤버는 값이 클래스의 모든 인스턴스에 대해 동일하다. 클래스가 모든 인스턴스가 액세스할 수 있는 전역 변수라고 볼 수 있지만, static 변수는 상수가 아니므로 언제나
huncozyboy.tistory.com
다시 돌아와서, Caffeine 같은 로컬 캐시는 getIfPresent() 호출만으로 조회가 이뤄지기 때문에 네트워크 왕복이나 디스크 I/O 없이 초고속으로 데이터를 반환할 수 있다. 그리고 발생할 수 있는 캐시 미스 시 자동으로 백업 레이어에서 데이터를 불러오고, 곧바로 로컬 캐시에 재저장하는 방식으로 2-Level 캐시의 이점을 살릴 수 있다. 단, 이 과정에서 메모리 관리가 제대로 이루어지지 않으면 오히려 전체 시스템 성능이 저하될 수 있으므로, 캐시 엔트리 개수와 무게 + 만료 정책(TTL) + 통 등의 설정을 공식 문서를 참고하여 충분히 검토한 뒤 적용하는 것을 추천한다
자세한 Caffeine 관련 내용은 아래 공식 오픈소스에 잘 나와 있으니, 해당 부분을 참고하면 구현하는 데 큰 어려움이 없을거라 생각한다
https://github.com/ben-manes/caffeine
GitHub - ben-manes/caffeine: A high performance caching library for Java
A high performance caching library for Java. Contribute to ben-manes/caffeine development by creating an account on GitHub.
github.com
2-Level 캐시 (Caffeine + Redis)
Global-only (CacheType.GLOBAL) : Redis 만 사용 → 여러 인스턴스간 공유 가능, JVM 힙 메모리가 아닌 메모리 부담은 Redis에게 위임
Composite (CacheType.COMPOSITE) : Caffeine + Redis 2계층 → 자주 호출되는 데이터는 JVM heap에서 초고속 조회,
Redis 장애 시에도 로컬에서 어느 정도 서빙 가능
간단하게 구현한 내용에 대한 설명은 위와 같은데 아래 본문에서 실제 구현한 코드를 보면서 좀 더 자세히 다루어보려고 한다


본문
아래는 게시글 조회시, 2-Level 캐싱 적용을 위해서 실제 구현했었던 코드의 일부 내용이다

LocalCacheConfig
@Configuration
public class LocalCacheConfig {
private static final int MAX_WEIGHT = 10_000_000;
@Bean
public LocalCacheManager localCacheManager() {
List<Cache> caches = CacheName.entries().stream()
.filter(g -> g.getCacheType() != CacheType.GLOBAL)
.map(g -> {
Caffeine<Object, Object> builder = Caffeine.newBuilder()
.expireAfterWrite(g.getTtl().toSeconds(), TimeUnit.SECONDS)
.maximumWeight(MAX_WEIGHT)
.weigher((key, value) -> estimateWeight(value));
return (Cache) new CaffeineCache(g.getCacheName(), builder.build());
})
.collect(Collectors.toList());
return new LocalCacheManager(caches);
}
private int estimateWeight(Object value) {
if (value == null) {
return 1;
}
return value.toString()
.getBytes(StandardCharsets.UTF_8)
.length;
}
}
LocalCacheConfig 클래스에서는 CacheType이 GLOBAL이 아닌, 즉 로컬 또는 COMPOSITE 캐시에 해당하는 모든 CacheName을 순회하면서 CaffeineCache 인스턴스를 생성해 LocalCacheManager에 등록해주었다
아래의 설정으로 TTL이 지나지 않았더라도, 엔트리 수가 전체 무게 합이 JVM 허용치 이상이면 오래된 항목부터 제거 되도록 설정해주었다
.expireAfterWrite(g.getTtl().toSeconds(), TimeUnit.SECONDS)
.maximumWeight(MAX_WEIGHT)
CompositeCacheConfig
@Configuration
@EnableCaching
public class CompositeCacheConfig {
@Primary
@Bean
public CacheManager cacheManager(
LocalCacheManager local,
@Qualifier("redisCacheManager") CacheManager redis
) {
return new CompositeCacheCacheManager(
List.of(local, redis),
local
);
}
CompositeCacheConfig에서는 2계층(Composite) 캐시를 활성화해서 cacheManager 빈이 스프링의 기본 CacheManager가 되어, 다른 캐시 매니저보다 우선 호출되도록 설정해주었고, 추가적으로 로컬 미스나 Redis 히트 발생시 네트워크 왕복 1회와 Redis 장애시 로컬 캐시에 남아 있는 데이터만으로 임시 서빙되도록 장애를 방지하였다
RedisCacheConfig
@Configuration
public class RedisCacheConfig {
private final RedisConnectionFactory cf;
public RedisCacheConfig(RedisConnectionFactory cf) {
this.cf = cf;
}
@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues();
}
@Bean(name = "redisCacheManager")
public CacheManager redisCacheManager(RedisCacheConfiguration cfg) {
var configs = CacheName.entries().stream()
.filter(g -> g.getCacheType() != CacheType.LOCAL)
.collect(Collectors.toMap(
CacheName::getCacheName,
g -> cfg.entryTtl(g.getTtl())
));
return RedisCacheManager.builder(RedisCacheWriter.nonLockingRedisCacheWriter(cf))
.withInitialCacheConfigurations(configs)
.build();
}
}
RedisCacheConfig에서는 RedisCacheManager를 생성하면서 캐시별 TTL과 직렬화 전략을 적용해주었다. 아래는 각각의 별도의 빈으로 주입해준 메서드와 관련한 설명이다
- redisCacheConfiguration
defaultCacheConfig()로 기본 설정을 가져온 뒤, 키를 UTF-8 문자열로 직렬화하고, 이후에 값을 JSON 형태로 직렬화하였다. 추가적으로 null 값을 캐시에 저장하지 않도록 해, 스토리지 낭비와 NullPointerException을 방지하였다 - redisCacheManager
CacheName.entries()에서 LOCAL 제외 캐시들만 필터링해, Redis에서 관리할 캐시 네임과 각각의 TTL을 맵으로 구성해주었고, Redis 접근 시 락 없이 동작하는 라이터를 사용하여 캐시 이름별로 개별 RedisCacheConfiguration을 적용했다. post_detail 캐시는 TTL 1분, posts_page는 TTL 5분 등 CacheName에 정의된 값을 반영할 수 있도록 해주었다
서비스단에서의 적용

게시글 단건 상세조회에선 실시간성이 중요한 댓글 수, 좋아요 수(추후 구현 예정) 등을 포함해 일관성 보장을 위해 Global-only 전략을 선택했고, Redis에 1분 TTL을 부여하고, @CacheEvict로 게시글 업데이트·삭제 시 즉시 무효화되도록 구성했다

게시글 전체 조회에서는 트래픽이 빈번한 페이지 요청이였기에 Composite 전략으로 처리했고, 로컬 캐시에는 최대 500개 엔트리, TTL 5분, Redis에도 동일 TTL을 설정해 다단계 캐시 효과를 극대화해줄 수 있었다 🔥
마치며

처음에 구현할때 그러면 캐시는 무조건 좋은거 아닌가 ? 라는 생각으로 접근했지만, TTL 설정 + 키 네이밍 + 이벤트 기반 무효화 + 캐시 미스 대응 + 장애 시 폴백 로직 등등 고려해야 할 사항이 너무 많아서, 실제 서비스에 적용하려면 유지보수를 위해 꼼꼼한 설계가 필수라는 생각이 들었다. 성능적인 이점을 가져다 주는건 분명한 사실이였던거 같지만, JVM 힙과 Redis 메모리 사용량을 적절히 조율하지 않으면 오히려 GC 딜레이나 Redis Out Of Memory와 같은 부정적인 영향을 줄 수 있겠다라는 생각도 했다
Redis Out Of Memory란 ?
Redis 인스턴스가 설정된 메모리 한도를 초과했을 때 발생하는 에러로, 캐시나 데이터가 계속 쌓여서 이 한도를 넘어서면 Redis가 더 이상 새로운 데이터를 저장할 수 없어 OOM 에러를 던지게된다
추후 구현하고 싶은 내용
이번에 캐싱을 구현하며 새롭게 알게된 사실은, 캐시 계층이 늘어나면서 모니터링과 장애 대응도 중요하다는 사실이였다. 그 이유는 TTL이 너무 짧으면 캐시 미스율이 올라가고, 너무 길면 staleness 상태가 될 위험이 있으므로, 서비스 특성에 맞춰 지속적으로 모니터링하며 적절히 조정해줘야할 필요성이 있다고 느꼈다
staleness란 ?
캐시가 너무 오래되어 최신 데이터와 정확도 측면에서 차이가 나는것

추후에 Prometheus와 Micrometer를 통해 cache_hits, cache_misses, evictions 같은 메트릭을 수집하고, Grafana 대시보드에서 실시간 모니터링해보고 싶다는 생각이 들었다. 캐싱은 성능 최적화의 강력한 도구지만, 실제 내가 운영하고 있는 서비스에 적용하려면, 시스템 복잡성과 운영 비용이 증가하므로 철저한 설계와 지속적인 모니터링, 장애 대비가 필수라는 생각도 들었다 👀
참고한 자료
개수, 무게 제한 referneces
https://github.com/ben-manes/caffeine/wiki/Eviction
Refresh referneces
https://github.com/ben-manes/caffeine/wiki/Refresh
Spring 캐시관련 referneces
https://docs.spring.io/spring-boot/reference/io/caching.html#io.caching.provider.redis