jongkwan.dev
개발 · Essay №004

캐싱 전략: 다층 캐시 아키텍처와 Redis 고급 활용

Cache-Aside, Write-Through, 스탬피드 방지, Redis Cluster 등 캐싱 전략 총정리

이종관2025년 1월 8일15 min read
Contents

개요

캐싱(Caching)은 자주 접근하는 데이터를 원본보다 빠른 저장소에 임시 보관하여 응답 시간을 단축하고 원본 데이터 소스의 부하를 줄이는 기법이다. 잘 설계된 캐싱 전략은 데이터베이스 쿼리를 90% 이상 줄이고, 응답 시간을 수십 배 개선할 수 있다.

2026년 현재, Redis는 여전히 인메모리 캐시의 표준이며, 다층 캐시 아키텍처(Multi-Level Caching)와 엣지 캐싱(Edge Caching)을 결합한 전략이 주류이다. 캐시 스탬피드(Cache Stampede) 방지, 캐시 일관성(Cache Consistency) 유지가 대규모 시스템 설계의 핵심 과제로 남아 있다.

다층 캐시 아키텍처 (Multi-Level Caching)

캐시 계층 구조

plaintext
사용자 요청


L0: CDN / Edge Cache          ← 글로벌 엣지 (지연 시간 < 50ms)
    │ MISS

L1: Reverse Proxy Cache        ← NGINX, Varnish
    │ MISS

L2: API Gateway Cache          ← Kong, AWS API Gateway
    │ MISS

L3: Application In-Process     ← Node.js Map, Caffeine (JVM)
    │ MISS

L4: Distributed Cache          ← Redis, Memcached
    │ MISS

L5: Primary Datastore          ← PostgreSQL, MongoDB

각 계층의 특성

계층저장소지연 시간용량범위
L0 (CDN)Cloudflare, CloudFront< 50ms (글로벌)대규모전 세계
L1 (Reverse Proxy)NGINX, Varnish< 1ms중간서버 단위
L3 (In-Process)로컬 메모리< 0.1ms소규모프로세스 단위
L4 (Distributed)Redis Cluster< 5ms대규모서비스 전체
L5 (DB)PostgreSQL10-100ms무제한-

다층 캐시의 과제

스탬피드 이중 발생: In-Process 캐시(L3)와 Redis(L4)가 동시에 만료되면, 모든 인스턴스가 동시에 Redis를 거쳐 DB에 접근하는 이중 스탬피드가 발생할 수 있다.

해결: L3과 L4의 TTL을 다르게 설정하고, L4의 TTL을 L3보다 길게 유지한다.

plaintext
L3 TTL: 30초 (빠른 만료, 자주 갱신)
L4 TTL: 300초 (L3 미스 시 안전망)

캐싱 패턴 (Caching Patterns)

1. Cache-Aside (Lazy Loading)

가장 널리 사용되는 패턴. 애플리케이션이 캐시와 데이터베이스를 직접 관리한다.

plaintext
읽기:
1. 캐시에서 조회 (GET key)
2. HIT → 캐시 데이터 반환
3. MISS → DB에서 조회 → 캐시에 저장 (SET key value EX ttl) → 데이터 반환
 
쓰기:
1. DB에 쓰기
2. 캐시 무효화 (DEL key)
typescript
async function getUser(userId: string): Promise<User> {
  // 1. 캐시 조회
  const cached = await redis.get(`user:${userId}`);
  if (cached) {
    return JSON.parse(cached);
  }
 
  // 2. DB 조회
  const user = await db.users.findById(userId);
  if (user) {
    // 3. 캐시에 저장 (TTL 5분)
    await redis.set(`user:${userId}`, JSON.stringify(user), 'EX', 300);
  }
 
  return user;
}

장점: 필요한 데이터만 캐싱, 캐시 장애 시 DB로 폴백 가능 단점: 첫 요청은 항상 느림(Cold Start), 캐시와 DB 간 불일치 가능

2. Read-Through

캐시 자체가 DB 접근을 관리한다. 애플리케이션은 캐시만 바라본다.

plaintext
읽기:
1. 캐시에 요청
2. HIT → 반환
3. MISS → 캐시가 자체적으로 DB 조회 → 저장 → 반환
  • 장점: 애플리케이션 로직 단순화
  • 단점: 캐시 라이브러리/미들웨어가 DB 접근 로직을 포함해야 함

3. Write-Through

데이터가 캐시와 DB에 동시에 기록된다.

plaintext
쓰기:
1. 캐시에 쓰기
2. 캐시가 DB에 쓰기 (동기)
3. 양쪽 모두 완료 후 응답

장점: 캐시와 DB가 항상 일관성 유지 단점: 쓰기 지연 시간 증가 (두 번 쓰기), 불필요한 데이터도 캐싱

4. Write-Back (Write-Behind)

데이터를 캐시에만 즉시 기록하고, DB에는 비동기로 나중에 기록한다.

plaintext
쓰기:
1. 캐시에 쓰기 → 즉시 응답
2. 백그라운드에서 DB에 쓰기 (배치 또는 지연)

장점: 매우 빠른 쓰기 성능, DB 부하 분산 단점: 데이터 손실 위험 (캐시 장애 시 미동기화 데이터 유실)

패턴 비교

패턴읽기 성능쓰기 성능일관성데이터 손실 위험복잡도
Cache-Aside좋음보통보통낮음낮음
Read-Through좋음보통보통낮음보통
Write-Through좋음느림높음매우 낮음보통
Write-Back좋음매우 빠름낮음높음높음

캐시 무효화 (Cache Invalidation)

"컴퓨터 과학에서 어려운 것은 딱 두 가지: 캐시 무효화와 이름 짓기." - Phil Karlton

TTL (Time-To-Live) 기반

캐시 항목에 만료 시간을 설정하여 자동 무효화한다.

redis
SET user:123 '{"name":"홍길동"}' EX 300  # 5분 후 자동 만료

TTL 설정 가이드:

  • 자주 변경되는 데이터: 짧은 TTL (30초 ~ 5분)
  • 거의 변경되지 않는 데이터: 긴 TTL (1시간 ~ 24시간)
  • 변경 시 즉시 무효화 필요: TTL + 이벤트 기반 무효화

이벤트 기반 무효화

데이터 변경 시 명시적으로 캐시를 삭제하는 방식이다.

typescript
async function updateUser(userId: string, data: UpdateUserDto) {
  // 1. DB 업데이트
  const updatedUser = await db.users.update(userId, data);
 
  // 2. 캐시 무효화
  await redis.del(`user:${userId}`);
 
  // 3. 관련 캐시도 무효화
  await redis.del(`user-profile:${userId}`);
  await redis.del(`user-orders:${userId}`);
 
  return updatedUser;
}

삭제 vs 갱신

전략동작장점단점
삭제 (Invalidate)캐시 키 삭제, 다음 조회 시 재캐싱단순, 안전다음 요청 시 DB 히트
갱신 (Update)캐시 값을 새 데이터로 직접 교체DB 히트 없음경합 상태(Race Condition) 위험

권장: 대부분의 경우 삭제 전략이 더 안전하다.

캐시 스탬피드 방지 (Cache Stampede Prevention)

문제

인기 있는 캐시 키가 만료되는 순간, 수백~수천 개의 동시 요청이 모두 DB를 직접 조회하여 DB가 과부하에 빠지는 현상이다. "Thundering Herd" 또는 "Dog-Pile Effect"라고도 한다.

plaintext
캐시 만료 → 동시 요청 1000개 → DB에 1000개 동일 쿼리 → DB 과부하

방지 전략

1. 분산 잠금 (Distributed Locking)

하나의 요청만 DB를 조회하고, 나머지는 잠금이 해제될 때까지 대기한다.

typescript
async function getWithLock(key: string): Promise<any> {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);
 
  const lockKey = `lock:${key}`;
  // SET NX (존재하지 않을 때만 설정) - 원자적 잠금
  const acquired = await redis.set(lockKey, '1', 'EX', 10, 'NX');
 
  if (acquired) {
    try {
      const data = await fetchFromDatabase(key);
      await redis.set(key, JSON.stringify(data), 'EX', 300);
      return data;
    } finally {
      await redis.del(lockKey);
    }
  } else {
    // 잠금 대기 후 재시도
    await sleep(50);
    return getWithLock(key);
  }
}

단순 잠금 키로 스탬피드를 방지하면 피크 시 DB 부하를 30-70% 절감할 수 있다.

2. 확률적 조기 만료 (Probabilistic Early Expiration)

TTL이 만료되기 전에 확률적으로 미리 갱신을 수행한다.

XFetch 알고리즘은 캐시 만료 전에 확률적으로 갱신을 시도하여 Stampede를 예방한다. delta는 캐시 재계산에 걸리는 시간이고, beta는 조기 갱신의 적극성을 조절하는 파라미터이다.

typescript
function shouldRefresh(ttlRemaining: number, delta: number, beta: number = 1.0): boolean {
  // XFetch 알고리즘
  const random = -delta * beta * Math.log(Math.random());
  return ttlRemaining < random;
}
 
// ttlRemaining이 적을수록 갱신 확률이 높아짐
// delta: 캐시 재계산에 소요되는 시간
// beta: 조기 만료 정도 조절 (기본 1.0)

3. Singleflight / Request Coalescing

동일한 키에 대한 동시 요청을 하나로 합쳐서 DB에 한 번만 조회한다.

go
// Go의 singleflight 패턴
group := &singleflight.Group{}
 
result, err, shared := group.Do("user:123", func() (interface{}, error) {
    return fetchFromDatabase("user:123")
})
// shared == true이면 다른 호출자와 결과를 공유한 것

4. 스탬피드 방지 전략 비교

전략구현 복잡도효과적합한 상황
분산 잠금보통높음대부분의 경우
확률적 조기 만료보통높음TTL이 긴 캐시
Singleflight낮음높음같은 프로세스 내 동시 요청
캐시 워밍낮음보통예측 가능한 패턴

Redis 고급 활용

Redis Cluster

데이터를 16,384개의 해시 슬롯에 분배하여 수평 확장한다.

plaintext
슬롯 0-5460     → Node A (+ Replica A')
슬롯 5461-10922 → Node B (+ Replica B')
슬롯 10923-16383 → Node C (+ Replica C')

Redis Sentinel

**고가용성(HA)**을 위한 모니터링 및 자동 장애 조치(Failover) 시스템이다.

plaintext
[Sentinel 1] [Sentinel 2] [Sentinel 3]
     │            │            │
     └────────────┼────────────┘

     ┌────────────┼────────────┐
     ▼            ▼            ▼
 [Master]    [Replica 1]  [Replica 2]
 
Master 장애 감지 → Sentinel 투표 → Replica 1 승격 → 클라이언트 리다이렉트

Redis 고급 데이터 구조

데이터 구조활용 사례예시
Sorted Set실시간 리더보드, 랭킹ZADD leaderboard 1000 "player1"
HyperLogLog고유 방문자 수 추정 (UV)0.81% 오차로 2^64개까지 카운트, 12KB 메모리
Stream이벤트 스트리밍, 메시지 큐Kafka 대안 (경량)
Bitmap사용자 활동 추적 (일별 로그인)SETBIT daily:login:2026-02-10 userId 1
Geospatial위치 기반 검색 (반경 내 가게)GEORADIUS stores 126.97 37.56 5 km

Redis 활용 예시: 실시간 리더보드

redis
# 점수 추가/갱신
ZADD game:leaderboard 1500 "player_A"
ZADD game:leaderboard 2300 "player_B"
ZADD game:leaderboard 1800 "player_C"
 
# 상위 10명 조회 (점수 내림차순)
ZREVRANGE game:leaderboard 0 9 WITHSCORES
 
# 특정 플레이어 순위 조회
ZREVRANK game:leaderboard "player_A"
 
# 점수 범위 조회
ZRANGEBYSCORE game:leaderboard 1000 2000

캐시 워밍 전략 (Cache Warming)

개념

서비스 시작 시 또는 배포 후 캐시가 비어있는 콜드 스타트(Cold Start) 상태에서 DB에 부하가 집중되는 것을 방지하기 위해, 사전에 캐시를 채워놓는 전략이다.

워밍 방법

  1. 스타트업 워밍: 서비스 부팅 시 인기 데이터를 미리 캐싱
  2. 스케줄 워밍: 크론 잡으로 주기적 캐시 갱신
  3. 이벤트 기반 워밍: 데이터 변경 이벤트(CDC) 발생 시 즉시 캐싱
  4. 점진적 전환: 신규 캐시 노드에 트래픽을 서서히 증가
typescript
// 스타트업 워밍 예시
async function warmCache() {
  const popularProducts = await db.products.findPopular({ limit: 1000 });
 
  const pipeline = redis.pipeline();
  for (const product of popularProducts) {
    pipeline.set(
      `product:${product.id}`,
      JSON.stringify(product),
      'EX', 3600
    );
  }
  await pipeline.exec();
 
  console.log(`캐시 워밍 완료: ${popularProducts.length}개 상품`);
}

마이크로서비스에서의 캐시 일관성

과제

마이크로서비스 환경에서는 여러 서비스가 동일 데이터를 캐싱할 수 있으며, 데이터 변경 시 모든 서비스의 캐시를 동기화해야 한다.

해결 방안

  1. 이벤트 기반 무효화: Kafka/Redis Pub/Sub으로 캐시 무효화 이벤트 브로드캐스트
  2. CDC 기반 자동 무효화: Debezium으로 DB 변경 감지 → 캐시 자동 갱신
  3. 짧은 TTL: 강한 일관성이 불필요하면 짧은 TTL로 최종 일관성 확보
  4. 캐시 태그 (Cache Tags): 관련 캐시 항목을 태그로 묶어 일괄 무효화
plaintext
User Service가 사용자 정보 변경

Kafka 이벤트: user.updated (userId: 123)

├── Order Service: redis.del("user-orders:123")
├── Profile Service: redis.del("user-profile:123")
└── Recommendation Service: redis.del("user-recs:123")

엣지 캐싱 (Edge Caching)

Cloudflare Workers와 엣지 캐싱

사용자에게 물리적으로 가장 가까운 엣지 서버에서 캐시를 제공하여 글로벌 지연 시간을 최소화한다. 엣지 캐싱을 활용하면 전 세계적으로 50ms 미만의 지연 시간을 달성할 수 있다.

plaintext
사용자 (한국) → 서울 엣지 PoP → 캐시 HIT → 즉시 응답 (< 50ms)
사용자 (한국) → 서울 엣지 PoP → 캐시 MISS → 오리진 서버 → 캐싱 → 응답

엣지 캐시 전략:

  • 정적 콘텐츠: 이미지, JS/CSS → 긴 TTL (1일~1년)
  • 동적 API 응답: Cache-Control 헤더 기반 → 짧은 TTL (1초~5분)
  • 개인화 콘텐츠: 캐시 불가 또는 Vary 헤더 활용

참고 자료