SJSBiz SJSBiz

Redis 캐싱 전략으로 서버 부하 줄이는 방법

읽는 시간 약 17분
(adsbygoogle = window.adsbygoogle || []).push({});

현대 웹 서비스에서 데이터베이스는 가장 큰 병목 지점 중 하나입니다. 동일한 데이터를 반복해서 조회하면 서버 리소스가 낭비되고 응답 속도가 느려집니다. 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를 방지하기 위해 분산 락을 사용합니다.

(adsbygoogle = window.adsbygoogle || []).push({});
// 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로 고가용성 구성
  • PipelineMulti로 네트워크 왕복 최소화

자주 묻는 질문

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 캐싱 전략 관련 궁금한 점이나 문제가 있으시면 댓글로 남겨 주세요. 도움이 되셨다면 이 글을 주변에 공유해 주시는 것도 잊지 마세요!

(adsbygoogle = window.adsbygoogle || []).push({});
깨비

함께 보면 좋은 글

댓글 0

첫 댓글을 남겨보세요.

error: Content is protected !!
광고보고 콘텐츠 계속 읽기
원치않으시면 뒤로가기를 해주세요

광고 차단 알림

광고 클릭 제한을 초과하여 광고가 차단되었습니다.

단시간에 반복적인 광고 클릭은 시스템에 의해 감지되며, IP가 수집되어 사이트 관리자가 확인 가능합니다.

광고보고 콘텐츠 계속 읽기
원치않으시면 뒤로가기를 해주세요