Redis 캐싱 전략 패턴 5가지 | Cache-Aside부터 Refresh-ahead까지
이 글의 핵심
다섯 가지 캐시 패턴과 TTL·무효화 전략을 Node.js·Redis 예제로 묶어 API 지연과 DB 부하를 동시에 다루는 방법을 정리합니다.
들어가며
Redis는 메모리 기반 키·값 저장소로, 세션, 레이트 리밋, 메시지 브로커로도 쓰이지만 가장 널리 쓰이는 용도 중 하나가 캐시입니다. 캐시를 어떻게 붙이느냐에 따라 일관성, 지연 시간, 장애 시 동작이 달라지는데, 이를 정리한 것이 Cache-Aside, Read-through, Write-through, Write-behind, Refresh-ahead 패턴입니다.
이 글은 각 패턴을 개념 → Node.js에서의 구현 스케치 → TTL·무효화 순으로 연결합니다. 스택 구성은 Docker Compose로 Redis 띄우기와 함께 보면 좋습니다. DB 원천 데이터와의 관계는 Node.js 데이터베이스 연동·C++ DB 연동에서 연결·풀·트랜잭션을, 엔진 선택은 PostgreSQL vs MySQL을 참고하세요. 배포 층면에서는 Nginx·Kubernetes(minikube)가 Redis·DB와 함께 엣지 → 앱 → 캐시 → DB 순으로 맞물립니다. 호스트 용량 이슈는 Linux 디스크/inode와도 연관됩니다.
비유로 말씀드리면, 요청이 DB까지 매번 가는 것은 원료를 매번 창고 끝까지 옮기는 것과 비슷하고, Redis 캐시는 라인 중간 버퍼에 가깝습니다. 패턴마다 누가 버퍼를 채우고 비우는지(앱·캐시 계층·DB)가 달라 일관성과 지연이 갈립니다.
이 글을 읽으면
- 다섯 가지 패턴의 책임 분리(앱 vs 캐시 vs DB)를 구분하실 수 있습니다
- TTL, 캐시 스탬피드, 무효화 전략을 실무 언어로 설명하실 수 있습니다
- Node 코드 수준에서 어느 지점에 훅을 두는지 파악하실 수 있습니다
목차
개념: 왜 패턴이 나뉘는가
기본 개념
- 캐시 적중(hit): 요청 데이터가 캐시에 있음 → DB 생략.
- 캐시 미스(miss): 캐시에 없음 → DB(또는 원천) 조회 후 캐시에 넣을지 결정.
- 일관성: DB가 갱신됐는데 캐시가 옛값을 주는 불일치를 얼마나 허용할지가 패턴 선택의 핵심입니다.
왜 필요한가
DB는 정확하지만 비용이 크고, Redis는 빠르지만 휘발성과 용량 한계가 있습니다. 패턴은 누가 캐시를 채우고·지우는지를 규약합니다.
패턴별 동작과 Node.js 스케치
아래는 ioredis와 비동기 함수를 가정한 최소 예시입니다.
// lib/redis.js
import Redis from 'ioredis';
export const redis = new Redis(process.env.REDIS_URL);
1. Cache-Aside (Lazy loading)
앱이 캐시를 먼저 보고, 없으면 DB에서 읽어 온 뒤 캐시에 저장합니다.
async function getUser(id) {
const key = `user:${id}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const row = await db.query('SELECT * FROM users WHERE id = $1', [id]);
if (!row) return null;
await redis.set(key, JSON.stringify(row), 'EX', 300); // TTL 300초
return row;
}
redis.set(..., 'EX', 300): 키에 초 단위 TTL을 겁니다. 300초가 지면 키가 사라져 오래된 스냅샷이 무한히 남지 않습니다.- 음수 캐시(없는 id)를 그대로 두면 캐시 포이즈닝이 될 수 있어, 필요하면 짧은 TTL의 빈 값이나 별도 플래그를 검토합니다.
특징: 구현이 단순하고 널리 씀. 쓰기 시 캐시를 지우거나 갱신하는 규칙을 앱이 책임집니다.
2. Read-through
캐시 라이브러리(또는 스토어)가 미스일 때 원천 로드를 대신 호출합니다. 앱은 캐시 API만 호출합니다.
// 개념 스케치: 래퍼가 미스 시 로더 실행
async function readThrough(key, ttlSec, loader) {
const hit = await redis.get(key);
if (hit) return JSON.parse(hit);
const value = await loader();
if (value != null)
await redis.set(key, JSON.stringify(value), 'EX', ttlSec);
return value;
}
특징: 로직이 한곳에 모이면 유지보수에 유리. ORM/캐시 모듈이 이 역할을 하기도 합니다.
3. Write-through
쓰기 요청 시 DB와 캐시를 함께 갱신해 일관성을 높입니다.
async function updateUser(id, data) {
await db.query('UPDATE users SET ... WHERE id = $1', [id]);
const row = await db.query('SELECT * FROM users WHERE id = $1', [id]);
await redis.set(`user:${id}`, JSON.stringify(row[0]), 'EX', 300);
}
특징: 읽기 경로가 단순해짐. 쓰기 지연은 두 번 쓰기만큼 늘 수 있습니다.
4. Write-behind (Write-back)
먼저 캐시에 쓰고, DB에는 비동기로 배치 반영합니다.
특징: 쓰기 처리량·지연에 유리할 수 있으나 손실 위험, 복잡한 복구가 따라옵니다. 결제·재고 등에는 부적합한 경우가 많습니다.
5. Refresh-ahead
만료 전에 백그라운드로 미리 갱신해 미스율을 줄입니다.
특징: 트래픽이 급증하는 핫 키에 유용. 백그라운드 잡 설계·중복 갱신 방지가 필요합니다.
TTL·무효화·스탬피드
TTL(Time To Live)
- 모든 키에 TTL을 두면 오래된 데이터가 자연스럽게 사라집니다.
- 너무 짧으면 DB 부하, 너무 길면 불일치 기간이 길어집니다.
쓰기 시 무효화 (Cache-Aside와 궁합)
async function updateUser(id, data) {
await db.query('UPDATE users SET ... WHERE id = $1', [id]);
await redis.del(`user:${id}`);
}
캐시 스탬피드
인기 키가 만료되는 순간 다수 요청이 DB로 몰릴 수 있습니다. 확률적 조기 만료, 싱글 플라이트(락 키로 한 요청만 DB 조회)로 완화합니다.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function getWithSingleFlight(key, ttl, loader) {
const lockKey = `lock:${key}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const got = await redis.set(lockKey, '1', 'EX', 10, 'NX');
if (!got) {
await sleep(50);
return getWithSingleFlight(key, ttl, loader);
}
try {
const value = await loader();
await redis.set(key, JSON.stringify(value), 'EX', ttl);
return value;
} finally {
await redis.del(lockKey);
}
}
set(lockKey, '1', 'EX', 10, 'NX'): 키가 없을 때만 잠금을 걸고(NX), 10초 뒤 자동 해제(EX)되어 데드락에 가까운 상태가 길게 남지 않게 합니다. 락을 못 잡은 요청은 잠시 뒤 다시 시도합니다.
고급: 프로덕션 고려사항
- 연결 풀: Redis 클라이언트와 DB 풀 크기를 프로세스 수에 맞게 조정합니다.
- 키 네이밍:
서비스:엔티티:id형태로 충돌을 줄입니다. - 압축: 큰 값은 MessagePack·압축을 검토합니다.
- 장애: Redis 다운 시 DB로 폴백할지, 요청 실패할지 정책을 명시합니다.
성능 비교 관점
| 패턴 | 읽기 지연 | 쓰기 지연 | 일관성 | 복잡도 |
|---|---|---|---|---|
| Cache-Aside | 낮음(히트 시) | 낮음 | TTL+무효화에 의존 | 낮음 |
| Read-through | 낮음 | — | 로더 구현에 따름 | 중간 |
| Write-through | 낮음 | 다소 높음 | 상대적으로 높음 | 중간 |
| Write-behind | 낮음 | 매우 낮음 | 설정하기 어려움 | 높음 |
| Refresh-ahead | 매우 낮음(히트) | — | TTL·선로딩 타이밍 | 높음 |
실무 사례
- 상품 상세: Cache-Aside + 짧은 TTL + 가격 변경 시 무효화.
- 공지 배너: Read-through 래퍼로 앱 코드 단순화.
- 피드·랭킹: 재계산 비용이 크면 Write-behind는 신중히, 대신 배치 사전 계산을 검토.
성능 전반은 Node.js 성능 가이드와 함께 보세요.
트러블슈팅
| 증상 | 원인 | 대응 |
|---|---|---|
| 간헐적으로 옛 데이터 | 무효화 누락 | 업데이트 경로 전부에서 del/버전 키 |
| Redis 메모리 폭주 | 큰 객체·무제한 키 | TTL, maxmemory-policy, 데이터 설계 |
| DB 부하 급증 | 스탬피드 | 싱글 플라이트, 조기 갱신 |
| 타임아웃 연쇄 | Redis 지연 | 연결 수·슬로우 로그·네트워크 분리 |
마무리
- Cache-Aside로 시작해 필요 시 Write-through·Refresh-ahead로 진화하는 팀이 많습니다.
- TTL과 명시적 무효화를 함께 쓰시면 운영이 수월합니다.
- DB 선택과 연결하려면 PostgreSQL vs MySQL, Node.js 데이터베이스 연동, C++ DB 연동을 이어서 읽으면 좋습니다.
프로덕션 체크리스트
- Redis 장애 시 서비스 degrade 정책(DB 직통, 에러, 서킷 브레이커)을 명문화합니다.
- 키 스키마와 TTL을 팀 규약으로 정해 운영자가
KEYS없이도 원인 추적이 가능하게 합니다. - 메모리 상한(
maxmemory)과 축출 정책을 캐시 용도에 맞게 설정합니다.