Redis 캐시 전략 패턴 정리

Redis 캐싱을 적용할 때 직렬화 설정만큼 중요한 것이 어떤 캐시 전략을 쓸지 정하는 일이다. 대부분의 조회 API는 Spring Cache의 @Cacheable로 충분하지만, 쓰기 일관성이나 폭주 트래픽 대응이 필요해지면 Cache-Aside 외의 패턴도 검토해야 한다.

1. Cache-Aside (Lazy Loading)

가장 일반적인 캐싱 패턴으로, Spring의 @Cacheable이 이 패턴을 구현한다.

동작 방식

  1. 애플리케이션이 캐시에서 데이터 조회
  2. 캐시에 데이터가 있으면 반환 (Cache Hit)
  3. 캐시에 데이터가 없으면 DB 조회 (Cache Miss)
  4. DB에서 조회한 데이터를 캐시에 저장 후 반환

구현 예시

@Service
public class UserService {

    @Cacheable(value = "users", key = "#userId")
    public UserDto getUser(Long userId) {
        // Cache Miss 시에만 실행됨
        return userRepository.findById(userId)
            .map(UserDto::from)
            .orElseThrow();
    }

    @CacheEvict(value = "users", key = "#userId")
    public void updateUser(Long userId, UserDto dto) {
        User user = userRepository.findById(userId).orElseThrow();
        user.update(dto);
        userRepository.save(user);
    }
}

장점

  • 구현이 간단함
  • 필요한 데이터만 캐싱하므로 메모리 효율적
  • 캐시 장애 시에도 DB 조회로 fallback 가능

단점

  • 첫 요청은 항상 느림
  • 캐시 만료 시점에 Cache Stampede가 생길 수 있음

적합한 경우

  • 읽기가 많고 쓰기가 적은 데이터
  • 모든 데이터를 미리 캐싱할 필요가 없는 경우

2. Write-Through

데이터를 쓸 때 DB와 캐시를 함께 갱신하는 패턴이다.

동작 방식

  1. 애플리케이션이 데이터 저장 요청
  2. DB 저장
  3. 메서드 반환값으로 캐시 갱신
  4. 완료 응답

Spring의 @CachePut은 메서드를 실행한 뒤 반환값을 캐시에 넣는다. 트랜잭션 롤백까지 고려한 강한 일관성이 필요하면 transaction-aware CacheManager를 쓰거나 커밋 이후 캐시를 갱신하도록 별도 처리가 필요하다.

구현 예시

@Service
public class UserService {

    @CachePut(value = "users", key = "#result.id")
    public UserDto createUser(UserCreateDto dto) {
        User user = User.create(dto);
        User saved = userRepository.save(user);
        return UserDto.from(saved);
    }

    @CachePut(value = "users", key = "#userId")
    public UserDto updateUser(Long userId, UserDto dto) {
        User user = userRepository.findById(userId).orElseThrow();
        user.update(dto);
        User saved = userRepository.save(user);
        return UserDto.from(saved);
    }
}

장점

  • 캐시와 DB 데이터 일관성을 유지하기 쉬움
  • 쓴 데이터를 곧바로 다시 읽는 흐름에서 유리함

단점

  • 쓰기 지연 시간이 증가함
  • 사용하지 않는 데이터도 캐싱될 수 있음

적합한 경우

  • 데이터 일관성이 중요한 경우
  • 저장 직후 조회가 자주 발생하는 경우

3. Write-Behind (Write-Back)

데이터를 캐시에 먼저 쓰고, 나중에 비동기로 DB에 저장하는 패턴이다.

동작 방식

  1. 애플리케이션이 캐시에 데이터 저장
  2. 즉시 응답 반환
  3. 백그라운드에서 일정 주기로 DB에 저장

구현 예시

@Service
public class ViewCountService {

    private final RedisTemplate<String, Long> redisTemplate;

    public void incrementViewCount(String articleId) {
        String key = "viewCount:" + articleId;
        redisTemplate.opsForValue().increment(key);
    }

    @Scheduled(fixedDelay = 60000)
    public void syncViewCountToDB() {
        ScanOptions options = ScanOptions.scanOptions()
            .match("viewCount:*")
            .count(1000)
            .build();

        try (Cursor<String> keys = redisTemplate.scan(options)) {
            while (keys.hasNext()) {
                String key = keys.next();
                Long count = redisTemplate.opsForValue().get(key);
                String articleId = key.replace("viewCount:", "");

                articleRepository.updateViewCount(articleId, count);
                redisTemplate.delete(key);
            }
        }
    }
}

장점

  • 쓰기 성능이 빠름
  • DB 부하를 배치 처리로 줄일 수 있음

단점

  • 캐시 장애 시 데이터 손실 가능성이 있음
  • 강한 일관성을 보장하기 어려움

적합한 경우

  • 조회수, 좋아요, 통계처럼 쓰기가 매우 빈번한 데이터
  • 일부 지연 반영이나 손실을 허용할 수 있는 데이터

4. Refresh-Ahead

캐시 만료 전에 미리 갱신하는 패턴이다.

동작 방식

  1. 캐시 TTL이 임박하면 백그라운드에서 데이터 갱신
  2. 사용자는 캐시된 데이터 조회
  3. Cache Miss 발생을 줄임

구현 예시

@Service
public class PopularArticleService {

    @Cacheable(value = "popularArticles", key = "'top10'")
    public List<ArticleDto> getPopularArticles() {
        return articleRepository.findTop10ByOrderByViewCountDesc()
            .stream()
            .map(ArticleDto::from)
            .collect(Collectors.toList());
    }

    @Scheduled(fixedDelay = 50000) // TTL 60초 가정
    public void refreshPopularArticles() {
        List<ArticleDto> articles = articleRepository.findTop10ByOrderByViewCountDesc()
            .stream()
            .map(ArticleDto::from)
            .collect(Collectors.toList());
        cacheManager.getCache("popularArticles").put("top10", articles);
    }
}

장점

  • 사용자가 Cache Miss를 만날 가능성을 줄임
  • 인기 데이터의 응답 시간을 안정적으로 유지하기 좋음

단점

  • 구현 복잡도가 증가함
  • 실제로 요청이 없는데도 불필요하게 갱신할 수 있음

적합한 경우

  • 인기 게시글, 랭킹처럼 접근 패턴을 예측할 수 있는 데이터
  • 항상 빠른 응답이 필요한 조회 API

5. Cache Stampede 방지 전략

여러 요청이 동시에 Cache Miss를 만나 DB에 몰리는 현상을 막는 방법이다.

문제 상황

// 캐시 만료 시점에 100개 요청이 동시에 들어오면
// 100개 모두 DB 조회 발생
@Cacheable("data")
public DataDto getData() {
    return repository.findData(); // 100번 실행됨
}

5.1 @Cacheable(sync = true)

@Cacheable(value = "data", key = "#id", sync = true)
public DataDto getData(Long id) {
    // 같은 인스턴스 안에서는 첫 번째 요청만 실행되고 나머지는 대기
    return repository.findById(id)
        .map(DataDto::from)
        .orElseThrow();
}

sync = true는 같은 애플리케이션 인스턴스 안에서 같은 키에 대한 동시 miss를 줄이는 옵션이다. 서버가 2대 이상인 이중화 환경에서는 인스턴스 A와 B가 서로의 메서드 실행 상태를 알지 못하므로, 같은 Redis 키가 동시에 만료되면 A에서도 한 번, B에서도 한 번 DB를 조회할 수 있다.

Redis에서도 writer 설정에 따라 동기화 범위와 효과가 달라진다. lockingRedisCacheWriter는 Redis lock key를 쓰지만 캐시 이름 단위로 잠그기 때문에 같은 캐시의 다른 키까지 대기할 수 있다. 클러스터 전체를 더 엄격하게 제어하려면 분산 락을, 만료 시점만 분산하려면 TTL 지터를 함께 검토한다.

5.2 분산 락 사용

아래 예시는 Redisson의 RLock을 사용하는 방식이다. 이 방식을 쓰려면 프로젝트에 Redisson 의존성을 추가하고, RedissonClient 빈을 설정해야 한다.

implementation "org.redisson:redisson-spring-boot-starter:3.x.x"
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.x.x</version>
</dependency>
@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
            .setAddress("redis://localhost:6379");
        return Redisson.create(config);
    }
}
@Service
public class DataService {

    private final RedissonClient redissonClient;
    private final CacheManager cacheManager;
    private final DataRepository repository;

    public DataDto getData(Long id) {
        String cacheKey = "data:" + id;
        DataDto cached = getFromCache(cacheKey);

        if (cached != null) {
            return cached;
        }

        RLock lock = redissonClient.getLock("lock:" + cacheKey);
        boolean locked = false;

        try {
            // 최대 5초 동안 락 획득을 기다리고, 락을 잡으면 10초 뒤 자동 해제된다.
            locked = lock.tryLock(5, 10, TimeUnit.SECONDS);
            if (locked) {
                // 락을 기다리는 동안 다른 인스턴스가 캐시를 채웠을 수 있으므로 다시 확인
                cached = getFromCache(cacheKey);
                if (cached != null) {
                    return cached;
                }

                DataDto data = repository.findById(id)
                    .map(DataDto::from)
                    .orElseThrow();

                saveToCache(cacheKey, data);
                return data;
            }
        } catch (InterruptedException e) {
            // 락 대기 중 인터럽트가 발생하면 인터럽트 상태를 복구하고 실패로 처리한다.
            Thread.currentThread().interrupt();
            throw new IllegalStateException("Interrupted while acquiring cache rebuild lock: " + cacheKey, e);
        } finally {
            if (locked && lock.isHeldByCurrentThread()) {
                // 현재 스레드가 잡은 락일 때만 해제한다.
                lock.unlock();
            }
        }

        throw new IllegalStateException("Failed to acquire cache rebuild lock: " + cacheKey);
    }

    private DataDto getFromCache(String cacheKey) {
        // 분산 락을 직접 다루기 때문에 @Cacheable 대신 CacheManager로 명시적으로 조회한다.
        Cache cache = cacheManager.getCache("data");
        return cache != null ? cache.get(cacheKey, DataDto.class) : null;
    }

    private void saveToCache(String cacheKey, DataDto data) {
        // TTL은 RedisCacheConfiguration의 "data" 캐시 설정을 따른다.
        Cache cache = cacheManager.getCache("data");
        if (cache != null) {
            cache.put(cacheKey, data);
        }
    }
}

getFromCache()saveToCache()는 예시를 단순하게 보이도록 분리한 캐시 조회/저장 메서드다. 분산 락으로 cache rebuild 구간을 직접 제어해야 하므로 @Cacheable에 전부 맡기기보다 CacheManager로 캐시를 명시적으로 읽고 쓴다. tryLock(5, 10, TimeUnit.SECONDS)는 최대 5초 동안 락 획득을 기다리고, 락을 잡으면 10초 뒤 자동으로 해제되도록 lease time을 거는 호출이다.

분산 락은 멀티 인스턴스 환경에서 같은 키의 cache rebuild를 한 번으로 줄이고 싶을 때 쓴다. 다만 락 획득 실패, 대기 시간, 락 만료 시간, 장애 시 처리 정책을 함께 정해야 한다. 위 예시처럼 락 획득 후 캐시를 한 번 더 확인하는 double-check도 필요하다.

Redisson을 사용할 때 락 해제는 보통 finally에서 처리한다. 현재 스레드가 잡은 락인지 확인한 뒤 unlock()을 호출하면, Redisson이 내부적으로 소유자 확인과 해제 처리를 수행한다.

RLock lock = redissonClient.getLock("lock:" + cacheKey);
boolean locked = false;

try {
    locked = lock.tryLock(5, 10, TimeUnit.SECONDS);
    if (!locked) {
        throw new IllegalStateException("Failed to acquire lock: " + cacheKey);
    }

    // DB 조회 후 캐시 재생성
} finally {
    if (locked && lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

Spring Cache는 캐시 추상화이지 분산 락 라이브러리가 아니므로 락 획득과 해제를 대신 처리해주지는 않는다. 직접 Redis 명령으로 락을 구현할 수도 있지만 토큰 비교 해제, TTL, 장애 상황을 모두 직접 다뤄야 하므로 보통은 Redisson 같은 검증된 라이브러리를 사용하는 편이 낫다.

5.3 확률적 조기 만료

public DataDto getData(Long id) {
    String cacheKey = "data:" + id;
    CachedData cached = getFromCacheWithTTL(cacheKey);

    if (cached != null) {
        long remainingTTL = cached.getRemainingTTL();
        long totalTTL = 600; // 10분

        double refreshProbability = 1.0 - ((double) remainingTTL / totalTTL);

        if (Math.random() < refreshProbability * 0.1) {
            CompletableFuture.runAsync(() -> refreshCache(id));
        }

        return cached.getData();
    }

    return loadAndCache(id);
}

TTL이 임박할수록 일부 요청이 미리 캐시를 갱신하게 하는 방식이다. 모든 요청이 만료 시점에 동시에 DB로 몰리는 상황을 줄일 수 있지만, 구현 복잡도가 올라가고 비동기 갱신 실패 처리가 필요하다.

5.4 음수 지터(TTL Jitter)

여러 키가 같은 순간에 생성되어 동시에 만료되는 경우는 sync = true로 막기 어렵다. TTL에 약간의 무작위성을 줘서 만료 시점을 흩으면 된다. 이때 수명을 늘리는 가산 지터 대신 줄이는 음수 지터를 쓰면 “TTL은 최대한 짧게”라는 기조를 깨지 않는다.

RedisCacheConfiguration.defaultCacheConfig()
    .entryTtl((key, value) -> {
        Duration baseTtl = Duration.ofSeconds(5);
        double ratio = 0.2;
        long maxJitter = (long) (baseTtl.toMillis() * ratio);
        long jitter = ThreadLocalRandom.current().nextLong(maxJitter + 1);
        return Duration.ofMillis(baseTtl.toMillis() - jitter);
    });

Spring Data Redis 3.2부터는 RedisCacheConfiguration.entryTtl(TtlFunction)으로 캐시 쓰기 시점마다 TTL을 계산할 수 있다. ratio 0.2 기준으로 5초 TTL은 [4.0s, 5.0s], 1분 TTL은 [48s, 60s] 범위에서 정해진다.

6. 패턴 선택 가이드

패턴 읽기 성능 쓰기 성능 일관성 구현 난이도 적합한 사용 사례
Cache-Aside 높음 중간 중간 낮음 일반적인 조회 API
Write-Through 높음 낮음 높음 중간 쓴 직후 바로 읽히는 데이터
Write-Behind 높음 매우 높음 낮음 높음 조회수, 좋아요, 통계
Refresh-Ahead 매우 높음 중간 중간 높음 인기 게시글, 랭킹

선택 기준

  1. 데이터 특성
    • 정적 데이터: Cache-Aside + 긴 TTL
    • 동적 데이터: Cache-Aside + 짧은 TTL 또는 Write-Through
    • 실시간 데이터: 캐싱 제외 또는 매우 짧은 TTL
  2. 트래픽 패턴
    • 읽기 위주: Cache-Aside
    • 쓰기 위주: Write-Behind
    • 예측 가능한 트래픽: Refresh-Ahead
  3. 일관성 요구사항
    • 강한 일관성 필요: Write-Through 또는 캐시 무효화 중심 설계
    • 최종 일관성 허용: Cache-Aside, Write-Behind
    • 일부 손실 허용: Write-Behind
  4. 성능 요구사항
    • 읽기 지연 최소화: Refresh-Ahead
    • 쓰기 지연 최소화: Write-Behind
    • 균형: Cache-Aside