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번 실행됨
}

이건 이론이 아니다. 실제 트래픽 장애에서 커넥션 대기 지점을 전수 분석했을 때, 캐시가 붙어 있는 조회에서도 전체 대기의 10% 이상이 발생했다. TTL이 만료되는 순간 몰려 있던 동시 요청이 전부 DB로 직행한 것이다. 캐시를 붙였다고 끝이 아니라, 만료 순간의 동작까지 설계해야 한다.

5.1 @Cacheable(sync = true)

sync는 캐시를 더 빨리 만드는 옵션이 아니라, 캐시가 비어 있을 때 발생하는 동일 키의 중복 계산을 합치는 옵션이다. 일반적인 @Cacheable에서는 여러 요청이 거의 동시에 Cache Miss를 확인하면 각각 원본 메서드를 실행한다. 따라서 캐시가 만료되는 순간에는 같은 데이터를 DB에서 여러 번 읽거나, 외부 API를 여러 번 호출할 수 있다.

sync = true를 사용하면 캐시 키를 기준으로 한 번에 하나의 요청만 값을 생성하도록 조정하고, 나머지 요청은 그 결과가 캐시에 저장될 때까지 기다린다. 첫 요청이 값을 생성하고 캐시에 저장하면 대기 중이던 요청은 같은 결과를 반환한다. 이를 요청 병합 또는 single-flight 방식으로 이해할 수 있다.

다만 이 동작은 캐시 키별로 적용된다. id = 1을 생성하는 요청과 id = 2를 생성하는 요청은 서로 다른 키이므로 동시에 실행될 수 있다. 반대로 키를 너무 단순하게 만들면 서로 다른 데이터 요청이 같은 잠금 단위에 묶여 불필요하게 대기할 수 있으므로, 캐시 키 설계가 중요하다.

@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를 조회할 수 있다.

또한 sync는 DB 트랜잭션을 하나로 묶거나 데이터 변경과 캐시 갱신의 일관성을 보장하지 않는다. 원본 메서드가 실패하면 대기 중인 요청도 실패할 수 있고, 생성 시간이 지나치게 길면 대기 요청의 지연 시간도 함께 커진다. 따라서 이 옵션은 읽기 작업의 동시 Cache Miss를 줄이는 국소적인 보호 장치로 보고, 분산 환경의 중복 실행이나 강한 일관성까지 해결한다고 기대해서는 안 된다.

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

실제로 트래픽 장애 대응 중에 이 조합(sync = true + lockingRedisCacheWriter)의 도입을 검토하다가 접었다. 분산 락이라 다중 인스턴스에서도 병합 자체는 되지만, 락이 캐시 키가 아니라 캐시명 단위라 무관한 게시판·페이지 요청까지 한 줄로 직렬화됐고, 결정적으로 락에 TTL이 없어서 값을 적재하던 인스턴스가 죽으면 해당 캐시 전체가 영구 정지할 수 있었다. 폭주 대비용 안전장치가 새로운 단일 장애점이 되는 셈이라 채택하지 않았다.

대신 리포지토리 레이어에 요청 병합(single-flight)을 직접 구현하는 방향을 잡았다. 같은 키의 동시 미스를 1건의 DB 호출로 병합하는 것인데, 평시에는 아무 효과가 없고 부하가 심할수록 강해지는 안전밸브라 폭주 대비에 적합하다. 직접 구현한다면 네 가지는 지켜야 한다.

  1. 대기 요청을 CompletableFuture.supplyAsync로 처리하지 않는다 — 별도 스레드풀에서는 SecurityContext 같은 ThreadLocal이 전파되지 않는다
  2. 대기하는 요청에는 반드시 타임아웃을 건다 — 선두 요청이 느려지면 대기열 전체가 함께 느려진다
  3. in-flight 키 제거는 finally에서 한다 — 빼먹으면 예외 한 번에 해당 키가 영구 병합 상태로 남는다
  4. 선두 요청이 실패했을 때 대기자들에게 예외를 전파할지, 각자 재시도하게 할지 정책을 미리 정한다

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 Fencing Token으로 늦은 쓰기 방어

분산 락을 잡았더라도 락 TTL이 먼저 만료될 수 있다. 예를 들어 A 인스턴스가 락을 잡고 DB 조회를 오래 수행하는 동안 lease time이 만료되면, B 인스턴스가 같은 락을 새로 잡고 더 최신 작업을 수행할 수 있다. 이때 A가 뒤늦게 결과를 저장하면 B의 결과를 덮어쓰는 문제가 생긴다. 이런 상황을 stale writer 문제라고 볼 수 있다.

이 문제를 줄이는 방법 중 하나가 fencing token이다. 락을 획득할 때마다 단조 증가하는 토큰을 발급하고, 실제 쓰기 대상은 자신이 받은 토큰이 현재 저장된 토큰보다 크거나 같을 때만 쓰기를 허용한다. 늦게 도착한 이전 작업자는 더 작은 토큰을 가지고 있으므로 쓰기가 거부된다.

Redis에서는 RAtomicLong으로 fencing token을 만들 수 있다.

@Service
public class FencedDataService {

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

    public void rebuildCacheWithFence(Long id) {
        String cacheKey = "data:" + id;
        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);
            }

            // 락을 획득한 순서대로 증가하는 fencing token을 발급한다.
            long fencingToken = redissonClient
                .getAtomicLong("fence:" + cacheKey)
                .incrementAndGet();

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

            // 실제 저장 시점에 token을 비교해서 늦게 도착한 이전 작업자의 쓰기를 막는다.
            saveIfCurrent(cacheKey, data, fencingToken);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("Interrupted while acquiring lock: " + cacheKey, e);
        } finally {
            if (locked && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

캐시에 저장할 때는 데이터와 token을 함께 저장한다. 아래 예시는 개념을 보여주기 위한 단순한 형태다.

public record FencedCacheValue<T>(T data, long fencingToken) {
}
private void saveIfCurrent(String cacheKey, DataDto data, long fencingToken) {
    Cache cache = cacheManager.getCache("data");
    if (cache == null) {
        return;
    }

    FencedCacheValue<DataDto> current = cache.get(cacheKey, FencedCacheValue.class);
    if (current != null && current.fencingToken() > fencingToken) {
        // 더 최신 token으로 저장된 값이 있으므로 늦게 끝난 이전 작업자의 결과는 버린다.
        return;
    }

    cache.put(cacheKey, new FencedCacheValue<>(data, fencingToken));
}

캐시가 아니라 DB row를 갱신하는 경우에는 DB 테이블에 fencing_token 컬럼을 두고 조건부 update를 걸 수 있다. DB의 조건부 update는 비교와 쓰기가 한 문장으로 처리되므로 더 강한 방어가 된다.

UPDATE payment_result
   SET status = ?,
       fencing_token = ?
 WHERE payment_id = ?
   AND fencing_token < ?;

위 쿼리에서는 새로 발급받은 fencing token을 SET fencing_token = ?AND fencing_token < ? 양쪽에 같은 값으로 전달한다. update count가 0이면 더 최신 token이 이미 반영된 상태이므로 현재 작업 결과는 버린다.

fencing token은 락 자체를 대체하는 기능이 아니다. 락은 동시에 같은 작업이 몰리는 것을 줄이고, fencing token은 락 TTL 만료나 지연 실행 때문에 늦게 도착한 작업자가 최신 결과를 덮어쓰는 것을 막는다. 핵심 쓰기 작업이라면 두 가지를 함께 고려하는 편이 안전하다.

5.4 확률적 조기 만료

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.5 음수 지터(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