Redis 캐시 전략: Cache-Aside

캐시 전략이 중요한 이유: “어떻게 캐싱하느냐”가 성능과 정합성을 결정한다

Redis를 캐시로 도입하는 것은 쉽다. 하지만 언제 캐시에 쓰고, 언제 읽고, 언제 무효화할 것인가를 잘못 설계하면 stale 데이터, 캐시 미스 폭증, DB 부하 전가 등 캐시가 없는 것보다 나쁜 상황이 된다. Redis 공식 문서와 캐싱 아키텍처 문헌에서 정의하는 핵심 패턴은 크게 네 가지다: Cache-Aside, Read-Through, Write-Through, Write-Behind(Write-Back).

이 글에서는 각 패턴의 동작 흐름, 정합성 특성, Redis 명령어 기반 구현, 그리고 Spring Boot·NestJS에서의 적용 방법을 정리한다.

네 가지 캐시 전략 비교

전략 읽기 흐름 쓰기 흐름 정합성 복잡도
Cache-Aside 앱이 캐시 → 미스 시 DB → 캐시 적재 앱이 DB 쓰기 → 캐시 무효화 최종 일관성 낮음
Read-Through 캐시 라이브러리가 미스 시 자동 로드 Cache-Aside와 동일 최종 일관성 낮음
Write-Through 캐시에서 읽기 캐시 + DB 동시 쓰기 (동기) 강한 일관성 중간
Write-Behind 캐시에서 읽기 캐시만 쓰기 → DB 비동기 반영 최종 일관성 (지연) 높음

Cache-Aside (Lazy Loading): 가장 널리 쓰이는 패턴

애플리케이션이 캐시와 DB를 직접 제어하는 패턴이다. 캐시 히트 시 즉시 반환하고, 미스 시 DB에서 읽어 캐시에 적재한다.

읽기 흐름

// NestJS + ioredis 예시
async findUser(id: number): Promise<User> {
  const cacheKey = `user:${id}`;

  // 1. 캐시 조회
  const cached = await this.redis.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);  // 캐시 히트 → 즉시 반환
  }

  // 2. 캐시 미스 → DB 조회
  const user = await this.userRepository.findOneBy({ id });
  if (!user) throw new NotFoundException();

  // 3. 캐시에 적재 (TTL 설정)
  await this.redis.set(cacheKey, JSON.stringify(user), 'EX', 3600);

  return user;
}

쓰기 흐름: 무효화(Invalidation) vs 갱신(Update)

// 방법 1: 캐시 무효화 (권장)
async updateUser(id: number, dto: UpdateUserDto): Promise<User> {
  const user = await this.userRepository.save({ id, ...dto });

  // DB 쓰기 후 캐시 삭제 → 다음 읽기 시 최신 데이터 적재
  await this.redis.del(`user:${id}`);

  return user;
}

// 방법 2: 캐시 갱신 (주의 필요)
async updateUser(id: number, dto: UpdateUserDto): Promise<User> {
  const user = await this.userRepository.save({ id, ...dto });

  // DB 쓰기 후 캐시도 갱신
  await this.redis.set(`user:${id}`, JSON.stringify(user), 'EX', 3600);

  return user;
}
쓰기 전략 장점 단점 권장 시점
무효화 (DEL) 단순, race condition 적음 다음 읽기 시 캐시 미스 1회 대부분의 경우 (기본 선택)
갱신 (SET) 미스 없이 최신 데이터 유지 동시 쓰기 시 stale 데이터 위험 읽기 빈도 극히 높고, 쓰기 충돌이 드문 경우

Read-Through: 캐시 라이브러리가 로딩을 대신

Read-Through는 Cache-Aside와 읽기 흐름이 동일하지만, 캐시 라이브러리/프록시가 미스 시 DB 로딩을 자동 처리한다. 애플리케이션은 항상 캐시에만 요청하면 된다.

// Spring Boot @Cacheable — Read-Through 패턴의 대표적 구현
@Service
public class UserService {

    @Cacheable(value = "users", key = "#id")
    public User findUser(Long id) {
        // 이 메서드는 캐시 미스 시에만 실행됨
        // 캐시 히트 시에는 메서드가 호출되지 않고 캐시 값 반환
        return userRepository.findById(id)
            .orElseThrow(() -> new NotFoundException());
    }

    @CacheEvict(value = "users", key = "#id")
    public User updateUser(Long id, UpdateUserDto dto) {
        // 메서드 실행 후 캐시 무효화
        return userRepository.save(dto.toEntity(id));
    }

    @CacheEvict(value = "users", allEntries = true)
    public void clearAllUserCache() {
        // users 캐시 전체 삭제
    }
}
# Spring Boot Redis 캐시 설정
# application.yml
spring:
  cache:
    type: redis
  data:
    redis:
      host: redis-service
      port: 6379
      timeout: 2000ms
  cache:
    redis:
      time-to-live: 3600000   # 1시간 (밀리초)
      cache-null-values: false

Write-Through: DB와 캐시에 동시 쓰기

Write-Through는 데이터를 쓸 때 캐시와 DB에 동기적으로 모두 기록하는 패턴이다. 캐시가 항상 최신 상태이므로 읽기 시 DB를 거칠 필요가 없다.

// Write-Through 구현
async createOrder(dto: CreateOrderDto): Promise<Order> {
  // 1. DB에 쓰기
  const order = await this.orderRepository.save(dto.toEntity());

  // 2. 캐시에도 동기적으로 쓰기 (두 작업이 모두 성공해야 완료)
  await this.redis.set(
    `order:${order.id}`,
    JSON.stringify(order),
    'EX', 7200
  );

  return order;
}

// 읽기: 캐시에서만 읽으면 됨 (항상 최신)
async findOrder(id: number): Promise<Order> {
  const cached = await this.redis.get(`order:${id}`);
  if (cached) return JSON.parse(cached);

  // 캐시 미스 (TTL 만료 등) → DB에서 복구
  const order = await this.orderRepository.findOneBy({ id });
  if (order) {
    await this.redis.set(`order:${id}`, JSON.stringify(order), 'EX', 7200);
  }
  return order;
}
항목 Cache-Aside Write-Through
쓰기 지연 DB만 쓰기 (빠름) DB + 캐시 동시 (느림)
읽기 정합성 무효화 전까지 stale 가능 항상 최신
캐시 미스 가능성 있음 (첫 읽기, TTL 만료) 낮음 (쓰기 시 적재)
실패 처리 캐시 실패 무시 가능 캐시 실패 = 전체 실패

Write-Behind (Write-Back): 캐시만 쓰고 DB는 나중에

Write-Behind는 캐시에만 즉시 쓰고, DB 반영은 비동기로 지연하는 패턴이다. 쓰기 성능이 극대화되지만, 캐시 장애 시 데이터 유실 위험이 있다.

// Write-Behind 개념 구현 (Redis + Queue)
async updateInventory(productId: number, quantity: number): Promise<void> {
  // 1. Redis에 즉시 쓰기 (앱 응답은 여기서 완료)
  await this.redis.set(`inventory:${productId}`, quantity.toString());

  // 2. DB 반영을 큐에 넣기 (비동기 처리)
  await this.queue.add('sync-inventory', {
    productId,
    quantity,
    timestamp: Date.now(),
  });
}

// 큐 컨슈머: 배치로 DB 반영
@Process('sync-inventory')
async handleSync(job: Job): Promise<void> {
  const { productId, quantity } = job.data;
  await this.inventoryRepository.update(productId, { quantity });
}
항목 Write-Through Write-Behind
쓰기 응답 시간 DB + 캐시 완료까지 대기 캐시 쓰기만 (매우 빠름)
DB 부하 매 쓰기마다 DB 접근 배치로 줄임
데이터 유실 위험 없음 있음 (캐시 장애 시)
구현 복잡도 중간 높음 (큐, 재시도, 멱등성)
적합한 워크로드 정합성 중요 (결제, 주문) 높은 쓰기 빈도 (조회수, 좋아요)

TTL 설계: 만료 전략의 핵심

# Redis TTL 명령어
SET user:1 '{"id":1,"name":"Alice"}' EX 3600     # 1시간
SET session:abc '...' PX 1800000                   # 30분 (밀리초)

# 동적 TTL: 자주 접근되는 키는 TTL 연장
GET user:1
EXPIRE user:1 3600    # 접근 시 TTL 리셋 (sliding expiration)
데이터 유형 권장 TTL 이유
사용자 프로필 1~24시간 변경 빈도 낮음, stale 허용
세션 30분 (sliding) 보안, 비활성 세션 자동 만료
상품 목록 5~15분 재고/가격 변동 반영 필요
검색 결과 1~5분 실시간성 중요
설정/메타데이터 TTL 없음 + 이벤트 무효화 변경 시에만 갱신

캐시 키 설계 패턴

// 좋은 키 설계: 계층적, 예측 가능, 충돌 없음
user:{id}                    // user:42
user:{id}:profile            // user:42:profile
user:{id}:orders:page:{n}    // user:42:orders:page:1
product:list:category:{cat}:page:{n}  // product:list:category:electronics:page:3

// 와일드카드 무효화 (주의: KEYS 명령은 프로덕션에서 사용 금지)
// Redis SCAN으로 패턴 매칭 삭제
async invalidateUserCache(userId: number): Promise<void> {
  let cursor = '0';
  do {
    const [nextCursor, keys] = await this.redis.scan(
      cursor, 'MATCH', `user:${userId}:*`, 'COUNT', 100
    );
    cursor = nextCursor;
    if (keys.length > 0) {
      await this.redis.del(...keys);
    }
  } while (cursor !== '0');

  // 루트 키도 삭제
  await this.redis.del(`user:${userId}`);
}

실전 체크리스트: Redis 캐시 전략 설계 7단계

  1. 패턴 선택 — 읽기 중심이면 Cache-Aside, 정합성 필수면 Write-Through, 쓰기 폭주면 Write-Behind
  2. 무효화 vs 갱신 결정 — 기본은 무효화(DEL), 동시 쓰기가 없고 읽기 빈도 극히 높으면 갱신(SET)
  3. TTL 정책 수립 — 데이터 유형별 TTL을 정하고 문서화, TTL 없는 키는 메모리 누수 원인
  4. 키 네이밍 규칙 통일{엔티티}:{id}:{속성} 패턴, 팀 전체 공유
  5. 직렬화 포맷 결정 — JSON(디버깅 용이) vs MessagePack(크기 절약), 일관되게 유지
  6. 장애 시 동작 설계 — Redis 다운 시 DB 직접 조회로 fallback, 캐시 실패가 서비스 실패로 이어지지 않도록
  7. 모니터링 지표 설정 — hit ratio, eviction count, memory usage를 Grafana에 등록

흔한 실수 4가지와 방지법

실수 1: DB 쓰기 후 캐시 무효화 순서를 거꾸로

증상: 캐시를 먼저 삭제하고 DB에 쓰는 사이에 다른 요청이 stale 데이터를 캐시에 다시 적재한다.

방지: 반드시 DB 쓰기 → 캐시 무효화 순서로 한다. DB 쓰기가 실패하면 캐시는 여전히 유효한 상태를 유지한다.

실수 2: TTL 없이 캐시에 적재

증상: Redis 메모리가 계속 증가하다 maxmemory에 도달해 eviction이 시작되고, 중요한 키까지 삭제된다.

방지: 모든 캐시 키에 TTL을 설정한다. 영구 데이터가 필요한 경우에도 매우 긴 TTL(24시간 등)을 설정하고 이벤트 기반으로 갱신한다.

실수 3: Write-Behind에서 큐 유실 미대비

증상: Redis 또는 큐 시스템이 장애로 중단되면서 DB에 반영되지 않은 쓰기가 유실된다.

방지: Write-Behind는 유실 허용 가능한 데이터(조회수, 좋아요 등)에만 사용한다. 결제, 주문 등 중요 데이터는 Write-Through 또는 Cache-Aside를 사용한다.

실수 4: KEYS 명령을 프로덕션에서 사용

증상: KEYS user:*로 패턴 삭제를 시도했더니 Redis가 수 초간 블로킹되어 전체 서비스 장애.

방지: Redis 공식 문서는 KEYS를 프로덕션에서 사용하지 말라고 명시한다. 패턴 매칭이 필요하면 SCAN 명령을 사용한다.

마무리

Redis 캐시 전략은 “캐시를 쓴다”가 아니라 “어떻게 읽고, 쓰고, 무효화할 것인가”를 설계하는 것이다. Cache-Aside는 가장 범용적이고, Write-Through는 정합성을 보장하며, Write-Behind는 쓰기 성능을 극대화한다. 대부분의 서비스는 Cache-Aside + TTL + 이벤트 무효화 조합으로 시작하는 것이 안전하다. 이 글은 Redis 공식 문서(Client-side Caching)와 Spring Boot 공식 문서(Caching)를 근거로 한다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux