[Huge Traffic Handling] Redis 캐싱 전략 — Cache-Aside부터 Write-back까지

Raha·2026년 4월 17일

Huge Traffic Handling

목록 보기
3/9

들어가며

지난 글에서는 Redis의 핵심 데이터 타입을 살펴봤다. String, List, Set, Hash, Sorted Set이 각각 어떤 문제를 해결하는지, 왜 Redis 안에서 직접 처리하는 게 빠른지를 이해했다.

이번 글에서는 한 발 더 나아가 Redis를 캐시로 활용하는 방법을 다룬다. 아래 질문들을 중심으로 이야기를 풀어나갈 것이다.

  • 캐시와 DB 사이의 데이터 불일치는 어떻게 해결할까?
  • 읽기가 많은 서비스와 쓰기가 많은 서비스는 캐싱 전략이 달라야 할까?
  • 메모리가 꽉 찼을 때 Redis는 어떤 데이터를 먼저 삭제할까?

1. 캐싱의 핵심 트레이드오프

Redis를 캐시로 사용하면 DB보다 훨씬 빠르게 데이터를 읽어올 수 있다. 인메모리 저장소이기 때문이다.

그런데 여기서 문제가 생긴다. DB에서 상품 가격이 변경됐는데, Redis에는 아직 이전 가격이 캐싱되어 있다면 사용자는 잘못된 데이터를 보게 된다.

캐싱 전략을 설계할 때는 항상 세 가지 요소를 고려해야 한다.

  • 데이터 일관성 — 캐시와 DB가 얼마나 동기화되어 있는가
  • 성능 — 읽기/쓰기 속도가 얼마나 빠른가
  • 데이터 안전성 — 장애 시 데이터 손실 위험이 얼마나 되는가

이 세 가지는 동시에 완벽하게 만족시키기 어렵다. 하나를 얻으면 다른 걸 포기해야 하는 경우가 많다. 아래에서 다룰 4가지 전략은 이 트레이드오프를 각각 다르게 선택한 결과다.


2. Cache-Aside

개념

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에서 최신 데이터를 다시 가져온다.

적합한 상황

읽기 요청이 많고 데이터가 자주 변경되지 않는 경우에 적합하다. 상품 목록, 뉴스 기사, 사용자 프로필 등이 대표적인 사례다.


3. Write-through

개념

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 응답을 기다려야 하고, 쓰기 요청이 많은 시스템에서는 병목이 생길 수 있다.

적합한 상황

데이터 일관성이 매우 중요한 경우에 적합하다. 금융 거래, 재고 관리, 주문 처리처럼 단 한 건의 데이터 불일치도 허용되지 않는 시스템이 대표적이다.


4. Write-back

개념

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 서버가 다운되면 그 데이터는 영구적으로 사라진다.

적합한 상황

쓰기 빈도가 매우 높고 데이터 손실이 조금 발생해도 무방한 경우에 적합하다. 게임 플레이 세션, 좋아요 카운트, 실시간 로그 수집 등이 대표적인 사례다.


5. Write-around

개념

Write-around는 쓰기 요청 시 Redis를 완전히 우회하고 DB에만 저장하는 전략이다. 캐시는 오로지 읽기 성능 향상에만 사용한다.

쓰기 요청
  → DB에만 저장 (Redis는 건드리지 않음)

읽기 요청
  → Cache-Aside와 동일하게 동작

코드 예제

public void saveWriteAround(CategoryRequest request) {
    // DB에만 저장
    create(request);

    // 기존 캐시 무효화 (선택적)
    redisTemplate.delete(CACHE_KEY);
}

장단점

쓰기 작업이 캐시를 거치지 않으므로 캐시 부하가 없다. 항상 DB가 최신 데이터임을 신뢰할 수 있다.

단점은 데이터를 쓴 직후 바로 읽으려 하면 Cache Miss가 발생해 DB까지 다녀와야 한다는 점이다.

적합한 상황

쓰기는 많지만 쓴 데이터를 즉시 읽을 필요가 없는 경우에 적합하다. 대용량 로그 수집, 배치 처리용 데이터 기록 등이 대표적이다.


6. 전략 비교

전략쓰기 대상읽기 성능쓰기 성능일관성데이터 손실
Cache-AsideDB만 (읽기 전략)빠름-중간없음
Write-throughRedis + DB 동시빠름느림강함없음
Write-backRedis만 (나중에 DB)빠름매우 빠름약함위험
Write-aroundDB만초기 느림보통강함없음

실무에서는 단일 전략만 사용하지 않는다. 예를 들어 이커머스 서비스라면 상품 목록 조회에는 Cache-Aside를, 주문 생성에는 Write-through를 조합해서 사용하는 것이 일반적이다.


7. TTL과 캐시 제거 정책

TTL (Time To Live)

TTL은 Redis에 저장된 키에 유효 기간을 설정하는 기능이다. 설정한 시간이 지나면 Redis가 자동으로 해당 데이터를 삭제한다.

// 1시간 TTL 설정
redisTemplate.opsForValue().set(CACHE_KEY, data, 1, TimeUnit.HOURS);

TTL은 데이터 특성에 따라 다르게 설정해야 한다.

  • 너무 짧으면 Cache Miss가 빈번해져 DB 부하가 증가하고 속도가 저하된다.
  • 너무 길면 오래된 데이터가 오래 유지되어 데이터 불일치가 발생한다.

환율 데이터는 하루에 한 번 바뀌니 24시간, 상품 목록은 자주 바뀌지 않으니 1시간처럼 데이터의 변경 주기를 기준으로 설정하는 것이 좋다.

LRU와 LFU

Redis는 인메모리 저장소이므로 메모리가 꽉 차면 기존 데이터 일부를 삭제해야 한다. 이때 어떤 데이터를 삭제할지 결정하는 것이 캐시 제거 정책이다.

LRU (Least Recently Used): 가장 오랫동안 사용되지 않은 데이터를 우선 삭제한다. 최근에 사용된 데이터는 다시 사용될 가능성이 높다는 가설에 기반한다. 사용자 세션, 최근 본 상품처럼 시간이 지남에 따라 가치가 떨어지는 데이터에 적합하다.

LFU (Least Frequently Used): 사용 빈도가 가장 낮은 데이터를 우선 삭제한다. 오랫동안 사용되지 않았어도 자주 사용되는 데이터라면 유지한다. 인기 상품, 메인 배너처럼 꾸준히 많이 조회되는 데이터에 적합하다.

두 정책의 차이가 명확하게 드러나는 상황이 있다. 어떤 상품 페이지가 어제 엄청 많이 조회됐지만 오늘은 아무도 안 본다면, LRU는 이 데이터를 삭제 대상으로 보지만 LFU는 누적 빈도가 높으므로 한동안 유지한다.

# redis.conf
maxmemory 512mb
maxmemory-policy allkeys-lru   # 또는 allkeys-lfu

마치며

캐싱 전략의 핵심은 "어떤 트레이드오프를 선택할 것인가"다. 속도, 일관성, 안전성을 동시에 완벽하게 만족시키는 전략은 없다. 서비스의 데이터 특성과 요구사항을 정확히 파악하고 적절한 전략을 선택하는 것이 중요하다.

profile
Backend Developer | Aspiring Full-Stack Enthusiast

0개의 댓글