지난 글에서는 Redis의 핵심 데이터 타입을 살펴봤다. String, List, Set, Hash, Sorted Set이 각각 어떤 문제를 해결하는지, 왜 Redis 안에서 직접 처리하는 게 빠른지를 이해했다.
이번 글에서는 한 발 더 나아가 Redis를 캐시로 활용하는 방법을 다룬다. 아래 질문들을 중심으로 이야기를 풀어나갈 것이다.
Redis를 캐시로 사용하면 DB보다 훨씬 빠르게 데이터를 읽어올 수 있다. 인메모리 저장소이기 때문이다.
그런데 여기서 문제가 생긴다. DB에서 상품 가격이 변경됐는데, Redis에는 아직 이전 가격이 캐싱되어 있다면 사용자는 잘못된 데이터를 보게 된다.
캐싱 전략을 설계할 때는 항상 세 가지 요소를 고려해야 한다.
이 세 가지는 동시에 완벽하게 만족시키기 어렵다. 하나를 얻으면 다른 걸 포기해야 하는 경우가 많다. 아래에서 다룰 4가지 전략은 이 트레이드오프를 각각 다르게 선택한 결과다.
Cache-Aside는 가장 널리 사용되는 캐싱 패턴이다. 애플리케이션이 캐시를 직접 관리하는 방식으로, 읽기 요청이 들어오면 항상 캐시를 먼저 확인한다.
읽기 요청
→ Redis 조회
→ 데이터 있음 (Cache Hit) : Redis에서 바로 반환
→ 데이터 없음 (Cache Miss) : DB 조회 → Redis에 저장 → 반환
도서관에 비유하면, 책상 위(Redis)에 책이 있으면 바로 읽고, 없으면 서고(DB)에서 꺼내서 책상 위에 올려두는 것이다. 다음에 또 필요하면 서고까지 안 가도 된다.
public List<CategoryResponse> findAllForCacheAside() {
String cached = redisTemplate.opsForValue().get(CACHE_KEY);
// Cache Hit
if (!ObjectUtils.isEmpty(cached)) {
return JsonUtil.fromJsonList(cached, CategoryResponse.class);
}
// Cache Miss: DB 조회 후 캐시에 저장
List<CategoryResponse> categories = findAll();
if (!categories.isEmpty()) {
redisTemplate.opsForValue().set(CACHE_KEY, JsonUtil.toJson(categories), 1, TimeUnit.HOURS);
}
return categories;
}
Cache-Aside의 장점은 읽기 성능이다. 자주 조회되는 데이터가 캐시에 올라오면 DB 접근 없이 빠르게 응답할 수 있다.
단점은 두 가지다. 첫째, Cache Miss 시 Redis도 확인하고 DB도 다녀오고 Redis에 저장까지 해야 하므로 그냥 DB에서 바로 가져오는 것보다 오히려 느릴 수 있다. 둘째, DB 데이터가 변경되어도 캐시는 TTL이 만료되기 전까지 오래된 데이터를 갖고 있는다.
TTL을 적절히 설정해서 두 번째 문제를 완화할 수 있다. 캐시에 유통기한을 붙이는 것이다. TTL이 지나면 Redis가 자동으로 데이터를 삭제하고, 다음 요청 시 DB에서 최신 데이터를 다시 가져온다.
읽기 요청이 많고 데이터가 자주 변경되지 않는 경우에 적합하다. 상품 목록, 뉴스 기사, 사용자 프로필 등이 대표적인 사례다.
Write-through는 데이터를 쓸 때 Redis와 DB를 동시에 업데이트하는 전략이다. 두 쓰기 작업이 모두 성공해야 완료로 간주한다.
쓰기 요청
→ Redis 업데이트
→ DB 업데이트
→ 둘 다 성공 : 완료
→ 하나라도 실패 : 롤백
@Transactional
public void saveWriteThrough(CategoryRequest request) {
create(request); // DB 저장
updateCacheCategories(); // 캐시 즉시 업데이트
}
private void updateCacheCategories() {
List<CategoryResponse> categories = findAll();
if (!categories.isEmpty()) {
redisTemplate.opsForValue().set(CACHE_KEY, JsonUtil.toJson(categories));
}
}
Cache-Aside의 단점이었던 데이터 불일치 문제가 해결된다. 쓰기할 때마다 캐시를 최신 상태로 유지하기 때문이다.
단점은 쓰기 성능 저하다. 매번 두 곳에 써야 하므로 DB 응답을 기다려야 하고, 쓰기 요청이 많은 시스템에서는 병목이 생길 수 있다.
데이터 일관성이 매우 중요한 경우에 적합하다. 금융 거래, 재고 관리, 주문 처리처럼 단 한 건의 데이터 불일치도 허용되지 않는 시스템이 대표적이다.
Write-back은 쓰기 요청 시 Redis에만 먼저 저장하고, DB에는 나중에 비동기로 반영하는 전략이다. 쓰기 성능을 극대화하는 데 초점을 맞춘다.
쓰기 요청
→ Redis에만 저장 → 즉시 응답
→ (나중에 비동기로) DB에 반영
public void saveWriteBack(CategoryRequest request) {
// Redis에 먼저 저장
redisTemplate.opsForValue().set(CACHE_KEY, JsonUtil.toJson(updatedCategories));
// DB는 비동기로 나중에 처리
saveToDatabaseAsync(request);
}
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveToDatabaseAsync(CategoryRequest request) {
create(request);
}
DB 응답을 기다릴 필요가 없으므로 쓰기 성능이 가장 빠르다. 여러 쓰기 요청을 모아서 DB에 한 번에 반영하는 배치 처리도 가능해 DB 부하를 줄일 수 있다.
치명적인 단점은 데이터 손실 위험이다. DB에 아직 반영되지 않고 Redis에만 존재하는 데이터가 있는 상태에서 Redis 서버가 다운되면 그 데이터는 영구적으로 사라진다.
쓰기 빈도가 매우 높고 데이터 손실이 조금 발생해도 무방한 경우에 적합하다. 게임 플레이 세션, 좋아요 카운트, 실시간 로그 수집 등이 대표적인 사례다.
Write-around는 쓰기 요청 시 Redis를 완전히 우회하고 DB에만 저장하는 전략이다. 캐시는 오로지 읽기 성능 향상에만 사용한다.
쓰기 요청
→ DB에만 저장 (Redis는 건드리지 않음)
읽기 요청
→ Cache-Aside와 동일하게 동작
public void saveWriteAround(CategoryRequest request) {
// DB에만 저장
create(request);
// 기존 캐시 무효화 (선택적)
redisTemplate.delete(CACHE_KEY);
}
쓰기 작업이 캐시를 거치지 않으므로 캐시 부하가 없다. 항상 DB가 최신 데이터임을 신뢰할 수 있다.
단점은 데이터를 쓴 직후 바로 읽으려 하면 Cache Miss가 발생해 DB까지 다녀와야 한다는 점이다.
쓰기는 많지만 쓴 데이터를 즉시 읽을 필요가 없는 경우에 적합하다. 대용량 로그 수집, 배치 처리용 데이터 기록 등이 대표적이다.
| 전략 | 쓰기 대상 | 읽기 성능 | 쓰기 성능 | 일관성 | 데이터 손실 |
|---|---|---|---|---|---|
| Cache-Aside | DB만 (읽기 전략) | 빠름 | - | 중간 | 없음 |
| Write-through | Redis + DB 동시 | 빠름 | 느림 | 강함 | 없음 |
| Write-back | Redis만 (나중에 DB) | 빠름 | 매우 빠름 | 약함 | 위험 |
| Write-around | DB만 | 초기 느림 | 보통 | 강함 | 없음 |
실무에서는 단일 전략만 사용하지 않는다. 예를 들어 이커머스 서비스라면 상품 목록 조회에는 Cache-Aside를, 주문 생성에는 Write-through를 조합해서 사용하는 것이 일반적이다.
TTL은 Redis에 저장된 키에 유효 기간을 설정하는 기능이다. 설정한 시간이 지나면 Redis가 자동으로 해당 데이터를 삭제한다.
// 1시간 TTL 설정
redisTemplate.opsForValue().set(CACHE_KEY, data, 1, TimeUnit.HOURS);
TTL은 데이터 특성에 따라 다르게 설정해야 한다.
환율 데이터는 하루에 한 번 바뀌니 24시간, 상품 목록은 자주 바뀌지 않으니 1시간처럼 데이터의 변경 주기를 기준으로 설정하는 것이 좋다.
Redis는 인메모리 저장소이므로 메모리가 꽉 차면 기존 데이터 일부를 삭제해야 한다. 이때 어떤 데이터를 삭제할지 결정하는 것이 캐시 제거 정책이다.
LRU (Least Recently Used): 가장 오랫동안 사용되지 않은 데이터를 우선 삭제한다. 최근에 사용된 데이터는 다시 사용될 가능성이 높다는 가설에 기반한다. 사용자 세션, 최근 본 상품처럼 시간이 지남에 따라 가치가 떨어지는 데이터에 적합하다.
LFU (Least Frequently Used): 사용 빈도가 가장 낮은 데이터를 우선 삭제한다. 오랫동안 사용되지 않았어도 자주 사용되는 데이터라면 유지한다. 인기 상품, 메인 배너처럼 꾸준히 많이 조회되는 데이터에 적합하다.
두 정책의 차이가 명확하게 드러나는 상황이 있다. 어떤 상품 페이지가 어제 엄청 많이 조회됐지만 오늘은 아무도 안 본다면, LRU는 이 데이터를 삭제 대상으로 보지만 LFU는 누적 빈도가 높으므로 한동안 유지한다.
# redis.conf
maxmemory 512mb
maxmemory-policy allkeys-lru # 또는 allkeys-lfu
캐싱 전략의 핵심은 "어떤 트레이드오프를 선택할 것인가"다. 속도, 일관성, 안전성을 동시에 완벽하게 만족시키는 전략은 없다. 서비스의 데이터 특성과 요구사항을 정확히 파악하고 적절한 전략을 선택하는 것이 중요하다.