1. 개요
대규모 트래픽을 처리하는 시스템에서 캐시(Cache)는 속도 향상을 위해 필수입니다. 보통 Redis나 Memcached 같은 외부 캐시를 도입하여 DB 부하를 줄이고 응답 속도를 개선합니다.
하지만 처리량이 매우 높은 상황에서는 외부 캐시로 가는 네트워크 비용조차 부담스러울 때가 있습니다. "데이터를 요청할 때마다 네트워크를 타야 할까?"라는 고민에서 출발한 것이 바로 이중 캐시(Two-Level Cache) 전략입니다. 로컬 캐시와 외부 캐시를 결합하여 성능을 끌어올리는 방법을 알아봅니다.
2. 간단한 설명
이중 캐시 전략은 이름 그대로 캐시를 두 계층으로 나누어 운영하는 방식입니다.
- L1 캐시 (Local Cache): 애플리케이션 메모리(JVM Heap 등)에 직접 저장합니다. 네트워크를 타지 않아 속도가 가장 빠르지만, 서버 간 공유가 되지 않고 용량 제한이 있습니다. (예: Caffeine, Ehcache)
- L2 캐시 (Global Cache): Redis처럼 외부에 별도로 존재하는 캐시 저장소입니다. 네트워크 비용이 발생하지만, 여러 서버가 데이터를 공유할 수 있고 용량 확장이 용이합니다.
[작동 원리]
- 데이터 요청 시 L1(로컬)을 먼저 확인합니다.
- L1에 없으면 L2(글로벌)를 확인합니다.
- L2에도 없으면 DB에서 조회 후 L2와 L1에 순차적으로 적재합니다.
3. 사용 사례
가장 큰 고민거리는 "서로 다른 서버에 있는 로컬 캐시를 어떻게 동기화할 것인가?"입니다. 이 정합성(Consistency) 문제를 해결하는 과정을 살펴봅니다.
사례: 인기 상품 목록 조회
홈 화면의 '베스트 상품'처럼 모든 사용자가 동일하게 조회하고, 변경 빈도보다 조회 빈도가 월등히 높은 데이터에 적합합니다.
[기존] 글로벌 캐시(Redis)만 사용 시 (Java)
트래픽이 폭주하면 Redis 또한 병목이 될 수 있습니다. 모든 요청이 네트워크 IO를 발생시키므로 Serialization/Deserialization 비용도 만만치 않습니다.
// 일반적인 글로벌 캐시 접근 (Spring Data Redis)
public List<Product> getBestProducts() {
// 매번 Redis와 통신 비용이 발생함
List<Product> products = redisTemplate.opsForValue().get("best_products");
if (products == null) {
products = productRepository.findBestProducts();
redisTemplate.opsForValue().set("best_products", products);
}
return products;
}
[개선] 이중 캐시 구조 도입과 Pub/Sub 정합성 확보 (Java)
L1 캐시(Caffeine)를 두어 조회 속도를 마이크로초 단위로 줄이고, 데이터 변경 시 Redis Pub/Sub을 이용해 모든 서버의 L1 캐시를 만료(Evict)시킵니다.
// 1. 로컬 캐시 (Caffeine) 설정
Cache<String, List<Product>> localCache = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.MINUTES)
.maximumSize(100)
.build();
public List<Product> getBestProducts() {
// 1단계: 로컬 캐시 확인 (No Network)
List<Product> products = localCache.getIfPresent("best_products");
if (products != null) {
return products;
}
// 2단계: 글로벌 캐시 확인 (Network I/O)
products = redisTemplate.opsForValue().get("best_products");
if (products == null) {
// 3단계: DB 조회 (Disk I/O)
products = productRepository.findBestProducts();
redisTemplate.opsForValue().set("best_products", products);
}
// 로컬 캐시에 적재
localCache.put("best_products", products);
return products;
}
// 데이터 수정 시 동기화 로직
public void updateProductInfo(Long productId) {
productRepository.update(productId);
redisTemplate.delete("product:" + productId);
// 핵심: 다른 서버들의 로컬 캐시도 깨워야 함 (Pub/Sub)
redisTemplate.convertAndSend("cache_invalidation", "product:" + productId);
}
이 구조를 사용하면 캐시 스탬피드('Cache Stampede', 만료 시점에 수많은 요청이 DB로 쏠리는 현상) 현상에도 방어하기 쉬워지며, 90% 이상의 트래픽을 L1에서 소화할 수 있게 됩니다.
4. 맺음말
이중 캐시는 성능 최적화의 끝판왕에 가깝습니다. 하지만 그만큼 시스템 복잡도가 올라가고, 제때 캐시를 비워주지 않으면 사용자가 갱신되지 않은 이전 데이터를 보게 될 위험이 큽니다. 따라서 모든 데이터에 적용하기보다, "조회는 엄청나게 많고 수정은 드문" 성격의 데이터(카테고리 정보, 공통 설정, 베스트 셀러 등)에 선별적으로 적용하는 것이 좋습니다.
'프로그래밍 PROGRAMMING > 자바 JAVA AND FRAMEWORKS' 카테고리의 다른 글
| [Spring] 스프링 배치 구조 정리: Tasklet부터 파티셔닝까지 (0) | 2026.01.31 |
|---|---|
| [JAVA] Redisson을 활용한 Redis 캐시 전략 (0) | 2026.01.30 |
| [JAVA/Cache] 캐시 스탬피드(Cache Stampede): 만료된 순간 시스템이 멈추는 이유와 해결책 (2) | 2026.01.28 |
| [SPRING/JPA] JPA 2차 캐시(Second-Level Cache): 영속성 컨텍스트와 성능 최적화 (0) | 2026.01.27 |
| [Spring] 트랜잭션 전파 속성(Propagation) 심층 분석과 활용 전략 (0) | 2026.01.24 |