프로덕션에서 실제로 동작하는 Redis 캐싱 전략
Cache-aside, write-through, 캐시 스탬피드 방지, TTL 전략, 무효화 패턴. 실제 코드 예제와 함께 프로덕션 Node.js 앱에서 사용해 온 Redis 패턴들.
API가 느리면 다들 "Redis 추가하면 되지"라고 말합니다. 하지만 6개월 후 캐시가 오래된 데이터를 제공하고, 무효화 로직이 40개 파일에 흩어져 있으며, 배포 시 캐시 스탬피드가 발생해서 캐시를 적용하지 않았을 때보다 데이터베이스를 더 심하게 다운시키는 상황에 대해서는 아무도 알려주지 않습니다.
저는 수년간 프로덕션에서 Redis를 운영해 왔습니다. 장난감이 아닌, 튜토리얼이 아닌 — 캐싱을 잘못하면 새벽 3시에 호출기가 울리는 실제 트래픽을 처리하는 시스템에서요. 이 글은 제가 올바르게 하는 법에 대해 배운 모든 것입니다.
왜 캐시를 사용하는가?#
당연한 것부터 시작합시다: 데이터베이스는 메모리에 비해 느립니다. PostgreSQL 쿼리가 15ms 걸리면 데이터베이스 기준으로는 빠른 편입니다. 하지만 그 쿼리가 모든 API 요청마다 실행되고, 초당 1,000개의 요청을 처리한다면, 초당 15,000ms의 누적 데이터베이스 시간이 됩니다. 커넥션 풀이 고갈됩니다. p99 지연 시간이 천정부지로 치솟습니다. 사용자는 스피너만 보고 있게 됩니다.
Redis는 대부분의 읽기를 1ms 미만에 처리합니다. 같은 데이터를 캐시하면 15ms 작업이 0.3ms 작업으로 바뀝니다. 이건 미세한 최적화가 아닙니다. 데이터베이스 레플리카 4개가 필요한 것과 0개가 필요한 것의 차이입니다.
하지만 캐싱은 공짜가 아닙니다. 복잡성이 추가되고, 일관성 문제가 발생하며, 완전히 새로운 종류의 장애 모드를 만들어냅니다. 무언가를 캐시하기 전에 스스로에게 물어보세요:
캐싱이 도움이 되는 경우:
- 데이터가 쓰기보다 읽기가 훨씬 많은 경우 (10:1 비율 이상)
- 기저 쿼리가 비용이 큰 경우 (조인, 집계, 외부 API 호출)
- 약간의 데이터 지연이 허용되는 경우 (상품 카탈로그, 사용자 프로필, 설정)
- 예측 가능한 접근 패턴이 있는 경우 (같은 키가 반복적으로 조회됨)
캐싱이 해가 되는 경우:
- 데이터가 지속적으로 변경되고 항상 최신이어야 하는 경우 (실시간 주가, 라이브 스코어)
- 모든 요청이 고유한 경우 (매개변수가 많은 검색 쿼리)
- 데이터셋이 매우 작은 경우 (전체가 앱 메모리에 들어가면 Redis를 건너뛰세요)
- 캐시 문제를 모니터링하고 디버깅할 운영 역량이 없는 경우
Phil Karlton은 컴퓨터 과학에서 어려운 것은 캐시 무효화와 이름 짓기 두 가지뿐이라고 유명하게 말했습니다. 둘 다 맞는 말이지만, 밤에 잠을 깨우는 것은 캐시 무효화입니다.
ioredis 설정#
패턴에 들어가기 전에 연결을 설정합시다. 저는 어디서나 ioredis를 사용합니다 — Node.js에서 가장 성숙한 Redis 클라이언트로, 적절한 TypeScript 지원, 클러스터 모드, Sentinel 지원, Lua 스크립팅을 갖추고 있습니다.
import Redis from "ioredis";
const redis = new Redis({
host: process.env.REDIS_HOST || "127.0.0.1",
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
db: Number(process.env.REDIS_DB) || 0,
maxRetriesPerRequest: 3,
retryStrategy(times) {
const delay = Math.min(times * 200, 5000);
return delay;
},
lazyConnect: true,
enableReadyCheck: true,
connectTimeout: 10000,
});
redis.on("error", (err) => {
console.error("[Redis] Connection error:", err.message);
});
redis.on("connect", () => {
console.log("[Redis] Connected");
});
export default redis;몇 가지 주목할 점이 있습니다. lazyConnect: true는 실제로 명령을 실행할 때까지 연결이 수립되지 않는다는 의미로, 테스트와 초기화 시 유용합니다. retryStrategy는 5초로 제한된 지수 백오프를 구현합니다 — 이것 없이는 Redis 장애 시 앱이 재연결 시도를 무차별적으로 보냅니다. 그리고 maxRetriesPerRequest: 3은 개별 명령이 영원히 멈추지 않고 빠르게 실패하도록 보장합니다.
Cache-Aside 패턴#
이것은 80%의 경우에 사용할 패턴입니다. "지연 로딩" 또는 "look-aside"라고도 합니다. 흐름은 간단합니다:
- 애플리케이션이 요청을 받음
- Redis에서 캐시된 값 확인
- 찾으면 (캐시 히트), 반환
- 못 찾으면 (캐시 미스), 데이터베이스 쿼리
- 결과를 Redis에 저장
- 결과 반환
타입이 지정된 구현은 다음과 같습니다:
import redis from "./redis";
interface CacheOptions {
ttl?: number; // 초
prefix?: string;
}
async function cacheAside<T>(
key: string,
fetcher: () => Promise<T>,
options: CacheOptions = {}
): Promise<T> {
const { ttl = 3600, prefix = "cache" } = options;
const cacheKey = `${prefix}:${key}`;
// 1단계: 캐시에서 읽기 시도
const cached = await redis.get(cacheKey);
if (cached !== null) {
try {
return JSON.parse(cached) as T;
} catch {
// 손상된 캐시 항목, 삭제하고 계속 진행
await redis.del(cacheKey);
}
}
// 2단계: 캐시 미스 — 소스에서 가져오기
const result = await fetcher();
// 3단계: 캐시에 저장 (await하지 않음 — 발사 후 잊기)
redis
.set(cacheKey, JSON.stringify(result), "EX", ttl)
.catch((err) => {
console.error(`[Cache] Failed to set ${cacheKey}:`, err.message);
});
return result;
}사용법은 이렇습니다:
interface User {
id: string;
name: string;
email: string;
plan: "free" | "pro" | "enterprise";
}
async function getUser(userId: string): Promise<User | null> {
return cacheAside<User | null>(
`user:${userId}`,
async () => {
const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
return row[0] ?? null;
},
{ ttl: 1800 } // 30분
);
}redis.set 호출을 발사 후 잊기로 처리한 것에 주목하세요. 이것은 의도적입니다. Redis가 다운되거나 느려도 요청은 여전히 완료됩니다. 캐시는 최적화이지 필수 사항이 아닙니다. 캐시에 쓰기가 실패하면 다음 요청이 그냥 다시 데이터베이스를 히트합니다. 별문제 아닙니다.
많은 cache-aside 구현에서 사람들이 놓치는 미묘한 버그가 있습니다: null 값을 캐싱하기. 사용자가 존재하지 않고 그 사실을 캐시하지 않으면, 해당 사용자에 대한 모든 요청이 데이터베이스를 히트합니다. 공격자가 임의의 사용자 ID를 요청해서 이를 악용할 수 있으며, 캐시를 무용지물로 만듭니다. 항상 부정적 결과도 캐시하세요 — 더 짧은 TTL로.
async function getUserSafe(userId: string): Promise<User | null> {
return cacheAside<User | null>(
`user:${userId}`,
async () => {
const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
return row[0] ?? null;
},
{
// null 결과에 더 짧은 TTL로 메모리 사용량 제한
// 하지만 반복되는 미스를 흡수할 만큼 충분히 긴
ttl: row ? 1800 : 300,
}
);
}실제로, 동적 TTL이 제대로 작동하도록 재구성해 봅시다:
async function getUserWithDynamicTTL(userId: string): Promise<User | null> {
const cacheKey = `cache:user:${userId}`;
const cached = await redis.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached) as User | null;
}
const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
const user: User | null = row[0] ?? null;
// 존재하는 결과는 30분, null 결과는 5분 캐시
const ttl = user ? 1800 : 300;
await redis.set(cacheKey, JSON.stringify(user), "EX", ttl);
return user;
}Write-Through와 Write-Behind#
Cache-aside는 읽기 중심 워크로드에 잘 작동하지만, 일관성 문제가 있습니다: 다른 서비스나 프로세스가 데이터베이스를 직접 업데이트하면 TTL이 만료될 때까지 캐시가 오래됩니다. Write-through와 write-behind 패턴이 등장합니다.
Write-Through#
Write-through에서는 모든 쓰기가 캐시 레이어를 통과합니다. 캐시가 먼저 업데이트된 다음 데이터베이스가 업데이트됩니다. 이것은 (쓰기가 항상 애플리케이션을 통과한다고 가정하면) 캐시가 항상 데이터베이스와 일치함을 보장합니다.
async function updateUser(
userId: string,
updates: Partial<User>
): Promise<User> {
// 1단계: 데이터베이스 업데이트
const updated = await db.query(
"UPDATE users SET name = COALESCE($2, name), email = COALESCE($3, email) WHERE id = $1 RETURNING *",
[userId, updates.name, updates.email]
);
const user: User = updated[0];
// 2단계: 캐시 즉시 업데이트
const cacheKey = `cache:user:${userId}`;
await redis.set(cacheKey, JSON.stringify(user), "EX", 1800);
return user;
}Cache-aside와의 핵심 차이: 읽기에서만이 아니라 모든 쓰기에서 캐시에 씁니다. 이것은 최근 업데이트된 데이터에 대해 캐시가 항상 웜한 상태임을 의미합니다.
트레이드오프: 모든 쓰기가 이제 데이터베이스와 Redis 모두를 건드리므로 쓰기 지연 시간이 증가합니다. Redis가 느리면 쓰기가 느려집니다. 대부분의 애플리케이션에서 읽기가 쓰기를 훨씬 초과하므로 이 트레이드오프는 가치가 있습니다.
Write-Behind (Write-Back)#
Write-behind는 대본을 뒤집습니다: 쓰기가 먼저 Redis에 가고, 데이터베이스는 비동기적으로 업데이트됩니다. 이것은 Redis가 데이터가 영속화되기 전에 다운되면 데이터 손실의 대가로 매우 빠른 쓰기를 제공합니다.
async function updateUserWriteBehind(
userId: string,
updates: Partial<User>
): Promise<User> {
const cacheKey = `cache:user:${userId}`;
// 현재 상태 읽기
const current = await redis.get(cacheKey);
const user = current ? JSON.parse(current) as User : null;
if (!user) throw new Error("User not in cache");
// 캐시 즉시 업데이트
const updated = { ...user, ...updates };
await redis.set(cacheKey, JSON.stringify(updated), "EX", 1800);
// 비동기 처리를 위해 데이터베이스 쓰기 큐에 넣기
await redis.rpush(
"write_behind:users",
JSON.stringify({ userId, updates, timestamp: Date.now() })
);
return updated;
}그런 다음 해당 큐를 비우는 별도의 워커가 있을 것입니다:
async function processWriteBehindQueue(): Promise<void> {
while (true) {
const item = await redis.blpop("write_behind:users", 5);
if (item) {
const { userId, updates } = JSON.parse(item[1]);
try {
await db.query(
"UPDATE users SET name = COALESCE($2, name), email = COALESCE($3, email) WHERE id = $1",
[userId, updates.name, updates.email]
);
} catch (err) {
// 실패 시 재시도 카운트와 함께 다시 큐에 넣기
console.error("[WriteBehind] Failed:", err);
await redis.rpush("write_behind:users:dlq", item[1]);
}
}
}
}실제로 write-behind는 거의 사용하지 않습니다. 데이터 손실 위험이 현실적입니다 — 워커가 큐를 처리하기 전에 Redis가 크래시하면 그 쓰기는 사라집니다. 조회수, 분석 이벤트, 중요하지 않은 사용자 선호도처럼 최종 일관성이 진정으로 허용되는 데이터에만 사용하세요.
TTL 전략#
TTL을 올바르게 설정하는 것은 보이는 것보다 더 미묘합니다. 모든 것에 고정 1시간 TTL은 구현하기 쉽고 거의 항상 틀립니다.
데이터 변동성 계층#
데이터를 세 계층으로 분류하고 그에 따라 TTL을 할당합니다:
const TTL = {
// 계층 1: 거의 변경되지 않음, 계산 비용이 큼
// 예: 상품 카탈로그, 사이트 설정, 기능 플래그
STATIC: 86400, // 24시간
// 계층 2: 가끔 변경됨, 중간 비용
// 예: 사용자 프로필, 팀 설정, 권한
MODERATE: 1800, // 30분
// 계층 3: 자주 변경됨, 계산 비용은 낮지만 자주 호출됨
// 예: 피드 데이터, 알림 수, 세션 정보
VOLATILE: 300, // 5분
// 계층 4: 임시, 레이트 리미팅과 락에 사용
EPHEMERAL: 60, // 1분
// Null 결과: 항상 짧은 수명
NOT_FOUND: 120, // 2분
} as const;TTL 지터: 우르르 몰려오는 떼 방지#
저를 물어뜯은 시나리오입니다: 앱을 배포하고, 캐시가 비어 있고, 10,000개의 요청이 모두 같은 데이터를 1시간 TTL로 캐시합니다. 1시간 후 10,000개의 키가 동시에 만료됩니다. 10,000개의 요청이 한꺼번에 데이터베이스를 히트합니다. 데이터베이스가 질식합니다. 프로덕션 Postgres 인스턴스가 다운되는 것을 본 적이 있습니다.
해결책은 지터 — TTL 값에 무작위성을 추가하는 것입니다:
function ttlWithJitter(baseTtl: number, jitterPercent = 0.1): number {
const jitter = baseTtl * jitterPercent;
const offset = Math.random() * jitter * 2 - jitter;
return Math.max(1, Math.round(baseTtl + offset));
}
// 이렇게 하지 말고: redis.set(key, value, "EX", 3600)
// 이렇게 하세요: redis.set(key, value, "EX", ttlWithJitter(3600))
// 3600 ± 10% = 3240에서 3960 사이의 랜덤 값이것은 만료를 하나의 윈도우에 분산시켜서, 10,000개의 키가 같은 초에 만료되는 대신 12분 윈도우에 걸쳐 만료됩니다. 데이터베이스는 절벽이 아닌 점진적인 트래픽 증가를 봅니다.
중요한 경로의 경우 20% 지터를 사용합니다:
const ttl = ttlWithJitter(3600, 0.2); // 2880~4320초슬라이딩 만료#
모든 접근마다 TTL이 리셋되어야 하는 세션과 같은 데이터의 경우, GETEX (Redis 6.2+)를 사용하세요:
async function getWithSlidingExpiry<T>(
key: string,
ttl: number
): Promise<T | null> {
// GETEX는 값을 가져오면서 원자적으로 TTL을 리셋함
const value = await redis.getex(key, "EX", ttl);
if (value === null) return null;
return JSON.parse(value) as T;
}이전 Redis 버전을 사용 중이라면 파이프라인을 사용하세요:
async function getWithSlidingExpiryCompat<T>(
key: string,
ttl: number
): Promise<T | null> {
const pipeline = redis.pipeline();
pipeline.get(key);
pipeline.expire(key, ttl);
const results = await pipeline.exec();
if (!results || !results[0] || results[0][1] === null) return null;
return JSON.parse(results[0][1] as string) as T;
}캐시 스탬피드 (우르르 몰려오는 떼)#
TTL 지터는 대량 만료에 도움이 되지만, 단일 키 스탬피드는 해결하지 못합니다: 인기 키가 만료되고 수백 개의 동시 요청이 모두 동시에 재생성하려 할 때.
홈페이지 피드를 5분 TTL로 캐시한다고 상상하세요. 만료됩니다. 50개의 동시 요청이 캐시 미스를 봅니다. 50개 모두 같은 비용이 큰 쿼리로 데이터베이스를 히트합니다. 사실상 자기 자신을 DDoS한 것입니다.
해결책 1: 뮤텍스 락#
하나의 요청만 캐시를 재생성합니다. 나머지는 대기합니다.
async function cacheAsideWithMutex<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 3600
): Promise<T | null> {
const cacheKey = `cache:${key}`;
const lockKey = `lock:${key}`;
// 먼저 캐시 시도
const cached = await redis.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached) as T;
}
// 락 획득 시도 (NX = 존재하지 않을 때만, EX = 자동 만료)
const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
if (acquired) {
try {
// 락을 얻었음 — 가져와서 캐시
const result = await fetcher();
await redis.set(
cacheKey,
JSON.stringify(result),
"EX",
ttlWithJitter(ttl)
);
return result;
} finally {
// 락 해제
await redis.del(lockKey);
}
}
// 다른 요청이 락을 보유 중 — 대기 후 재시도
await sleep(100);
const retried = await redis.get(cacheKey);
if (retried !== null) {
return JSON.parse(retried) as T;
}
// 여전히 캐시 없음 — 데이터베이스로 직접
// (락 보유자가 실패한 경우 처리)
return fetcher();
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}위의 락 해제에 미묘한 경쟁 조건이 있습니다. 락 보유자가 10초 (락 TTL)보다 오래 걸리면, 다른 요청이 락을 획득하고, 그런 다음 첫 번째 요청이 두 번째 요청의 락을 삭제합니다. 적절한 수정은 고유 토큰을 사용하는 것입니다:
import { randomUUID } from "crypto";
async function acquireLock(
lockKey: string,
ttl: number
): Promise<string | null> {
const token = randomUUID();
const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
return acquired ? token : null;
}
async function releaseLock(lockKey: string, token: string): Promise<boolean> {
// Lua 스크립트가 원자적 확인 후 삭제를 보장
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
const result = await redis.eval(script, 1, lockKey, token);
return result === 1;
}이것은 기본적으로 단순화된 Redlock입니다. 단일 인스턴스 Redis에서는 충분합니다. Redis 클러스터나 Sentinel 설정의 경우 전체 Redlock 알고리즘을 살펴보세요 — 하지만 솔직히 캐싱 스탬피드 방지에는 이 단순한 버전이 잘 작동합니다.
해결책 2: 확률적 조기 만료#
제가 가장 좋아하는 접근 방식입니다. 키가 만료될 때까지 기다리는 대신, 만료 직전에 무작위로 재생성합니다. 이 아이디어는 Vattani, Chierichetti, Lowenstein의 논문에서 왔습니다.
interface CachedValue<T> {
data: T;
cachedAt: number;
ttl: number;
}
async function cacheWithEarlyExpiration<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 3600
): Promise<T> {
const cacheKey = `cache:${key}`;
const cached = await redis.get(cacheKey);
if (cached !== null) {
const entry = JSON.parse(cached) as CachedValue<T>;
const age = (Date.now() - entry.cachedAt) / 1000;
const remaining = entry.ttl - age;
// XFetch 알고리즘: 만료가 다가올수록 확률적으로 재생성
// beta * Math.log(Math.random())은 음수를 생성하며
// 만료가 가까워질수록 더 커짐 (더 음수)
const beta = 1; // 튜닝 파라미터, 1이 잘 작동함
const shouldRegenerate =
remaining - beta * Math.log(Math.random()) * -1 <= 0;
if (!shouldRegenerate) {
return entry.data;
}
// 재생성으로 이동
console.log(`[Cache] Early regeneration triggered for ${key}`);
}
const data = await fetcher();
const entry: CachedValue<T> = {
data,
cachedAt: Date.now(),
ttl,
};
// Redis가 재생성하기 전에 만료되지 않도록 추가 버퍼로 설정
await redis.set(
cacheKey,
JSON.stringify(entry),
"EX",
Math.round(ttl * 1.1)
);
return data;
}이 접근 방식의 아름다움: 키의 남은 TTL이 감소할수록 재생성 확률이 증가합니다. 1,000개의 동시 요청 중 하나 또는 두 개만 재생성을 트리거하고 나머지는 캐시된 데이터를 계속 제공합니다. 락 없음, 조정 없음, 대기 없음.
해결책 3: Stale-While-Revalidate#
백그라운드에서 재생성하는 동안 오래된 값을 제공합니다. 어떤 요청도 fetcher를 기다리지 않기 때문에 최고의 지연 시간을 제공합니다.
async function staleWhileRevalidate<T>(
key: string,
fetcher: () => Promise<T>,
options: {
freshTtl: number; // 데이터가 "신선한" 기간
staleTtl: number; // 오래된 데이터를 제공할 수 있는 기간
}
): Promise<T | null> {
const cacheKey = `cache:${key}`;
const metaKey = `meta:${key}`;
const [cached, meta] = await redis.mget(cacheKey, metaKey);
if (cached !== null) {
const parsedMeta = meta ? JSON.parse(meta) : null;
const isFresh =
parsedMeta && Date.now() - parsedMeta.cachedAt < options.freshTtl * 1000;
if (!isFresh) {
// 데이터가 오래됨 — 제공하되 백그라운드 새로고침 트리거
revalidateInBackground(key, cacheKey, metaKey, fetcher, options);
}
return JSON.parse(cached) as T;
}
// 완전한 캐시 미스 — 동기적으로 가져와야 함
return fetchAndCache(key, cacheKey, metaKey, fetcher, options);
}
async function fetchAndCache<T>(
key: string,
cacheKey: string,
metaKey: string,
fetcher: () => Promise<T>,
options: { freshTtl: number; staleTtl: number }
): Promise<T> {
const data = await fetcher();
const totalTtl = options.freshTtl + options.staleTtl;
const pipeline = redis.pipeline();
pipeline.set(cacheKey, JSON.stringify(data), "EX", totalTtl);
pipeline.set(
metaKey,
JSON.stringify({ cachedAt: Date.now() }),
"EX",
totalTtl
);
await pipeline.exec();
return data;
}
function revalidateInBackground<T>(
key: string,
cacheKey: string,
metaKey: string,
fetcher: () => Promise<T>,
options: { freshTtl: number; staleTtl: number }
): void {
// 다중 백그라운드 새로고침을 방지하기 위해 락 사용
const lockKey = `revalidate_lock:${key}`;
redis
.set(lockKey, "1", "EX", 30, "NX")
.then((acquired) => {
if (!acquired) return;
return fetchAndCache(key, cacheKey, metaKey, fetcher, options)
.finally(() => redis.del(lockKey));
})
.catch((err) => {
console.error(`[SWR] Background revalidation failed for ${key}:`, err);
});
}사용법:
const user = await staleWhileRevalidate<User>("user:123", fetchUserFromDB, {
freshTtl: 300, // 5분 신선
staleTtl: 3600, // 재검증하는 동안 최대 1시간 오래된 것 제공
});지연 시간이 절대적인 신선도보다 중요한 사용자 대면 항목에 이 패턴을 사용합니다. 대시보드 데이터, 프로필 페이지, 상품 목록 — 모두 완벽한 후보입니다.
캐시 무효화#
Phil Karlton은 농담이 아니었습니다. 무효화는 캐싱이 "쉬운 최적화"에서 "분산 시스템 문제"로 전환되는 지점입니다.
단순 키 기반 무효화#
가장 쉬운 경우: 사용자를 업데이트하면 캐시 키를 삭제합니다.
async function updateUserAndInvalidate(
userId: string,
updates: Partial<User>
): Promise<User> {
const user = await db.query(
"UPDATE users SET name = $2 WHERE id = $1 RETURNING *",
[userId, updates.name]
);
// 캐시 무효화
await redis.del(`cache:user:${userId}`);
return user[0];
}이것은 사용자 데이터가 다른 캐시된 결과에 나타날 때까지 작동합니다. 팀 구성원 목록에 포함되어 있을 수 있습니다. 검색 결과에 있을 수 있습니다. 14개의 다른 캐시된 API 응답에 있을 수 있습니다. 이제 어떤 캐시 키가 어떤 엔티티를 포함하는지 추적해야 합니다.
태그 기반 무효화#
캐시 항목에 포함하는 엔티티를 태그하고, 태그로 무효화합니다.
async function setWithTags<T>(
key: string,
value: T,
ttl: number,
tags: string[]
): Promise<void> {
const pipeline = redis.pipeline();
// 값 저장
pipeline.set(`cache:${key}`, JSON.stringify(value), "EX", ttl);
// 각 태그의 집합에 키 추가
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, `cache:${key}`);
pipeline.expire(`tag:${tag}`, ttl + 3600); // 태그 집합은 값보다 오래 살아남음
}
await pipeline.exec();
}
async function invalidateByTag(tag: string): Promise<number> {
const keys = await redis.smembers(`tag:${tag}`);
if (keys.length === 0) return 0;
const pipeline = redis.pipeline();
for (const key of keys) {
pipeline.del(key);
}
pipeline.del(`tag:${tag}`);
await pipeline.exec();
return keys.length;
}사용법:
// 팀 데이터를 캐시할 때 모든 멤버 ID로 태그
const team = await fetchTeam(teamId);
await setWithTags(
`team:${teamId}`,
team,
1800,
[
`entity:team:${teamId}`,
...team.members.map((m) => `entity:user:${m.id}`),
]
);
// 사용자 42가 프로필을 업데이트하면, 그들을 포함하는 모든 것을 무효화
await invalidateByTag("entity:user:42");이벤트 기반 무효화#
더 큰 시스템의 경우 Redis Pub/Sub을 사용해서 무효화 이벤트를 브로드캐스트합니다:
// 발행자 (API 서비스에서)
async function publishInvalidation(
entityType: string,
entityId: string
): Promise<void> {
await redis.publish(
"cache:invalidate",
JSON.stringify({ entityType, entityId, timestamp: Date.now() })
);
}
// 구독자 (각 앱 인스턴스에서)
const subscriber = new Redis(/* 같은 설정 */);
subscriber.subscribe("cache:invalidate", (err) => {
if (err) console.error("[PubSub] Subscribe error:", err);
});
subscriber.on("message", async (_channel, message) => {
const { entityType, entityId } = JSON.parse(message);
await invalidateByTag(`entity:${entityType}:${entityId}`);
console.log(`[Cache] Invalidated ${entityType}:${entityId}`);
});이것은 다중 인스턴스 배포에서 중요합니다. 로드 밸런서 뒤에 4개의 앱 서버가 있으면, 서버 1의 무효화가 모든 서버로 전파되어야 합니다. Pub/Sub이 이것을 자동으로 처리합니다.
패턴 기반 무효화 (주의해서)#
때로는 패턴에 일치하는 모든 키를 무효화해야 합니다. 프로덕션에서 절대 KEYS를 사용하지 마세요. 전체 키스페이스를 스캔하는 동안 Redis 서버를 차단합니다. 수백만 개의 키가 있으면 몇 초 동안 차단할 수 있습니다 — Redis 용어로 영겁입니다.
대신 SCAN을 사용하세요:
async function invalidateByPattern(pattern: string): Promise<number> {
let cursor = "0";
let deletedCount = 0;
do {
const [nextCursor, keys] = await redis.scan(
cursor,
"MATCH",
pattern,
"COUNT",
100
);
cursor = nextCursor;
if (keys.length > 0) {
await redis.del(...keys);
deletedCount += keys.length;
}
} while (cursor !== "0");
return deletedCount;
}
// 특정 팀의 모든 캐시된 데이터 무효화
await invalidateByPattern("cache:team:42:*");SCAN은 증분적으로 반복합니다 — 서버를 절대 차단하지 않습니다. COUNT 힌트는 반복당 반환할 키 수를 제안합니다 (힌트이지 보장이 아닙니다). 큰 키스페이스에서 이것이 유일한 안전한 접근입니다.
그렇긴 하지만 패턴 기반 무효화는 코드 냄새입니다. 자주 스캔하고 있다면 키 구조를 재설계하거나 태그를 사용하세요. SCAN은 키스페이스에 대해 O(N)이며 유지보수 작업을 위한 것이지 핫 경로를 위한 것이 아닙니다.
문자열 너머의 데이터 구조#
대부분의 개발자는 Redis를 JSON 문자열을 위한 키-값 저장소로 취급합니다. 스위스 아미 나이프를 사서 병따개만 사용하는 것과 같습니다. Redis는 풍부한 데이터 구조를 가지고 있으며, 올바른 것을 선택하면 전체 복잡성 범주를 제거할 수 있습니다.
객체를 위한 해시#
전체 객체를 JSON으로 직렬화하는 대신, Redis 해시로 저장하세요. 이렇게 하면 전체를 역직렬화하지 않고 개별 필드를 읽고 업데이트할 수 있습니다.
// 사용자를 해시로 저장
async function setUserHash(user: User): Promise<void> {
const key = `user:${user.id}`;
await redis.hset(key, {
name: user.name,
email: user.email,
plan: user.plan,
updatedAt: Date.now().toString(),
});
await redis.expire(key, 1800);
}
// 특정 필드 읽기
async function getUserPlan(userId: string): Promise<string | null> {
return redis.hget(`user:${userId}`, "plan");
}
// 단일 필드 업데이트
async function upgradeUserPlan(
userId: string,
plan: string
): Promise<void> {
await redis.hset(`user:${userId}`, "plan", plan);
}
// 전체 해시를 객체로 읽기
async function getUserHash(userId: string): Promise<User | null> {
const data = await redis.hgetall(`user:${userId}`);
if (!data || Object.keys(data).length === 0) return null;
return {
id: userId,
name: data.name,
email: data.email,
plan: data.plan as User["plan"],
};
}해시는 작은 객체에 대해 메모리 효율적이며 (Redis가 내부적으로 컴팩트한 ziplist 인코딩을 사용) 직렬화/역직렬화 오버헤드를 피합니다. 트레이드오프: 먼저 평탄화하지 않으면 중첩된 객체를 저장하는 기능을 잃습니다.
리더보드와 레이트 리미팅을 위한 정렬 집합#
정렬 집합은 Redis의 가장 저평가된 데이터 구조입니다. 모든 멤버에 점수가 있고, 집합은 항상 점수로 정렬됩니다. 리더보드, 순위, 슬라이딩 윈도우 레이트 리미팅에 완벽합니다.
// 리더보드
async function addScore(
leaderboard: string,
userId: string,
score: number
): Promise<void> {
await redis.zadd(leaderboard, score, userId);
}
async function getTopPlayers(
leaderboard: string,
count: number = 10
): Promise<Array<{ userId: string; score: number }>> {
const results = await redis.zrevrange(
leaderboard,
0,
count - 1,
"WITHSCORES"
);
const players: Array<{ userId: string; score: number }> = [];
for (let i = 0; i < results.length; i += 2) {
players.push({
userId: results[i],
score: parseFloat(results[i + 1]),
});
}
return players;
}
async function getUserRank(
leaderboard: string,
userId: string
): Promise<number | null> {
const rank = await redis.zrevrank(leaderboard, userId);
return rank !== null ? rank + 1 : null; // 0-인덱스를 1-인덱스로
}슬라이딩 윈도우 레이트 리미팅:
async function slidingWindowRateLimit(
identifier: string,
windowMs: number,
maxRequests: number
): Promise<{ allowed: boolean; remaining: number }> {
const key = `ratelimit:${identifier}`;
const now = Date.now();
const windowStart = now - windowMs;
const pipeline = redis.pipeline();
// 윈도우 밖의 항목 제거
pipeline.zremrangebyscore(key, 0, windowStart);
// 현재 요청 추가
pipeline.zadd(key, now, `${now}:${Math.random()}`);
// 윈도우 내 요청 수 세기
pipeline.zcard(key);
// 전체 키에 만료 설정
pipeline.expire(key, Math.ceil(windowMs / 1000));
const results = await pipeline.exec();
const count = results?.[2]?.[1] as number;
return {
allowed: count <= maxRequests,
remaining: Math.max(0, maxRequests - count),
};
}이것은 고정 윈도우 카운터 접근보다 더 정확하며, 한 윈도우 끝과 다음 윈도우 시작에서의 폭주가 사실상 레이트 리미트를 두 배로 만드는 경계 문제를 겪지 않습니다.
큐를 위한 리스트#
Redis 리스트의 LPUSH/BRPOP은 훌륭한 경량 작업 큐를 만듭니다:
interface Job {
id: string;
type: string;
payload: Record<string, unknown>;
createdAt: number;
}
// 생산자
async function enqueueJob(
queue: string,
type: string,
payload: Record<string, unknown>
): Promise<string> {
const job: Job = {
id: randomUUID(),
type,
payload,
createdAt: Date.now(),
};
await redis.lpush(`queue:${queue}`, JSON.stringify(job));
return job.id;
}
// 소비자 (작업이 가용할 때까지 블록)
async function dequeueJob(
queue: string,
timeout: number = 5
): Promise<Job | null> {
const result = await redis.brpop(`queue:${queue}`, timeout);
if (!result) return null;
return JSON.parse(result[1]) as Job;
}기본 큐잉보다 더 복잡한 것 (재시도, 데드 레터 큐, 우선순위, 지연된 작업)에는 Redis를 기반으로 하지만 모든 엣지 케이스를 처리하는 BullMQ를 사용하세요.
고유 추적을 위한 집합#
고유 방문자를 추적하거나, 이벤트를 중복 제거하거나, 멤버십을 확인해야 하나요? 집합은 추가, 제거, 멤버십 확인이 O(1)입니다.
// 일별 고유 방문자 추적
async function trackVisitor(
page: string,
visitorId: string
): Promise<boolean> {
const key = `visitors:${page}:${new Date().toISOString().split("T")[0]}`;
const isNew = await redis.sadd(key, visitorId);
// 48시간 후 자동 만료
await redis.expire(key, 172800);
return isNew === 1; // 1 = 새 멤버, 0 = 이미 존재
}
// 고유 방문자 수 가져오기
async function getUniqueVisitors(page: string, date: string): Promise<number> {
return redis.scard(`visitors:${page}:${date}`);
}
// 사용자가 이미 액션을 수행했는지 확인
async function hasUserVoted(pollId: string, userId: string): Promise<boolean> {
return (await redis.sismember(`votes:${pollId}`, userId)) === 1;
}매우 큰 집합 (수백만 멤버)의 경우, HyperLogLog를 고려하세요. 카디널리티에 관계없이 12KB의 메모리만 사용하며, ~0.81% 표준 오차의 대가를 치릅니다:
// 대략적인 고유 카운트를 위한 HyperLogLog
async function trackVisitorApprox(
page: string,
visitorId: string
): Promise<void> {
const key = `hll:visitors:${page}:${new Date().toISOString().split("T")[0]}`;
await redis.pfadd(key, visitorId);
await redis.expire(key, 172800);
}
async function getApproxUniqueVisitors(
page: string,
date: string
): Promise<number> {
return redis.pfcount(`hll:visitors:${page}:${date}`);
}직렬화: JSON vs MessagePack#
JSON은 Redis 직렬화의 기본 선택입니다. 읽을 수 있고, 보편적이며, 대부분의 경우 충분합니다. 하지만 높은 처리량 시스템에서는 직렬화/역직렬화 오버헤드가 누적됩니다.
JSON의 문제#
const user = {
id: "usr_abc123",
name: "Ahmet Kousa",
email: "ahmet@example.com",
plan: "pro",
preferences: {
theme: "dark",
language: "tr",
notifications: true,
},
};
// JSON: 189 바이트
const jsonStr = JSON.stringify(user);
console.log(Buffer.byteLength(jsonStr)); // 189
// 핫 경로에서의 JSON.parse: 호출당 ~0.02ms
// 초당 10,000 요청에서: 초당 200ms의 총 CPU 시간MessagePack 대안#
MessagePack은 JSON보다 더 작고 빠른 바이너리 직렬화 형식입니다:
npm install msgpackrimport { pack, unpack } from "msgpackr";
// MessagePack: ~140 바이트 (25% 더 작음)
const packed = pack(user);
console.log(packed.length); // ~140
// Buffer로 저장
await redis.set("user:123", packed);
// Buffer로 읽기
const raw = await redis.getBuffer("user:123");
if (raw) {
const data = unpack(raw);
}get 대신 getBuffer 사용에 주목하세요 — 이것이 중요합니다. get은 문자열을 반환하며 바이너리 데이터를 손상시킵니다.
큰 값을 위한 압축#
큰 캐시 값 (수백 개의 항목이 있는 API 응답, 렌더링된 HTML)의 경우, 압축을 추가하세요:
import { promisify } from "util";
import { gzip, gunzip } from "zlib";
const gzipAsync = promisify(gzip);
const gunzipAsync = promisify(gunzip);
async function setCompressed<T>(
key: string,
value: T,
ttl: number
): Promise<void> {
const json = JSON.stringify(value);
// 1KB보다 큰 경우에만 압축 (작은 값에는 압축 오버헤드가 가치 없음)
if (Buffer.byteLength(json) > 1024) {
const compressed = await gzipAsync(json);
await redis.set(key, compressed, "EX", ttl);
} else {
await redis.set(key, json, "EX", ttl);
}
}
async function getCompressed<T>(key: string): Promise<T | null> {
const raw = await redis.getBuffer(key);
if (!raw) return null;
try {
// 먼저 압축 해제 시도
const decompressed = await gunzipAsync(raw);
return JSON.parse(decompressed.toString()) as T;
} catch {
// 압축되지 않음, 일반 JSON으로 파싱
return JSON.parse(raw.toString()) as T;
}
}제 테스트에서 gzip 압축은 보통 JSON 페이로드 크기를 70-85% 줄입니다. 50KB API 응답이 8KB가 됩니다. Redis 메모리 비용을 지불할 때 이것이 중요합니다 — 키당 메모리가 적으면 같은 인스턴스에 더 많은 키를 넣을 수 있습니다.
트레이드오프: 압축은 작업당 1-3ms의 CPU 시간을 추가합니다. 대부분의 애플리케이션에서 이것은 무시할 수 있습니다. 극도로 낮은 지연 시간 경로에서는 건너뛰세요.
제 추천#
프로파일링에서 병목으로 보이지 않는 한 JSON을 사용하세요. Redis에서 JSON의 가독성과 디버그 가능성 (redis-cli GET key로 실제로 값을 읽을 수 있음)은 95%의 애플리케이션에서 MessagePack의 성능 이점보다 큽니다. 1KB보다 큰 값에만 압축을 추가하세요.
Next.js에서의 Redis#
Next.js는 자체 캐싱 이야기가 있지만 (데이터 캐시, 전체 라우트 캐시 등), Redis는 내장 캐싱이 처리할 수 없는 격차를 채웁니다 — 특히 여러 인스턴스 간에 캐시를 공유하거나 배포 간에 캐시를 유지해야 할 때.
API 라우트 응답 캐싱#
// app/api/products/route.ts
import { NextResponse } from "next/server";
import redis from "@/lib/redis";
export async function GET(request: Request) {
const url = new URL(request.url);
const category = url.searchParams.get("category") || "all";
const cacheKey = `api:products:${category}`;
// 캐시 확인
const cached = await redis.get(cacheKey);
if (cached) {
return NextResponse.json(JSON.parse(cached), {
headers: {
"X-Cache": "HIT",
"Cache-Control": "public, s-maxage=60",
},
});
}
// 데이터베이스에서 가져오기
const products = await db.products.findMany({
where: category !== "all" ? { category } : undefined,
orderBy: { createdAt: "desc" },
take: 50,
});
// 지터와 함께 5분간 캐시
await redis.set(
cacheKey,
JSON.stringify(products),
"EX",
ttlWithJitter(300)
);
return NextResponse.json(products, {
headers: {
"X-Cache": "MISS",
"Cache-Control": "public, s-maxage=60",
},
});
}X-Cache 헤더는 디버깅에 매우 유용합니다. 지연 시간이 급증하면 빠른 curl -I로 캐시가 작동하는지 알 수 있습니다.
세션 저장소#
상태가 있는 애플리케이션에서 Redis를 사용한 Next.js 세션이 JWT를 이깁니다:
// lib/session.ts
import { randomUUID } from "crypto";
import redis from "./redis";
interface Session {
userId: string;
role: string;
createdAt: number;
data: Record<string, unknown>;
}
const SESSION_TTL = 86400; // 24시간
const SESSION_PREFIX = "session:";
export async function createSession(
userId: string,
role: string
): Promise<string> {
const sessionId = randomUUID();
const session: Session = {
userId,
role,
createdAt: Date.now(),
data: {},
};
await redis.set(
`${SESSION_PREFIX}${sessionId}`,
JSON.stringify(session),
"EX",
SESSION_TTL
);
return sessionId;
}
export async function getSession(
sessionId: string
): Promise<Session | null> {
const key = `${SESSION_PREFIX}${sessionId}`;
// GETEX를 사용해서 매 접근마다 TTL 새로고침 (슬라이딩 만료)
const raw = await redis.getex(key, "EX", SESSION_TTL);
if (!raw) return null;
return JSON.parse(raw) as Session;
}
export async function destroySession(sessionId: string): Promise<void> {
await redis.del(`${SESSION_PREFIX}${sessionId}`);
}
// 사용자의 모든 세션 파괴 ("모든 곳에서 로그아웃"에 유용)
export async function destroyAllUserSessions(
userId: string
): Promise<void> {
// 사용자->세션 인덱스를 유지해야 함
const sessionIds = await redis.smembers(`user_sessions:${userId}`);
if (sessionIds.length > 0) {
const pipeline = redis.pipeline();
for (const sid of sessionIds) {
pipeline.del(`${SESSION_PREFIX}${sid}`);
}
pipeline.del(`user_sessions:${userId}`);
await pipeline.exec();
}
}레이트 리미팅 미들웨어#
// middleware.ts (또는 미들웨어에서 사용되는 헬퍼)
import redis from "@/lib/redis";
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number;
}
export async function rateLimit(
identifier: string,
limit: number = 60,
windowSeconds: number = 60
): Promise<RateLimitResult> {
const key = `rate:${identifier}`;
const now = Math.floor(Date.now() / 1000);
const windowStart = now - windowSeconds;
// 원자적 레이트 리미팅을 위한 Lua 스크립트
const script = `
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[3])
local count = redis.call('ZCARD', KEYS[1])
redis.call('EXPIRE', KEYS[1], ARGV[4])
return count
`;
const count = (await redis.eval(
script,
1,
key,
windowStart,
now,
`${now}:${Math.random()}`,
windowSeconds
)) as number;
return {
allowed: count <= limit,
remaining: Math.max(0, limit - count),
resetAt: now + windowSeconds,
};
}Lua 스크립트가 여기서 중요합니다. 이것 없이는 ZREMRANGEBYSCORE + ZADD + ZCARD 시퀀스가 원자적이지 않아, 높은 동시성에서 카운트가 부정확할 수 있습니다. Lua 스크립트는 Redis에서 원자적으로 실행됩니다 — 다른 명령과 인터리브될 수 없습니다.
Next.js를 위한 분산 락#
여러 Next.js 인스턴스가 있고 하나만 작업을 처리하도록 해야 할 때 (예정된 이메일 전송이나 정리 작업 실행):
// lib/distributed-lock.ts
import { randomUUID } from "crypto";
import redis from "./redis";
export async function withLock<T>(
lockName: string,
fn: () => Promise<T>,
options: { ttl?: number; retryDelay?: number; maxRetries?: number } = {}
): Promise<T | null> {
const { ttl = 30, retryDelay = 200, maxRetries = 10 } = options;
const token = randomUUID();
const lockKey = `dlock:${lockName}`;
// 락 획득 시도
for (let attempt = 0; attempt < maxRetries; attempt++) {
const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
if (acquired) {
try {
// 장기 실행 작업을 위해 자동으로 락 연장
const extender = setInterval(async () => {
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("expire", KEYS[1], ARGV[2])
else
return 0
end
`;
await redis.eval(script, 1, lockKey, token, ttl);
}, (ttl * 1000) / 3);
const result = await fn();
clearInterval(extender);
return result;
} finally {
// 여전히 소유하고 있을 때만 락 해제
const releaseScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
await redis.eval(releaseScript, 1, lockKey, token);
}
}
// 재시도 전 대기
await new Promise((r) => setTimeout(r, retryDelay));
}
// 모든 재시도 후에도 락을 획득할 수 없음
return null;
}사용법:
// cron으로 트리거되는 API 라우트에서
export async function POST() {
const result = await withLock("daily-report", async () => {
// 하나의 인스턴스만 이것을 실행
const report = await generateDailyReport();
await sendReportEmail(report);
return report;
});
if (result === null) {
return NextResponse.json(
{ message: "Another instance is already processing" },
{ status: 409 }
);
}
return NextResponse.json({ success: true });
}ttl/3의 락 연장 간격이 중요합니다. 이것 없이 작업이 락 TTL보다 오래 걸리면 락이 만료되고 다른 인스턴스가 잡습니다. 연장자는 작업이 실행되는 동안 락을 유지합니다.
모니터링과 디버깅#
Redis는 그렇지 않을 때까지 빠릅니다. 문제가 발생하면 가시성이 필요합니다.
캐시 히트율#
가장 중요한 단일 메트릭입니다. 애플리케이션에서 추적하세요:
// lib/cache-metrics.ts
import redis from "./redis";
const METRICS_KEY = "metrics:cache";
export async function recordCacheHit(): Promise<void> {
await redis.hincrby(METRICS_KEY, "hits", 1);
}
export async function recordCacheMiss(): Promise<void> {
await redis.hincrby(METRICS_KEY, "misses", 1);
}
export async function getCacheStats(): Promise<{
hits: number;
misses: number;
hitRate: number;
}> {
const stats = await redis.hgetall(METRICS_KEY);
const hits = parseInt(stats.hits || "0", 10);
const misses = parseInt(stats.misses || "0", 10);
const total = hits + misses;
return {
hits,
misses,
hitRate: total > 0 ? hits / total : 0,
};
}
// 매일 메트릭 리셋
export async function resetCacheStats(): Promise<void> {
await redis.del(METRICS_KEY);
}건강한 캐시 히트율은 90% 이상입니다. 80% 미만이면 TTL이 너무 짧거나, 캐시 키가 너무 구체적이거나, 접근 패턴이 생각보다 더 무작위한 것입니다.
INFO 명령#
INFO 명령은 Redis의 내장 건강 대시보드입니다:
redis-cli INFO memory# Memory
used_memory:1234567
used_memory_human:1.18M
used_memory_peak:2345678
used_memory_peak_human:2.24M
maxmemory:0
maxmemory_policy:noeviction
mem_fragmentation_ratio:1.23
모니터링할 핵심 메트릭:
- used_memory vs maxmemory: 한계에 접근하고 있는가?
- mem_fragmentation_ratio: 1.5 이상이면 Redis가 논리적 메모리보다 상당히 더 많은 RSS를 사용하고 있음. 재시작을 고려하세요.
- evicted_keys: 0이 아니고 퇴거를 의도하지 않았다면 메모리가 부족합니다.
redis-cli INFO stats주시할 것:
- keyspace_hits / keyspace_misses: 서버 수준 히트율
- total_commands_processed: 처리량
- instantaneous_ops_per_sec: 현재 처리량
MONITOR (극도의 주의와 함께 사용)#
MONITOR는 Redis 서버에서 실행되는 모든 명령을 실시간으로 스트리밍합니다. 디버깅에 매우 유용하고 프로덕션에서 매우 위험합니다.
# 프로덕션에서 절대 이것을 실행한 채로 두지 마세요
# 상당한 오버헤드를 추가하고 민감한 데이터를 로깅할 수 있음
redis-cli MONITOR1614556800.123456 [0 127.0.0.1:52340] "SET" "cache:user:123" "{\"name\":\"Ahmet\"}" "EX" "1800"
1614556800.234567 [0 127.0.0.1:52340] "GET" "cache:user:456"
MONITOR를 정확히 두 가지에 사용합니다: 개발 중 키 네이밍 문제 디버깅, 그리고 특정 코드 경로가 예상대로 Redis를 히트하는지 확인. 절대 30초 이상 사용하지 않습니다. 다른 디버깅 옵션을 이미 소진하지 않았다면 프로덕션에서 사용하지 않습니다.
키스페이스 알림#
키가 만료되거나 삭제될 때 알고 싶으세요? Redis가 이벤트를 발행할 수 있습니다:
# 만료 및 퇴거 이벤트에 대한 키스페이스 알림 활성화
redis-cli CONFIG SET notify-keyspace-events Exconst subscriber = new Redis(/* 설정 */);
// 키 만료 이벤트 수신
subscriber.subscribe("__keyevent@0__:expired", (err) => {
if (err) console.error("Subscribe error:", err);
});
subscriber.on("message", (_channel, expiredKey) => {
console.log(`Key expired: ${expiredKey}`);
// 중요한 키를 사전에 재생성
if (expiredKey.startsWith("cache:homepage")) {
regenerateHomepageCache().catch(console.error);
}
});이것은 사전 캐시 워밍에 유용합니다 — 사용자가 캐시 미스를 트리거하길 기다리는 대신, 만료되는 즉시 중요한 항목을 재생성합니다.
메모리 분석#
Redis 메모리가 예상치 못하게 증가하면 어떤 키가 가장 많이 소비하는지 찾아야 합니다:
# 가장 큰 키 10개 샘플
redis-cli --bigkeys# 가장 큰 키를 찾기 위해 전체 키스페이스 스캔 중
[00.00%] 지금까지 발견된 가장 큰 string '"cache:search:electronics"' 524288 바이트
[25.00%] 지금까지 발견된 가장 큰 zset '"leaderboard:global"' 150000 멤버
[50.00%] 지금까지 발견된 가장 큰 hash '"session:abc123"' 45 필드
더 상세한 분석:
# 특정 키의 메모리 사용량 (바이트)
redis-cli MEMORY USAGE "cache:search:electronics"// 프로그래밍 방식 메모리 분석
async function analyzeMemory(pattern: string): Promise<void> {
let cursor = "0";
const stats: Array<{ key: string; bytes: number }> = [];
do {
const [nextCursor, keys] = await redis.scan(
cursor,
"MATCH",
pattern,
"COUNT",
100
);
cursor = nextCursor;
for (const key of keys) {
const bytes = await redis.memory("USAGE", key);
if (bytes) {
stats.push({ key, bytes: bytes as number });
}
}
} while (cursor !== "0");
// 크기 내림차순 정렬
stats.sort((a, b) => b.bytes - a.bytes);
console.log("메모리 사용량 상위 20개 키:");
for (const { key, bytes } of stats.slice(0, 20)) {
const mb = (bytes / 1024 / 1024).toFixed(2);
console.log(` ${key}: ${mb} MB`);
}
}퇴거 정책#
Redis 인스턴스에 maxmemory 제한이 있으면 (있어야 합니다), 퇴거 정책을 설정하세요:
# redis.conf 또는 CONFIG SET으로
maxmemory 512mb
maxmemory-policy allkeys-lru사용 가능한 정책:
- noeviction: 메모리가 가득 차면 오류 반환 (기본값, 캐싱에 최악)
- allkeys-lru: 가장 최근에 사용되지 않은 키 퇴거 (캐싱에 가장 좋은 범용 선택)
- allkeys-lfu: 가장 적게 사용된 키 퇴거 (일부 키가 폭발적으로 접근되면 더 좋음)
- volatile-lru: TTL이 설정된 키만 퇴거 (캐시와 영구 데이터를 혼합할 때 유용)
- allkeys-random: 무작위 퇴거 (놀랍게도 괜찮음, 오버헤드 없음)
순수 캐싱 워크로드에는 allkeys-lfu가 보통 최선의 선택입니다. 최근에 접근하지 않았더라도 자주 접근하는 키를 메모리에 유지합니다.
모두 합치기: 프로덕션 캐시 모듈#
다음은 우리가 논의한 모든 것을 결합한 프로덕션에서 사용하는 완전한 캐시 모듈입니다:
// lib/cache.ts
import Redis from "ioredis";
const redis = new Redis({
host: process.env.REDIS_HOST || "127.0.0.1",
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
maxRetriesPerRequest: 3,
retryStrategy(times) {
return Math.min(times * 200, 5000);
},
});
// TTL 계층
const TTL = {
STATIC: 86400,
MODERATE: 1800,
VOLATILE: 300,
EPHEMERAL: 60,
NOT_FOUND: 120,
} as const;
type TTLTier = keyof typeof TTL;
function ttlWithJitter(base: number, jitter = 0.1): number {
const offset = base * jitter * (Math.random() * 2 - 1);
return Math.max(1, Math.round(base + offset));
}
// 스탬피드 보호가 있는 핵심 cache-aside
async function get<T>(
key: string,
fetcher: () => Promise<T>,
options: {
tier?: TTLTier;
ttl?: number;
tags?: string[];
swr?: { freshTtl: number; staleTtl: number };
} = {}
): Promise<T> {
const { tier = "MODERATE", tags } = options;
const baseTtl = options.ttl ?? TTL[tier];
const cacheKey = `c:${key}`;
// 캐시 확인
const cached = await redis.get(cacheKey);
if (cached !== null) {
try {
const parsed = JSON.parse(cached);
recordHit();
return parsed as T;
} catch {
await redis.del(cacheKey);
}
}
recordMiss();
// 스탬피드 방지를 위한 락 획득
const lockKey = `lock:${key}`;
const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
if (!acquired) {
// 다른 프로세스가 가져오는 중 — 잠시 대기 후 캐시 재시도
await new Promise((r) => setTimeout(r, 150));
const retried = await redis.get(cacheKey);
if (retried) return JSON.parse(retried) as T;
}
try {
const result = await fetcher();
const ttl = ttlWithJitter(baseTtl);
const pipeline = redis.pipeline();
pipeline.set(cacheKey, JSON.stringify(result), "EX", ttl);
// 태그 연관 저장
if (tags) {
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, cacheKey);
pipeline.expire(`tag:${tag}`, ttl + 3600);
}
}
await pipeline.exec();
return result;
} finally {
await redis.del(lockKey);
}
}
// 무효화
async function invalidate(...keys: string[]): Promise<void> {
if (keys.length === 0) return;
await redis.del(...keys.map((k) => `c:${k}`));
}
async function invalidateByTag(tag: string): Promise<number> {
const keys = await redis.smembers(`tag:${tag}`);
if (keys.length === 0) return 0;
const pipeline = redis.pipeline();
for (const key of keys) {
pipeline.del(key);
}
pipeline.del(`tag:${tag}`);
await pipeline.exec();
return keys.length;
}
// 메트릭
function recordHit(): void {
redis.hincrby("metrics:cache", "hits", 1).catch(() => {});
}
function recordMiss(): void {
redis.hincrby("metrics:cache", "misses", 1).catch(() => {});
}
async function stats(): Promise<{
hits: number;
misses: number;
hitRate: string;
}> {
const raw = await redis.hgetall("metrics:cache");
const hits = parseInt(raw.hits || "0", 10);
const misses = parseInt(raw.misses || "0", 10);
const total = hits + misses;
return {
hits,
misses,
hitRate: total > 0 ? ((hits / total) * 100).toFixed(1) + "%" : "N/A",
};
}
export const cache = {
get,
invalidate,
invalidateByTag,
stats,
redis,
TTL,
};애플리케이션 전체에서의 사용법:
import { cache } from "@/lib/cache";
// 단순 cache-aside
const products = await cache.get("products:featured", fetchFeaturedProducts, {
tier: "VOLATILE",
tags: ["entity:products"],
});
// 커스텀 TTL
const config = await cache.get("app:config", fetchAppConfig, {
ttl: 43200, // 12시간
});
// 상품 업데이트 후
await cache.invalidateByTag("entity:products");
// 건강 확인
const metrics = await cache.stats();
console.log(`캐시 히트율: ${metrics.hitRate}`);제가 저질렀던 흔한 실수들 (여러분은 안 하시도록)#
1. maxmemory를 설정하지 않기. Redis는 OS가 죽일 때까지 모든 가용 메모리를 행복하게 사용합니다. 항상 제한을 설정하세요.
2. 프로덕션에서 KEYS 사용하기. 서버를 차단합니다. SCAN을 사용하세요. 모니터링 스크립트의 KEYS * 호출이 3초의 다운타임을 일으켰을 때 배웠습니다.
3. 너무 공격적으로 캐싱하기. 모든 것이 캐시될 필요는 없습니다. 데이터베이스 쿼리가 2ms 걸리고 분당 10번 호출되면, 캐싱은 무시할 수 있는 이점을 위해 복잡성을 추가합니다.
4. 직렬화 비용 무시하기. 한번 2MB JSON blob을 캐시하고 캐시 읽기가 왜 느린지 어리둥절했습니다. 직렬화 오버헤드가 절약하려던 데이터베이스 쿼리보다 컸습니다.
5. 우아한 저하 없음. Redis가 다운되면 앱은 여전히 작동해야 합니다 — 그냥 더 느리게. 모든 캐시 호출을 데이터베이스로 폴백하는 try/catch로 감싸세요. 절대 캐시 실패가 사용자 대면 오류가 되게 하지 마세요.
async function resilientGet<T>(
key: string,
fetcher: () => Promise<T>
): Promise<T> {
try {
return await cache.get(key, fetcher);
} catch (err) {
console.error(`[Cache] Degraded mode for ${key}:`, err);
return fetcher(); // 캐시를 완전히 우회
}
}6. 퇴거를 모니터링하지 않기. Redis가 키를 퇴거하고 있다면, 프로비저닝이 부족하거나 너무 많이 캐싱하고 있는 것입니다. 어느 쪽이든 알아야 합니다.
7. 캐싱과 영구 데이터 사이에 Redis 인스턴스를 공유하기. 별도의 인스턴스 (또는 최소한 별도의 데이터베이스)를 사용하세요. 작업 큐 항목을 삭제하는 캐시 퇴거 정책은 모두에게 나쁜 날입니다.
마무리#
Redis 캐싱은 어렵지 않지만, 잘못하기 쉽습니다. Cache-aside로 시작하고, 첫날부터 TTL 지터를 추가하고, 히트율을 모니터링하고, 모든 것을 캐시하려는 충동에 저항하세요.
최고의 캐싱 전략은 새벽 3시에 뭔가 고장났을 때 추론할 수 있는 것입니다. 간단하게 유지하고, 관측 가능하게 유지하고, 모든 캐시된 값은 데이터 상태에 대해 사용자에게 한 거짓말이라는 것을 기억하세요 — 여러분의 일은 그 거짓말을 가능한 한 작고 짧게 유지하는 것입니다.