Redis 캐싱 전략으로 서버 부하 줄이는 방법
현대 웹 서비스에서 데이터베이스는 가장 큰 병목 지점 중 하나입니다. 동일한 데이터를 반복해서 조회하면 서버 리소스가 낭비되고 응답 속도가 느려집니다. Redis는 인메모리 데이터 저장소로, 자주 조회되는 데이터를 메모리에 캐싱하여 데이터베이스 부하를 획기적으로 줄이고 응답 속도를 개선할 수 있습니다. 이번 글에서는 Redis 캐싱의 핵심 개념부터 실무 전략까지 상세히 정리해 드리겠습니다.
Redis란 무엇인가
Redis(Remote Dictionary Server)는 오픈소스 인메모리 데이터 구조 저장소입니다. 2009년 Salvatore Sanfilippo가 개발했으며, 문자열, 해시, 리스트, 세트, 정렬된 세트 등 다양한 데이터 타입을 지원합니다. 디스크 기반 데이터베이스와 달리 모든 데이터를 메모리에 저장하므로 초당 수십만 건의 읽기/쓰기가 가능합니다.
Redis는 단순한 캐시를 넘어 세션 저장소, 실시간 순위표, 메시지 브로커, 분산 락 등 다양한 용도로 활용됩니다. 하지만 가장 일반적인 사용 사례는 데이터베이스 쿼리 결과나 API 응답을 캐싱하는 것입니다.
캐싱이 필요한 이유
| 지표 | MySQL (디스크) | Redis (메모리) |
|---|---|---|
| 읽기 속도 | 수 ms ~ 수십 ms | 수십 ~ 수백 μs (100배 이상 빠름) |
| 동시 처리량 | 수천 QPS | 수십만 QPS |
| CPU 사용 | 높음 (쿼리 파싱, 디스크 I/O) | 매우 낮음 |
| 네트워크 부하 | 큼 | 매우 작음 |
예를 들어 상품 상세 페이지는 동일한 데이터를 수천 명의 사용자가 동시에 조회합니다. 매번 데이터베이스에 쿼리를 날리는 대신 Redis에 캐싱해두면 데이터베이스 부하를 거의 제로에 가깝게 줄일 수 있습니다.
주요 캐싱 전략
📌 Cache-Aside (Lazy Loading)
가장 일반적인 캐싱 전략입니다. 애플리케이션이 캐시를 직접 관리하며, 캐시에 데이터가 없을 때만 데이터베이스를 조회합니다.
// Cache-Aside 패턴 (Node.js + ioredis 예시)
async function getProduct(productId) {
const cacheKey = `product:${productId}`;
// 1. 캐시 조회
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached); // 캐시 히트
}
// 2. 캐시 미스 시 DB 조회
const product = await db.query('SELECT * FROM products WHERE id = ?', [productId]);
// 3. 캐시에 저장 (TTL 1시간)
await redis.setex(cacheKey, 3600, JSON.stringify(product));
return product;
}
- 장점: 구현이 간단, 캐시와 DB가 독립적으로 운영됨
- 단점: 캐시 미스 시 DB 조회 발생, 첫 요청은 느림
- 적합한 경우: 읽기가 많고 쓰기가 적은 데이터, 실시간성이 크게 중요하지 않은 데이터
📌 Write-Through
데이터를 DB에 쓰는 동시에 캐시에도 저장하는 전략입니다. 캐시와 DB가 항상 동기화되어 캐시 미스가 거의 발생하지 않습니다.
// Write-Through 패턴
async function updateProduct(productId, data) {
const cacheKey = `product:${productId}`;
// 1. DB 업데이트
await db.query('UPDATE products SET ? WHERE id = ?', [data, productId]);
// 2. 캐시 동시 업데이트
await redis.setex(cacheKey, 3600, JSON.stringify(data));
return data;
}
- 장점: 캐시 미스 최소화, 데이터 일관성 유지
- 단점: 쓰기 시 두 번의 저장 발생, 불필요한 캐시 데이터 증가
- 적합한 경우: 자주 조회되면서도 자주 변경되는 데이터
📌 Write-Behind (Write-Back)
데이터를 캐시에만 먼저 쓰고, 비동기적으로 DB에 반영하는 전략입니다. 쓰기 성능이 매우 중요한 경우 사용합니다.
// Write-Behind 패턴
async function createOrder(orderData) {
const cacheKey = `order:${orderData.id}`;
// 1. 캐시에만 저장 (즉시 응답)
await redis.setex(cacheKey, 3600, JSON.stringify(orderData));
// 2. 메시지 큐에 DB 저장 요청 (비동기)
await messageQueue.publish('db.write', orderData);
return orderData;
}
// 별도 워커에서 DB 저장
queueWorker.on('db.write', async (data) => {
await db.query('INSERT INTO orders SET ?', data);
});
- 장점: 쓰기 속도 극대화, DB 부하 분산
- 단점: 캐시 장비 장애 시 데이터 유실 위험, 복잡도 증가
- 적합한 경우: 대량 쓰기가 발생하는 로그, 통계 데이터
📌 Read-Through
캐시 제공자(Redis 자체 또는 캐시 라이브러리)가 캐시 미스 시 자동으로 DB를 조회하는 전략입니다. 애플리케이션 코드가 더 간결해집니다.
// Read-Through (RedisGears 또는 캐시 추상화 라이브러리 사용)
// 애플리케이션은 캐시만 바라보며, 미스 처리는 캐시 계층에서 담당
const product = await cacheManager.get(
`product:${id}`,
() => db.query('SELECT * FROM products WHERE id = ?', [id]),
{ ttl: 3600 }
);
캐시 무효화 전략
캐싱의 가장 어려운 부분은 캐시 무효화(Cache Invalidation)입니다. 데이터가 변경되었을 때 캐시를 적절히 갱신하지 않으면 오래된 데이터(Stale Data)가 사용자에게 노출됩니다.
⏰ TTL (Time To Live)
가장 간단한 무효화 방법입니다. 데이터에 유효 기간을 설정하고, 만료되면 자동 삭제됩니다.
// TTL 설정 (초 단위)
await redis.setex('product:123', 3600, data); // 1시간
await redis.setex('session:abc', 1800, userData); // 30분
await redis.setex('otp:def', 300, code); // 5분
// Redis 7+ EXAT: Unix timestamp 기반 만료
await redis.set('product:123', data, 'EXAT', 1754000000);
🎯 명시적 무효화
데이터 변경 시 직접 캐시를 삭제하거나 갱신합니다. 정확하지만 구현 복�도가 높아집니다.
// 데이터 변경 시 캐시 삭제 (Delete-Aside)
async function updateProduct(productId, data) {
await db.query('UPDATE products SET ? WHERE id = ?', [data, productId]);
// 관련 캐시 전체 삭제
await redis.del(`product:${productId}`);
await redis.del('products:list'); // 목록 캐시도 무효화
}
// 또는 캐시 갱신 (Update-Aside)
await redis.setex(`product:${productId}`, 3600, JSON.stringify(data));
🔄 Cache Warming
서비스 재시작이나 배포 후 미리 캐시를 채워두는 전략입니다. 캐시 미스로 인한 DB 부하 집중을 방지합니다.
// 서비스 시작 시 인기 데이터 미리 캐싱
async function warmCache() {
const popularProducts = await db.query(
'SELECT * FROM products WHERE view_count > 1000 ORDER BY view_count DESC LIMIT 100'
);
for (const product of popularProducts) {
await redis.setex(
`product:${product.id}`,
7200,
JSON.stringify(product)
);
}
}
Redis 고급 활용 기법
🔒 분산 락 (Distributed Lock)
캐시 미스 시 다수의 요청이 동시에 DB를 조회하는 Cache Stampede를 방지하기 위해 분산 락을 사용합니다.
// Redlock 알고리즘 기반 분산 락
async function getProductWithLock(productId) {
const cacheKey = `product:${productId}`;
const lockKey = `lock:${cacheKey}`;
const lockValue = Date.now().toString();
// 1. 캐시 확인
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// 2. 락 획득 시도 (10초 만료)
const locked = await redis.set(lockKey, lockValue, 'EX', 10, 'NX');
if (locked) {
try {
// 락 획득 성공: DB 조회 후 캐시 저장
const product = await db.query('SELECT * FROM products WHERE id = ?', [productId]);
await redis.setex(cacheKey, 3600, JSON.stringify(product));
return product;
} finally {
await redis.del(lockKey); // 락 해제
}
} else {
// 락 획득 실패: 잠시 대기 후 캐시 재조회
await new Promise(resolve => setTimeout(resolve, 100));
return getProductWithLock(productId); // 재귀 호출
}
}
📊 Rate Limiting
Redis를 활용하여 API 요청 제한을 구현할 수 있습니다. Sliding Window 알고리즘으로 정밀하게 제어합니다.
// Sliding Window Rate Limiting
async function isRateLimited(userId, maxRequests = 100, windowSeconds = 60) {
const key = `rate_limit:${userId}`;
const now = Date.now();
const windowStart = now - (windowSeconds * 1000);
// 현재 윈도우 내 요청 수 집계
await redis.zremrangebyscore(key, 0, windowStart); // 오래된 데이터 삭제
const count = await redis.zcard(key);
if (count >= maxRequests) {
return true; // 제한 초과
}
// 현재 요청 기록
await redis.zadd(key, now, `${now}:${Math.random()}`);
await redis.expire(key, windowSeconds);
return false;
}
⚡ Pub/Sub 실시간 알림
Redis Pub/Sub을 활용하여 캐시 무효화 이벤트를 실시간으로 전파할 수 있습니다. 다중 서버 환경에서 유용합니다.
// 서버 A: 캐시 무효화 이벤트 발행
await redis.publish('cache:invalidate', JSON.stringify({
pattern: 'product:123'
}));
// 서버 B: 이벤트 수신 후 로컬 캐시 무효화
redis.subscribe('cache:invalidate');
redis.on('message', (channel, message) => {
const { pattern } = JSON.parse(message);
localCache.delete(pattern); // 로컬 인메모리 캐시 무효화
});
자주 하는 실수와 해결책
❌ 캐시에 민감한 데이터 저장
Redis는 기본적으로 암호화되지 않은 메모리에 데이터를 저장합니다. 개인정보, 비밀번호, 카드 정보 등 민감한 데이터는 캐싱하지 마세요. 필수적이라면 애플리케이션 레벨에서 암호화 후 저장하세요.
❌ TTL 없이 무한 캐싱
TTL을 설정하지 않으면 메모리가 계속 증가하여 Redis 서버가 다운될 수 있습니다. 모든 캐시 데이터에는 적절한 TTL을 설정하고, 메모리 정책(maxmemory-policy)을 구성하세요.
# redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru # LRU 방식으로 오래된 키 자동 삭제
❌ 대용량 객체 직접 캐싱
수 MB 이상의 JSON을 하나의 키에 저장하면 직렬화/역직렬화 오버헤드와 네트워크 전송 시간이 증가합니다. 큰 객체는 필드 단위로 분할하거나, 압축 후 저장하세요.
// 해시로 분할 저장 (HGET/HSET)
await redis.hset(`product:${id}`, {
name: product.name,
price: product.price,
stock: product.stock
});
// 필요한 필드만 조회
const stock = await redis.hget(`product:${id}`, 'stock');
실무 체크리스트
- ✅ 캐싱 전략을 데이터 특성에 맞게 선택 (Cache-Aside, Write-Through 등)
- ✅ TTL을 모든 캐시 데이터에 설정할 것
- ✅ 메모리 정책(maxmemory-policy)을 LRU 또는 LFU로 설정
- ✅ 분산 락으로 Cache Stampede 방지
- ✅ 캐시 무효화 로직을 데이터 변경 지점에 명확히 연결
- ✅ 모니터링으로 캐시 히트율(hit rate)을 80% 이상 유지
- ✅ 민감 데이터는 캐싱하지 않거나 암호화 후 저장
- ✅ Redis Sentinel 또는 Redis Cluster로 고가용성 구성
- ✅ Pipeline과 Multi로 네트워크 왕복 최소화
자주 묻는 질문
Redis와 Memcached 중 어떤 것을 선택해야 하나요?
신규 프로젝트는 Redis를 권장합니다. Redis는 Memcached보다 다양한 데이터 타입, 지속성, 복제, 클러스터링을 지원합니다. 단순한 키-값 캐시만 필요하고 메모리 효율이 최우선이라면 Memcached를 고려할 수 있습니다.
캐시 히트율이 낮아요
히트율이 50% 이하라면 캐싱 대상이 부적절하거나 TTL이 너무 짧은 것입니다. 자주 조회되는 데이터를 분석하고, TTL을 조정하며, Cache Warming을 적용하세요. 히트율 80% 이상이 이상적입니다.
Redis를 여러 서버에서 공유할 수 있나요?
네트워크로 연결된 서버들이 동일한 Redis 인스턴스를 바라보는 것은 일반적인 구성입니다. 다만 단일 Redis 서버는 SPOF(단일 장애 지점)가 되므로, Redis Sentinel(자동 페일오버) 또는 Redis Cluster(샤딩)로 고가용성을 확보하세요.
# Redis Sentinel 구성 (3대 이상)
sentinel monitor mymaster 127.0.0.1 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 60000
마무리
이번 글에서는 Redis 캐싱 전략으로 서버 부하 줄이는 방법을 정리해 드렸습니다. 적절한 캐싱 전략은 데이터베이스 부하를 수십 배 줄이고, 사용자 경험을 획기적으로 개선할 수 있습니다.
핵심을 정리하면 다음과 같습니다: 읽기 중심 데이터는 Cache-Aside, 쓰기도 빈번한 데이터는 Write-Through, 대량 쓰기는 Write-Back을 고려하며, TTL로 메모리를 관리하고, 분산 락으로 Cache Stampede를 방지하고, 캐시 무효화는 데이터 변경 시점에 명확히 연결하세요.
Redis 캐싱 전략 관련 궁금한 점이나 문제가 있으시면 댓글로 남겨 주세요. 도움이 되셨다면 이 글을 주변에 공유해 주시는 것도 잊지 마세요!


댓글 0
첫 댓글을 남겨보세요.