캐싱: 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이라고도 함)를 사용하면 애플리케이션이 캐시를 직접 관리합니다:
- 읽기: 캐시에서 먼저 찾습니다. 미스가 발생하면 데이터베이스에서 읽고, 결과를 캐시에 기록한 후 반환합니다.
- 쓰기: 먼저 데이터베이스를 업데이트한 다음 캐시의 항목을 삭제합니다. 다음 읽기에서 새 데이터를 다시 로드합니다.
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로 전환하고 캐시가 실제로 데이터베이스 액세스 횟수를 줄이는지 증명하는 테스트를 항상 수행하세요.