콘텐츠로 이동

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 테마는 “프로덕션에서 장애 없이 돌리기”다. Redis는 단순한 속도 향상 도구가 아니라 복원력(Resilience)의 핵심 인프라로 작동한다:

복원력 시나리오Redis가 하는 역할
DB 과부하 → 장애캐시가 DB 앞에서 부하를 흡수, DB를 보호
트래픽 급증 (Spike)메모리 조회로 DB 쿼리 대체 → 응답 유지
Cache StampedeMutex Lock / Jitter로 DB 과부하 방지
Rate LimitingINCR + EXPIRE로 분당 호출 제한 → 시스템 보호
Redis 자체 장애Fallback 전략 + Circuit Breaker로 서비스 유지
분산 환경 중복 실행SETNX 기반 분산 Lock으로 Race Condition 방지

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


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과 함께 운영한다.


패턴 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

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 참고.


문제 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 초과 여부 확인
Terminal window
# TTL 10초짜리 키 생성
SETEX hot-key 10 "popular-data"
# 10초 후 TTL 확인
TTL hot-key # → -2 (만료됨)
# 여러 클라이언트가 동시에 MISS하는 상황 시뮬레이션
# → 실무에서는 TTL Jitter 또는 Mutex Lock으로 방지
// 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을 적절히 설정하여 최악의 경우에도 자연 만료되도록 보장