대규모 트래픽 제어를 위한 Redis 분산 캐시 아키텍처

시스템의 확장성을 논할 때 데이터베이스(DB)는 언제나 가장 먼저 병목이 발생하는 지점입니다. 웹 서버는 스케일 아웃(Scale-out)이 비교적 용이하지만, 상태를 가진 DB는 수평 확장에 물리적, 비용적 한계가 명확하기 때문입니다.

초당 수만 건 이상의 요청(RPS)이 발생하는 환경에서 디스크 I/O를 기반으로 하는 DB에 모든 부하를 전가하는 것은 시스템 붕괴를 자초하는 행위입니다. 인메모리(In-memory) 기반의 캐싱 레이어는 단순한 속도 개선을 넘어, DB의 생존을 보장하는 필수 아키텍처 요소입니다.

읽기 전략의 표준: Look-aside 패턴 구현

캐시를 배치하는 전략은 다양하지만, 범용적으로 가장 많이 사용되는 방식은 Look-aside(Lazy Loading) 패턴입니다. 애플리케이션이 캐시를 먼저 조회하고, 데이터가 없을 때만(Cache Miss) DB에 접근하여 데이터를 가져온 뒤 캐시에 적재하는 방식입니다.

이 구조는 Redis가 다운되더라도 DB를 통해 서비스가 지속될 수 있다는 장점이 있으나, 캐시 미스 시점에 발생하는 레이턴시(Latency)와 초기 구동 시 데이터가 없는 'Cold Start' 문제를 고려해야 합니다.

다음은 TypeScript와 Redis를 활용한 프로덕션 레벨의 Look-aside 패턴 구현 예시입니다.

async function getResource(key: string): Promise<Resource> {
  // 1. 캐시 조회
  const cachedValue = await redisClient.get(key);
  
  if (cachedValue) {
    return JSON.parse(cachedValue);
  }

  // 2. Cache Miss 발생 시 DB 조회 (DB 부하 방지를 위한 락 필요 가능성 있음)
  const dbValue = await database.repository.findOne({ where: { key } });

  if (dbValue) {
    // 3. 캐시 적재 (TTL 설정 필수)
    // TTL에 Jitter(임의의 시간)를 추가하여 만료 시간 동기화 방지
    const ttl = 3600 + Math.floor(Math.random() * 300);
    await redisClient.setex(key, ttl, JSON.stringify(dbValue));
  }

  return dbValue;
}
Key Point: TTL Jitter
모든 캐시 키의 만료 시간(TTL)을 동일하게 설정하면, 특정 시점에 수많은 키가 동시에 만료되어 DB에 요청이 폭주하는 Cache Stampede 현상이 발생할 수 있습니다. 위 코드처럼 난수(Randomness)를 더해 만료 시점을 분산시켜야 합니다.

Redis vs Memcached 기술 사양 비교

분산 캐시 솔루션을 선택할 때 Redis가 사실상의 표준(De facto)으로 자리 잡았으나, Memcached가 더 유리한 시나리오도 분명 존재합니다. 단순한 객체 캐싱만 필요하고 멀티스레드 아키텍처의 이점을 살려야 한다면 Memcached는 여전히 강력한 대안입니다.

두 기술의 핵심 아키텍처 차이를 비교 분석합니다.

기능 Redis Memcached
스레드 모델 Single Thread (이벤트 루프 기반) Multi Thread (멀티 코어 활용 유리)
자료구조 String, List, Set, Sorted Set, Hash 등 다양함 String (단순 Key-Value)
데이터 영속성 지원 (RDB 스냅샷, AOF 로그) 미지원 (재부팅 시 데이터 소실)
복제/클러스터 Master-Slave, Sentinel, Cluster 기본 지원 클라이언트 측 해싱(Consistent Hashing) 필요
메모리 관리 jemalloc 사용, 파편화 관리가 복잡할 수 있음 Slab Allocator 사용, 메모리 파편화 적음

Redis는 Single Thread로 동작하기 때문에 O(N)의 복잡도를 가진 명령(KEYS, FLUSHALL 등)을 실행할 경우 전체 서비스가 블로킹될 위험이 있습니다. 반면 Memcached는 멀티스레드를 지원하므로 처리량을 극대화해야 하는 단순 캐싱 시나리오에서 이점을 가집니다. 하지만 현대의 MSA 환경에서는 데이터 구조의 다양성과 고가용성(HA) 기능 때문에 Redis가 더 선호되는 추세입니다.

캐시 일관성과 Thundering Herd 문제 해결

캐싱 시스템 도입 시 가장 까다로운 문제는 데이터 일관성(Consistency) 유지와 Thundering Herd(Cache Stampede) 현상입니다.

1. 데이터 불일치 최소화

Write-through 패턴을 사용하여 쓰기 시점에 캐시와 DB를 동시에 업데이트하면 일관성은 높아지지만, 쓰기 성능이 저하됩니다. 대안으로 Cache Invalidation(캐시 삭제) 전략을 주로 사용합니다. 데이터 수정이 발생하면 캐시를 갱신하는 대신 삭제(Delete)하고, 다음 조회 요청 시 갱신된 데이터를 DB에서 가져오도록 유도하는 것이 동시성 이슈를 줄이는 안전한 방법입니다.

2. Thundering Herd 방지: PER 알고리즘

인기 있는 키(Hot Key)가 만료되는 순간, 수백 개의 요청이 동시에 DB로 쇄도하여 병목을 유발할 수 있습니다. 이를 방지하기 위해 확률적 조기 재계산(Probabilistic Early Recomputation, PER) 알고리즘을 적용할 수 있습니다.

TTL이 완전히 만료되기 전, 일정 확률로 미리 캐시를 갱신하도록 설계하여 요청이 한순간에 몰리는 것을 분산시키는 기법입니다.

// PER 알고리즘 의사 코드
// currentTime: 현재 시간
// ttl: 남은 만료 시간
// beta: 가중치 상수 (보통 1.0)
// delta: 데이터 갱신에 걸리는 예상 시간

if (currentTime - (ttl * beta * Math.log(Math.random())) > expiry) {
    recomputeCache();
}
주의: 메모리 정책 설정 (maxmemory-policy)
Redis를 캐시 용도로만 사용할 경우 redis.conf에서 maxmemory-policy를 반드시 allkeys-lru 또는 volatile-lru로 설정해야 합니다. 기본값인 noeviction으로 설정될 경우, 메모리가 가득 찼을 때 새로운 키를 저장하지 못하고 에러를 반환하여 장애로 이어질 수 있습니다.

안정적인 아키텍처의 핵심

캐시 시스템은 단순히 데이터를 임시 저장하는 공간이 아니라, 전체 시스템의 처리량을 결정짓는 완충제 역할을 수행합니다. 로컬 캐시(Ehcache, Caffeine)와 글로벌 캐시(Redis)를 혼합한 2-Tier Caching 전략을 사용하면 네트워크 비용까지 절감할 수 있습니다.

성공적인 캐싱 전략은 '무엇을 캐싱할 것인가'보다 '언제 캐시를 만료시킬 것인가'에 대한 명확한 정책 수립에서 시작됩니다. 비즈니스 로직의 데이터 민감도에 맞춰 적절한 TTL 설정과 갱신 정책을 적용하십시오.

OlderNewest

Post a Comment