Spring 2-Tier 캐시란?
대규모 트래픽 환경에서 단일 캐시 레이어만으로는 한계가 있습니다. L1(로컬 Caffeine) + L2(분산 Redis) 2단계 캐시를 결합하면, 로컬 캐시의 초고속 응답과 분산 캐시의 일관성을 동시에 확보할 수 있습니다. 이 글에서는 Spring Boot 환경에서 Caffeine과 Redis를 결합한 2-Tier 캐시 구현을 코드 레벨로 깊이 있게 다룹니다.
왜 2-Tier 캐시가 필요한가
단일 레이어 캐시의 트레이드오프를 먼저 이해해야 합니다.
| 구분 | 로컬 캐시 (Caffeine) | 분산 캐시 (Redis) |
|---|---|---|
| 응답 속도 | ~1μs (힙 내 접근) | ~1ms (네트워크 왕복) |
| 인스턴스 간 공유 | 불가 | 가능 |
| 메모리 제약 | JVM 힙 내 | 외부 서버 확장 |
| 장애 영향 | 인스턴스 재시작 시 소실 | Redis 장애 시 전체 영향 |
2-Tier 전략은 L1 Caffeine에서 먼저 조회 → L1 미스 시 L2 Redis 조회 → L2 미스 시 DB 조회 후 양쪽 저장이라는 흐름으로, 두 캐시의 장점만 취합니다.
아키텍처 개요
┌─────────────────────────────────────────┐
│ Application │
│ ┌─────────┐ │
│ │ @Cacheable │ │
│ └─────┬───┘ │
│ ▼ │
│ ┌──────────────┐ miss ┌──────────┐│
│ │ L1: Caffeine │ ───────► │ L2: Redis ││
│ │ (로컬 힙) │ │ (분산) ││
│ └──────────────┘ └─────┬────┘│
│ miss│ │
│ ▼ │
│ ┌──────────┐ │
│ │ Database │ │
│ └──────────┘ │
└─────────────────────────────────────────┘
프로젝트 의존성 설정
Spring Boot 3.x 기준 build.gradle.kts 설정입니다.
dependencies {
implementation("org.springframework.boot:spring-boot-starter-cache")
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("com.github.ben-manes.caffeine:caffeine:3.1.8")
implementation("com.fasterxml.jackson.core:jackson-databind")
}
TwoTierCache 핵심 구현
Spring의 Cache 인터페이스를 구현하여, L1/L2를 투명하게 연결합니다.
public class TwoTierCache implements Cache {
private final String name;
private final com.github.benmanes.caffeine.cache.Cache<Object, Object> l1;
private final RedisTemplate<String, Object> redisTemplate;
private final Duration l2Ttl;
public TwoTierCache(String name,
com.github.benmanes.caffeine.cache.Cache<Object, Object> l1,
RedisTemplate<String, Object> redisTemplate,
Duration l2Ttl) {
this.name = name;
this.l1 = l1;
this.redisTemplate = redisTemplate;
this.l2Ttl = l2Ttl;
}
@Override
public String getName() { return name; }
@Override
public Object getNativeCache() { return l1; }
@Override
public ValueWrapper get(Object key) {
// 1단계: L1 로컬 캐시 조회
Object l1Value = l1.getIfPresent(key);
if (l1Value != null) {
return new SimpleValueWrapper(l1Value);
}
// 2단계: L2 Redis 조회
String redisKey = buildRedisKey(key);
Object l2Value = redisTemplate.opsForValue().get(redisKey);
if (l2Value != null) {
// L1에 승격 저장
l1.put(key, l2Value);
return new SimpleValueWrapper(l2Value);
}
return null; // 양쪽 모두 미스
}
@Override
public void put(Object key, Object value) {
// L1과 L2 동시 저장
l1.put(key, value);
redisTemplate.opsForValue()
.set(buildRedisKey(key), value, l2Ttl);
}
@Override
public void evict(Object key) {
l1.invalidate(key);
redisTemplate.delete(buildRedisKey(key));
}
@Override
public void clear() {
l1.invalidateAll();
Set<String> keys = redisTemplate.keys(name + ":*");
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
}
}
private String buildRedisKey(Object key) {
return name + ":" + key.toString();
}
}
CacheManager 빈 등록
TwoTierCacheManager를 구현하여, 캐시 이름별로 L1/L2 설정을 다르게 적용할 수 있습니다.
@Configuration
@EnableCaching
public class TwoTierCacheConfig {
@Bean
public CacheManager cacheManager(RedisTemplate<String, Object> redisTemplate) {
return new AbstractCacheManager() {
@Override
protected Collection<? extends Cache> loadCaches() {
return List.of(
buildCache("products", 500, Duration.ofMinutes(5), Duration.ofHours(1)),
buildCache("users", 200, Duration.ofMinutes(2), Duration.ofMinutes(30))
);
}
private TwoTierCache buildCache(String name,
int l1MaxSize,
Duration l1Ttl,
Duration l2Ttl) {
var caffeine = Caffeine.newBuilder()
.maximumSize(l1MaxSize)
.expireAfterWrite(l1Ttl)
.recordStats()
.build();
return new TwoTierCache(name, caffeine, redisTemplate, l2Ttl);
}
};
}
@Bean
public RedisTemplate<String, Object> redisTemplate(
RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(
new GenericJackson2JsonRedisSerializer());
return template;
}
}
서비스 레이어에서 사용
기존 @Cacheable 어노테이션을 그대로 사용합니다. 내부적으로 2-Tier가 동작합니다.
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository repo;
@Cacheable(value = "products", key = "#id")
public ProductDto findById(Long id) {
return repo.findById(id)
.map(ProductDto::from)
.orElseThrow(() -> new NotFoundException("상품 없음: " + id));
}
@CacheEvict(value = "products", key = "#id")
public void update(Long id, ProductUpdateRequest req) {
Product product = repo.findById(id)
.orElseThrow();
product.update(req);
repo.save(product);
}
@CacheEvict(value = "products", allEntries = true)
public void clearAll() {
// L1 + L2 전체 무효화
}
}
다중 인스턴스 L1 무효화: Redis Pub/Sub
2-Tier 캐시의 가장 큰 과제는 L1 일관성입니다. 인스턴스 A에서 캐시를 evict해도 인스턴스 B의 L1에는 여전히 stale 데이터가 남습니다. Redis Pub/Sub으로 해결합니다.
@Component
@RequiredArgsConstructor
public class CacheEvictionPublisher {
private final StringRedisTemplate stringRedisTemplate;
private static final String CHANNEL = "cache:eviction";
public void publishEviction(String cacheName, Object key) {
String message = cacheName + "|" + key;
stringRedisTemplate.convertAndSend(CHANNEL, message);
}
}
@Component
@RequiredArgsConstructor
public class CacheEvictionSubscriber implements MessageListener {
private final CacheManager cacheManager;
@Override
public void onMessage(Message message, byte[] pattern) {
String payload = new String(message.getBody());
String[] parts = payload.split("\|", 2);
String cacheName = parts[0];
String key = parts[1];
Cache cache = cacheManager.getCache(cacheName);
if (cache instanceof TwoTierCache ttc) {
// L1만 무효화 (L2는 발행 측에서 이미 처리)
ttc.evictL1Only(key);
}
}
}
@Configuration
public class RedisPubSubConfig {
@Bean
public RedisMessageListenerContainer listenerContainer(
RedisConnectionFactory factory,
CacheEvictionSubscriber subscriber) {
var container = new RedisMessageListenerContainer();
container.setConnectionFactory(factory);
container.addMessageListener(subscriber,
new PatternTopic("cache:eviction"));
return container;
}
}
TwoTierCache에 evictL1Only 메서드를 추가합니다.
// TwoTierCache 클래스에 추가
public void evictL1Only(Object key) {
l1.invalidate(key);
}
@Override
public void evict(Object key) {
l1.invalidate(key);
redisTemplate.delete(buildRedisKey(key));
// 다른 인스턴스에 L1 무효화 전파
evictionPublisher.publishEviction(name, key);
}
TTL 전략: L1 ≪ L2 원칙
L1과 L2의 TTL 설정은 캐시 일관성의 핵심입니다.
| 캐시 이름 | L1 TTL | L1 크기 | L2 TTL | 용도 |
|---|---|---|---|---|
| products | 5분 | 500건 | 1시간 | 상품 정보 |
| users | 2분 | 200건 | 30분 | 사용자 프로필 |
| configs | 30초 | 50건 | 10분 | 설정값 (변경 잦음) |
핵심 원칙: L1 TTL은 항상 L2 TTL보다 짧게 설정합니다. L1이 더 길면, L2에서 만료된 데이터가 L1에 남아 stale 상태가 됩니다.
Caffeine 통계 모니터링
운영 환경에서 캐시 적중률을 Micrometer + Actuator로 모니터링합니다.
@Component
@RequiredArgsConstructor
public class CacheMetricsExporter {
private final CacheManager cacheManager;
private final MeterRegistry meterRegistry;
@Scheduled(fixedRate = 60_000)
public void exportMetrics() {
cacheManager.getCacheNames().forEach(name -> {
Cache cache = cacheManager.getCache(name);
if (cache instanceof TwoTierCache ttc) {
var stats = ttc.getL1Stats();
Gauge.builder("cache.l1.hit.rate", stats, CacheStats::hitRate)
.tag("cache", name)
.register(meterRegistry);
Gauge.builder("cache.l1.eviction.count", stats,
CacheStats::evictionCount)
.tag("cache", name)
.register(meterRegistry);
}
});
}
}
// TwoTierCache에 추가
public CacheStats getL1Stats() {
return l1.stats();
}
Redis 장애 시 Fallback
Redis가 다운되어도 L1 캐시와 DB로 서비스가 유지되어야 합니다. try-catch로 L2 장애를 격리합니다.
@Override
public ValueWrapper get(Object key) {
// L1 조회
Object l1Value = l1.getIfPresent(key);
if (l1Value != null) {
return new SimpleValueWrapper(l1Value);
}
// L2 조회 (장애 격리)
try {
String redisKey = buildRedisKey(key);
Object l2Value = redisTemplate.opsForValue().get(redisKey);
if (l2Value != null) {
l1.put(key, l2Value);
return new SimpleValueWrapper(l2Value);
}
} catch (RedisConnectionFailureException e) {
log.warn("Redis 연결 실패, L1+DB fallback: {}", e.getMessage());
}
return null;
}
@Override
public void put(Object key, Object value) {
l1.put(key, value);
try {
redisTemplate.opsForValue()
.set(buildRedisKey(key), value, l2Ttl);
} catch (RedisConnectionFailureException e) {
log.warn("Redis 쓰기 실패, L1만 저장: {}", e.getMessage());
}
}
Cache Stampede 방지
다수의 요청이 동시에 캐시 미스를 일으키면 DB에 부하가 집중됩니다. Caffeine의 refreshAfterWrite와 Redis 분산 락으로 해결합니다.
// L1: Caffeine refreshAfterWrite + LoadingCache
var caffeine = Caffeine.newBuilder()
.maximumSize(500)
.expireAfterWrite(Duration.ofMinutes(10))
.refreshAfterWrite(Duration.ofMinutes(4))
.build(key -> loadFromL2OrDb(key));
// L2: Redis SETNX 기반 분산 락
public Object getWithLock(Object key) {
ValueWrapper cached = get(key);
if (cached != null) return cached.get();
String lockKey = "lock:" + buildRedisKey(key);
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(10));
if (Boolean.TRUE.equals(acquired)) {
try {
// DB에서 로드 후 캐시 저장
Object value = loadFromDb(key);
put(key, value);
return value;
} finally {
redisTemplate.delete(lockKey);
}
} else {
// 락 획득 실패: 짧은 대기 후 재조회
Thread.sleep(50);
return get(key).get();
}
}
application.yml 설정
spring:
data:
redis:
host: redis.internal
port: 6379
timeout: 2000ms
lettuce:
pool:
max-active: 16
max-idle: 8
min-idle: 4
cache:
type: none # 커스텀 CacheManager 사용
app:
cache:
two-tier:
enabled: true
l1-default-ttl: 5m
l1-default-max-size: 500
l2-default-ttl: 1h
테스트 전략
@SpringBootTest
@Testcontainers
class TwoTierCacheTest {
@Container
static GenericContainer<?> redis =
new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@Test
void l1HitSkipsRedis() {
// given
cache.put("key1", "value1");
// when: Redis 연결 끊기
redis.stop();
// then: L1에서 정상 조회
assertThat(cache.get("key1").get()).isEqualTo("value1");
}
@Test
void l1MissFallsToL2() {
// given: L2에만 데이터 존재
redisTemplate.opsForValue().set("products:key2", "value2");
// when
ValueWrapper result = cache.get("key2");
// then
assertThat(result.get()).isEqualTo("value2");
// L1에 승격 확인
assertThat(cache.getL1().getIfPresent("key2"))
.isEqualTo("value2");
}
}
운영 체크리스트
| 항목 | 확인 사항 |
|---|---|
| L1 TTL < L2 TTL | stale 데이터 방지 |
| Pub/Sub 구독 확인 | 다중 인스턴스 L1 무효화 |
| Redis 장애 fallback | try-catch로 L2 격리 |
| 직렬화 호환성 | DTO 변경 시 역직렬화 실패 대비 |
| 메모리 사이징 | L1 maximumSize × 객체 크기 ≤ 힙 10% |
| 히트율 모니터링 | L1 히트율 80%+ 목표 |
마치며
Spring 2-Tier 캐시는 로컬 캐시의 속도와 분산 캐시의 일관성을 모두 확보하는 강력한 패턴입니다. 핵심은 L1 TTL을 짧게 유지하고, Pub/Sub으로 다중 인스턴스 L1을 동기화하며, Redis 장애 시에도 서비스가 유지되도록 fallback을 구현하는 것입니다. Caffeine의 recordStats()를 활용한 히트율 모니터링도 빼놓을 수 없습니다. 운영 환경에서는 캐시 워밍업, 직렬화 버전 관리, Stampede 방지까지 고려하면 안정적인 고성능 캐시 레이어를 구축할 수 있습니다.