Redis 캐시: TTL, Eviction 그리고 Cache Stampede

1. 개요

Redis는 데이터를 캐시하기 위한 인기 있는 선택지로, 데이터베이스의 부하를 줄이고 응답 시간을 단축합니다. 하지만 Redis를 사용하는 것이 단순히 set과 get만으로는 부족합니다. TTL(Time To Live)이 어떻게 동작하는지, Redis가 메모리가 부족할 때 key를 어떤 메커니즘으로 제거하는지(eviction), 그리고 가장 중요한 것은 cache stampede — 하나의 hot key가 만료될 때 수백 개의 요청이 동시에 데이터베이스에 몰려드는 현상을 어떻게 방지할 것인지를 이해해야 합니다.

이 글은 기본적인 TTL 설정부터 Redis의 eviction policy, 그리고 stampede를 방지하기 위한 실전 기법까지 다룹니다: mutex lock, 논리적 만료(logical expiration), 그리고 request coalescing. 이 글을 읽고 나면, 데이터베이스를 압도하지 않으면서도 높은 부하를 견딜 수 있는 cache layer를 설계하는 방법을 알 수 있을 것입니다.

2. Spring Boot에서 Redis 설정

먼저 dependency를 추가합니다:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

연결 설정:

spring:
  data:
    redis:
      host: localhost
      port: 6379
      timeout: 2000ms
      lettuce:
        pool:
          max-active: 16
          max-idle: 8

RedisTemplate bean을 사용하여 커스텀 데이터 타입으로 key/value를 조작합니다:

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

GenericJackson2JsonRedisSerializer는 object를 클래스 정보를 포함한 JSON으로 저장하여, 나중에 읽을 때 올바른 타입으로 역직렬화할 수 있습니다. JdkSerializationRedisSerializer보다 redis-cli로 읽기 쉽습니다.

3. TTL — Time To Live

3.1. Key를 쓸 때 TTL 설정

@Service
public class ProductCacheService {

    private final RedisTemplate<String, Object> redisTemplate;

    public ProductCacheService(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void cacheProduct(Long productId, Product product) {
        String key = "product:" + productId;
        redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
    }
}

set(key, product, Duration.ofMinutes(10)) 라인은 10분의 TTL과 함께 key를 저장합니다. 이 시간 이후 Redis는 자동으로 key를 삭제합니다. 이를 passive expiration과 active expiration의 조합이라고 합니다 (Redis는 100ms마다 TTL이 있는 key를 무작위로 스캔하여 조기에 삭제하고, 만료된 key가 메모리에 남아있는 것을 방지합니다).

남은 TTL을 확인합니다:

redis-cli TTL product:123
(integer) 542

값이 -1이면 key는 존재하지만 TTL이 없습니다(영구 존재). -2이면 key가 존재하지 않습니다.

3.2. 고정 TTL vs 랜덤 TTL (jitter)

만약 수천 개의 key가 동일한 TTL로 동시에 캐시되면, 모두 한 번에 만료됩니다. 이는 “cache miss” 폭주를 유발합니다. 해결책은 jitter를 추가하는 것입니다: TTL에 랜덤 값을 약간 더합니다.

public void cacheProductWithJitter(Long productId, Product product) {
    long baseSeconds = 600;
    long jitter = ThreadLocalRandom.current().nextLong(0, 60);
    redisTemplate.opsForValue()
        .set("product:" + productId, product, Duration.ofSeconds(baseSeconds + jitter));
}

각 key는 약간 다른 시점에 만료됩니다 (600–660초), 많은 key가 한 시점에 만료되는 “thundering herd” 효과를 방지합니다.

주의: Jitter는 하나의 hot key만으로 인한 cache stampede를 해결하지 못합니다(예: 홈페이지, 베스트셀러 제품). 이 문제는 섹션 5의 기법이 필요합니다.

4. Redis 메모리 부족 시 Eviction policy

TTL은 key가 시간이 지남에 따라 자동으로 만료되도록 처리합니다. 하지만 Redis가 maxmemory 한계에 도달하면, key가 TTL을 가지든 말든 상관없이 eviction policy에 따라 key를 제거해야 합니다.

redis.conf에서 설정:

maxmemory 512mb
maxmemory-policy allkeys-lru

일반적인 policy:

Policy 의미
noeviction 아무것도 삭제하지 않고, 추가 쓰기 시 OOM 오류 반환. Redis 기본값.
allkeys-lru 최근에 사용되지 않은 key를 삭제(Least Recently Used), 모든 key에 적용.
volatile-lru 위와 동일하지만 TTL이 있는 key만 삭제.
allkeys-lfu 접근 빈도가 가장 낮은 key 삭제(Least Frequently Used).
volatile-ttl 가장 가까운 만료 시간을 가진 key 우선 삭제(가장 짧은 TTL).
volatile-random TTL이 있는 key 중 무작위로 삭제.

순수 캐시 용도에서는 allkeys-lru 또는 allkeys-lfu가 안전한 선택입니다. Redis가 오류를 반환하는 대신 자동으로 메모리를 정리하기 때문입니다. Redis를 캐시와 중요한 데이터(예: session token)를 동시에 저장하는 용도로 사용한다면, 인스턴스를 분리하거나 TTL이 있는 key만 삭제하도록 volatile-lru를 사용하세요.

주의: noeviction(기본값)은 Redis 메모리가 가득 찰 때 애플리케이션이 OOM command not allowed 오류를 받게 합니다. Redis를 캐시로 사용한다면, 항상 적절한 maxmemory-policy를 설정하고 기본값으로 두지 마세요.

현재 설정 확인:

redis-cli CONFIG GET maxmemory-policy
1) "maxmemory-policy"
2) "allkeys-lru"

5. Cache Stampede란

Cache stampede (또는 dogpile effect)는 초당 수백/수천 번 읽혀지는 hot key가 갑자기 만료될 때 발생합니다. 거의 동시에 모든 요청이 cache miss를 경험하고, 모두 데이터를 다시 구축하기 위해 데이터베이스로 몰려가서, 몇 백 밀리초 동안 데이터베이스가 과부하됩니다.

public Product getProduct(Long id) {
    String key = "product:" + id;
    Product cached = (Product) redisTemplate.opsForValue().get(key);
    if (cached != null) {
        return cached;
    }
    // key 방금 만료됨 -> 수백 개의 thread가 동시에 여기로
    Product product = productRepository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));
    redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
    return product;
}

베스트셀러 제품의 key와 같은 경우, 이 코드는 일반적으로 완벽하지만, TTL 만료 시점 정확히 productRepository.findById가 같은 id에 대해 수백 번 호출될 수 있습니다.

5.1. 해결책: Distributed lock (mutex)

한 개의 요청만 캐시를 다시 구축하게 하고, 다른 요청들은 기다리거나 이전 값을 사용합니다.

public Product getProductSafely(Long id) {
    String key = "product:" + id;
    Product cached = (Product) redisTemplate.opsForValue().get(key);
    if (cached != null) {
        return cached;
    }

    String lockKey = "lock:product:" + id;
    Boolean acquired = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", Duration.ofSeconds(5));

    if (Boolean.TRUE.equals(acquired)) {
        try {
            Product product = productRepository.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id));
            redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
            return product;
        } finally {
            redisTemplate.delete(lockKey);
        }
    } else {
        // lock을 얻지 못함 -> 잠시 기다렸다가 캐시를 다시 읽기 시도
        return waitAndRetry(key, id);
    }
}

private Product waitAndRetry(String key, Long id) {
    for (int i = 0; i < 10; i++) {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        Product cached = (Product) redisTemplate.opsForValue().get(key);
        if (cached != null) {
            return cached;
        }
    }
    // 대기 시간 초과, fallback으로 DB 직접 호출
    return productRepository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));
}

setIfAbsent는 Redis의 SET lockKey 1 NX EX 5 명령과 동일합니다 — 원자적이며, 단 하나의 thread만 lock 생성에 성공합니다. Lock을 얻지 못한 thread는 데이터베이스를 호출하는 대신 100ms마다 캐시를 다시 확인합니다.

5.2. 해결책: 논리적 만료 (TTL 실제 사용 안함)

Redis가 자동으로 key를 삭제하도록 놔두는 대신, 만료 시점을 value에 논리적으로 저장하고, 실제로 key가 삭제되지 않도록 합니다. 데이터가 “논리적으로 만료”된 것을 감지했을 때, 한 개의 thread가 백그라운드에서 다시 구축하고, 다른 thread는 이전 데이터를 즉시 반환합니다 (stale-while-revalidate).

public record CachedValue<T>(T data, Instant logicalExpireAt) {}

public Product getProductWithLogicalTtl(Long id) {
    String key = "product:" + id;
    CachedValue<Product> cached =
        (CachedValue<Product>) redisTemplate.opsForValue().get(key);

    if (cached == null) {
        return rebuildAndCache(id, key);
    }

    if (Instant.now().isAfter(cached.logicalExpireAt())) {
        String lockKey = "lock:product:" + id;
        Boolean acquired = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", Duration.ofSeconds(5));
        if (Boolean.TRUE.equals(acquired)) {
            // 백그라운드에서 비동기로 rebuild, 현재 요청에는 이전 데이터 반환
            CompletableFuture.runAsync(() -> {
                try {
                    rebuildAndCache(id, key);
                } finally {
                    redisTemplate.delete(lockKey);
                }
            });
        }
    }
    return cached.data(); // 항상 데이터 반환, 다소 오래된 데이터일 수 있음
}

private Product rebuildAndCache(Long id, String key) {
    Product product = productRepository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));
    CachedValue<Product> value = new CachedValue<>(product, Instant.now().plusSeconds(600));
    // 물리적 TTL이 논리적 TTL보다 길어야 rebuild 중에 key가 사라지지 않음
    redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(15));
    return product;
}

핵심 포인트: Redis의 물리적 TTL(15분)은 논리적 TTL(10분)보다 항상 깁니다. Key는 Redis에 의해 갑자기 삭제되지 않습니다 — 단지 “오래된” 것으로 표시되고 백그라운드에서 rebuild됩니다. 요청은 데이터베이스를 기다릴 필요가 없으며, 데이터가 논리적으로 만료된 직후에도 마찬가지입니다.

6. Stampede 방지 동작 테스트

여러 thread가 동시에 cache miss할 때 database 호출이 단 한 번만 발생하는지 확인하는 테스트를 작성합니다:

<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
class ProductCacheServiceTest {

    @Autowired
    private ProductCacheService cacheService;

    @MockBean
    private ProductRepository productRepository;

    @Test
    void chiMotThreadDuocPhepGoiDatabaseKhiCacheMiss() throws InterruptedException {
        Long productId = 42L;
        Product product = new Product(productId, "Ban phim co");
        given(productRepository.findById(productId)).willReturn(Optional.of(product));

        int threadCount = 50;
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executor.submit(() -> {
                cacheService.getProductSafely(productId);
                latch.countDown();
            });
        }
        latch.await(5, TimeUnit.SECONDS);
        executor.shutdown();

        verify(productRepository, times(1)).findById(productId);
    }
}
@Test
void khongCoLockThiDatabaseBiGoiNhieuLan() {
    Long productId = 99L;
    Product product = new Product(productId, "Ban phim co");
    given(productRepository.findById(productId)).willReturn(Optional.of(product));

    List<Product> results = IntStream.range(0, 50)
        .parallel()
        .mapToObj(i -> cacheService.getProductUnsafe(productId))
        .toList();

    assertThat(results).hasSize(50);
    verify(productRepository, atLeast(2)).findById(productId); // stampede 발생 증명
}

첫 번째 테스트는 getProductSafely(lock 포함)를 사용하여 50개의 thread가 실행되어도 findById가 정확히 1번만 호출되는지 예상합니다. 두 번째 테스트는 lock이 없는 버전을 사용하여 반대 시나리오를 보여줍니다 — findById가 여러 번 호출되는 것을 확인하여 stampede가 실제로 발생함을 증명합니다.

7. 일반적인 실수들

  • TTL 설정 잊음: key가 영구적으로 존재하고, Redis 메모리가 점점 차올라서, eviction policy에만 의존해야 함 — 필요한 key가 실수로 삭제될 위험이 있습니다.
  • Hot key의 TTL이 너무 짧음: cache miss 빈도 증가, stampede 가능성 증가. Traffic이 많은 key는 짧은 물리적 TTL보다 논리적 만료를 우선합니다.
  • Lock에 TTL 없음: lock을 보유 중인 instance가 중간에 crash되면, lock이 영구적으로 남아있어서 다른 thread는 계속 대기합니다. 항상 lock에 TTL을 설정하세요 (setIfAbsent에 Duration 전달).
  • Redis 캐시에 noeviction 사용: Redis 메모리가 가득 찰 때 애플리케이션이 오류를 던집니다. 캐시로만 사용하는 경우에만 자동 정리가 필요합니다. 잃으면 안 되는 데이터 저장에만 적합합니다.
  • Object 직렬화 불일치: Product class 필드를 변경할 때 버전 관리를 하지 않으면, 이전 캐시에서 역직렬화할 때 오류가 발생할 수 있습니다. 캐시를 무효화할 때는 key prefix를 변경하여 스키마 변경을 처리하세요.

8. 결론

TTL은 캐시된 데이터가 시간에 따라 자동으로 갱신되도록 합니다만, 많은 key가 동시에 만료되는 것을 피하려면 jitter가 필요합니다. Eviction policy는 Redis 메모리가 부족할 때 어떻게 처리하는지 결정합니다 — 순수 캐시용도로는 allkeys-lru/allkeys-lfu를 선택하고, 기본값인 noeviction은 피하세요. Hot key의 경우 단순한 TTL만으로는 부족합니다: mutex lock 또는 논리적 만료를 사용하여 단 한 개의 thread만 캐시를 rebuild하도록 하고, 나머지는 이전 데이터를 읽도록 해야 합니다. 이 세 가지 기법을 올바르게 적용하면, 단순한 set/get보다 훨씬 높은 부하를 견딜 수 있는 캐시 layer를 만들 수 있습니다.

rediscachespring-bootperformancejava