Spring 2-Tier 캐시 전략

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;
    }
}

TwoTierCacheevictL1Only 메서드를 추가합니다.

// 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의 refreshAfterWriteRedis 분산 락으로 해결합니다.

// 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 방지까지 고려하면 안정적인 고성능 캐시 레이어를 구축할 수 있습니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux