Redis를 활용한 복원력 패턴
분류: Layer 6 - 운영 심화: 관측성 & 복원력 | 작성일: 2026-04-01
📌 Redis 내부 원리(자료구조, 영속화, 클러스터, ElastiCache)는 L8/redis-internals.md에서 다룹니다. 이 문서는 복원력 패턴 (Cache Stampede, Circuit Breaker, Rate Limiting, Fallback) 에만 집중합니다.
1. 한 줄 정의 & 관련 문서 안내
섹션 제목: “1. 한 줄 정의 & 관련 문서 안내”Redis는 데이터를 메모리에 저장하는 오픈소스 Key-Value 스토어로, 초당 수십만 건의 읽기/쓰기를 처리하며 캐시·세션·큐·실시간 랭킹 등 다양한 역할을 수행한다.
이 문서의 범위: Redis를 복원력(Resilience) 도구로 활용하는 패턴에 집중한다. Redis 내부 자료구조 원리, RDB/AOF 영속화 비교, 클러스터 구성, ElastiCache 운영 튜닝 등 DB 심화 주제는 L8의
redis-internals.md를 참고한다.
2. 왜 L6 복원력 토픽인가
섹션 제목: “2. 왜 L6 복원력 토픽인가”프론트엔드 개발자 관점에서: 브라우저 Cache API(
caches.open())와 HTTPCache-Control헤더는 프론트엔드에서 익숙한 캐시 레이어다.Cache-Control: max-age=300은 “5분간 유효한 응답을 저장”하는 TTL 기반 캐시이고, Redis의SET key value EX 300이 정확히 같은 개념이다. 브라우저 캐시가 사용자의 로컬 RAM을 아끼듯, Redis는 DB 쿼리를 아끼는 서버 사이드 캐시다.
L6 테마는 “프로덕션에서 장애 없이 돌리기”다. Redis는 단순한 속도 향상 도구가 아니라 복원력(Resilience)의 핵심 인프라로 작동한다:
| 복원력 시나리오 | Redis가 하는 역할 |
|---|---|
| DB 과부하 → 장애 | 캐시가 DB 앞에서 부하를 흡수, DB를 보호 |
| 트래픽 급증 (Spike) | 메모리 조회로 DB 쿼리 대체 → 응답 유지 |
| Cache Stampede | Mutex Lock / Jitter로 DB 과부하 방지 |
| Rate Limiting | INCR + EXPIRE로 분당 호출 제한 → 시스템 보호 |
| Redis 자체 장애 | Fallback 전략 + Circuit Breaker로 서비스 유지 |
| 분산 환경 중복 실행 | SETNX 기반 분산 Lock으로 Race Condition 방지 |
BackOps 환경에서 NestJS API가 같은 DB 쿼리를 반복 실행하거나, 세션 관리가 필요하거나, BullMQ 큐를 쓰고 있다면 — 이미 Redis를 사용 중이거나 사용해야 할 상황이다.
3. 핵심 개념
섹션 제목: “3. 핵심 개념”3-1. 캐시 전략
섹션 제목: “3-1. 캐시 전략”Cache Aside (Lazy Loading) — 가장 일반적
섹션 제목: “Cache Aside (Lazy Loading) — 가장 일반적”요청 → 캐시 확인 → [HIT] 바로 반환 → [MISS] DB 조회 → 캐시 저장 → 반환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 Aside | Write Through | Write Back |
|---|---|---|---|
| 구현 복잡도 | 낮음 | 중간 | 높음 |
| 데이터 일관성 | 낮음 (MISS 후 TTL까지 stale) | 높음 | 낮음 (장애 시 유실) |
| 읽기 성능 | 첫 요청 느림 (MISS) | 빠름 | 빠름 |
| 쓰기 성능 | 빠름 | 느림 (DB+캐시 동시) | 매우 빠름 |
| 데이터 손실 위험 | 없음 | 없음 | 있음 |
| 적합한 경우 | 읽기 많은 일반 API | 읽기+쓰기 균형 | 초고빈도 쓰기 (조회수 등) |
TTL 전략
섹션 제목: “TTL 전략”// 데이터 특성별 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 → 동시 만료 방지3-2. Redis 장애 시 복원력 패턴
섹션 제목: “3-2. Redis 장애 시 복원력 패턴”Redis는 빠르지만 단일 장애점(SPOF)이 될 수 있다. Redis가 다운됐을 때 서비스 전체가 멈추지 않도록 복원력 패턴을 적용해야 한다.
패턴 1 — Fallback 전략 (Redis 다운 시 DB 직접 조회)
섹션 제목: “패턴 1 — Fallback 전략 (Redis 다운 시 DB 직접 조회)”Redis 연결 실패를 감지하면 DB로 Fallback하되, DB 과부하를 막는 것이 핵심이다.
@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과 함께 운영한다.
패턴 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는 상태 전환이 자동화되고 모니터링이 명확하므로 프로덕션에서 권장된다.
패턴 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 |
3-3. Rate Limiting — 시스템 보호
섹션 제목: “3-3. Rate Limiting — 시스템 보호”INCR + EXPIRE의 원자적 조합으로 구현하는 고정 윈도우 Rate Limiter.
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로 분산 락을 구현한다.
@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("결제가 이미 처리 중입니다."); }}3-5. 자료구조와 복원력 활용
섹션 제목: “3-5. 자료구조와 복원력 활용”Redis가 제공하는 자료구조를 복원력 관점에서 선택하는 기준이다.
| 자료구조 | 핵심 커맨드 | 복원력 관점 활용 |
|---|---|---|
| String | SET, GET, SETEX, INCR | Rate Limiting (INCR), JWT 블랙리스트 |
| Hash | HMSET, HGET, HGETALL | 유저 프로필·권한 캐싱 (필드 단위 갱신) |
| List | LPUSH, RPOP, LTRIM, LRANGE | 간이 작업 큐 (재시도 불필요한 경우) |
| Set | SADD, SISMEMBER, SINTER | 로그아웃 토큰 블랙리스트, 태그 교집합 |
| Sorted Set | ZADD, 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참고.
4. 실무에서 어떻게 쓰이나
섹션 제목: “4. 실무에서 어떻게 쓰이나”BackOps 환경 복원력 사용 패턴
섹션 제목: “BackOps 환경 복원력 사용 패턴”| 패턴 | 구현 | 복원력 효과 |
|---|---|---|
| API 응답 캐싱 | Cache Aside — DB 앞 캐시 레이어 | DB 과부하 방지, 응답 속도 유지 |
| Rate Limiting | INCR + EXPIRE — 분당 호출 횟수 제한 | 악의적 트래픽/버스트로부터 시스템 보호 |
| JWT 블랙리스트 | Set에 로그아웃 토큰 저장, TTL 자동 만료 | 토큰 탈취 시 강제 무효화 |
| 분산 Lock | SET NX — 동시 실행 방지 | 멀티 인스턴스 환경에서 중복 작업 격리 |
| Cache Stampede 방지 | Jitter + Mutex Lock | 키 만료 시 DB 과부하 연쇄 방지 |
| Circuit Breaker | opossum 라이브러리 | Redis 장애 시 자동 격리 + DB Fallback |
| BullMQ 백엔드 | Redis List + Sorted Set 기반 큐 | 비동기 작업 내구성 보장 (AOF 필수) |
5. 내 업무와 어떻게 연결되나
섹션 제목: “5. 내 업무와 어떻게 연결되나”- 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 참고)
6. 비교 / 대안
섹션 제목: “6. 비교 / 대안”Redis vs 다른 캐시 솔루션
섹션 제목: “Redis vs 다른 캐시 솔루션”| 솔루션 | 특징 | 적합한 경우 |
|---|---|---|
| Redis | 다양한 자료구조, 영속화, Pub/Sub | 범용 캐시 + 큐 + 세션 |
| Memcached | 단순 Key-Value, 멀티스레드 | 순수 캐시, 대규모 멀티코어 |
| In-Memory (Node.js) | 별도 인프라 없음 | 단일 인스턴스, 소규모 |
| DynamoDB DAX | DynamoDB 전용 캐시 | DynamoDB 사용 중인 경우 |
ElastiCache Redis vs Memcached 비교, 클러스터 모드 운영은
L8/redis-internals.md참고.
6.5. 트러블슈팅
섹션 제목: “6.5. 트러블슈팅”문제 1: Cache Stampede (Thundering Herd)
섹션 제목: “문제 1: Cache Stampede (Thundering Herd)”증상: 인기 캐시 키가 만료되는 순간 수백 개의 요청이 동시에 DB로 몰려 과부하 발생.
원인: 캐시 MISS 후 모든 요청이 동시에 DB 조회 시도.
→ 해결법: 섹션 3-2 Cache Stampede 방지 패턴 참고 (TTL Jitter / Mutex Lock / Probabilistic Early Expiry 세 가지 방법 코드 포함).
문제 2: 메모리 초과 (OOM)
섹션 제목: “문제 2: 메모리 초과 (OOM)”증상: Redis 메모리 한계 도달 → 새 데이터 저장 거부 또는 예기치 않은 키 삭제.
원인:
- TTL 미설정으로 키가 영구 누적
- 대용량 데이터를 Redis에 저장
- 메모리 Eviction 정책 미설정
해결법
# redis.conf 또는 ElastiCache 파라미터 그룹maxmemory 2gbmaxmemory-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% 초과 시 경보).
문제 3: Hot Key 문제
섹션 제목: “문제 3: Hot Key 문제”증상: 특정 키(인기 상품, 공지사항 등)에 요청이 집중되어 해당 샤드의 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 });문제 4: 연결 누수 (Connection Leak)
섹션 제목: “문제 4: 연결 누수 (Connection Leak)”증상: 시간이 지남에 따라 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를 참고한다.
7. 체크리스트
섹션 제목: “7. 체크리스트”설계 단계
섹션 제목: “설계 단계”- 캐시 대상 데이터의 변경 빈도와 일관성 요구사항을 파악했는가?
- 적절한 캐시 전략(Cache Aside / Write Through / Write Back)을 선택했는가?
- 모든 키에 TTL을 설정했는가? (영구 키는 의도적인 경우만)
- Cache Stampede 방지를 위한 Jitter 또는 Lock 전략을 적용했는가?
- 키 네이밍 규칙을 정의했는가? (예:
{서비스}:{도메인}:{ID})
운영 단계
섹션 제목: “운영 단계”-
maxmemory와maxmemory-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사용)
8. 키워드
섹션 제목: “8. 키워드”| 키워드 | 설명 |
|---|---|
| In-Memory DB | 데이터를 RAM에 저장하는 데이터베이스 |
| Cache Aside | 읽기 시 캐시 MISS 후 DB 조회하는 전략 |
| Write Through | 쓰기 시 DB와 캐시를 동시 갱신하는 전략 |
| Write Back | 캐시에만 먼저 쓰고 DB에는 비동기 반영하는 전략 |
| TTL | Time To Live — 캐시 만료 시간 |
| TTL Jitter | 동시 만료 방지를 위한 TTL 랜덤 편차 |
| Cache Stampede | 캐시 만료 시 다수 요청이 DB로 몰리는 현상 |
| Thundering Herd | Cache Stampede의 별칭 |
| Hot Key | 특정 키에 요청이 집중되어 샤드 병목이 생기는 문제 |
| Circuit Breaker | N회 연속 실패 시 호출을 차단하고 Fallback으로 우회하는 패턴 |
| Fallback | 주 경로(Redis) 실패 시 대체 경로(DB 직접 조회)로 전환하는 전략 |
| Cache Warming | Redis 복구 후 캐시를 미리 채워 Cold Start를 방지하는 작업 |
| Bulkhead | 장애를 특정 구간에 격리하여 전체 시스템으로 전파되지 않도록 하는 패턴 |
| Mutex Lock (SETNX) | SET NX로 구현하는 분산 락 — Cache Stampede 방지에 활용 |
| Probabilistic Early Expiry | TTL 만료 전 확률적으로 미리 갱신하는 X-Fetch 기반 전략 |
| Rate Limiting | INCR + EXPIRE로 분당 호출 수를 제한하여 시스템을 보호하는 패턴 |
| Eviction | 메모리 초과 시 키를 삭제하는 정책 |
| ioredis | Node.js용 Redis 클라이언트 라이브러리 |
| BullMQ | ioredis 기반 NestJS 잡 큐 라이브러리 |
| ElastiCache | AWS 관리형 Redis/Memcached 서비스 |
8.5. 추천 리소스
섹션 제목: “8.5. 추천 리소스”- AWS Docs - Database Caching Strategies Using Redis — Cache Aside / Write Through / Write Back 패턴을 AWS 관점에서 정리한 공식 화이트페이퍼 (입문)
- Building High-Performance APIs With Redis Cache in NestJS - Medium — 실제 NestJS 프로젝트를 스케일링하면서 얻은 Redis 캐싱 교훈 (중급)
- Thundering Herd Problem: Solutions & Prevention - Howtech — Cache Stampede 발생 원인과 Mutex Lock / X-Fetch / Jitter 해결법을 도식과 함께 설명 (중급)
- How to Handle Cache Stampede in Redis — TTL Jitter, Mutex Lock, Probabilistic Early Expiration 세 가지 전략을 코드와 함께 비교 설명 (중급)
9. 직접 확인해보기
섹션 제목: “9. 직접 확인해보기”실습 1: Docker로 Redis 띄우기
섹션 제목: “실습 1: Docker로 Redis 띄우기”docker run -d --name redis-test -p 6379:6379 redis:7-alpinedocker 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 # → 1INCR counter:api:1 # → 2실습 2: NestJS에서 Redis 연결 확인
섹션 제목: “실습 2: NestJS에서 Redis 연결 확인”# NestJS 프로젝트에서npm install ioredis
REDIS_HOST=localhostREDIS_PORT=6379// 연결 테스트const redis = new Redis({ host: "localhost", port: 6379 });await redis.ping(); // → 'PONG'실습 3: Rate Limiting 동작 확인
섹션 제목: “실습 3: Rate Limiting 동작 확인”# redis-cli에서INCR rate:user1:$(date +%s | cut -c-8) # 1분 단위 키에 INCR# 100번 반복 후 limit 초과 여부 확인실습 4: Cache Stampede 관찰
섹션 제목: “실습 4: Cache Stampede 관찰”# TTL 10초짜리 키 생성SETEX hot-key 10 "popular-data"
# 10초 후 TTL 확인TTL hot-key # → -2 (만료됨)
# 여러 클라이언트가 동시에 MISS하는 상황 시뮬레이션# → 실무에서는 TTL Jitter 또는 Mutex Lock으로 방지실습 5: ElastiCache 연결 (AWS)
섹션 제목: “실습 5: ElastiCache 연결 (AWS)”// 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), },);10. 요약
섹션 제목: “10. 요약”Redis는 복원력(Resilience) 인프라로서 DB 과부하를 흡수하고 장애 전파를 차단한다.
캐시 전략 선택 요약:
- 읽기 중심 일반 API →
Cache Aside - 읽기/쓰기 균형, 일관성 중요 →
Write Through - 초고빈도 쓰기, 허용 가능한 손실 있음 →
Write Back
복원력 패턴 우선순위:
- Fallback + Circuit Breaker: Redis 장애가 DB 장애로 연쇄되지 않도록 격리
- Cache Stampede 방지: TTL Jitter (최소) → 고트래픽 키는 Mutex Lock
- Rate Limiting: INCR + EXPIRE로 트래픽 급증 시 시스템 보호
- 분산 Lock: SETNX로 멀티 인스턴스 환경에서 중복 작업 격리
운영 필수 사항:
- 모든 키에 TTL 설정
maxmemory-policy allkeys-lru설정KEYS *대신SCAN사용 (블로킹 방지)- 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을 적절히 설정하여 최악의 경우에도 자연 만료되도록 보장