캐싱: cache-aside, write-through 및 TTL

1. 개요

캐싱은 데이터베이스의 부하를 줄이고 응답 시간을 단축하는 데 도움이 됩니다. 하지만 그 대신 오래된 데이터를 수용해야 합니다. 이 글은 cache-aside와 write-through라는 두 가지 일반적인 캐시 읽기/쓰기 전략을 소개합니다. 또한 **TTL(time-to-live)**을 사용하여 데이터의 수용 가능한 오래된 정도를 제한하는 방법도 설명합니다.

이 글을 마친 후에는 순수 Java로 두 전략을 모두 구현할 수 있고, Spring Cache의 어노테이션을 사용하도록 전환할 수 있으며, Caffeine과 Redis에 대한 TTL을 구성할 수 있고, 일반적인 실수를 피할 수 있을 것입니다.

2. 기본 개념

코드를 살펴보기 전에 몇 가지 용어를 통일하겠습니다:

  • 캐시 히트: 데이터가 캐시에 이미 있어서 바로 반환됩니다.
  • 캐시 미스: 캐시에 데이터가 없어서 원본 소스(데이터베이스, 다른 서비스)에서 읽어야 합니다.
  • 오래된 데이터: 캐시의 데이터가 데이터베이스의 데이터와 다릅니다.
  • TTL: 항목의 생명 시간입니다. TTL이 끝나면 항목은 더 이상 유효하지 않은 것으로 간주됩니다.

모든 캐싱 전략은 한 가지 질문을 중심으로 합니다: 데이터가 변경될 때 누가 캐시를 업데이트해야 하고, 언제 업데이트해야 할까요?

이 글 전체에서 사용할 간단한 도메인은 다음과 같습니다:

public record Product(long id, String name, BigDecimal price) {}

public interface ProductRepository {
    Optional<Product> findById(long id);
    Product save(Product product);
}

3. 직접 구현한 TTL 캐시

메커니즘을 명확히 하기 위해 TTL이 있는 작은 캐시를 직접 작성하겠습니다. Supplier<Instant>를 통해 시간을 가져오므로 테스트에서 시계를 ‘빨리 감을’ 수 있습니다.

public class TtlCache<K, V> {

    private record Entry<V>(V value, Instant expiresAt) {}

    private final Map<K, Entry<V>> store = new ConcurrentHashMap<>();
    private final Duration ttl;
    private final Supplier<Instant> now;

    public TtlCache(Duration ttl, Supplier<Instant> now) {
        this.ttl = ttl;
        this.now = now;
    }

    public Optional<V> get(K key) {
        Entry<V> entry = store.get(key);
        if (entry == null) {
            return Optional.empty();
        }
        if (now.get().isAfter(entry.expiresAt())) {
            store.remove(key, entry);
            return Optional.empty();
        }
        return Optional.of(entry.value());
    }

    public void put(K key, V value) {
        store.put(key, new Entry<>(value, now.get().plus(ttl)));
    }

    public void evict(K key) {
        store.remove(key);
    }
}

이 클래스의 작동 방식:

  • put은 값을 만료 시간 expiresAt = now + ttl과 함께 저장합니다.
  • get은 만료 시간을 확인합니다. 항목이 만료되었으면 삭제되고 함수가 Optional.empty()를 반환합니다. 즉, 캐시 미스입니다.
  • store.remove(key, entry)는 읽은 정확한 항목만 삭제합니다. 따라서 다른 스레드가 방금 새 값을 put했다면 그 값은 실수로 삭제되지 않습니다.

이 클래스는 데모 목적으로는 충분합니다. 프로덕션에서는 Caffeine이나 Redis를 사용해야 합니다(6단원).

4. Cache-aside

4.1. 개념

cache-aside(또는 lazy loading이라고도 함)를 사용하면 애플리케이션이 캐시를 직접 관리합니다:

  1. 읽기: 캐시에서 먼저 찾습니다. 미스가 발생하면 데이터베이스에서 읽고, 결과를 캐시에 기록한 후 반환합니다.
  2. 쓰기: 먼저 데이터베이스를 업데이트한 다음 캐시의 항목을 삭제합니다. 다음 읽기에서 새 데이터를 다시 로드합니다.
public class ProductService {

    private final ProductRepository repository;
    private final TtlCache<Long, Product> cache;

    public ProductService(ProductRepository repository, TtlCache<Long, Product> cache) {
        this.repository = repository;
        this.cache = cache;
    }

    public Optional<Product> findById(long id) {
        Optional<Product> cached = cache.get(id);
        if (cached.isPresent()) {
            return cached;                       // 캐시 히트
        }
        Optional<Product> fromDb = repository.findById(id);
        fromDb.ifPresent(p -> cache.put(id, p)); // 캐시에 로드
        return fromDb;
    }

    public Product updatePrice(long id, BigDecimal newPrice) {
        Product current = repository.findById(id).orElseThrow();
        Product saved = repository.save(new Product(id, current.name(), newPrice));
        cache.evict(id);                         // DB 쓰기 완료 후 삭제
        return saved;
    }
}

설명:

  • findById는 캐시 미스 때만 repository를 호출합니다. 제품이 없으면 아무것도 캐시하지 않습니다.
  • updatePrice는 먼저 데이터베이스에 쓴 다음 evict합니다. 이 순서가 중요하며, 8단원에서 그 이유를 설명합니다.
  • 새 값으로 덮어쓰는 대신 항목을 삭제하도록 선택했습니다. 캐시는 읽기 경로의 한 곳에서만 로드되므로 이 방법이 더 간단합니다.

4.2. 언제 사용할 것인가

Cache-aside는 다음과 같은 경우에 적합합니다:

  • 읽기가 쓰기보다 훨씬 많은 경우, 예를 들어 제품 카탈로그 또는 사용자 프로필.
  • 실제로 액세스한 데이터만 캐시하려는 경우.
  • 캐시가 다운되었을 때도 시스템이 계속 실행되어야 하는 경우. 그러면 모든 요청이 데이터베이스로 직접 가므로 느리지만 오류가 발생하지 않습니다.

단점: 각 미스 후 첫 번째 요청은 항상 느립니다. 또한 다른 프로세스가 캐시를 삭제하지 않고 데이터베이스에 직접 쓰면 TTL이 끝날 때까지 데이터가 오래됩니다.

5. Write-through

5.1. 개념

write-through를 사용하면 모든 쓰기 작업이 캐시 레이어를 통과합니다. 이 레이어는 데이터베이스에 동기식으로 쓰고 새 값으로 캐시를 업데이트합니다. 쓰기 명령이 반환될 때 캐시와 데이터베이스가 동기화됩니다.

public class WriteThroughProductStore {

    private final ProductRepository repository;
    private final TtlCache<Long, Product> cache;

    public WriteThroughProductStore(ProductRepository repository, TtlCache<Long, Product> cache) {
        this.repository = repository;
        this.cache = cache;
    }

    public Product save(Product product) {
        Product saved = repository.save(product); // 1. DB에 쓰기 (동기식)
        cache.put(saved.id(), saved);             // 2. 캐시 업데이트
        return saved;
    }

    public Optional<Product> findById(long id) {
        return cache.get(id).or(() -> {
            Optional<Product> fromDb = repository.findById(id);
            fromDb.ifPresent(p -> cache.put(id, p));
            return fromDb;
        });
    }
}

Cache-aside와의 차이점:

  • save는 삭제하는 대신 저장된 값으로 캐시를 덮어씁니다. 바로 다음 읽기는 캐시 히트입니다.
  • repository.save가 성공할 때만 캐시에 씁니다. 데이터베이스가 예외를 던지면 캐시가 건드려지지 않으므로 캐시에 저장된 적 없는 데이터를 포함할 수 없습니다.
  • findById는 여전히 폴백 분기가 필요합니다. 항목이 이미 만료되었거나 save를 통해 기록된 적이 없을 수 있기 때문입니다.

5.2. 언제 사용할 것인가

Write-through는 방금 쓴 데이터가 바로 다시 읽힐 때 적합합니다. 예를 들어 쇼핑 카트나 사용자 설정입니다. 대가로:

  • 각 쓰기는 두 곳에 써야 하므로 약간 더 느립니다.
  • 캐시는 아무도 읽지 않는 많은 데이터를 포함할 수 있습니다. TTL과 크기 제한을 함께 사용해야 합니다.

참고: write-through를 write-behind(write-back)와 혼동하지 마세요. Write-behind는 먼저 캐시에 기록한 다음 데이터베이스에 비동기식으로 씁니다. 더 빠르지만 캐시 노드가 플러시 전에 죽으면 데이터가 손실됩니다. 그 위험을 감수할 수 있을 때만 write-behind를 사용하세요.

6. Spring Cache 사용

직접 작성하면 메커니즘을 이해하는 데 도움이 되지만, 실제 프로젝트에서는 Caffeine(로컬 캐시) 또는 Redis(분산 캐시)로 Spring Cache를 사용해야 합니다. 의존성을 추가하세요:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

@EnableCaching으로 캐싱을 활성화하세요:

@SpringBootApplication
@EnableCaching
public class ShopApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}

6.1. @Cacheable과 @CacheEvict를 사용한 Cache-aside

@Service
public class CatalogService {

    private final ProductRepository repository;

    public CatalogService(ProductRepository repository) {
        this.repository = repository;
    }

    @Cacheable(cacheNames = "products", key = "#id", unless = "#result == null")
    public Optional<Product> findById(long id) {
        return repository.findById(id);
    }

    @CacheEvict(cacheNames = "products", key = "#product.id")
    public Product update(Product product) {
        return repository.save(product);
    }
}
  • @Cacheable은 선언적 형태의 cache-aside입니다. Spring이 먼저 캐시를 확인하고 미스가 발생하면 메서드를 호출한 후 결과를 저장합니다.
  • Optional 반환 타입의 경우 Spring은 내부 값을 캐시합니다. unless = "#result == null"은 찾지 못한 경우를 캐시하는 것을 방지합니다.
  • @CacheEvict는 기본적으로 메서드가 성공적으로 반환된 후에 실행됩니다. 이것이 정확히 “DB에 쓴 다음 캐시 삭제”의 순서입니다.

6.2. @CachePut을 사용한 Write-through

@CachePut(cacheNames = "products", key = "#result.id")
public Product save(Product product) {
    return repository.save(product);
}

@CachePut은 항상 메서드를 실행한 다음 반환된 값을 캐시에 씁니다. 이는 5단원의 WriteThroughProductStore.save와 동일합니다.

참고: 어노테이션 캐싱은 프록시를 통해 작동합니다. 같은 클래스 내의 다른 메서드에서 this.findById(...)를 호출하면 캐싱이 완전히 무시됩니다. 다른 빈을 통해 호출하세요.

7. TTL 구성

7.1. Caffeine

spring:
  cache:
    type: caffeine
    cache-names: products
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=10m
  • expireAfterWrite=10m: 항목은 마지막 쓰기 후 10분이 지나면 만료됩니다.
  • maximumSize=10000: 제한을 초과하면 Caffeine이 덜 사용된 항목을 제거합니다. 캐시가 힙을 모두 사용하는 것을 방지하려면 항상 이 제한을 설정하세요.

7.2. Redis

spring-boot-starter-data-redis를 추가한 후 다음과 같이 구성하세요:

spring.cache.type=redis
spring.cache.redis.time-to-live=10m
spring.cache.redis.key-prefix=shop::

Redis는 TTL이 끝나면 자동으로 키를 삭제합니다. 이 캐시는 애플리케이션의 여러 인스턴스 간에 공유됩니다.

7.3. TTL을 얼마로 설정할 것인가?

모든 경우에 적용할 수 있는 숫자는 없습니다. 자문해 보세요: 데이터가 최대 얼마나 오래될 수 있을까요?

환율, 재고        : 몇 초 – 1분
제품 가격, 카탈로그 : 5 – 30분
설정, 지역/도시 목록 : 몇 시간 – 1일

짧은 TTL은 더 신선한 데이터를 제공하지만 히트율이 낮습니다. 긴 TTL은 그 반대입니다. 능동적으로 제거한 후에도 TTL을 놓친 제거에 대한 안전망으로 설정해야 합니다.

7.4. 캐시 스탐피드를 피하기 위해 jitter 추가

TTL 10분으로 한 번에 10,000개의 제품을 로드한다고 가정하세요. 동시에 만료되고 데이터베이스가 대량의 요청을 받게 됩니다. 이 현상을 캐시 스탐피드라고 합니다. 해결책은 TTL에 임의의 범위를 추가하는 것입니다:

public void put(K key, V value) {
    long jitterSeconds = ThreadLocalRandom.current().nextLong(0, 60);
    Instant expiresAt = now.get().plus(ttl).plusSeconds(jitterSeconds);
    store.put(key, new Entry<>(value, expiresAt));
}

Spring + Caffeine의 경우 @Cacheable(..., sync = true)를 추가할 수 있습니다. 여러 스레드가 동일한 키를 놓쳤을 때 하나의 스레드만 데이터베이스로 호출되고 나머지 스레드는 결과를 기다립니다.

8. 테스트 작성

데이터베이스 액세스 횟수를 세기 위해 가짜 리포지토리와 제어 가능한 시계를 사용합니다:

class ProductServiceTest {

    static class CountingRepository implements ProductRepository {
        final Map<Long, Product> data = new HashMap<>();
        int findCalls = 0;

        public Optional<Product> findById(long id) {
            findCalls++;
            return Optional.ofNullable(data.get(id));
        }

        public Product save(Product p) {
            data.put(p.id(), p);
            return p;
        }
    }

    AtomicReference<Instant> clock = new AtomicReference<>(Instant.parse("2026-01-01T00:00:00Z"));
    CountingRepository repository = new CountingRepository();
    ProductService service = new ProductService(
            repository, new TtlCache<>(Duration.ofMinutes(10), clock::get));

    @BeforeEach
    void setUp() {
        repository.save(new Product(1L, "Nón lá", new BigDecimal("50000")));
    }

    @Test
    void secondReadIsServedFromCache() {
        service.findById(1L);
        service.findById(1L);

        assertThat(repository.findCalls).isEqualTo(1);
    }

    @Test
    void entryExpiresAfterTtl() {
        service.findById(1L);
        clock.set(clock.get().plus(Duration.ofMinutes(11)));
        service.findById(1L);

        assertThat(repository.findCalls).isEqualTo(2);
    }

    @Test
    void updateEvictsStaleValue() {
        service.findById(1L);
        service.updatePrice(1L, new BigDecimal("45000"));

        assertThat(service.findById(1L))
                .get()
                .extracting(Product::price)
                .isEqualTo(new BigDecimal("45000"));
    }
}
  • secondReadIsServedFromCache: 두 번째 읽기는 데이터베이스를 터치하지 않습니다.
  • entryExpiresAfterTtl: 시계가 10분 이상 진행된 후 캐시 미스가 발생하고 데이터베이스가 다시 호출됩니다.
  • updateEvictsStaleValue: 업데이트 후 읽는 사람은 캐시의 이전 가격이 아닌 새로운 가격을 봅니다.

9. 일반적인 오류

데이터베이스에 쓰기 전에 캐시를 삭제합니다. 다음 시나리오를 생각해 보세요: 스레드 A가 캐시를 삭제합니다. 스레드 B는 읽고 미스가 발생하며 데이터베이스에서 오래된 값을 가져온 후 캐시에 씁니다. 그 후 스레드 A가 새 값을 데이터베이스에 씁니다. 결과적으로 캐시는 TTL이 끝날 때까지 이전 값을 유지합니다. 먼저 데이터베이스에 쓴 다음 캐시를 삭제하세요.

커밋되지 않은 트랜잭션 내에서 캐시합니다. @Transactional 메서드 내에서 @CachePut을 수행한 후 트랜잭션이 롤백되면 캐시에 데이터베이스에 없는 데이터가 포함됩니다. 캐시 작업을 트랜잭션 외부 계층에 배치하거나 TransactionAwareCacheManagerProxy를 사용하세요.

여러 인스턴스의 로컬 캐시입니다. Caffeine은 각 인스턴스의 JVM에 있습니다. 인스턴스 A의 @CacheEvict는 인스턴스 B의 캐시를 삭제할 수 없습니다. 여러 인스턴스를 실행하면서 일관성이 필요한 경우 Redis를 사용하거나 짧은 TTL로 오래된 데이터를 수용하세요.

변경 가능한 캐시 객체입니다. Caffeine은 캐시에 있는 동일한 참조를 반환합니다. 코드가 그 객체를 수정하면 캐시도 수정했습니다. 이 글의 예제처럼 record 또는 불변 객체를 사용하세요.

참고: 데이터가 지속적으로 변경되고 절대적인 일관성이 필요할 때는 캐싱하지 마세요. 예를 들어 계정 잔액이나 결제 시점의 남은 좌석 수입니다. 이런 경우 적절한 잠금으로 데이터베이스를 직접 읽는 것이 더 안전합니다.

10. 결론

이 글은 두 가지 주요 캐싱 전략을 비교했습니다:

  • Cache-aside: 애플리케이션이 미스 시 캐시를 로드하고 쓰기 후 캐시를 삭제합니다. 이것은 간단하고 오류 허용도가 높으며 대부분의 읽기 많은 시나리오에 적합합니다.
  • Write-through: 모든 쓰기 작업이 데이터베이스와 캐시를 동기식으로 업데이트하므로 쓰기 후 즉시 신선한 데이터를 얻을 수 있습니다.

어떤 전략을 선택하든 TTL을 안전망으로, 캐시 크기를 제한하고, 스탐피드를 피하기 위해 jitter를 추가해야 합니다. @Cacheable + Caffeine으로 시작하세요. 애플리케이션이 여러 인스턴스로 확장되면 Redis로 전환하고 캐시가 실제로 데이터베이스 액세스 횟수를 줄이는지 증명하는 테스트를 항상 수행하세요.

system-designcachingspring-bootcaffeineredis