콘텐츠로 이동

Redis를 활용한 복원력 패턴

분류: Layer 6 - 운영 심화: 관측성 & 복원력 | 작성일: 2026-04-01

📌 Redis 내부 원리(자료구조, 영속화, 클러스터, ElastiCache)는 L8/redis-internals.md에서 다룹니다. 이 문서는 복원력 패턴 (Cache Stampede, Circuit Breaker, Rate Limiting, Fallback) 에만 집중합니다.


Redis는 데이터를 메모리에 저장하는 오픈소스 Key-Value 스토어로, 초당 수십만 건의 읽기/쓰기를 처리하며 캐시·세션·큐·실시간 랭킹 등 다양한 역할을 수행한다.

이 문서의 범위: Redis를 복원력(Resilience) 도구로 활용하는 패턴에 집중한다. Redis 내부 자료구조 원리, RDB/AOF 영속화 비교, 클러스터 구성, ElastiCache 운영 튜닝 등 DB 심화 주제는 L8의 redis-internals.md 를 참고한다.


프론트엔드 개발자 관점에서: 브라우저 Cache API(caches.open())와 HTTP Cache-Control 헤더는 프론트엔드에서 익숙한 캐시 레이어다. Cache-Control: max-age=300은 “5분간 유효한 응답을 저장”하는 TTL 기반 캐시이고, Redis의 SET key value EX 300이 정확히 같은 개념이다. 브라우저 캐시가 사용자의 로컬 RAM을 아끼듯, Redis는 DB 쿼리를 아끼는 서버 사이드 캐시다.

L6 테마는 “프로덕션에서 장애 없이 돌리기”다. 이 토픽이 푸는 문제는 다음 정량 SLA 시나리오로 정의된다.

문제 시나리오 (정량 SLA): NestJS API 10대(pod)가 부하분산기 뒤에서 5K RPS를 처리한다. DB 쿼리 p50=20ms, p99=200ms이고 PostgreSQL connection pool은 인스턴스당 20개(총 200개), API SLA는 p99 ≤ 300ms다. 트래픽이 7K RPS로 늘면 connection pool이 포화되어 p99가 500ms+로 발산하고 SLA가 깨진다. Redis 캐시로 hit rate 95% 달성 시 DB 트래픽은 7K → 350 RPS로 줄어 SLA가 복원된다 — 즉, Redis는 단순한 속도 향상이 아니라 DB 자원 보호 레이어다.

캐시 실패 시 amplification (비대칭): hit rate H%인 캐시가 죽으면 DB 트래픽이 1/(1-H) 배로 증폭된다. H=95% → 20×, H=99% → 100×. 캐시 의존도가 높을수록 캐시 장애가 DB 즉사로 직결되는 비대칭이다 (CloudIQ — From Cache Miss to Controlled Degradation — 95% hit rate 캐시가 실패하면 DB QPS 20× burst 시나리오). 실제로 Facebook 2010 outage는 캐시 설정 오류가 cascade되어 수 시간 outage로 이어진 대표 사례다. 그래서 캐시 도입과 동시에 캐시 장애 대응(Fallback/Circuit Breaker)이 필수이며 — 이 토픽이 L6(복원력) 레이어에 속하는 이유다. Redis는 단순한 속도 향상 도구가 아니라 복원력(Resilience)의 핵심 인프라로 작동한다:

복원력 시나리오Redis가 하는 역할실측 효과
DB 과부하 → 장애캐시가 DB 앞에서 부하를 흡수, DB를 보호DB 쿼리 평균 200ms → Redis 캐시 조회 1ms 미만
트래픽 급증 (Spike)메모리 조회로 DB 쿼리 대체 → 응답 유지단일 인스턴스 기준 SET 최대 ~180,000 req/s
Cache StampedeMutex Lock / Jitter로 DB 과부하 방지동시 만료 요청 → DB 연쇄 과부하 방지
Rate LimitingINCR + EXPIRE로 분당 호출 제한 → 시스템 보호단일 IP 기준 100K QPS까지 지원
Redis 자체 장애Fallback 전략 + Circuit Breaker로 서비스 유지Circuit OPEN 후 30초 내 Half-Open 자동 전환
분산 환경 중복 실행SETNX 기반 분산 Lock으로 Race Condition 방지멀티 인스턴스 환경에서 중복 실행 0건 보장

수치 출처: Redis 공식 벤치마크 기준 (redis-benchmark 도구, 100K ops 기준 GET p50=0.143ms, SET ~180K req/s). redis.io/docs/benchmarks

BackOps 환경에서 NestJS API가 같은 DB 쿼리를 반복 실행하거나, 세션 관리가 필요하거나, BullMQ 큐를 쓰고 있다면 — 이미 Redis를 사용 중이거나 사용해야 할 상황이다.

2.1 선행 기술의 한계 — In-Process 캐시에서 Redis 분산 캐시의 등장

섹션 제목: “2.1 선행 기술의 한계 — In-Process 캐시에서 Redis 분산 캐시의 등장”

NestJS @nestjs/cache-manager의 기본 메모리 store나 Node.js Map, node-cache 같은 In-Process 캐시는 단일 인스턴스 환경에선 충분하다. 그러나 멀티 pod 환경에서는 다음 한계가 드러난다 (codewithmukesh — Distributed Caching in ASP.NET Core with Redis — 한 서버가 로컬 캐시를 갱신해도 다른 서버는 모르므로 사용자가 stale 데이터를 받는 일관성 문제).

  • 상태 분리 → 데이터 일관성 깨짐: 10개 pod이 각자 로컬 캐시를 갖는다. Pod 1이 상품 가격을 갱신하고 자기 캐시를 무효화해도 Pod 2~10은 여전히 이전 가격을 반환해, 사용자는 새로고침 때마다 다른 가격을 보는 silent inconsistency가 생긴다.
  • Hit rate 실효 저하: 부하분산기가 요청을 N개 pod로 분산하므로 동일 키도 pod마다 독립 캐싱된다. 이론적 hit rate가 95%여도 pod 단위로 보면 사실상 1/N에 수렴해 DB 보호 효과가 약해진다 (위 amplification 식에서 H가 작아질수록 위험이 커진다).
  • Pod 재시작 시 cold start burst: 배포·오토스케일링으로 pod이 교체되면 메모리 캐시가 통째로 날아간다 → 직후 DB 쿼리 burst가 발생해 위 5K RPS 시나리오에서 connection pool이 일시 포화될 수 있다.

Redis 분산 캐시가 해소하는 메커니즘: 캐시 상태를 별도 네트워크 노드에 두고 모든 pod이 공유한다. 한 번의 LAN RTT(~0.5ms)를 지불하는 대신 일관성·hit rate·pod 재시작 견딤성을 얻는다. 대신 새 SPOF(Single Point of Failure)가 생긴다 — Redis 자체 장애가 전체 서비스 장애로 직결될 위험이다. 이 두 번째 한계를 해소하기 위해 본 문서 후반의 Fallback(3-2 패턴 1)·Circuit Breaker(3-2 패턴 2)·Hot Key 대응 2단계 캐시(6.5 문제 3 — L1 in-process + L2 Redis)가 등장했다. 즉 in-process 캐시를 완전히 버리는 것이 아니라, Redis로 일관성 한계를 풀고 다시 in-process를 L1으로 얹어 SPOF 위험과 hot key 부하를 함께 해소하는 2층 구조다. 이 토픽이 사라지면 멀티 인스턴스 환경의 캐시 일관성과 DB 과부하 보호 두 축이 동시에 무너진다 — 그게 frontmatter lineage_oneliner의 “DB 과부하 흡수와 장애 격리”가 가리키는 자리다.


Cache Aside (Lazy Loading) — 가장 일반적

섹션 제목: “Cache Aside (Lazy Loading) — 가장 일반적”
요청 → 캐시 확인 → [HIT] 바로 반환
→ [MISS] DB 조회 → 캐시 저장 → 반환
product.service.ts
async getProduct(id: number): Promise<Product> {
const cacheKey = `product:${id}`;
// 1. 캐시 확인
const cached = await this.redis.get(cacheKey);
if (cached) {
return JSON.parse(cached); // Cache HIT
}
// 2. DB 조회 (Cache MISS)
const product = await this.productRepository.findOne({ where: { id } });
// 3. 캐시 저장 (TTL: 10분)
await this.redis.setex(cacheKey, 600, JSON.stringify(product));
return product;
}
// 데이터 변경 시 캐시 무효화
async updateProduct(id: number, dto: UpdateProductDto): Promise<Product> {
const product = await this.productRepository.save({ id, ...dto });
await this.redis.del(`product:${id}`); // 캐시 삭제
return product;
}

Write Through — 쓰기 시 캐시 동시 갱신

섹션 제목: “Write Through — 쓰기 시 캐시 동시 갱신”
쓰기 요청 → DB 저장 + 캐시 저장 (동시)
async createOrUpdateProduct(dto: CreateProductDto): Promise<Product> {
// DB와 캐시를 동시에 갱신
const product = await this.productRepository.save(dto);
await this.redis.setex(
`product:${product.id}`,
600,
JSON.stringify(product)
);
return product;
}

Write Back (Write Behind) — 캐시 먼저, DB는 나중에

섹션 제목: “Write Back (Write Behind) — 캐시 먼저, DB는 나중에”
쓰기 요청 → 캐시만 저장 → (비동기) → DB 저장
// 주의: Redis 장애 시 데이터 유실 위험 존재
async updateViewCount(postId: number): Promise<void> {
// 캐시에만 카운트 증가 (DB 부하 감소)
await this.redis.incr(`post:views:${postId}`);
// 별도 스케줄러가 주기적으로 DB에 flush
}
// scheduler.service.ts (5분마다 실행)
@Cron('*/5 * * * *')
async flushViewCountsToDb(): Promise<void> {
// KEYS * 는 블로킹 커맨드 → 운영에서 SCAN으로 대체 필수
let cursor = '0';
do {
const [nextCursor, keys] = await this.redis.scan(cursor, 'MATCH', 'post:views:*', 'COUNT', 100);
cursor = nextCursor;
for (const key of keys) {
const postId = key.split(':')[2];
const count = await this.redis.getdel(key);
if (count) {
await this.postRepository.increment({ id: parseInt(postId) }, 'views', parseInt(count));
}
}
} while (cursor !== '0');
}
항목Cache AsideWrite ThroughWrite Back
구현 복잡도낮음중간높음
데이터 일관성낮음 (MISS 후 TTL까지 stale)높음낮음 (장애 시 유실)
읽기 성능첫 요청 느림 (MISS)빠름빠름
쓰기 성능빠름느림 (DB+캐시 동시)매우 빠름
데이터 손실 위험없음없음있음
적합한 경우읽기 많은 일반 API읽기+쓰기 균형초고빈도 쓰기 (조회수 등)
// 데이터 특성별 TTL 가이드
const TTL = {
SESSION: 3600, // 세션: 1시간
USER_PROFILE: 1800, // 유저 프로필: 30분
PRODUCT_LIST: 300, // 상품 목록: 5분
REALTIME_RANK: 60, // 실시간 랭킹: 1분
CONFIG: 86400, // 공통 설정: 1일
};
// TTL Jitter: Cache Stampede 방지용 랜덤 편차 추가
function withJitter(baseTtl: number, jitterRange: number = 60): number {
return baseTtl + Math.floor(Math.random() * jitterRange);
}
await this.redis.setex(key, withJitter(TTL.PRODUCT_LIST), value);
// 300~360초 사이 랜덤 TTL → 동시 만료 방지

Redis는 빠르지만 단일 장애점(SPOF)이 될 수 있다. Redis가 다운됐을 때 서비스 전체가 멈추지 않도록 복원력 패턴을 적용해야 한다.

패턴 1 — Fallback 전략 (Redis 다운 시 DB 직접 조회)

섹션 제목: “패턴 1 — Fallback 전략 (Redis 다운 시 DB 직접 조회)”

Redis 연결 실패를 감지하면 DB로 Fallback하되, DB 과부하를 막는 것이 핵심이다.

cache-resilient.service.ts
@Injectable()
export class CacheResilientService {
private redisHealthy = true;
private retryTimer: NodeJS.Timeout | null = null;
constructor(
@Inject("REDIS_CLIENT") private readonly redis: Redis,
private readonly productRepository: ProductRepository,
) {}
async getProduct(id: number): Promise<Product> {
// Redis가 비정상이면 즉시 DB로 Fallback
if (!this.redisHealthy) {
return this.productRepository.findOne({ where: { id } });
}
try {
const cacheKey = `product:${id}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const product = await this.productRepository.findOne({ where: { id } });
await this.redis.setex(cacheKey, 600, JSON.stringify(product));
return product;
} catch (err) {
// Redis 연결 실패 감지 → Circuit Open
this.markRedisUnhealthy();
// DB로 Fallback
return this.productRepository.findOne({ where: { id } });
}
}
private markRedisUnhealthy(): void {
if (this.redisHealthy) {
this.redisHealthy = false;
// 30초 후 Redis 재시도 (Cache Warming 트리거)
this.retryTimer = setTimeout(async () => {
try {
await this.redis.ping();
this.redisHealthy = true; // 복구 확인
} catch {
this.markRedisUnhealthy(); // 아직 복구 안 됨 → 재스케줄
}
}, 30_000);
}
}
}

핵심 원칙: Redis 장애가 DB 장애로 연쇄되지 않도록 격리(Bulkhead). Fallback 시 DB 부하가 급증하므로 반드시 Rate Limiting과 함께 운영한다.

Fallback 결정 기준 (DB headroom 기반): 섹션 2의 amplification 식 1/(1-H)를 적용하면, hit rate 95% 캐시가 죽었을 때 DB가 정상 트래픽의 20× burst를 견뎌야 Fallback이 안전하다. DB headroom이 부족하면 Fallback은 오히려 DB 장애를 유발한다 (CloudIQ — From Cache Miss to Controlled Degradation — 8095% hit rate 캐시가 죽으면 RU 520× burst로 DB가 즉사할 수 있어 controlled degradation이 필요하다는 사례 분석). 결정 분기:

DB headroom (정상 대비)95% hit rate 캐시가 죽었을 때권장 전략
< 5×DB 즉사 위험Fallback 금지. Redis 장애 시 503 반환 + circuit 회복 대기가 더 안전
5× ~ 20×위험 영역Fallback + Rate Limiter 필수 (평소 DB QPS × headroom 배수 임계값)
> 20×흡수 가능Fallback 단독 가능. 단 DB connection pool/CPU 알람은 반드시 유지

위 표는 단순 비교가 아니라 결정 분기다. 섹션 2의 5K RPS 시나리오에 적용하면 평소 DB 350 QPS → Fallback 시 7K QPS가 필요해 20× headroom이 요구되는데, RDS db.r6g.large급 인스턴스는 대략 그 한도에 걸린다 → 5~20× 구간이라 Rate Limiter 동반이 필수라는 결론이 나온다.

Silent failure — “Redis가 살아있지만 느려진 경우”: 위 markRedisUnhealthy 핸들러는 catch 블록이 발동돼야 동작한다. 그런데 Redis가 OOM 직전이거나 네트워크 jitter로 평소 0.5ms 응답이 200ms로 늘어나도 connection refuse 같은 명시적 에러는 안 나오므로 — Node.js의 event loop만 막혀 전체 API p99가 발산하고 Circuit Breaker도 “실패”로 인식 못 한다. 진단·예방 절차:

Terminal window
# Redis 응답 지연 외부 측정 (운영 환경에서 1초마다 샘플)
redis-cli -h <endpoint> --latency-history -i 1
# 예상 정상 출력: min: 0, max: 1, avg: 0.50 (count 100)
# silent degradation 신호: avg > 10ms 또는 max > 100ms가 지속되면 의심
// ioredis는 commandTimeout 기본값이 무한대 — silent 지연을 명시 에러로 전환
new Redis({
host: process.env.REDIS_HOST,
commandTimeout: 200, // 200ms 초과 시 즉시 에러 → catch 블록·Circuit Breaker가 감지
maxRetriesPerRequest: 1, // 무한 재시도로 인한 latency 누적 차단
});

commandTimeout이 없으면 Redis가 느려져도 에러가 안 나서 Circuit Breaker가 차단을 못 한다 — 이게 가장 흔한 silent failure 패턴이다. 다음 단계: 위 ioredis 설정을 적용한 뒤 --latency-history 출력에서 avg가 정상 범위인지 24시간 모니터링하고, Circuit Breaker의 open 이벤트 로그가 실제 지연 burst와 시간상으로 일치하는지 확인한다.


패턴 2 — Circuit Breaker + Cache 조합

섹션 제목: “패턴 2 — Circuit Breaker + Cache 조합”

Redis 응답이 연속 N회 실패하면 일정 시간 Redis 호출 자체를 차단한다. retry-backoff-idempotency.md에서 다루는 Circuit Breaker 패턴을 Redis 레이어에도 적용한다.

// opossum 라이브러리 활용
import * as CircuitBreaker from "opossum";
@Injectable()
export class RedisCircuitService {
private breaker: CircuitBreaker;
constructor(@Inject("REDIS_CLIENT") private readonly redis: Redis) {
this.breaker = new CircuitBreaker((key: string) => this.redis.get(key), {
timeout: 500, // 500ms 초과 시 실패로 간주
errorThresholdPercentage: 50, // 50% 실패율에서 Open
resetTimeout: 30_000, // 30초 후 Half-Open 상태로 전환
});
this.breaker.fallback((key: string) => null); // Open 시 null 반환 → 호출자가 DB Fallback
this.breaker.on("open", () =>
console.warn("[Redis CB] Circuit OPEN — Redis bypassed"),
);
this.breaker.on("halfOpen", () =>
console.log("[Redis CB] Circuit HALF-OPEN — probing Redis"),
);
this.breaker.on("close", () =>
console.log("[Redis CB] Circuit CLOSED — Redis recovered"),
);
}
async get(key: string): Promise<string | null> {
return this.breaker.fire(key) as Promise<string | null>;
}
}

Circuit 상태 전환:

Closed (정상) → 오류율 50% 초과 → Open (차단, DB로만)
Open → 30초 경과 → Half-Open (소량 테스트) → 성공 시 Closed 복귀

선택 기준: Fallback 전략은 간단하지만 Redis 상태를 직접 추적해야 한다. Circuit Breaker는 상태 전환이 자동화되고 모니터링이 명확하므로 프로덕션에서 권장된다.

Redis에서 학습한 패턴은 동일한 원리로 다른 도메인에도 적용된다. Redis 레이어에서 익힌 패턴을 전이(Transfer)하면 아래 도메인에서도 같은 문제를 동일한 사고방식으로 해결할 수 있다.

패턴Redis 레이어HTTP 클라이언트 (외부 API 호출)DB 커넥션 풀API Gateway / NGINX
Circuit Breakeropossum으로 Redis 호출 차단axios retry + 연속 실패 시 호출 차단HikariCP connectionTimeout 초과 시 차단NGINX proxy_next_upstream 실패 시 업스트림 차단
Rate LimitingINCR + EXPIRE (고정/슬라이딩 윈도우)token_bucket 미들웨어DB 커넥션 수 상한 (max_pool_size)limit_req_zone (NGINX), API Gateway Usage Plan
FallbackRedis MISS → DB 직접 조회외부 API 실패 → 로컬 캐시 응답Primary DB 실패 → Read ReplicaCDN origin 실패 → 정적 오류 페이지
Cache Stampede 방지TTL Jitter / Mutex Lock (SETNX)Conditional Request (ETag, If-None-Match)Query cache 만료 시 SELECT FOR UPDATECDN stampede → stale-while-revalidate

핵심 통찰: Circuit Breaker는 “N회 연속 실패 → 호출 차단 → 일정 시간 후 재시도”라는 상태 기계(State Machine)다. Redis·HTTP 클라이언트·DB 커넥션 어디에 적용하든 Closed → Open → Half-Open 전환 논리는 동일하다. (Martin Fowler - Circuit Breaker, AWS Prescriptive Guidance)


패턴 3 — Cache Stampede 방지 (DB 과부하 격리)

섹션 제목: “패턴 3 — Cache Stampede 방지 (DB 과부하 격리)”

캐시 키가 만료되는 순간 수백 개 요청이 동시에 DB로 몰리는 현상. DB 부하 급증 → 장애로 연쇄된다.

방법 1 — TTL Jitter (가장 간단)

// 동일한 TTL로 한꺼번에 만료되는 것을 방지
function withJitter(baseTtl: number, jitterRange: number = 60): number {
return baseTtl + Math.floor(Math.random() * jitterRange);
}
await this.redis.setex(key, withJitter(600), value); // 600~660초 사이 랜덤

방법 2 — Mutex Lock (SETNX 기반 분산 락)

async getWithLock(key: string, fetchFn: () => Promise<any>): Promise<any> {
const cached = await this.redis.get(key);
if (cached) return JSON.parse(cached);
const lockKey = `lock:${key}`;
const lockValue = `${Date.now()}-${Math.random()}`;
// NX: 없을 때만 설정 → 락 획득 시도
const acquired = await this.redis.set(lockKey, lockValue, "EX", 10, "NX");
if (acquired === "OK") {
try {
const data = await fetchFn();
await this.redis.setex(key, 600, JSON.stringify(data));
return data;
} finally {
// 내가 설정한 락인지 확인 후 삭제 (다른 프로세스의 락 삭제 방지)
const current = await this.redis.get(lockKey);
if (current === lockValue) await this.redis.del(lockKey);
}
} else {
// 락 획득 실패 → 100ms 후 재시도 (다른 인스턴스가 DB 조회 중)
await new Promise((resolve) => setTimeout(resolve, 100));
return this.getWithLock(key, fetchFn);
}
}

방법 3 — Probabilistic Early Expiration (X-Fetch)

// TTL이 남아있어도 만료 임박 시 확률적으로 미리 갱신
async getWithEarlyExpiry(key: string, fetchFn: () => Promise<any>, ttl: number): Promise<any> {
const raw = await this.redis.get(key);
const remaining = await this.redis.ttl(key);
if (raw) {
// 남은 TTL이 짧을수록 갱신 확률 증가 (X-Fetch 알고리즘)
const shouldRefresh = remaining < -Math.log(Math.random()) * (ttl * 0.1);
if (!shouldRefresh) return JSON.parse(raw);
}
const data = await fetchFn();
await this.redis.setex(key, ttl, JSON.stringify(data));
return data;
}
방법구현 난이도효과적합한 상황
TTL Jitter낮음낮음~중간다수 키를 동일 TTL로 설정할 때
Mutex Lock (SETNX)중간높음단일 키, DB 조회 비용이 클 때
Probabilistic Early Expiry높음높음트래픽이 지속적으로 높은 Hot Key

INCR + EXPIRE의 원자적 조합으로 구현하는 고정 윈도우 Rate Limiter.

rate-limit.service.ts
async checkRateLimit(userId: number, limit: number = 100): Promise<boolean> {
const key = `rate:${userId}:${Math.floor(Date.now() / 60000)}`; // 1분 단위 윈도우
const count = await this.redis.incr(key);
if (count === 1) {
await this.redis.expire(key, 60); // 최초 생성 시만 TTL 설정 (원자성 보장)
}
return count <= limit; // true: 허용, false: 차단
}

슬라이딩 윈도우 Rate Limiter — Sorted Set 활용

async checkSlidingRateLimit(userId: number, limit: number = 100, windowMs: number = 60000): Promise<boolean> {
const now = Date.now();
const key = `rate:sliding:${userId}`;
const pipeline = this.redis.pipeline();
pipeline.zremrangebyscore(key, 0, now - windowMs); // 만료된 요청 제거
pipeline.zadd(key, now, `${now}-${Math.random()}`); // 현재 요청 추가
pipeline.zcard(key); // 윈도우 내 요청 수 조회
pipeline.expire(key, Math.ceil(windowMs / 1000)); // TTL 갱신
const results = await pipeline.exec();
const count = results?.[2]?.[1] as number;
return count <= limit;
}
방식구현 난이도정확도메모리 사용적합한 경우
고정 윈도우낮음낮음낮음간단한 API 보호
슬라이딩 윈도우중간높음높음정확한 트래픽 제어

3-4. 분산 Lock — Race Condition 방지

섹션 제목: “3-4. 분산 Lock — Race Condition 방지”

멀티 인스턴스 환경에서 동일 작업이 중복 실행되지 않도록 Redis로 분산 락을 구현한다.

distributed-lock.service.ts
@Injectable()
export class DistributedLockService {
constructor(@Inject("REDIS_CLIENT") private readonly redis: Redis) {}
async withLock<T>(
lockKey: string,
ttlSeconds: number,
fn: () => Promise<T>
): Promise<T | null> {
const lockValue = `${process.pid}-${Date.now()}-${Math.random()}`;
const acquired = await this.redis.set(
`lock:${lockKey}`,
lockValue,
"EX",
ttlSeconds,
"NX"
);
if (acquired !== "OK") {
return null; // 락 획득 실패 → 다른 인스턴스가 처리 중
}
try {
return await fn();
} finally {
// 내가 설정한 락만 삭제 (Lua 스크립트로 원자성 보장)
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
await this.redis.eval(script, 1, `lock:${lockKey}`, lockValue);
}
}
}
// 사용 예: 결제 중복 처리 방지
async processPayment(orderId: string): Promise<void> {
const result = await this.lockService.withLock(
`payment:${orderId}`,
30, // 30초 TTL
async () => {
// 락 획득 성공 → 결제 처리
await this.paymentGateway.charge(orderId);
}
);
if (result === null) {
throw new Error("결제가 이미 처리 중입니다.");
}
}

Redis가 제공하는 자료구조를 복원력 관점에서 선택하는 기준이다.

자료구조핵심 커맨드복원력 관점 활용
StringSET, GET, SETEX, INCRRate Limiting (INCR), JWT 블랙리스트
HashHMSET, HGET, HGETALL유저 프로필·권한 캐싱 (필드 단위 갱신)
ListLPUSH, RPOP, LTRIM, LRANGE간이 작업 큐 (재시도 불필요한 경우)
SetSADD, SISMEMBER, SINTER로그아웃 토큰 블랙리스트, 태그 교집합
Sorted SetZADD, ZREVRANGEBYSCORE, ZREVRANK실시간 랭킹, 슬라이딩 윈도우 Rate Limit
// redis.module.ts — NestJS 전역 클라이언트 등록
import { Module, Global } from "@nestjs/common";
import { Redis } from "ioredis";
@Global()
@Module({
providers: [
{
provide: "REDIS_CLIENT",
useFactory: () =>
new Redis({
host: process.env.REDIS_HOST || "localhost",
port: 6379,
password: process.env.REDIS_PASSWORD,
}),
},
],
exports: ["REDIS_CLIENT"],
})
export class RedisModule {}

자료구조 내부 원리(Skip List, Hash Table 구현 등)는 L8/redis-internals.md 참고.


패턴구현복원력 효과
API 응답 캐싱Cache Aside — DB 앞 캐시 레이어DB 과부하 방지, 응답 속도 유지
Rate LimitingINCR + EXPIRE — 분당 호출 횟수 제한악의적 트래픽/버스트로부터 시스템 보호
JWT 블랙리스트Set에 로그아웃 토큰 저장, TTL 자동 만료토큰 탈취 시 강제 무효화
분산 LockSET NX — 동시 실행 방지멀티 인스턴스 환경에서 중복 작업 격리
Cache Stampede 방지Jitter + Mutex Lock키 만료 시 DB 과부하 연쇄 방지
Circuit Breakeropossum 라이브러리Redis 장애 시 자동 격리 + DB Fallback
BullMQ 백엔드Redis List + Sorted Set 기반 큐비동기 작업 내구성 보장 (AOF 필수)

  • NestJS API 서버: 잦은 DB 조회 API에 @nestjs/cache-manager 또는 직접 ioredis로 Cache Aside 적용 → 응답 시간 단축
  • 세션 관리: Stateless JWT에 Redis 블랙리스트를 결합하여 강제 로그아웃 구현
  • BullMQ 사용 중: 이미 Redis에 의존 중 → AOF 영속화 확인 필요 (L8 redis-internals.md 참고)
  • AWS 환경: ElastiCache(Redis) 사용 시 클러스터 모드 설정과 키슬롯 분산 이해 필요 (L8 redis-internals.md 참고)

솔루션특징적합한 경우
Redis다양한 자료구조, 영속화, Pub/Sub범용 캐시 + 큐 + 세션
Memcached단순 Key-Value, 멀티스레드순수 캐시, 대규모 멀티코어
In-Memory (Node.js)별도 인프라 없음단일 인스턴스, 소규모
DynamoDB DAXDynamoDB 전용 캐시DynamoDB 사용 중인 경우

ElastiCache Redis vs Memcached 비교, 클러스터 모드 운영L8/redis-internals.md 참고.

Redis 단일 인스턴스가 한계에 도달하는 정량적 조건

섹션 제목: “Redis 단일 인스턴스가 한계에 도달하는 정량적 조건”

Redis 단일 인스턴스 운영이 적합한 범위와 Cluster 전환이 필요한 임계점이다.

조건단일 인스턴스 권장 범위Cluster 전환 신호
메모리32GB 미만 (운영 안정성 기준)32GB 초과 또는 maxmemory 80% 상시 도달
Write QPS~100K req/s 이하 (단일 IP 기준)100K 초과 → 샤드 추가 또는 Cluster 전환
Read QPS~180K req/s 이하 (단일 인스턴스)초과 시 Read Replica 추가 또는 Cluster
데이터 영속성 필요 시RDB(스냅샷) — 성능 영향 최소AOF(매 쓰기 기록) — 고빈도 쓰기 시 IO 증가
AOF + 고빈도 쓰기appendfsync everysec (균형)appendfsync always → throughput ~30% 감소 가능

RDB/AOF 성능 비용 요약:

  • RDB (기본 스냅샷): 주기적 BGSAVE — fork() 비용만 발생, 일반 캐시 용도에 적합
  • AOF appendfsync everysec: 초당 1회 디스크 동기화 — 성능·안전성 균형
  • AOF appendfsync always: 모든 쓰기마다 fsync — 데이터 손실 0이지만 write throughput 대폭 감소

수치 출처: Redis 공식 벤치마크, Redis Enterprise 메모리 한도


문제 1: Cache Stampede (Thundering Herd)

섹션 제목: “문제 1: Cache Stampede (Thundering Herd)”

증상: 인기 캐시 키가 만료되는 순간 수백 개의 요청이 동시에 DB로 몰려 과부하 발생.

원인: 캐시 MISS 후 모든 요청이 동시에 DB 조회 시도.

해결법: 섹션 3-2 Cache Stampede 방지 패턴 참고 (TTL Jitter / Mutex Lock / Probabilistic Early Expiry 세 가지 방법 코드 포함).


증상: Redis 메모리 한계 도달 → 새 데이터 저장 거부 또는 예기치 않은 키 삭제.

원인:

  • TTL 미설정으로 키가 영구 누적
  • 대용량 데이터를 Redis에 저장
  • 메모리 Eviction 정책 미설정

해결법

Terminal window
# redis.conf 또는 ElastiCache 파라미터 그룹
maxmemory 2gb
maxmemory-policy allkeys-lru # 가장 오래된 키부터 삭제
# Eviction 정책 종류
# noeviction → 메모리 꽉 차면 에러 (기본값, 위험)
# allkeys-lru → 전체 키 중 LRU 방식 삭제 (캐시 용도에 적합)
# volatile-lru → TTL 있는 키 중 LRU 삭제
# allkeys-random → 무작위 삭제
# volatile-ttl → TTL 짧은 키 먼저 삭제
// NestJS에서 TTL 필수 설정
// TTL 없이 저장하지 않도록 Wrapper 강제화
async safeSet(key: string, value: any, ttl: number): Promise<void> {
if (!ttl || ttl <= 0) {
throw new Error(`Redis key "${key}" must have a positive TTL`);
}
await this.redis.setex(key, ttl, JSON.stringify(value));
}

모니터링: ElastiCache 콘솔에서 DatabaseMemoryUsagePercentage 지표 알람 설정 (80% 초과 시 경보).


증상: 특정 키(인기 상품, 공지사항 등)에 요청이 집중되어 해당 샤드의 CPU가 과부하.

원인: 클러스터 모드에서 하나의 키는 단일 노드에만 존재. 집중적인 읽기/쓰기가 노드 병목 유발.

해결법 1 - 로컬 캐시 (In-Memory) 결합

// NestJS에서 2단계 캐싱: 로컬(L1) + Redis(L2)
import * as NodeCache from "node-cache";
@Injectable()
export class TwoLevelCacheService {
private localCache = new NodeCache({ stdTTL: 10 }); // 10초 로컬 캐시
async get(key: string, fetchFn: () => Promise<any>): Promise<any> {
// L1: 로컬 캐시 확인 (네트워크 없음, 극초고속)
const local = this.localCache.get(key);
if (local !== undefined) return local;
// L2: Redis 확인
const redis = await this.redis.get(key);
if (redis) {
const parsed = JSON.parse(redis);
this.localCache.set(key, parsed); // L1에도 저장
return parsed;
}
// DB 조회
const data = await fetchFn();
await this.redis.setex(key, 300, JSON.stringify(data));
this.localCache.set(key, data);
return data;
}
}

해결법 2 - 읽기 복제본(Read Replica) 활용

// ElastiCache 읽기 전용 복제본 엔드포인트 사용
// 쓰기: Primary, 읽기: Reader Endpoint (AWS가 자동 분산)
const redisWriter = new Redis({ host: process.env.REDIS_PRIMARY_HOST });
const redisReader = new Redis({ host: process.env.REDIS_READER_HOST });

증상: 시간이 지남에 따라 Redis 연결 수가 증가, 성능 저하.

원인: Pub/Sub용 Redis 연결을 subscribe 후 해제하지 않거나, 요청마다 새 연결 생성.

해결법

// 잘못된 예: 매 요청마다 새 연결 생성
async badExample() {
const client = new Redis(); // 연결 생성
await client.get('key');
// 해제 안 함 → 누수!
}
// 올바른 예: 모듈에서 단일 인스턴스 주입
// redis.module.ts의 'REDIS_CLIENT' provider 재사용
// Pub/Sub은 duplicate()로 별도 연결 후 onModuleDestroy에서 해제
@Injectable()
export class SubscriberService implements OnModuleInit, OnModuleDestroy {
private subscriber: Redis;
constructor(@Inject('REDIS_CLIENT') private redis: Redis) {}
async onModuleInit() {
this.subscriber = this.redis.duplicate();
await this.subscriber.subscribe('channel');
}
async onModuleDestroy() {
await this.subscriber.quit(); // 모듈 종료 시 연결 해제
}
}

문제 5: Redis Cluster 모드에서 CROSSSLOT 에러

섹션 제목: “문제 5: Redis Cluster 모드에서 CROSSSLOT 에러”

증상: Redis Cluster 환경(ElastiCache 클러스터 모드)에서 CROSSSLOT Keys in request don't hash to the same slot 에러 발생

원인: Redis Cluster는 키를 16,384개의 슬롯에 분산 저장한다. MGET, MSET, Pipeline, 트랜잭션(MULTI/EXEC) 등 여러 키를 한 번에 다루는 커맨드는 모든 키가 동일한 슬롯에 있어야 한다.

해결법

// 클러스터 모드에서 동작 안 함
await redis.mget("user:123", "user:456", "user:789");
// → 세 키가 다른 슬롯에 있을 경우 CROSSSLOT 에러
// 해결법 1: 개별 GET으로 변경
const [u1, u2, u3] = await Promise.all([
redis.get("user:123"),
redis.get("user:456"),
redis.get("user:789"),
]);
// 해결법 2: Hash Tag로 동일 슬롯 강제 지정
// {user} 부분이 슬롯 계산에 사용됨 → 모두 같은 슬롯에 저장됨
await redis.mget("{user}:123", "{user}:456", "{user}:789");

Hash Slot 원리와 클러스터 운영 방법은 L8/redis-internals.md를 참고한다.


  • 캐시 대상 데이터의 변경 빈도와 일관성 요구사항을 파악했는가?
  • 적절한 캐시 전략(Cache Aside / Write Through / Write Back)을 선택했는가?
  • 모든 키에 TTL을 설정했는가? (영구 키는 의도적인 경우만)
  • Cache Stampede 방지를 위한 Jitter 또는 Lock 전략을 적용했는가?
  • 키 네이밍 규칙을 정의했는가? (예: {서비스}:{도메인}:{ID})
  • maxmemorymaxmemory-policy를 적절히 설정했는가?
  • DatabaseMemoryUsagePercentage 모니터링 알람을 설정했는가?
  • ElastiCache 클러스터 모드 선택이 적절한가? (데이터량 기준)
  • 읽기 엔드포인트(Reader Endpoint)를 사용하여 읽기 부하를 분산하는가?
  • BullMQ 사용 시 Redis AOF 영속화가 활성화되어 있는가?
  • Redis 장애 시 DB Fallback 경로가 있는가? (서비스 중단 방지)
  • Circuit Breaker를 적용하여 Redis 장애가 전파되지 않도록 격리했는가?
  • Cache Stampede 방지 전략(Jitter / Mutex Lock)을 적용했는가?
  • Rate Limiting으로 트래픽 급증 시 시스템을 보호하는가?
  • Redis 다운 시 DB 과부하를 막는 추가 방어(Connection Pooling, Rate Limit)가 있는가?
  • Redis 클라이언트를 싱글톤으로 관리하고 있는가? (연결 재사용)
  • 민감 데이터(비밀번호 등)를 Redis에 평문으로 저장하지 않는가?
  • KEYS * 같은 블로킹 커맨드를 운영 환경에서 사용하지 않는가? (SCAN 사용)

키워드설명
In-Memory DB데이터를 RAM에 저장하는 데이터베이스
Cache Aside읽기 시 캐시 MISS 후 DB 조회하는 전략
Write Through쓰기 시 DB와 캐시를 동시 갱신하는 전략
Write Back캐시에만 먼저 쓰고 DB에는 비동기 반영하는 전략
TTLTime To Live — 캐시 만료 시간
TTL Jitter동시 만료 방지를 위한 TTL 랜덤 편차
Cache Stampede캐시 만료 시 다수 요청이 DB로 몰리는 현상
Thundering HerdCache Stampede의 별칭
Hot Key특정 키에 요청이 집중되어 샤드 병목이 생기는 문제
Circuit BreakerN회 연속 실패 시 호출을 차단하고 Fallback으로 우회하는 패턴
Fallback주 경로(Redis) 실패 시 대체 경로(DB 직접 조회)로 전환하는 전략
Cache WarmingRedis 복구 후 캐시를 미리 채워 Cold Start를 방지하는 작업
Bulkhead장애를 특정 구간에 격리하여 전체 시스템으로 전파되지 않도록 하는 패턴
Mutex Lock (SETNX)SET NX로 구현하는 분산 락 — Cache Stampede 방지에 활용
Probabilistic Early ExpiryTTL 만료 전 확률적으로 미리 갱신하는 X-Fetch 기반 전략
Rate LimitingINCR + EXPIRE로 분당 호출 수를 제한하여 시스템을 보호하는 패턴
Eviction메모리 초과 시 키를 삭제하는 정책
ioredisNode.js용 Redis 클라이언트 라이브러리
BullMQioredis 기반 NestJS 잡 큐 라이브러리
ElastiCacheAWS 관리형 Redis/Memcached 서비스


Terminal window
docker run -d --name redis-test -p 6379:6379 redis:7-alpine
docker exec -it redis-test redis-cli
# 기본 커맨드 실습
SET greeting "Hello Redis"
GET greeting # → "Hello Redis"
SETEX session:abc 60 "userId:42"
TTL session:abc # → 60 (초)
INCR counter:api:1 # → 1
INCR counter:api:1 # → 2

실습 2: NestJS에서 Redis 연결 확인

섹션 제목: “실습 2: NestJS에서 Redis 연결 확인”
.env
# NestJS 프로젝트에서
npm install ioredis
REDIS_HOST=localhost
REDIS_PORT=6379
// 연결 테스트
const redis = new Redis({ host: "localhost", port: 6379 });
await redis.ping(); // → 'PONG'
Terminal window
# redis-cli에서
INCR rate:user1:$(date +%s | cut -c-8) # 1분 단위 키에 INCR
# 100번 반복 후 limit 초과 여부 확인

예상 결과 / 성공 판단 기준:

  • 첫 번째 INCR → (integer) 1, TTL이 60초로 자동 설정됨
  • 100번 실행 후 → (integer) 100 (limit 도달)
  • 101번째 호출 → 애플리케이션 레이어에서 false 반환 (Redis에서 차단 X, 카운트 값으로 판단)
  • 1분 경과 후 → 키 자동 소멸, GET rate:user1:...(nil)
Terminal window
# TTL 10초짜리 키 생성
SETEX hot-key 10 "popular-data"
# 10초 후 TTL 확인
TTL hot-key # → -2 (만료됨)
# 여러 클라이언트가 동시에 MISS하는 상황 시뮬레이션
# → 실무에서는 TTL Jitter 또는 Mutex Lock으로 방지

예상 결과 / 성공 판단 기준:

  • SETEX hot-key 10 "popular-data" 직후 TTL hot-key(integer) 10 (감소 중)
  • 10초 경과 후 TTL hot-key(integer) -2 (키 없음 = 만료됨)
  • Stampede 시뮬레이션: 만료 직후 다수 요청이 동시에 GET hot-key → 모두 (nil) 반환 → 각각 DB 호출 발생
  • Jitter 적용 후: 동일 키들이 600~660초 범위로 분산 만료 → 동시 MISS 요청 수 대폭 감소
// AWS ElastiCache 클러스터 모드 연결
import { Cluster } from "ioredis";
const cluster = new Cluster(
[{ host: process.env.ELASTICACHE_PRIMARY_ENDPOINT, port: 6379 }],
{
redisOptions: {
tls: {}, // ElastiCache는 TLS 필수
password: process.env.REDIS_AUTH_TOKEN,
},
clusterRetryStrategy: (times) => Math.min(times * 200, 2000),
},
);

Redis는 복원력(Resilience) 인프라로서 DB 과부하를 흡수하고 장애 전파를 차단한다.

캐시 전략 선택 요약:

  • 읽기 중심 일반 API → Cache Aside
  • 읽기/쓰기 균형, 일관성 중요 → Write Through
  • 초고빈도 쓰기, 허용 가능한 손실 있음 → Write Back

복원력 패턴 우선순위:

  1. Fallback + Circuit Breaker: Redis 장애가 DB 장애로 연쇄되지 않도록 격리
  2. Cache Stampede 방지: TTL Jitter (최소) → 고트래픽 키는 Mutex Lock
  3. Rate Limiting: INCR + EXPIRE로 트래픽 급증 시 시스템 보호
  4. 분산 Lock: SETNX로 멀티 인스턴스 환경에서 중복 작업 격리

운영 필수 사항:

  1. 모든 키에 TTL 설정
  2. maxmemory-policy allkeys-lru 설정
  3. KEYS * 대신 SCAN 사용 (블로킹 방지)
  4. Redis 클라이언트 싱글톤 관리 (연결 누수 방지)

Redis 내부 구조(자료구조 원리, RDB/AOF, 클러스터 운영, ElastiCache 튜닝)는 L8/redis-internals.md를 참고한다.


11. 실전 장애 대응 시나리오 (On-Call Runbook)

섹션 제목: “11. 실전 장애 대응 시나리오 (On-Call Runbook)”

Redis/캐시 관련 on-call 시 가장 자주 마주치는 시나리오

시나리오 A: “API 응답이 갑자기 느려졌다” (Redis 연결 문제 의심)

섹션 제목: “시나리오 A: “API 응답이 갑자기 느려졌다” (Redis 연결 문제 의심)”
즉각 확인 (5분 이내):
1단계: ElastiCache 지표 확인
경로: CloudWatch → ElastiCache → Redis Metrics
확인 지표:
- EngineCPUUtilization: 90% 이상이면 Redis 과부하
- CurrConnections: 평소 대비 급증했는지 확인
- CacheHitRate: 갑자기 떨어졌으면 Cache Stampede 의심
2단계: Redis 연결 상태 확인
redis-cli -h [endpoint] INFO clients
→ connected_clients 수치가 maxclients에 근접하면 연결 포화
→ blocked_clients > 0이면 블로킹 커맨드(BLPOP 등) 확인
3단계: Slow Log 확인
redis-cli -h [endpoint] SLOWLOG GET 10
→ 실행 시간이 긴 커맨드 확인
→ KEYS *, SMEMBERS (대용량), SORT 등 블로킹 커맨드가 있으면 원인
4단계: 조치
→ 블로킹 커맨드: KEYS → SCAN으로 코드 수정
→ 연결 포화: maxclients 확인, 연결 누수 코드 점검
→ Cache Stampede: TTL Jitter 또는 Mutex Lock 적용

시나리오 B: “Redis 메모리가 꽉 찼다” (OOM)

섹션 제목: “시나리오 B: “Redis 메모리가 꽉 찼다” (OOM)”
증상: Redis에 새 데이터 저장 시 에러 발생 또는 예기치 않은 키 삭제
즉각 확인:
1. ElastiCache 콘솔에서 DatabaseMemoryUsagePercentage 확인
→ 90% 이상이면 즉시 대응 필요
2. 메모리 사용 분석
redis-cli -h [endpoint] INFO memory
→ used_memory_human: 현재 사용량
→ maxmemory_human: 최대 한도
3. 큰 키 찾기 (비동기로 실행)
redis-cli -h [endpoint] --bigkeys
→ 비정상적으로 큰 키 식별
즉각 조치:
1. maxmemory-policy가 noeviction이면 allkeys-lru로 변경
→ ElastiCache 파라미터 그룹에서 변경 (재시작 불필요)
2. TTL 없는 키 확인 및 정리
redis-cli -h [endpoint] --scan --pattern '*' | head -100
→ TTL 없는 키가 많으면 코드에서 TTL 강제화 적용
3. 장기 해결: 노드 크기 업그레이드 또는 클러스터 모드 전환

시나리오 C: “캐시가 무효화되지 않아 오래된 데이터가 보인다”

섹션 제목: “시나리오 C: “캐시가 무효화되지 않아 오래된 데이터가 보인다””
증상: 상품 가격을 변경했는데 API에서 여전히 이전 가격이 반환됨
확인 순서:
1. 해당 키의 캐시 값 직접 확인
redis-cli -h [endpoint] GET "product:123"
→ 캐시에 이전 데이터가 남아있는지 확인
2. 캐시 무효화 코드 확인
→ 데이터 변경 시 redis.del(key) 호출이 있는지 확인
→ Write Through 패턴이면 캐시 갱신 코드 확인
3. TTL 확인
redis-cli -h [endpoint] TTL "product:123"
→ TTL이 매우 길거나 -1(영구)이면 문제
즉각 조치:
→ redis-cli DEL "product:123" (수동 캐시 삭제)
→ 코드에 캐시 무효화 로직이 없으면 추가
재발 방지:
→ Cache Aside 패턴에서 데이터 변경 시 반드시 DEL 호출
→ TTL을 적절히 설정하여 최악의 경우에도 자연 만료되도록 보장