캐싱 전략
캐싱이란?
캐싱(Caching)은 자주 사용되는 데이터를 빠르게 접근 가능한 임시 저장소에 보관하여 응답 속도를 높이고 원본 데이터 소스의 부하를 줄이는 기법이다.
캐시 없음:
Client → [Application] → [DB] → 응답 (매번 DB 쿼리)
100ms 200ms 총 300ms
캐시 있음:
Client → [Application] → [Cache] → 응답 (Cache Hit)
100ms 1ms 총 101ms
or
Client → [Application] → [Cache Miss] → [DB] → [Cache 저장] → 응답
100ms 1ms 200ms 1ms 총 302ms (최초 1회)
캐시 핵심 용어
Cache Hit: 요청한 데이터가 캐시에 있음 → 빠른 응답
Cache Miss: 요청한 데이터가 캐시에 없음 → 원본 조회 필요
Cache Hit Ratio = Cache Hit 수 / 전체 요청 수 (높을수록 좋음)
Hot Data: 자주 접근되는 데이터 (캐싱 우선 대상)
Cold Data: 거의 접근되지 않는 데이터
Stale: 캐시 데이터가 원본과 다를 수 있는 상태 (만료됨)
Eviction: 캐시가 꽉 찼을 때 기존 항목 제거
TTL: Time To Live, 캐시 유효 기간
Cache-Aside (Lazy Loading)
가장 일반적인 캐싱 패턴이다. 애플리케이션이 직접 캐시와 DB를 모두 관리한다.
읽기 동작
1. 애플리케이션이 캐시 조회
2. Cache Hit → 캐시 데이터 반환 (종료)
3. Cache Miss → DB 조회
4. DB 결과를 캐시에 저장 (TTL 설정)
5. DB 결과 반환
┌──────────┐ 조회 ┌───────┐ Miss ┌────┐
│ App │ ───────→ │ Cache │ ─────→ │ DB │
│ │ ←─────── │ │ ←───── │ │
└──────────┘ 반환 └───────┘ 저장 └────┘
구현 예시
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final RedisTemplate<String, User> redisTemplate;
private static final Duration TTL = Duration.ofMinutes(30);
public User getUser(Long userId) {
String key = "user:" + userId;
ValueOperations<String, User> ops = redisTemplate.opsForValue();
// 1. 캐시 조회
User cached = ops.get(key);
if (cached != null) {
return cached; // Cache Hit
}
// 2. Cache Miss → DB 조회
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
// 3. 캐시 저장
ops.set(key, user, TTL);
return user;
}
// 데이터 변경 시 캐시 무효화
@Transactional
public void updateUser(Long userId, UserUpdateRequest request) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
user.update(request);
userRepository.save(user);
// 캐시 삭제 (다음 조회 시 DB에서 최신 데이터 로드)
redisTemplate.delete("user:" + userId);
}
}
장단점과 적합한 상황
| 구분 | 내용 |
|---|---|
| 장점 | 실제 요청된 데이터만 캐싱 (효율적 메모리 사용) |
| 장점 | 캐시 장애가 전체 시스템에 영향 없음 (DB로 폴백) |
| 장점 | 구현 단순, 다양한 캐시 시스템과 호환 |
| 단점 | 최초 요청은 항상 Cache Miss (초기 지연) |
| 단점 | DB와 캐시 데이터 일시적 불일치 가능 |
| 단점 | 캐시 스탬피드 취약 |
| 적합 | 읽기 비율이 높은 워크로드 |
| 적합 | 데이터 접근 패턴이 불규칙한 경우 |
Read-Through
캐시가 DB 앞에 위치하여 모든 읽기 요청이 캐시를 통과한다. Cache Miss 시 캐시 자체가 DB를 조회하고 저장한다.
동작
Client → Cache → (Miss) → DB
↑ Miss 시 캐시가 직접 DB 조회 후 저장
↓ Hit 시 캐시가 직접 응답
Cache-Aside와 차이:
Cache-Aside: App이 Cache와 DB 모두 직접 관리
Read-Through: App은 Cache만 바라봄, DB 접근은 캐시가 담당
구현 예시 (Spring Cache + 커스텀 로더)
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeValuesWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
}
@Service
public class ProductService {
@Cacheable(value = "products", key = "#productId")
public Product getProduct(Long productId) {
// Cache Miss 시 이 메서드 실행 → 결과가 자동으로 캐시에 저장
return productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
}
@CacheEvict(value = "products", key = "#productId")
@Transactional
public void updateProduct(Long productId, ProductUpdateRequest request) {
// 캐시 무효화 후 DB 업데이트
Product product = productRepository.findById(productId)
.orElseThrow();
product.update(request);
}
@CachePut(value = "products", key = "#result.id")
@Transactional
public Product createProduct(ProductCreateRequest request) {
// 생성 후 즉시 캐시에도 저장
return productRepository.save(Product.from(request));
}
}
장단점
| 구분 | 내용 |
|---|---|
| 장점 | 애플리케이션 코드 단순 (캐싱 로직 분리) |
| 장점 | 항상 캐시를 통해 읽으므로 일관된 인터페이스 |
| 단점 | 캐시 제공자가 DB 접근 로직 내장 필요 |
| 단점 | 처음에는 Cache Miss 불가피 (캐시 웜업 필요) |
Write-Through
데이터를 쓸 때 캐시와 DB에 동시에 저장한다. 캐시와 DB가 항상 동기화된다.
동작
Client → App → Cache → DB (동기 쓰기)
↗ 캐시에도 즉시 저장
장점: 읽기 시 항상 최신 데이터
단점: 쓰기 지연 증가 (Cache + DB 모두 완료 후 응답)
구현 예시
@Service
@RequiredArgsConstructor
public class InventoryService {
private final InventoryRepository inventoryRepository;
private final RedisTemplate<String, Integer> redisTemplate;
@Transactional
public void updateStock(Long productId, int quantity) {
// 1. DB 업데이트
inventoryRepository.updateStock(productId, quantity);
// 2. 캐시도 즉시 업데이트 (Write-Through)
String key = "stock:" + productId;
redisTemplate.opsForValue().set(key, quantity, Duration.ofHours(1));
}
public int getStock(Long productId) {
String key = "stock:" + productId;
Integer cached = redisTemplate.opsForValue().get(key);
if (cached != null) return cached;
int stock = inventoryRepository.findStockByProductId(productId);
redisTemplate.opsForValue().set(key, stock, Duration.ofHours(1));
return stock;
}
}
장단점과 적합한 상황
| 구분 | 내용 |
|---|---|
| 장점 | 캐시와 DB 항상 일치 (강한 정합성) |
| 장점 | 읽기 시 항상 최신 데이터 보장 |
| 단점 | 쓰기 지연 증가 (캐시+DB 동시 쓰기) |
| 단점 | 읽히지 않는 데이터도 캐시에 저장 (메모리 낭비) |
| 적합 | 금융 잔액, 재고 등 정확성이 중요한 데이터 |
| 적합 | Write-Heavy보다는 Read-Heavy 데이터에 효과적 |
Write-Behind (Write-Back)
데이터를 캐시에만 먼저 쓰고, DB 동기화는 나중에 비동기로 처리한다.
동작
Client → App → Cache (즉시 응답)
↓ 비동기
DB (나중에 배치 처리)
장점: 쓰기 성능 극대화
단점: 캐시 장애 시 캐시에만 있는 데이터 유실 위험
구현 예시
@Component
@RequiredArgsConstructor
public class ViewCountService {
private final RedisTemplate<String, Long> redisTemplate;
private final ArticleRepository articleRepository;
// 조회수 증가: Redis에만 즉시 기록
public void incrementViewCount(Long articleId) {
String key = "viewcount:" + articleId;
redisTemplate.opsForValue().increment(key);
}
// 30초마다 Redis → DB 동기화
@Scheduled(fixedDelay = 30000)
public void flushViewCounts() {
Set<String> keys = redisTemplate.keys("viewcount:*");
if (keys == null || keys.isEmpty()) return;
for (String key : keys) {
Long count = redisTemplate.opsForValue().get(key);
if (count == null || count == 0) continue;
Long articleId = Long.parseLong(key.replace("viewcount:", ""));
// DB 업데이트 후 캐시 초기화
articleRepository.incrementViewCount(articleId, count);
redisTemplate.opsForValue().set(key, 0L);
}
}
}
장단점과 적합한 상황
| 구분 | 내용 |
|---|---|
| 장점 | 쓰기 성능 극대화 (캐시 쓰기만큼 빠름) |
| 장점 | DB 부하 대폭 감소 (배치로 묶어서 처리) |
| 단점 | 캐시 장애 시 미동기화 데이터 유실 |
| 단점 | 구현 복잡도 높음 |
| 단점 | 캐시-DB 간 일시적 불일치 |
| 적합 | 조회수, 좋아요 수 등 빈번한 갱신이 필요한 데이터 |
| 적합 | 일부 유실이 허용되는 통계성 데이터 |
Write-Around
쓰기 시 캐시를 우회하여 DB에만 저장한다. 읽기 시에만 캐시를 활용한다.
동작
쓰기: Client → App → DB (캐시 건너뜀)
읽기: Client → App → Cache → (Miss 시) DB → Cache 저장
목적: 한 번 쓰고 거의 읽지 않는 데이터로 캐시 오염 방지
구현 예시
@Service
@RequiredArgsConstructor
public class LogService {
private final LogRepository logRepository;
private final RedisTemplate<String, Log> redisTemplate;
// 로그 저장: 캐시 우회, DB에만 저장
@Transactional
public void saveLog(LogCreateRequest request) {
logRepository.save(Log.from(request));
// 캐시에 저장하지 않음 (Write-Around)
}
// 최근 로그 조회: 자주 조회되는 경우에만 캐싱
public List<Log> getRecentLogs(Long userId, int limit) {
String key = "recent-logs:" + userId;
List<Log> cached = (List<Log>) redisTemplate.opsForValue().get(key);
if (cached != null) return cached;
List<Log> logs = logRepository.findRecentByUserId(userId, limit);
redisTemplate.opsForValue().set(key, logs, Duration.ofMinutes(5));
return logs;
}
}
장단점
| 구분 | 내용 |
|---|---|
| 장점 | 일회성 데이터로 캐시 메모리 낭비 방지 |
| 장점 | 캐시는 실제 자주 읽히는 데이터만 보유 |
| 단점 | 쓰기 후 최초 읽기는 반드시 Cache Miss |
| 적합 | 로그, 이벤트 기록 등 쓰기 후 잘 읽지 않는 데이터 |
Refresh-Ahead (Read-Ahead)
캐시 만료 전에 미리 데이터를 갱신하는 전략이다. TTL 만료로 인한 Cache Miss와 지연을 방지한다.
동작
TTL = 60초, Refresh Factor = 0.8
t=0s: 캐시 저장
t=48s: TTL의 80% 시점 → 백그라운드에서 미리 갱신
t=60s: TTL 만료 전에 이미 새 데이터로 교체
요청이 t=50s에 오면:
→ 캐시 Hit (이미 t=48s에 갱신됨)
→ 지연 없음
구현 예시
@Component
@RequiredArgsConstructor
public class ExchangeRateCache {
private final RedisTemplate<String, BigDecimal> redisTemplate;
private final ExchangeRateApiClient apiClient;
private static final Duration TTL = Duration.ofMinutes(5);
private static final double REFRESH_FACTOR = 0.8;
public BigDecimal getRate(String currency) {
String key = "rate:" + currency;
BigDecimal rate = redisTemplate.opsForValue().get(key);
if (rate == null) {
// Cache Miss → 동기 갱신
rate = refreshRate(currency);
} else {
// TTL의 80% 지점이면 비동기 갱신
Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
long threshold = (long)(TTL.toSeconds() * (1 - REFRESH_FACTOR));
if (ttl != null && ttl < threshold) {
asyncRefresh(currency);
}
}
return rate;
}
@Async
public void asyncRefresh(String currency) {
refreshRate(currency);
}
private BigDecimal refreshRate(String currency) {
BigDecimal rate = apiClient.fetchRate(currency);
redisTemplate.opsForValue().set("rate:" + currency, rate, TTL);
return rate;
}
}
장단점
| 구분 | 내용 |
|---|---|
| 장점 | Cache Miss로 인한 지연 거의 없음 |
| 장점 | TTL 만료 시점에 급격한 부하 방지 |
| 단점 | 불필요한 데이터도 미리 갱신 (리소스 낭비 가능) |
| 단점 | 갱신 타이밍 계산 로직 복잡 |
| 적합 | 환율, 주가 등 주기적으로 갱신되는 데이터 |
| 적합 | 지연에 민감한 실시간 서비스 |
패턴 비교 요약
| 패턴 | 쓰기 주체 | 읽기 주체 | 정합성 | 쓰기 성능 | 복잡도 |
|---|---|---|---|---|---|
| Cache-Aside | App | App | 낮음 | 보통 | 낮음 |
| Read-Through | App | Cache | 낮음 | 보통 | 중간 |
| Write-Through | App | Cache | 높음 | 낮음 | 중간 |
| Write-Behind | Cache | Cache | 낮음 | 높음 | 높음 |
| Write-Around | App(DB만) | App | 중간 | 높음 | 낮음 |
| Refresh-Ahead | Background | Cache | 중간 | - | 높음 |
데이터 정합성 문제
문제 1: Cache Invalidation 타이밍
잘못된 순서:
1. DB 업데이트 성공
2. 캐시 삭제 실패 → 구 데이터 계속 서빙
올바른 순서 (Cache-Aside):
1. 캐시 삭제 (먼저)
2. DB 업데이트
또는
DB 업데이트 후 캐시 삭제 실패 시 재시도 로직 필수:
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 100))
public void evictCache(String key) {
redisTemplate.delete(key);
}
문제 2: 동시성 문제 (Race Condition)
시나리오:
Thread A: DB 읽기 → (구 데이터) 캐시 저장
Thread B: DB 업데이트 → 캐시 삭제
Thread A: 삭제된 캐시에 구 데이터 저장 → 구 데이터로 오염!
해결:
1. 캐시 저장 시 버전 번호 또는 타임스탬프 포함
→ 새 버전 값이 있으면 덮어쓰지 않음
2. 분산 락 사용 (Redisson)
3. TTL을 짧게 설정하여 자연 만료 빠르게
문제 3: Double Delete 패턴
// Read-Through + Write-Through 환경에서 안전한 무효화
@Transactional
public void updateUser(Long userId, UserUpdateRequest request) {
// 1. 캐시 먼저 삭제
redisTemplate.delete("user:" + userId);
// 2. DB 업데이트
userRepository.save(user);
// 3. 트랜잭션 커밋 후 캐시 재삭제 (이벤트 기반)
// TransactionalEventListener로 커밋 후 실행 보장
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onUserUpdated(UserUpdatedEvent event) {
redisTemplate.delete("user:" + event.getUserId());
}
캐시 스탬피드 (Cache Stampede)
문제
많이 요청되는 캐시 키가 만료되는 순간, 수많은 요청이 동시에 Cache Miss → DB 쿼리 폭주 발생.
t=0: "popular-product:1" 캐시 만료
t=0~0.1s: 1000개 요청 동시 Cache Miss
→ 1000개 DB 쿼리 동시 발생 → DB 과부하 → 장애
해결책 1: 뮤텍스 락 (Mutex Lock)
public Product getProduct(Long productId) {
String key = "product:" + productId;
Product cached = redisTemplate.opsForValue().get(key);
if (cached != null) return cached;
// 락 획득 시도 (한 스레드만 DB 조회)
String lockKey = "lock:" + key;
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(5));
if (Boolean.TRUE.equals(acquired)) {
try {
// 락 획득한 스레드만 DB 조회
Product product = productRepository.findById(productId).orElseThrow();
redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
return product;
} finally {
redisTemplate.delete(lockKey);
}
} else {
// 락 못 얻은 스레드는 잠깐 대기 후 캐시 재조회
Thread.sleep(50);
return getProduct(productId); // 재귀 또는 루프로 재시도
}
}
해결책 2: 확률적 조기 만료 (Probabilistic Early Expiration)
public Product getProduct(Long productId) {
String key = "product:" + productId;
CachedValue<Product> cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
// 남은 TTL이 적을수록 높은 확률로 미리 갱신 (XFetch 알고리즘)
long remainingTtl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
double delta = 1.0; // 튜닝 파라미터
boolean shouldRefresh = Math.random() < (delta * Math.log(remainingTtl) / remainingTtl);
if (!shouldRefresh) {
return cached.getValue();
}
}
Product product = productRepository.findById(productId).orElseThrow();
redisTemplate.opsForValue().set(key, new CachedValue<>(product), Duration.ofMinutes(10));
return product;
}
해결책 3: TTL 지터(Jitter) 추가
// 모든 캐시가 같은 시각에 만료되지 않도록 TTL에 랜덤성 추가
Random random = new Random();
Duration ttl = Duration.ofMinutes(10).plusSeconds(random.nextInt(60));
redisTemplate.opsForValue().set(key, value, ttl);
캐시 웜업 (Cache Warmup)
서비스 시작 시 자주 사용되는 데이터를 미리 캐시에 로드한다.
@Component
@RequiredArgsConstructor
public class CacheWarmup implements ApplicationRunner {
private final ProductService productService;
private final ProductRepository productRepository;
@Override
public void run(ApplicationArguments args) {
log.info("캐시 웜업 시작");
// 인기 상품 Top 100 미리 캐싱
List<Long> popularProductIds = productRepository.findTop100PopularIds();
popularProductIds.parallelStream().forEach(id -> {
try {
productService.getProduct(id); // 조회 시 캐시 저장
} catch (Exception e) {
log.warn("캐시 웜업 실패 - productId: {}", id, e);
}
});
log.info("캐시 웜업 완료: {}개 상품", popularProductIds.size());
}
}
다단계 캐시 (Multi-Level Cache)
L1 (로컬 캐시) + L2 (Redis) 구조
Client → App
→ L1 캐시 (JVM 내 메모리, 수 나노초)
→ L2 캐시 (Redis, 수 밀리초)
→ DB (수십~수백 밀리초)
L1 Hit: 가장 빠름, 네트워크 없음
L1 Miss → L2 Hit: Redis 네트워크 왕복
L2 Miss → DB Hit: DB 쿼리
Spring Boot + Caffeine (L1) + Redis (L2)
@Configuration
public class MultiLevelCacheConfig {
// L1: Caffeine 로컬 캐시
@Bean
public Cache<Long, Product> localCache() {
return Caffeine.newBuilder()
.maximumSize(1000) // 최대 1000개 항목
.expireAfterWrite(1, TimeUnit.MINUTES) // 1분 TTL
.recordStats()
.build();
}
}
@Service
@RequiredArgsConstructor
public class ProductService {
private final Cache<Long, Product> localCache; // L1
private final RedisTemplate<String, Product> redis; // L2
private final ProductRepository repository; // DB
public Product getProduct(Long productId) {
// L1 조회
Product product = localCache.getIfPresent(productId);
if (product != null) {
return product;
}
// L2 조회
product = redis.opsForValue().get("product:" + productId);
if (product != null) {
localCache.put(productId, product); // L1 저장
return product;
}
// DB 조회
product = repository.findById(productId).orElseThrow();
redis.opsForValue().set("product:" + productId, product, Duration.ofMinutes(10)); // L2 저장
localCache.put(productId, product); // L1 저장
return product;
}
}
다단계 캐시 주의사항
문제: L1 캐시 일관성
- 여러 애플리케이션 인스턴스가 각자 L1 캐시를 가짐
- DB 업데이트 시 모든 인스턴스의 L1 캐시 무효화 어려움
해결: Redis Pub/Sub을 이용한 캐시 무효화 이벤트 브로드캐스트
// 캐시 무효화 시
redisTemplate.convertAndSend("cache-invalidation", "product:" + productId);
// 각 인스턴스에서 구독
@Component
public class CacheInvalidationListener implements MessageListener {
@Override
public void onMessage(Message message, byte[] pattern) {
String key = new String(message.getBody());
String productId = key.replace("product:", "");
localCache.invalidate(Long.parseLong(productId));
}
}
L1 vs L2 데이터 분리 전략
L1 (로컬, 소용량, 짧은 TTL):
- 초당 수천 번 이상 읽히는 핫 데이터
- 크기가 작은 참조 데이터 (코드 테이블, 설정값)
- 실시간성보다 속도가 중요한 경우
L2 (Redis, 대용량, 긴 TTL):
- 수 MB 크기의 중형 오브젝트
- 인스턴스 간 공유가 필요한 세션 데이터
- 캐시 일관성이 중요한 데이터
캐시 Eviction 정책
| 정책 | 설명 | 적합한 상황 |
|---|---|---|
| LRU (Least Recently Used) | 가장 오래 사용 안 한 항목 제거 | 최근 접근 데이터 중요 |
| LFU (Least Frequently Used) | 사용 빈도가 가장 낮은 항목 제거 | 인기 데이터 유지 |
| FIFO | 가장 먼저 들어온 항목 제거 | 시간 순서 중요 |
| Random | 무작위 제거 | 단순, 예측 불가 |
| TTL | 만료 시간 기준 제거 | 시간 기반 유효성 |
Redis maxmemory-policy 설정:
allkeys-lru: 모든 키 중 LRU 제거
volatile-lru: TTL 있는 키 중 LRU 제거
allkeys-lfu: 모든 키 중 LFU 제거 (Redis 4.0+)
volatile-ttl: TTL이 짧은 키부터 제거
noeviction: 메모리 꽉 차면 에러 반환 (기본값)
설정 예시 (redis.conf):
maxmemory 4gb
maxmemory-policy allkeys-lru