1. Redis와 캐시의 기본 개념
캐시(Cache)란?
캐시는 자주 사용되는 데이터를 빠르게 접근할 수 있는 임시 저장소입니다. 다음과 같은 장점이 있습니다:
- 응답 시간 단축으로 사용자 경험 향상
- 데이터베이스와 백엔드 서비스의 부하 감소
- 네트워크 트래픽 절감
Redis란?
Redis(Remote Dictionary Server)는 인메모리 데이터 구조 저장소로, 주요 특징은 다음과 같습니다:
- 인메모리 저장으로 빠른 읽기/쓰기 성능 제공
- 문자열, 해시, 리스트 등 다양한 데이터 구조 지원
- 선택적 디스크 저장 가능(RDB 스냅샷, AOF 로그)
- 복제 및 고가용성 지원(마스터-슬레이브, Sentinel, Cluster)
- 원자적 연산 처리 가능
2. 캐시 전략의 기본 패턴
1. Cache-Aside (Lazy Loading)
가장 일반적인 패턴으로, 데이터가 필요할 때만 캐시에 로드합니다.
동작 방식:
- 캐시에서 데이터 조회
- 캐시에 없으면(Cache Miss) 데이터 소스에서 데이터 가져옴
- 가져온 데이터를 캐시에 저장하고 반환
public Data getData(String key) {
// 1. 캐시에서 조회
Data cachedData = cache.get(key);
if (cachedData != null) {
return cachedData; // 캐시 히트
}
// 2. 캐시에 없으면 DB에서 조회
Data data = database.get(key);
// 3. DB에서 조회한 데이터를 캐시에 저장
if (data != null) {
cache.put(key, data);
}
return data;
}
장점: 필요한 데이터만 캐싱하여 메모리를 효율적으로 사용
단점: 초기 요청 시 Cache Miss로 지연 발생 가능
2. Write-Through
데이터를 DB에 쓸 때 동시에 캐시에도 기록하는 패턴입니다.
동작 방식:
- 데이터를 DB에 쓰기 전에 먼저 캐시에 씀
- 캐시 쓰기가 성공하면 DB에 씀
public void saveData(String key, Data data) {
// 1. 캐시에 데이터 저장
cache.put(key, data);
// 2. DB에 데이터 저장
database.save(key, data);
}
장점: 캐시와 DB가 항상 동기화됨
단점: 모든 쓰기 작업이 두 번 발생하여 지연 시간 증가
3. Write-Behind (Write-Back)
데이터를 캐시에 먼저 쓰고, 나중에 비동기적으로 DB에 기록합니다.
동작 방식:
- 데이터를 캐시에만 씀
- 일정 시간 후 또는 특정 조건 충족 시 DB에 기록
public void saveData(String key, Data data) {
// 1. 캐시에 데이터 저장
cache.put(key, data);
// 2. 비동기로 DB 저장 작업 큐에 추가
writeQueue.add(new WriteTask(key, data));
}
// 백그라운드 스레드에서 실행
public void processPendingWrites() {
while (true) {
WriteTask task = writeQueue.take(); // 큐에서 작업 가져오기
database.save(task.getKey(), task.getData());
}
}
장점: 쓰기 작업이 빠르고, 대량의 쓰기를 일괄 처리하여 DB 부하 감소
단점: 캐시와 DB 간 일시적 불일치 가능성, 캐시 서버 장애 시 데이터 손실 위험
4. 캐시 제거 정책(Cache Eviction Policies)
캐시 메모리가 부족할 때 어떤 데이터를 제거할지 결정하는 정책입니다.
주요 정책:
- LRU (Least Recently Used): 가장 오래 사용되지 않은 항목 제거
- LFU (Least Frequently Used): 가장 적게 사용된 항목 제거
- FIFO (First In First Out): 가장 먼저 들어온 항목 제거
- TTL (Time To Live): 특정 시간이 지난 항목 제거
Redis에서는 maxmemory-policy 설정을 통해 다양한 제거 정책을 지정할 수 있습니다.
3. 캐시 사용 시 주요 문제점
1. 캐시 일관성(Cache Consistency)
캐시와 데이터베이스 간 데이터 일치 정도를 의미합니다.
일관성 유형:
- 강한 일관성: 데이터 변경 후 모든 후속 읽기가 새 값을 반환
- 최종 일관성: 일정 시간 후 모든 읽기가 최신 값을 반환
- 약한 일관성: 일부 읽기는 오래된 값을 반환할 수 있음
2. 캐시 무효화(Cache Invalidation)
캐시된 데이터가 더 이상 유효하지 않을 때 제거하는 과정입니다.
무효화 전략:
- 명시적 무효화: 데이터 변경 시 관련 캐시를 직접 삭제
- TTL 기반 무효화: 일정 시간 후 자동 삭제
- 이벤트 기반 무효화: 데이터 변경 이벤트 발생 시 캐시 갱신
3. 동시성 문제(Concurrency Issues)
여러 스레드나 프로세스가 동시에 같은 캐시 데이터에 접근할 때 발생하는 문제입니다.
주요 동시성 문제:
- Race Condition: 여러 프로세스의 동시 수정으로 인한 충돌
- Dirty Read: 커밋되지 않은 변경사항을 다른 프로세스가 읽는 현상
- Lost Update: 한 프로세스의 업데이트가 다른 프로세스에 의해 덮어쓰이는 현상
4. 캐시 폭주(Cache Stampede)
TTL이 만료되는 시점에 여러 요청이 동시에 데이터 소스를 조회하는 현상입니다.
완화 방법:
- Jitter 추가: TTL에 무작위성 추가
- Background Refresh: TTL 만료 전에 백그라운드에서 캐시 갱신
- Lock 사용: 첫 번째 요청만 DB를 조회하도록 함
5. 캐시 오염(Cache Pollution)
자주 사용되지 않는 데이터가 캐시를 차지하여 효율성을 떨어뜨리는 현상입니다.
방지 방법:
- 적절한 TTL 설정: 자주 사용되지 않는 데이터는 짧은 TTL 적용
- 캐시 제거 정책 최적화: 애플리케이션 특성에 맞는 정책 선택
- 캐시 크기 제한: 메모리 사용량 제한 설정
6. 콜드 스타트(Cold Start)
서버 재시작이나 캐시 클리어 후 캐시가 비어있는 상태에서 시작하는 현상입니다.
대응 방법:
- 워밍업 스크립트: 서버 시작 시 주요 데이터 미리 캐싱
- 점진적 롤아웃: 서버를 순차적으로 재시작
- 영구 캐시 사용: Redis의 영속성 기능 활용
7. 캐시 침투(Cache Penetration)
존재하지 않는 데이터를 지속적으로 요청하여 캐시와 DB에 부하를 주는 현상입니다.
방지 방법:
- 부정 캐싱(Negative Caching): 존재하지 않는 결과도 캐싱
- Bloom Filter: 존재하지 않는 키를 확률적으로 필터링
- 요청 제한(Rate Limiting): API 요청 횟수 제한
// 부정 캐싱 예시
public Product getProduct(Long productId) {
String key = "product:" + productId;
// 캐시에서 데이터 조회
Object cached = redisTemplate.opsForValue().get(key);
// 존재하지 않는다고 이미 캐싱된 경우
if (cached != null && cached.equals("NOT_FOUND")) {
return null;
}
// 캐시에서 찾은 경우
if (cached != null && cached instanceof Product) {
return (Product) cached;
}
// DB에서 조회
Optional<Product> productOpt = productRepository.findById(productId);
if (productOpt.isPresent()) {
Product product = productOpt.get();
redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(30));
return product;
} else {
// 존재하지 않는 결과도 짧은 시간(5분) 캐싱
redisTemplate.opsForValue().set(key, "NOT_FOUND", Duration.ofMinutes(5));
return null;
}
}
4. Redis 캐시 사용 시 자주 사용되는 패턴들
1. 다중 레벨 캐싱(Multi-Level Caching)
여러 계층의 캐시를 사용하여 성능과 비용 효율성을 높이는 패턴입니다.
일반적인 구성:
- L1 캐시: 애플리케이션 내 로컬 메모리 캐시 (Caffeine)
- L2 캐시: 분산 캐시 (Redis)
- L3 저장소: 원본 데이터 소스 (MySQL)
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
// L1 캐시: Caffeine 로컬 캐시
CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager();
caffeineCacheManager.setCaffeine(Caffeine.newBuilder()
.maximumSize(500)
.expireAfterWrite(Duration.ofSeconds(30)));
// L2 캐시: Redis 분산 캐시
RedisCacheManager redisCacheManager = RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10)))
.build();
// 다중 캐시 매니저
return new CompositeCacheManager(caffeineCacheManager, redisCacheManager);
}
}
장점:
- 더 빠른 응답 시간 (로컬 캐시는 네트워크 호출 없음)
- Redis 부하 감소
- 서비스 복원력 향상 (Redis 장애 시에도 로컬 캐시 사용 가능)
2. 캐시 프리페칭(Cache Prefetching)
예상되는 데이터를 미리 캐시에 로드하는 패턴입니다.
@Scheduled(fixedRate = 60000) // 1분마다 실행
public void prefetchPopularProducts() {
List<Long> popularProductIds = analyticService.getTop100PopularProductIds();
for (Long productId : popularProductIds) {
Product product = productRepository.findById(productId).orElse(null);
if (product != null) {
String key = "product:" + productId;
redisTemplate.opsForValue().set(key, product, Duration.ofHours(1));
}
}
log.info("Prefetched {} popular products to cache", popularProductIds.size());
}
사용 사례:
- 아침 피크 시간 전에 인기 상품 미리 캐싱
- 사용자 로그인 후 예상되는 다음 요청 데이터 미리 캐싱
- 계절성 이벤트 전에 관련 데이터 미리 캐싱
3. 캐시 버스팅(Cache Busting)
리소스 URL에 버전이나 해시를 추가하여 캐시를 관리하는 패턴입니다.
public String getProductCacheKey(Long productId, Long version) {
return "product:" + productId + ":v" + version;
}
public void updateProduct(Product product) {
// 버전 업데이트
product.setVersion(product.getVersion() + 1);
productRepository.save(product);
// 새 버전으로 캐시 업데이트
String key = getProductCacheKey(product.getId(), product.getVersion());
redisTemplate.opsForValue().set(key, product, Duration.ofHours(24));
// 버전 정보 저장
redisTemplate.opsForValue().set("product:" + product.getId() + ":latest_version", product.getVersion());
}
public Product getProduct(Long productId) {
// 최신 버전 확인
Long latestVersion = redisTemplate.opsForValue().get("product:" + productId + ":latest_version");
if (latestVersion == null) {
// 버전 정보가 없으면 DB에서 조회
Product product = productRepository.findById(productId).orElse(null);
if (product != null) {
updateProduct(product);
}
return product;
}
// 최신 버전 캐시 조회
String key = getProductCacheKey(productId, latestVersion);
Product product = redisTemplate.opsForValue().get(key);
if (product != null) {
return product;
}
// 캐시 미스 시 DB 조회 후 캐싱
product = productRepository.findById(productId).orElse(null);
if (product != null) {
updateProduct(product);
}
return product;
}
장점:
- 캐시 무효화 없이 새 버전 사용 가능
- 이전 버전과 새 버전을 동시에 유지 가능
- 롤백이 쉬움
4. 읽기 전용 복제본 캐싱(Read-Through Replica Caching)
읽기 전용 데이터베이스 복제본에서 데이터를 캐시하는 패턴입니다.
@Service
public class ProductCacheService {
@Autowired
private ReplicaProductRepository replicaProductRepository;
@Autowired
private RedisTemplate<String, Product> redisTemplate;
public Product getProduct(Long productId) {
String key = "product:" + productId;
Product cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached;
}
// 읽기 전용 복제본에서 조회
Product product = replicaProductRepository.findById(productId).orElse(null);
if (product != null) {
redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(30));
}
return product;
}
}
장점:
- 주 데이터베이스 부하 감소
- 읽기 성능 향상
- 데이터 일관성 향상 (복제 지연 시간만큼만 지연)
5. Spring Boot에서의 Redis 설정
1. 의존성 추가
Maven의 경우 pom.xml에 다음 의존성을 추가합니다:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Gradle의 경우 build.gradle에 다음을 추가합니다:
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
2. Redis 연결 설정
application.properties 또는 application.yml 파일에 Redis 연결 정보를 설정합니다:
# application.properties
spring.redis.host=localhost
spring.redis.port=6379
spring.redis.password= # 필요한 경우 비밀번호 설정
spring.redis.database=0 # 사용할 Redis DB 인덱스
또는 YAML 형식:
# application.yml
spring:
redis:
host: localhost
port: 6379
password: # 필요한 경우 비밀번호 설정
database: 0 # 사용할 Redis DB 인덱스
3. RedisTemplate 설정
Redis와 상호작용하기 위한 RedisTemplate 빈을 구성합니다:
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// 직렬화 설정
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));
template.afterPropertiesSet();
return template;
}
}
4. 캐시 매니저 설정 (Spring Cache와 함께 사용할 경우)
Spring의 캐시 추상화를 활용하려면 RedisCacheManager를 설정합니다:
@EnableCaching
@Configuration
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10)) // 기본 TTL 설정 (10분)
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
)
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new Jackson2JsonRedisSerializer<>(Object.class)
)
);
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build();
}
}
6. 실무 캐시 예제: Redis를 사용한 상품 정보 캐싱
기본 상품 정보 캐싱 구현
상품 정보를 캐시하는 가장 기본적인 구현은 다음과 같습니다:
public Product getProduct(Long productId) {
String key = "product:" + productId;
// 1. Redis에서 캐시 조회
Product cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
log.debug("Cache hit for product: {}", productId);
return cached;
}
// 2. 캐시 miss인 경우 DB 조회
log.debug("Cache miss for product: {}", productId);
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
// 3. Redis에 캐시 저장 (TTL 60초)
redisTemplate.opsForValue().set(key, product, Duration.ofSeconds(60));
return product;
}
이 코드는 Cache-Aside 패턴의 일반적인 구현입니다.
실제 발생한 문제 시나리오
- 관리자가 상품의 가격을 10,000원에서 8,000원으로 할인 적용
- DB에는 정상적으로 가격이 8,000원으로 변경됨
- 사용자는 여전히 웹사이트에서 10,000원의 가격을 보게 됨
- 소비자는 할인 적용이 안 됐다고 불만 제기
- 개발자가 DB를 확인해보니 가격은 이미 8,000원으로 변경되어 있음
문제 원인: 캐시 무효화 누락
문제의 원인을 살펴보니 상품 수정 로직에서 DB는 업데이트하지만 캐시는 갱신하지 않고 있었습니다:
public void updateProduct(Long productId, ProductUpdateRequest request) {
// 1. DB에서 상품 조회
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
// 2. 상품 정보 업데이트
product.setName(request.getName());
product.setPrice(request.getPrice());
product.setDescription(request.getDescription());
// 3. DB에 저장
productRepository.save(product);
// ❌ 캐시 무효화 로직이 누락됨
}
기존 캐시는 여전히 유효하기 때문에, TTL이 만료될 때까지(60초) 사용자는 계속해서 오래된 가격 정보를 보게 됩니다. 이는 사용자 경험을 해치고 비즈니스에 직접적인 영향을 줄 수 있는 심각한 문제입니다.
캐시 동기화에서 가장 흔한 실수: DB만 업데이트하고 캐시 무효화를 잊는 것입니다.
해결 1단계: 캐시 무효화(Cache Invalidation) 적용
가장 간단한 해결책은 데이터가 변경될 때마다 관련 캐시를 명시적으로 제거하는 것입니다:
public void updateProduct(Long productId, ProductUpdateRequest request) {
// 1. DB에서 상품 조회
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
// 2. 상품 정보 업데이트
product.setName(request.getName());
product.setPrice(request.getPrice());
product.setDescription(request.getDescription());
// 3. DB에 저장
productRepository.save(product);
// 4. ✅ 캐시 무효화
String key = "product:" + productId;
redisTemplate.delete(key);
log.info("Cache invalidated for product: {}", productId);
}
이렇게 하면 사용자는 항상 최신 데이터를 볼 수 있습니다. 다만, 다음 요청 시 캐시 miss가 발생하여 DB 조회가 필요하다는 단점이 있습니다.
대안: 캐시 갱신(Cache Update) 전략
캐시를 삭제하는 대신 갱신하는 방법도 있습니다:
public void updateProduct(Long productId, ProductUpdateRequest request) {
// 1. DB에서 상품 조회
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
// 2. 상품 정보 업데이트
product.setName(request.getName());
product.setPrice(request.getPrice());
product.setDescription(request.getDescription());
// 3. DB에 저장
Product updatedProduct = productRepository.save(product);
// 4. ✅ 캐시 갱신
String key = "product:" + productId;
redisTemplate.opsForValue().set(key, updatedProduct, Duration.ofSeconds(60));
log.info("Cache updated for product: {}", productId);
}
이 방식은 다음 요청 시 캐시 miss를 방지할 수 있지만, 업데이트 시점에 항상 전체 객체를 다시 캐싱해야 한다는 단점이 있습니다.
7. 실무 캐시 문제 해결
문제 2: Race Condition
캐시 무효화를 적용한 후에도, 여전히 Race Condition 문제가 발생할 수 있습니다. 특히 트래픽이 많은 상품의 경우 더 심각합니다.
Race Condition 시나리오:
- 사용자 A 요청: 캐시 miss → DB 조회 시작
- 사용자 B 요청: 캐시 miss → DB 조회 시작
- 사용자 A: DB 조회 완료 → Redis에 데이터 저장
- 사용자 B: DB 조회 완료 → Redis에 데이터 저장 (A보다 조회가 늦었는데도 A의 결과를 덮어씀)
이로 인해 먼저 조회된 최신 데이터가 나중에 조회된 오래된 데이터로 덮어써지는 문제가 발생할 수 있습니다.
해결 방법: 분산 락(Distributed Lock) 적용
Redis를 활용한 분산 환경에서는 Redisson을 이용한 분산 락을 적용하는 것이 좋습니다:
public Product getProduct(Long productId) {
String key = "product:" + productId;
Product cached = redisTemplate.opsForValue().get(key);
if (cached != null) return cached;
// 분산 락 적용
RLock lock = redissonClient.getLock("lock:product:" + productId);
try {
// 최대 3초 동안 락 획득 시도, 획득 후 1초 후에 자동 해제
if (lock.tryLock(3, 1, TimeUnit.SECONDS)) {
try {
// 락 획득 후 다시 캐시 확인 (Double-checking)
cached = redisTemplate.opsForValue().get(key);
if (cached != null) return cached;
// DB 조회 및 캐시 저장
Product product = productRepository.findById(productId).orElseThrow();
redisTemplate.opsForValue().set(key, product, Duration.ofSeconds(60));
return product;
} finally {
lock.unlock(); // 락 해제
}
} else {
// 락 획득 실패 시 DB에서 직접 조회 (캐싱 없이)
return productRepository.findById(productId).orElseThrow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("락 획득 중 인터럽트 발생", e);
}
}
분산 락을 적용하면 한 번에 하나의 스레드만 캐시를 갱신할 수 있어, Race Condition 문제를 해결할 수 있습니다.
문제 3: Cache Stampede (동시 캐시 붕괴)
캐시의 TTL이 만료되면 수많은 요청이 동시에 DB로 몰리는 Cache Stampede 현상이 발생할 수 있습니다. 특히 인기 상품의 캐시가 만료되는 시점에 문제가 더 심각합니다.
Cache Stampede 시나리오:
- 인기 상품의 캐시 TTL이 정확히 60초로 설정됨
- 60초 후 캐시 만료
- 수백 개의 동시 요청이 모두 캐시 miss 발생
- 모든 요청이 DB에 동시에 쿼리 실행
- DB 부하 증가 → 서버 응답 지연 → 사용자 경험 저하
해결 방법:
1. 랜덤 TTL(Jitter) 적용
캐시 만료 시간에 랜덤성을 추가하여 동시에 만료되는 것을 방지합니다:
// TTL을 60~90초 사이의 랜덤한 값으로 설정
int jitter = new Random().nextInt(30); // 0~29초의 추가 시간
redisTemplate.opsForValue().set(key, product, Duration.ofSeconds(60 + jitter));
2. 선점적 캐시 갱신(Proactive Cache Refresh)
캐시 TTL이 만료되기 전에 백그라운드에서 미리 갱신하는 방법입니다:
@Scheduled(fixedRate = 30000) // 30초마다 실행
public void refreshPopularProductCaches() {
List<Long> popularProductIds = getPopularProductIds();
for (Long productId : popularProductIds) {
String key = "product:" + productId;
// TTL이 15초 미만으로 남은 경우에만 갱신
Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
if (ttl != null && ttl > 0 && ttl < 15) {
Product product = productRepository.findById(productId).orElse(null);
if (product != null) {
redisTemplate.opsForValue().set(key, product, Duration.ofSeconds(60 + new Random().nextInt(30)));
log.info("Proactively refreshed cache for product: {}", productId);
}
}
}
}
3. 빈 캐시 방어(Cache Sentinel) 패턴
캐시가 없을 때 임시로 "빈" 값을 넣어 다른 요청이 DB로 가는 것을 방지합니다:
public Product getProduct(Long productId) {
String key = "product:" + productId;
// 캐시 조회
Object cached = redisTemplate.opsForValue().get(key);
// 캐시에 EMPTY_RESULT 표시가 있는 경우 (존재하지 않는 상품)
if (cached != null && cached.equals("EMPTY_RESULT")) {
throw new ProductNotFoundException(productId);
}
// 정상적인 캐시 히트
if (cached != null && cached instanceof Product) {
return (Product) cached;
}
// 분산 락 적용 코드...
try {
// DB 조회
Optional<Product> productOpt = productRepository.findById(productId);
if (productOpt.isPresent()) {
Product product = productOpt.get();
redisTemplate.opsForValue().set(key, product, Duration.ofSeconds(60 + new Random().nextInt(30)));
return product;
} else {
// 존재하지 않는 상품은 EMPTY_RESULT로 캐싱 (Negative Caching)
// 짧은 TTL(10초)을 적용하여 오래 지속되지 않도록 함
redisTemplate.opsForValue().set(key, "EMPTY_RESULT", Duration.ofSeconds(10));
throw new ProductNotFoundException(productId);
}
} finally {
// 락 해제 코드...
}
}
8. 캐시 동기화 전략 종합 비교
문제 발생 상황 해결 전략 장점 단점
캐시 무효화 누락 | DB만 갱신되고 캐시는 그대로 | 명시적 캐시 삭제<br>redisTemplate.delete(key) | 구현이 간단함 | 캐시 miss 발생 |
캐시 갱신<br>redisTemplate.set(key, newData) | 캐시 miss 없음 | 전체 객체 다시 로드 필요 | ||
Race Condition | 동시에 여러 요청이 캐시 갱신 | Redisson 분산 락 | 정확한 동시성 제어 | 복잡성 증가, 성능 영향 |
Cache Stampede | 캐시 만료 시 DB에 부하 집중 | 랜덤 TTL (Jitter) | 구현이 쉬움 | 완벽한 해결책 아님 |
선점적 캐시 갱신 | 사용자 경험 향상 | 추가 스케줄링 필요 | ||
캐시 없을 때 빈 값 설정 | 무의미한 DB 조회 방지 | 복잡성 증가 |
9. 고급 캐시 전략: 이벤트 기반 캐시 갱신
더 복잡한 시스템에서는 이벤트 기반 캐시 갱신 전략이 효과적입니다. Redis의 Pub/Sub 또는 Kafka를 활용한 방식으로, DB 변경 시 관련 서비스에 알림을 보내 캐시를 갱신합니다.
// DB 상품 업데이트 후 이벤트 발행
public void updateProduct(Long productId, ProductUpdateRequest request) {
// DB 업데이트...
// 이벤트 발행
ProductUpdatedEvent event = new ProductUpdatedEvent(productId, updatedProduct);
kafkaTemplate.send("product-updates", event);
}
// 다른 서비스에서 이벤트 수신하여 캐시 갱신
@KafkaListener(topics = "product-updates")
public void handleProductUpdate(ProductUpdatedEvent event) {
String key = "product:" + event.getProductId();
redisTemplate.opsForValue().set(key, event.getProduct(), Duration.ofSeconds(60 + new Random().nextInt(30)));
log.info("Cache updated for product: {} via event", event.getProductId());
}
이 방식의 장점은 서비스 간 결합도를 낮추고 각 마이크로서비스가 자신의 캐시를 독립적으로 관리할 수 있다는 것입니다.
10. 실무에서의 추가 팁
1. 모니터링 체계 구축
캐시 hit/miss 비율, 캐시 크기, 메모리 사용량 등을 모니터링하여 캐시 전략의 효과를 지속적으로 평가하세요:
// 캐시 hit/miss 측정을 위한 메트릭 추가
@Autowired
private MeterRegistry registry;
public Product getProduct(Long productId) {
String key = "product:" + productId;
Product cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
registry.counter("cache.hit", "type", "product").increment();
return cached;
} else {
registry.counter("cache.miss", "type", "product").increment();
// DB 조회 및 캐싱...
}
}
2. 다중 레이어 캐시 전략
로컬 캐시(Caffeine)와 분산 캐시(Redis)를 함께 사용하여 성능을 최적화할 수 있습니다:
@Cacheable(value = "localProductCache", key = "#productId", unless = "#result == null")
public Product getProductWithLocalCache(Long productId) {
String key = "product:" + productId;
Product cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached;
}
// Redis에 없는 경우 DB 조회 및 Redis 캐싱
// ...
}
3. Spring Cache 추상화 활용
스프링의 캐시 추상화를 활용하면 더 깔끔한 코드로 캐시를 관리할 수 있습니다:
@Cacheable(value = "products", key = "#productId", unless = "#result == null")
public Product getProduct(Long productId) {
return productRepository.findById(productId).orElseThrow();
}
@CacheEvict(value = "products", key = "#productId")
public void updateProduct(Long productId, ProductUpdateRequest request) {
// DB 업데이트 로직...
}
11. 성능 측정 및 모니터링
Redis 캐시 활용에 있어서 중요한 점은 실제로 성능 개선이 이루어지고 있는지 측정하고 모니터링하는 것입니다.
주요 모니터링 지표
- Cache Hit Ratio (캐시 적중률): 캐시가 얼마나 효과적으로 사용되고 있는지를 나타내는 가장 중요한 지표입니다.보통 80-95%의 적중률이 좋은 결과로 간주됩니다. 적중률이 낮다면 캐시 전략을 재검토해야 합니다.
- 캐시 적중률 = 캐시 히트 수 / (캐시 히트 수 + 캐시 미스 수) × 100%
- 응답 시간 (Response Time): 캐시 사용 전후의 응답 시간을 측정하여 실제 성능 향상을 확인합니다.
- 캐시 성능 향상률 = (캐시 미사용 시 응답시간 - 캐시 사용 시 응답시간) / 캐시 미사용 시 응답시간 × 100%
- 메모리 사용량: Redis의 메모리 사용량을 모니터링하여 메모리 부족 문제를 예방합니다.
- redis-cli info memory
- 만료 키 수: 자동으로 만료되는 키의 수를 모니터링하여 TTL 설정이 적절한지 확인합니다.
- redis-cli info stats | grep expired_keys
모니터링 도구
- Redis INFO 명령어: 기본적인 모니터링 정보를 제공합니다.
- redis-cli info
- Redis Monitor: 실시간으로 Redis 서버에 전송되는 명령어를 모니터링합니다.(주의: 프로덕션 환경에서는 성능 영향이 있을 수 있음)
- redis-cli monitor
- Prometheus + Grafana: 장기적인 모니터링과 알림 설정에 유용합니다.
- @Configuration public class PrometheusConfig { @Bean public MeterRegistry meterRegistry() { PrometheusMeterRegistry registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); return registry; } }
- Spring Boot Actuator: Redis 캐시 지표를 쉽게 노출시킵니다.
- # application.properties management.endpoints.web.exposure.include=health,info,metrics,prometheus
12. 마무리: 캐시 전략의 핵심은 일관성과 신선도 사이의 균형
캐시는 성능을 크게 향상시키지만, 데이터 일관성과 캐시 신선도 사이에 적절한 균형을 맞추는 것이 중요합니다. 비즈니스 요구사항에 따라 다음을 고려하세요:
- 실시간성이 중요한 데이터: TTL을 짧게 설정하거나 Write-Through 패턴으로 즉시 갱신
- 자주 조회되지만 변경이 적은 데이터: 긴 TTL과 이벤트 기반 무효화 조합
- 비즈니스 중요도가 높은 데이터: 엄격한 캐시 무효화와 검증 로직 추가
기억하세요: 완벽한 캐시 전략은 없습니다. 비즈니스 요구사항과 시스템 특성에 맞는 전략을 선택하고, 지속적으로 모니터링하며 개선해 나가는 것이 중요합니다.
참고 자료
- Redis 공식 문서: https://redis.io/documentation
- Redisson 프로젝트: https://github.com/redisson/redisson
- Spring Cache 문서: https://docs.spring.io/spring-framework/docs/current/reference/html/integration.html#cache
- Redis 성능 최적화 가이드: https://redis.io/topics/latency
- "Redis in Action" by Josiah L. Carlson
- "Designing Data-Intensive Applications" by Martin Kleppmann
'DB' 카테고리의 다른 글
데이터베이스 종류 와 선택(PostgreSQL,MySQL,MongoDB,Redis,Elasticsearch,Cassandra,Neo4j) (1) | 2024.11.27 |
---|---|
Hibernate 란?? (주요특징, 아키텍쳐, 장단점 등) (0) | 2024.11.23 |
pk 2개, 여러개 설정 - SQLGate (0) | 2023.09.26 |
SQL 시퀀스란 시퀀스 만들기(SQLGate) (0) | 2023.09.25 |