이 글에서 얻는 것
- 캐시를 “빠르게 만들기”가 아니라, 일관성/운영/비용까지 포함해 설계하는 감각을 얻습니다.
- Cache-Aside/Write-through/Write-behind를 트래픽/정합성 요구에 맞춰 선택할 수 있습니다.
- 캐시에서 자주 터지는 함정(Stampede/Penetration/Hot Key/무효화 지옥)을 예방하는 기준이 생깁니다.
들어가며
Redis는 인메모리 데이터 저장소로, 캐싱을 통해 애플리케이션 성능을 수십 배 향상시킬 수 있습니다. 이 글에서는 Redis 자료구조부터 실전 캐싱 패턴, 분산 환경 전략까지 다룹니다.
난이도: ⭐⭐ 중급 예상 학습 시간: 45분
1. Redis 기초
1.1 Redis란?
Redis (REmote DIctionary Server):
- In-Memory Key-Value 저장소
- 영속성 지원 (RDB, AOF)
- 다양한 자료구조 제공
- Single-Threaded (I/O Multiplexing)
성능:
- 읽기/쓰기: 100,000+ ops/sec
- 평균 응답 시간: <1ms
- vs RDBMS: 10~100배 빠름
┌─────────────────────────────────────┐
│ Application │
└────────────┬────────────────────────┘
↓
┌───────────────┐
│ Redis (Cache) │ ← 빠름 (메모리)
└───────┬───────┘
↓ (Cache Miss)
┌───────────────┐
│ Database (DB) │ ← 느림 (디스크)
└───────────────┘
1.2 Redis 자료구조
// 1. String - 단순 값 저장
jedis.set("user:123:name", "Alice");
String name = jedis.get("user:123:name"); // "Alice"
// TTL 설정 (초 단위)
jedis.setex("session:abc", 3600, "user_data"); // 1시간
// 숫자 증감
jedis.incr("page:views"); // 1
jedis.incrBy("page:views", 10); // 11
// 2. Hash - 객체 저장
jedis.hset("user:123", "name", "Alice");
jedis.hset("user:123", "email", "alice@example.com");
jedis.hset("user:123", "age", "25");
Map<String, String> user = jedis.hgetAll("user:123");
// {"name": "Alice", "email": "alice@example.com", "age": "25"}
// 3. List - 순서 있는 컬렉션
jedis.lpush("queue:tasks", "task1", "task2", "task3");
String task = jedis.rpop("queue:tasks"); // "task1" (FIFO)
jedis.lpush("recent:posts", "post1", "post2", "post3");
List<String> posts = jedis.lrange("recent:posts", 0, 9); // 최근 10개
// 4. Set - 중복 없는 집합
jedis.sadd("tags:123", "java", "spring", "redis");
Set<String> tags = jedis.smembers("tags:123");
// 집합 연산
jedis.sadd("user:123:following", "456", "789");
jedis.sadd("user:456:followers", "123", "789");
Set<String> mutualFriends = jedis.sinter("user:123:following", "user:456:followers");
// 5. Sorted Set - 정렬된 집합
jedis.zadd("leaderboard", 100, "Alice");
jedis.zadd("leaderboard", 200, "Bob");
jedis.zadd("leaderboard", 150, "Charlie");
// 순위 조회 (점수 높은 순)
List<String> top3 = jedis.zrevrange("leaderboard", 0, 2);
// ["Bob", "Charlie", "Alice"]
// 점수로 필터링
Set<String> highScorers = jedis.zrangeByScore("leaderboard", 150, Double.MAX_VALUE);
2. 캐싱 전략 패턴
2.1 Cache-Aside (Lazy Loading)
가장 일반적인 패턴:
1. 캐시 조회
2. 캐시 미스 → DB 조회
3. DB 결과를 캐시에 저장
4. 결과 반환
┌──────────────────────────────────────┐
│ 1. Read Request │
│ ↓ │
│ 2. Redis GET (Cache Check) │
│ ├─ Hit → Return Cache │
│ └─ Miss │
│ ↓ │
│ 3. DB SELECT │
│ ↓ │
│ 4. Redis SET (Cache Update) │
│ ↓ │
│ 5. Return DB Result │
└──────────────────────────────────────┘
구현:
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
private final RedisTemplate<String, Product> redisTemplate;
private static final String CACHE_KEY_PREFIX = "product:";
private static final Duration CACHE_TTL = Duration.ofHours(1);
public Product getProduct(Long productId) {
String cacheKey = CACHE_KEY_PREFIX + productId;
// 1. 캐시 조회
Product cachedProduct = redisTemplate.opsForValue().get(cacheKey);
if (cachedProduct != null) {
log.info("Cache Hit: {}", cacheKey);
return cachedProduct; // Cache Hit
}
// 2. 캐시 미스 → DB 조회
log.info("Cache Miss: {}", cacheKey);
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
// 3. 캐시에 저장
redisTemplate.opsForValue().set(cacheKey, product, CACHE_TTL);
return product;
}
// 쓰기 시 캐시 무효화
public Product updateProduct(Long productId, ProductUpdateRequest request) {
Product product = productRepository.findById(productId).orElseThrow();
product.update(request);
productRepository.save(product);
// 캐시 삭제 (다음 읽기 시 재생성)
String cacheKey = CACHE_KEY_PREFIX + productId;
redisTemplate.delete(cacheKey);
return product;
}
}
장점:
- 필요한 데이터만 캐싱 (메모리 효율적)
- 구현 간단
- 캐시 장애 시에도 DB 조회 가능
단점:
- Cache Miss 시 DB 부하 발생
- 데이터 불일치 가능 (TTL 내)
2.2 Write-Through
쓰기 시 캐시와 DB에 동시 저장:
1. 데이터 쓰기 요청
2. 캐시 업데이트
3. DB 업데이트
4. 완료
┌──────────────────────────────────────┐
│ 1. Write Request │
│ ↓ │
│ 2. Redis SET (Cache Update) │
│ ↓ │
│ 3. DB INSERT/UPDATE │
│ ↓ │
│ 4. Return Success │
└──────────────────────────────────────┘
구현:
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
private final RedisTemplate<String, Product> redisTemplate;
@Transactional
public Product createProduct(ProductCreateRequest request) {
// 1. DB 저장
Product product = Product.from(request);
Product savedProduct = productRepository.save(product);
// 2. 캐시 저장 (동시)
String cacheKey = CACHE_KEY_PREFIX + savedProduct.getId();
redisTemplate.opsForValue().set(cacheKey, savedProduct, CACHE_TTL);
return savedProduct;
}
@Transactional
public Product updateProduct(Long productId, ProductUpdateRequest request) {
// 1. DB 업데이트
Product product = productRepository.findById(productId).orElseThrow();
product.update(request);
Product updatedProduct = productRepository.save(product);
// 2. 캐시 업데이트 (동시)
String cacheKey = CACHE_KEY_PREFIX + productId;
redisTemplate.opsForValue().set(cacheKey, updatedProduct, CACHE_TTL);
return updatedProduct;
}
}
장점:
- 항상 최신 데이터 유지
- 읽기 성능 일정 (항상 캐시에 있음)
단점:
- 쓰기 성능 저하 (캐시 + DB 두 번)
- 쓰지 않는 데이터도 캐싱 (메모리 낭비)
2.3 Write-Behind (Write-Back)
캐시에 먼저 쓰고, 비동기로 DB 반영:
1. 캐시 업데이트
2. 즉시 응답
3. 백그라운드에서 DB 업데이트
┌──────────────────────────────────────┐
│ 1. Write Request │
│ ↓ │
│ 2. Redis SET (Cache Update) │
│ ↓ │
│ 3. Return Success (즉시) │
│ │
│ (비동기) │
│ 4. DB INSERT/UPDATE (백그라운드) │
└──────────────────────────────────────┘
구현:
@Service
@RequiredArgsConstructor
public class ViewCountService {
private final RedisTemplate<String, String> redisTemplate;
private final PostRepository postRepository;
private static final String VIEW_COUNT_KEY = "post:view:";
// 조회수 증가 (캐시만)
public void incrementViewCount(Long postId) {
String key = VIEW_COUNT_KEY + postId;
redisTemplate.opsForValue().increment(key);
// 즉시 반환 (빠름!)
}
// 스케줄러로 주기적 DB 동기화
@Scheduled(fixedDelay = 60000) // 1분마다
public void syncViewCountsToDB() {
Set<String> keys = redisTemplate.keys(VIEW_COUNT_KEY + "*");
if (keys == null || keys.isEmpty()) {
return;
}
for (String key : keys) {
Long postId = extractPostId(key);
String viewCountStr = redisTemplate.opsForValue().get(key);
if (viewCountStr != null) {
int viewCount = Integer.parseInt(viewCountStr);
// DB 업데이트
postRepository.updateViewCount(postId, viewCount);
// 캐시 삭제 (동기화 완료)
redisTemplate.delete(key);
}
}
log.info("View counts synchronized to DB");
}
private Long extractPostId(String key) {
return Long.parseLong(key.replace(VIEW_COUNT_KEY, ""));
}
}
장점:
- 쓰기 성능 매우 빠름 (캐시만)
- DB 부하 감소 (배치 처리)
단점:
- 데이터 유실 위험 (캐시 장애 시)
- 구현 복잡 (동기화 로직)
2.4 Refresh-Ahead
만료 전에 미리 갱신:
1. 캐시 조회
2. TTL 확인
3. TTL 임박 시 백그라운드 갱신
┌──────────────────────────────────────┐
│ 1. Read Request │
│ ↓ │
│ 2. Redis GET │
│ ├─ TTL > 50% → Return Cache │
│ └─ TTL < 50% │
│ ↓ │
│ 3. Return Cache (즉시) │
│ (비동기) │
│ 4. DB SELECT & Cache Refresh │
└──────────────────────────────────────┘
구현:
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
private final RedisTemplate<String, Product> redisTemplate;
private final ExecutorService executorService = Executors.newFixedThreadPool(10);
private static final Duration CACHE_TTL = Duration.ofMinutes(10);
private static final double REFRESH_THRESHOLD = 0.5; // 50%
public Product getProduct(Long productId) {
String cacheKey = CACHE_KEY_PREFIX + productId;
// 1. 캐시 조회
Product cachedProduct = redisTemplate.opsForValue().get(cacheKey);
if (cachedProduct != null) {
// 2. TTL 확인
Long ttl = redisTemplate.getExpire(cacheKey, TimeUnit.SECONDS);
if (ttl != null && ttl < CACHE_TTL.getSeconds() * REFRESH_THRESHOLD) {
// 3. TTL 임박 → 비동기 갱신
executorService.submit(() -> refreshCache(productId));
}
return cachedProduct;
}
// Cache Miss → 동기 조회
return loadFromDB(productId);
}
private void refreshCache(Long productId) {
log.info("Refreshing cache for product: {}", productId);
Product product = loadFromDB(productId);
String cacheKey = CACHE_KEY_PREFIX + productId;
redisTemplate.opsForValue().set(cacheKey, product, CACHE_TTL);
}
private Product loadFromDB(Long productId) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
String cacheKey = CACHE_KEY_PREFIX + productId;
redisTemplate.opsForValue().set(cacheKey, product, CACHE_TTL);
return product;
}
}
장점:
- Cache Miss 최소화
- 항상 빠른 응답 시간
단점:
- 예측 가능한 접근 패턴에만 유효
- 불필요한 갱신 가능
3. 캐시 무효화 전략
3.1 TTL 기반 만료
// 고정 TTL
redisTemplate.opsForValue().set(key, value, Duration.ofHours(1));
// 비즈니스 로직 기반 TTL
public Duration calculateTTL(Product product) {
if (product.isHotItem()) {
return Duration.ofHours(24); // 인기 상품: 24시간
} else if (product.isNewArrival()) {
return Duration.ofHours(6); // 신상품: 6시간
} else {
return Duration.ofHours(1); // 일반 상품: 1시간
}
}
// 랜덤 TTL (Cache Stampede 방지)
public Duration randomizedTTL(Duration baseTTL) {
long baseSeconds = baseTTL.getSeconds();
long jitter = (long) (baseSeconds * 0.1 * Math.random()); // ±10%
return Duration.ofSeconds(baseSeconds + jitter);
}
3.2 이벤트 기반 무효화
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
private final RedisTemplate<String, Product> redisTemplate;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public Product updateProduct(Long productId, ProductUpdateRequest request) {
Product product = productRepository.findById(productId).orElseThrow();
product.update(request);
Product updatedProduct = productRepository.save(product);
// 이벤트 발행
eventPublisher.publishEvent(new ProductUpdatedEvent(productId));
return updatedProduct;
}
}
@Component
@RequiredArgsConstructor
public class CacheInvalidationListener {
private final RedisTemplate<String, Product> redisTemplate;
@EventListener
public void handleProductUpdated(ProductUpdatedEvent event) {
Long productId = event.getProductId();
// 1. 상품 캐시 삭제
redisTemplate.delete("product:" + productId);
// 2. 관련 캐시 삭제
redisTemplate.delete("product:" + productId + ":reviews");
redisTemplate.delete("product:" + productId + ":related");
log.info("Cache invalidated for product: {}", productId);
}
}
3.3 패턴 기반 일괄 삭제
// 패턴 매칭으로 여러 키 삭제
public void invalidateCacheByPattern(String pattern) {
Set<String> keys = redisTemplate.keys(pattern);
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
log.info("Deleted {} cache keys matching pattern: {}", keys.size(), pattern);
}
}
// 사용 예시:
// 특정 사용자의 모든 캐시 삭제
invalidateCacheByPattern("user:123:*");
// 특정 카테고리의 모든 상품 캐시 삭제
invalidateCacheByPattern("product:category:electronics:*");
주의사항:
KEYS명령은 블로킹 (프로덕션에서 사용 금지)- 대신
SCAN사용 (논블로킹)
public void invalidateCacheByScan(String pattern) {
ScanOptions options = ScanOptions.scanOptions()
.match(pattern)
.count(100)
.build();
try (Cursor<byte[]> cursor = redisTemplate.getConnectionFactory()
.getConnection()
.scan(options)) {
List<String> keysToDelete = new ArrayList<>();
while (cursor.hasNext()) {
keysToDelete.add(new String(cursor.next()));
if (keysToDelete.size() >= 100) {
redisTemplate.delete(keysToDelete);
keysToDelete.clear();
}
}
if (!keysToDelete.isEmpty()) {
redisTemplate.delete(keysToDelete);
}
}
}
4. 실전 문제 해결
4.1 Cache Stampede (캐시 스탬피드)
문제:
- 인기 데이터의 캐시가 만료
- 동시에 수천 개 요청 발생
- 모두 DB 조회 → DB 과부하
시나리오:
T=0: 캐시 만료
T=1: 1000개 요청 동시 도착
→ 모두 Cache Miss
→ 1000개 DB 쿼리 동시 실행
→ DB 다운!
해결책 1: Lock 기반
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
private final RedisTemplate<String, Product> redisTemplate;
private final RedissonClient redissonClient;
public Product getProduct(Long productId) {
String cacheKey = "product:" + productId;
String lockKey = "lock:product:" + productId;
// 1. 캐시 조회
Product cachedProduct = redisTemplate.opsForValue().get(cacheKey);
if (cachedProduct != null) {
return cachedProduct;
}
// 2. Lock 획득 시도
RLock lock = redissonClient.getLock(lockKey);
try {
// 3. Lock 획득 (최대 3초 대기, 10초 후 자동 해제)
boolean acquired = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (acquired) {
try {
// Double Check (Lock 대기 중 다른 스레드가 캐시 생성했을 수 있음)
cachedProduct = redisTemplate.opsForValue().get(cacheKey);
if (cachedProduct != null) {
return cachedProduct;
}
// DB 조회 (1개 스레드만)
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
// 캐시 저장
redisTemplate.opsForValue().set(cacheKey, product, Duration.ofHours(1));
return product;
} finally {
lock.unlock();
}
} else {
// Lock 획득 실패 → 잠시 대기 후 재시도
Thread.sleep(100);
return getProduct(productId); // 재귀 호출
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Lock 획득 중 인터럽트", e);
}
}
}
해결책 2: 확률적 조기 갱신 (Probabilistic Early Expiration)
public Product getProduct(Long productId) {
String cacheKey = "product:" + productId;
Product cachedProduct = redisTemplate.opsForValue().get(cacheKey);
if (cachedProduct != null) {
Long ttl = redisTemplate.getExpire(cacheKey, TimeUnit.SECONDS);
if (ttl != null && shouldRefresh(ttl)) {
// 확률적으로 갱신
CompletableFuture.runAsync(() -> refreshCache(productId));
}
return cachedProduct;
}
// Cache Miss
return loadFromDB(productId);
}
private boolean shouldRefresh(long ttl) {
// TTL이 짧을수록 갱신 확률 높음
double probability = 1.0 - (double) ttl / CACHE_TTL.getSeconds();
return Math.random() < probability;
}
4.2 Cache Penetration (캐시 관통)
문제:
- 존재하지 않는 데이터 조회
- 캐시에 없음 → DB 조회
- DB에도 없음 → 계속 반복
- 악의적 공격 가능
해결책 1: Null 캐싱
public Product getProduct(Long productId) {
String cacheKey = "product:" + productId;
// 1. 캐시 조회
String cached = (String) redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
if ("NULL".equals(cached)) {
throw new ProductNotFoundException(productId); // Null 캐시
}
return deserialize(cached);
}
// 2. DB 조회
Optional<Product> productOpt = productRepository.findById(productId);
if (productOpt.isPresent()) {
Product product = productOpt.get();
redisTemplate.opsForValue().set(cacheKey, serialize(product), Duration.ofHours(1));
return product;
} else {
// 3. Null 캐싱 (짧은 TTL)
redisTemplate.opsForValue().set(cacheKey, "NULL", Duration.ofMinutes(5));
throw new ProductNotFoundException(productId);
}
}
해결책 2: Bloom Filter
@Component
public class ProductBloomFilter {
private final BloomFilter<Long> bloomFilter;
public ProductBloomFilter() {
// 예상 항목 수: 1,000,000, 오류율: 0.01%
this.bloomFilter = BloomFilter.create(
Funnels.longFunnel(),
1_000_000,
0.0001
);
}
// 초기화 시 모든 상품 ID 추가
@PostConstruct
public void init() {
List<Long> productIds = productRepository.findAllIds();
productIds.forEach(bloomFilter::put);
}
public boolean mightExist(Long productId) {
return bloomFilter.mightContain(productId);
}
public void add(Long productId) {
bloomFilter.put(productId);
}
}
@Service
public class ProductService {
public Product getProduct(Long productId) {
// 1. Bloom Filter 체크
if (!bloomFilter.mightExist(productId)) {
throw new ProductNotFoundException(productId); // 확실히 없음
}
// 2. 캐시 조회
// 3. DB 조회
// ...
}
}
4.3 Hot Key 문제
문제:
- 특정 키에 트래픽 집중
- 단일 Redis 인스턴스 과부하
해결책: Local Cache + Redis (2-Level Cache)
@Configuration
public class CacheConfig {
@Bean
public Cache<String, Product> localCache() {
return Caffeine.newBuilder()
.maximumSize(10_000) // 최대 10,000개
.expireAfterWrite(Duration.ofMinutes(5)) // L1: 5분
.recordStats()
.build();
}
}
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
private final RedisTemplate<String, Product> redisTemplate;
private final Cache<String, Product> localCache;
public Product getProduct(Long productId) {
String cacheKey = "product:" + productId;
// 1. L1 캐시 (Local - Caffeine)
Product cachedProduct = localCache.getIfPresent(cacheKey);
if (cachedProduct != null) {
log.debug("L1 Cache Hit: {}", cacheKey);
return cachedProduct;
}
// 2. L2 캐시 (Redis)
cachedProduct = redisTemplate.opsForValue().get(cacheKey);
if (cachedProduct != null) {
log.debug("L2 Cache Hit: {}", cacheKey);
localCache.put(cacheKey, cachedProduct); // L1에도 저장
return cachedProduct;
}
// 3. DB 조회
log.debug("Cache Miss: {}", cacheKey);
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
// L1 & L2에 저장
localCache.put(cacheKey, product);
redisTemplate.opsForValue().set(cacheKey, product, Duration.ofHours(1));
return product;
}
// 캐시 무효화 시 L1, L2 모두 삭제
public void invalidateCache(Long productId) {
String cacheKey = "product:" + productId;
localCache.invalidate(cacheKey);
redisTemplate.delete(cacheKey);
}
}
성능 비교:
| 계층 | 응답 시간 | 처리량 |
|---|---|---|
| L1 (Local) | <0.1ms | 1,000,000+ ops/sec |
| L2 (Redis) | <1ms | 100,000+ ops/sec |
| DB (MySQL) | 10~100ms | 1,000~10,000 ops/sec |
5. Spring Cache Abstraction
5.1 @Cacheable 사용
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1)) // 기본 TTL
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(
new StringRedisSerializer()
))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()
));
// 캐시별 TTL 설정
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
cacheConfigurations.put("products",
config.entryTtl(Duration.ofHours(24)));
cacheConfigurations.put("users",
config.entryTtl(Duration.ofMinutes(30)));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.withInitialCacheConfigurations(cacheConfigurations)
.build();
}
}
@Service
public class ProductService {
// 캐시 조회 (Cache-Aside)
@Cacheable(value = "products", key = "#productId")
public Product getProduct(Long productId) {
// Cache Miss 시에만 실행됨
return productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
}
// 캐시 업데이트 (Write-Through)
@CachePut(value = "products", key = "#result.id")
public Product updateProduct(Long productId, ProductUpdateRequest request) {
Product product = productRepository.findById(productId).orElseThrow();
product.update(request);
return productRepository.save(product);
// 반환값이 캐시에 저장됨
}
// 캐시 삭제
@CacheEvict(value = "products", key = "#productId")
public void deleteProduct(Long productId) {
productRepository.deleteById(productId);
}
// 여러 캐시 삭제
@CacheEvict(value = "products", allEntries = true)
public void deleteAllProducts() {
productRepository.deleteAll();
}
// 조건부 캐싱
@Cacheable(value = "products", key = "#productId", condition = "#productId > 100")
public Product getProductConditional(Long productId) {
// productId > 100일 때만 캐싱
return productRepository.findById(productId).orElseThrow();
}
// SpEL 활용
@Cacheable(value = "products",
key = "#productId",
unless = "#result == null || #result.price < 1000")
public Product getExpensiveProduct(Long productId) {
// 가격이 1000 이상인 상품만 캐싱
return productRepository.findById(productId).orElseThrow();
}
}
5.2 커스텀 키 생성
@Component
public class CustomKeyGenerator implements KeyGenerator {
@Override
public Object generate(Object target, Method method, Object... params) {
// 클래스명:메서드명:파라미터
StringBuilder sb = new StringBuilder();
sb.append(target.getClass().getSimpleName());
sb.append(":");
sb.append(method.getName());
sb.append(":");
for (Object param : params) {
if (param != null) {
sb.append(param.toString());
sb.append(":");
}
}
return sb.toString();
}
}
// 사용
@Cacheable(value = "products", keyGenerator = "customKeyGenerator")
public Product getProduct(Long productId) {
// 키: ProductService:getProduct:123:
return productRepository.findById(productId).orElseThrow();
}
6. 성능 모니터링
6.1 Cache Hit Rate 측정
@Component
@RequiredArgsConstructor
public class CacheMetricsService {
private final MeterRegistry meterRegistry;
public void recordCacheHit(String cacheName) {
meterRegistry.counter("cache.hit", "cache", cacheName).increment();
}
public void recordCacheMiss(String cacheName) {
meterRegistry.counter("cache.miss", "cache", cacheName).increment();
}
public double getHitRate(String cacheName) {
double hits = getCount("cache.hit", cacheName);
double misses = getCount("cache.miss", cacheName);
if (hits + misses == 0) {
return 0.0;
}
return hits / (hits + misses);
}
private double getCount(String metricName, String cacheName) {
Counter counter = meterRegistry.find(metricName)
.tag("cache", cacheName)
.counter();
return counter != null ? counter.count() : 0.0;
}
}
@Service
public class ProductService {
public Product getProduct(Long productId) {
String cacheKey = "product:" + productId;
Product cachedProduct = redisTemplate.opsForValue().get(cacheKey);
if (cachedProduct != null) {
cacheMetricsService.recordCacheHit("products");
return cachedProduct;
}
cacheMetricsService.recordCacheMiss("products");
Product product = productRepository.findById(productId).orElseThrow();
redisTemplate.opsForValue().set(cacheKey, product, Duration.ofHours(1));
return product;
}
}
6.2 Redis 모니터링
# Redis CLI
redis-cli
# 통계 조회
INFO stats
# 출력:
total_commands_processed:1234567
instantaneous_ops_per_sec:567
keyspace_hits:890123
keyspace_misses:123456
evicted_keys:0
expired_keys:45678
# Hit Rate 계산:
# keyspace_hits / (keyspace_hits + keyspace_misses) * 100
# = 890123 / (890123 + 123456) * 100 = 87.8%
# 메모리 사용량
INFO memory
# 느린 쿼리 로그
SLOWLOG GET 10
# Prometheus + Grafana 연동
management:
endpoints:
web:
exposure:
include: prometheus,health,metrics
metrics:
export:
prometheus:
enabled: true
tags:
application: my-app
요약
캐시 전략 선택
- Cache-Aside: 가장 흔한 기본값(읽기 많고, 쓰기 적을 때 유리)
- Write-through: 일관성은 좋지만 쓰기 비용이 늘 수 있음
- Write-behind: 매우 빠르지만 최종 일관성/손실 리스크를 감수
실전 함정과 대응
- Stampede(동시 미스): single flight/락/조기 만료(early refresh)
- Penetration(없는 키 조회 폭탄): null 캐싱/블룸 필터
- Hot key(특정 키 폭주): 2-level cache, 샤딩, 최신값 코얼레싱
운영 포인트
- 무효화 전략(TTL/event-driven/tagging)을 “도메인 이벤트/쓰기 경로”와 연결해 설계
- Hit rate, eviction, 메모리 사용량, p95/p99 레이턴시를 함께 본다
마무리
Redis 캐싱은 애플리케이션 성능을 극적으로 향상시킬 수 있는 핵심 기술입니다. Cache-Aside, Write-Through, Write-Behind 등 다양한 전략을 이해하고, Cache Stampede, Cache Penetration 같은 실전 문제를 해결할 수 있어야 합니다.
핵심 요약:
- 자료구조 - String, Hash, List, Set, Sorted Set 활용
- 캐싱 전략 - Cache-Aside (일반), Write-Through (일관성), Write-Behind (성능)
- 문제 해결 - Stampede (Lock), Penetration (Bloom Filter), Hot Key (2-Level)
- Spring 통합 - @Cacheable, RedisCacheManager
- 모니터링 - Hit Rate 측정, INFO stats 분석
다음 단계:
- 실전 프로젝트에 Redis 캐싱 적용
- 분산 캐시 전략 (Redis Cluster, Sentinel) 학습
- 대용량 트래픽 처리 경험 쌓기
시리즈 3 “백엔드 심화 학습” 8개 포스트 완료! 다음 시리즈도 기대해주세요! 🚀
💬 댓글