캐싱 전략: 다층 캐시 아키텍처와 Redis 고급 활용
Cache-Aside, Write-Through, 스탬피드 방지, Redis Cluster 등 캐싱 전략 총정리
개요
캐싱(Caching)은 자주 접근하는 데이터를 원본보다 빠른 저장소에 임시 보관하여 응답 시간을 단축하고 원본 데이터 소스의 부하를 줄이는 기법이다. 잘 설계된 캐싱 전략은 데이터베이스 쿼리를 90% 이상 줄이고, 응답 시간을 수십 배 개선할 수 있다.
2026년 현재, Redis는 여전히 인메모리 캐시의 표준이며, 다층 캐시 아키텍처(Multi-Level Caching)와 엣지 캐싱(Edge Caching)을 결합한 전략이 주류이다. 캐시 스탬피드(Cache Stampede) 방지, 캐시 일관성(Cache Consistency) 유지가 대규모 시스템 설계의 핵심 과제로 남아 있다.
다층 캐시 아키텍처 (Multi-Level Caching)
캐시 계층 구조
사용자 요청
│
▼
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) | PostgreSQL | 10-100ms | 무제한 | - |
다층 캐시의 과제
스탬피드 이중 발생: In-Process 캐시(L3)와 Redis(L4)가 동시에 만료되면, 모든 인스턴스가 동시에 Redis를 거쳐 DB에 접근하는 이중 스탬피드가 발생할 수 있다.
해결: L3과 L4의 TTL을 다르게 설정하고, L4의 TTL을 L3보다 길게 유지한다.
L3 TTL: 30초 (빠른 만료, 자주 갱신)
L4 TTL: 300초 (L3 미스 시 안전망)캐싱 패턴 (Caching Patterns)
1. Cache-Aside (Lazy Loading)
가장 널리 사용되는 패턴. 애플리케이션이 캐시와 데이터베이스를 직접 관리한다.
읽기:
1. 캐시에서 조회 (GET key)
2. HIT → 캐시 데이터 반환
3. MISS → DB에서 조회 → 캐시에 저장 (SET key value EX ttl) → 데이터 반환
쓰기:
1. DB에 쓰기
2. 캐시 무효화 (DEL key)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 접근을 관리한다. 애플리케이션은 캐시만 바라본다.
읽기:
1. 캐시에 요청
2. HIT → 반환
3. MISS → 캐시가 자체적으로 DB 조회 → 저장 → 반환- 장점: 애플리케이션 로직 단순화
- 단점: 캐시 라이브러리/미들웨어가 DB 접근 로직을 포함해야 함
3. Write-Through
데이터가 캐시와 DB에 동시에 기록된다.
쓰기:
1. 캐시에 쓰기
2. 캐시가 DB에 쓰기 (동기)
3. 양쪽 모두 완료 후 응답장점: 캐시와 DB가 항상 일관성 유지 단점: 쓰기 지연 시간 증가 (두 번 쓰기), 불필요한 데이터도 캐싱
4. Write-Back (Write-Behind)
데이터를 캐시에만 즉시 기록하고, DB에는 비동기로 나중에 기록한다.
쓰기:
1. 캐시에 쓰기 → 즉시 응답
2. 백그라운드에서 DB에 쓰기 (배치 또는 지연)장점: 매우 빠른 쓰기 성능, DB 부하 분산 단점: 데이터 손실 위험 (캐시 장애 시 미동기화 데이터 유실)
패턴 비교
| 패턴 | 읽기 성능 | 쓰기 성능 | 일관성 | 데이터 손실 위험 | 복잡도 |
|---|---|---|---|---|---|
| Cache-Aside | 좋음 | 보통 | 보통 | 낮음 | 낮음 |
| Read-Through | 좋음 | 보통 | 보통 | 낮음 | 보통 |
| Write-Through | 좋음 | 느림 | 높음 | 매우 낮음 | 보통 |
| Write-Back | 좋음 | 매우 빠름 | 낮음 | 높음 | 높음 |
캐시 무효화 (Cache Invalidation)
"컴퓨터 과학에서 어려운 것은 딱 두 가지: 캐시 무효화와 이름 짓기." - Phil Karlton
TTL (Time-To-Live) 기반
캐시 항목에 만료 시간을 설정하여 자동 무효화한다.
SET user:123 '{"name":"홍길동"}' EX 300 # 5분 후 자동 만료TTL 설정 가이드:
- 자주 변경되는 데이터: 짧은 TTL (30초 ~ 5분)
- 거의 변경되지 않는 데이터: 긴 TTL (1시간 ~ 24시간)
- 변경 시 즉시 무효화 필요: TTL + 이벤트 기반 무효화
이벤트 기반 무효화
데이터 변경 시 명시적으로 캐시를 삭제하는 방식이다.
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"라고도 한다.
캐시 만료 → 동시 요청 1000개 → DB에 1000개 동일 쿼리 → DB 과부하방지 전략
1. 분산 잠금 (Distributed Locking)
하나의 요청만 DB를 조회하고, 나머지는 잠금이 해제될 때까지 대기한다.
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이 만료되기 전에 확률적으로 미리 갱신을 수행한다.
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의 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개의 해시 슬롯에 분배하여 수평 확장한다.
슬롯 0-5460 → Node A (+ Replica A')
슬롯 5461-10922 → Node B (+ Replica B')
슬롯 10923-16383 → Node C (+ Replica C')Redis Sentinel
**고가용성(HA)**을 위한 모니터링 및 자동 장애 조치(Failover) 시스템이다.
[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 활용 예시: 실시간 리더보드
# 점수 추가/갱신
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에 부하가 집중되는 것을 방지하기 위해, 사전에 캐시를 채워놓는 전략이다.
워밍 방법
- 스타트업 워밍: 서비스 부팅 시 인기 데이터를 미리 캐싱
- 스케줄 워밍: 크론 잡으로 주기적 캐시 갱신
- 이벤트 기반 워밍: 데이터 변경 이벤트(CDC) 발생 시 즉시 캐싱
- 점진적 전환: 신규 캐시 노드에 트래픽을 서서히 증가
// 스타트업 워밍 예시
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}개 상품`);
}마이크로서비스에서의 캐시 일관성
과제
마이크로서비스 환경에서는 여러 서비스가 동일 데이터를 캐싱할 수 있으며, 데이터 변경 시 모든 서비스의 캐시를 동기화해야 한다.
해결 방안
- 이벤트 기반 무효화: Kafka/Redis Pub/Sub으로 캐시 무효화 이벤트 브로드캐스트
- CDC 기반 자동 무효화: Debezium으로 DB 변경 감지 → 캐시 자동 갱신
- 짧은 TTL: 강한 일관성이 불필요하면 짧은 TTL로 최종 일관성 확보
- 캐시 태그 (Cache Tags): 관련 캐시 항목을 태그로 묶어 일괄 무효화
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 미만의 지연 시간을 달성할 수 있다.
사용자 (한국) → 서울 엣지 PoP → 캐시 HIT → 즉시 응답 (< 50ms)
사용자 (한국) → 서울 엣지 PoP → 캐시 MISS → 오리진 서버 → 캐싱 → 응답엣지 캐시 전략:
- 정적 콘텐츠: 이미지, JS/CSS → 긴 TTL (1일~1년)
- 동적 API 응답: Cache-Control 헤더 기반 → 짧은 TTL (1초~5분)
- 개인화 콘텐츠: 캐시 불가 또는 Vary 헤더 활용
참고 자료
- Designing Data-Intensive Applications - Martin Kleppmann (Chapter 5)
- Redis Documentation: https://redis.io/docs
- System Design Interview - Alex Xu
- Cloudflare Workers Documentation: https://developers.cloudflare.com/workers