Redis 캐싱 전략 패턴 5가지 | Cache-Aside부터 Refresh-ahead까지
이 글의 핵심
캐시를 붙이는 것보다 어려운 것은 데이터를 바꾸는 모든 경로에서 캐시를 제때 지우는 일입니다. 다섯 가지 캐싱 패턴의 책임 분리와 Cache-Aside의 경쟁 조건, 스탬피드를 막는 싱글 플라이트 잠금의 함정, 영속성과 축출 정책이 캐시와 세션을 한 인스턴스에 섞었을 때 어떤 문제를 만드는지 Node.js 예제로 설명합니다.
Redis는 메모리 기반 키·값 저장소로 세션, 레이트 리밋, 메시지 브로커로도 쓰이지만 가장 흔한 용도는 캐시입니다. 캐시를 붙이는 것 자체는 어렵지 않은데, 어떻게 붙이느냐에 따라 일관성과 지연, 장애가 났을 때의 동작이 크게 달라집니다. 그 선택지가 Cache-Aside, Read-through, Write-through, Write-behind, Refresh-ahead라는 다섯 가지 패턴으로 정리되어 왔습니다. 이 글은 그 흐름을 Node.js(ioredis 기준)로 풀어 봅니다. Redis는 Docker Compose로 Redis처럼 띄워 두었고, DB 쪽은 Node DB 연동과 PostgreSQL vs MySQL 정도를 이미 알고 있다고 가정합니다.
무효화 실패담부터 시작하겠습니다. 상품 가격 캐시에 product:123 같은 키를 쓰면서, 어드민에서 가격을 바꾸는 API에만 del을 넣고 안심하는 경우가 흔합니다. 그런데 재고·프로모션 연동처럼 다른 쓰기 경로가 UPDATE만 하고 캐시를 건드리지 않으면, “모바일 앱에서는 여전히 옛 가격이 보인다”는 문의가 들어옵니다. TTL이 길수록 어떤 사용자는 새 값을, 어떤 사용자는 옛 값을 보는 상태가 오래 이어집니다. 제 경험상 이 문제의 해결은 코드 한 줄이 아니라, 가격과 재고를 바꾸는 모든 쓰기 경로를 검색해 목록을 만들고 그 경로들의 무효화 방식(삭제, 버전 키, 이벤트)을 하나로 통일하는 작업이었습니다. 캐시를 넣는 데는 30분이면 충분하지만 빼는 설계(무효화)에 며칠이 걸리는 이유가 여기에 있습니다.
비유하자면 매번 DB에 가는 것은 창고 끝까지 매번 뛰어가는 것이고, Redis는 작업대 앞에 둔 버퍼에 가깝습니다. 패턴마다 다른 것은 누가 그 버퍼를 채우고 누가 비우느냐입니다.
Redis 내부 동작: 단일 스레드와 epoll
Redis의 명령 처리는 전통적으로 단일 스레드입니다. Redis 6부터 네트워크 읽기·쓰기를 돕는 I/O 스레드가 생겼지만, “사용자 명령은 한 줄로 순서대로 실행된다”는 구조는 같습니다. 그래서 O(N) 명령인 KEYS *나 큰 집합의 정렬, 수백만 원소짜리 키의 DEL이 도는 동안에는 다른 모든 요청이 함께 기다립니다. 운영에서 키 목록이 필요하면 KEYS 대신 커서 기반의 SCAN을, 큰 키 삭제에는 백그라운드로 메모리를 해제하는 UNLINK를 쓰는 이유입니다. 네트워크 쪽은 epoll 같은 I/O 멀티플렉싱으로 한 프로세스가 수많은 연결을 받고, 실제 데이터 구조 변경은 그 단일 경로에서 일어납니다. 이 구조 덕분에 개별 명령은 원자적으로 실행되며, 뒤에서 볼 SET NX 같은 잠금이 성립합니다.
Node.js도 이벤트 루프 하나에서 콜백을 순서대로 처리하므로, “직렬로 처리되는 구간이 여러 층에 있다”고 생각하면 병목을 추적하기 쉽습니다. ioredis로 파이프라인이나 클러스터를 쓸 때는 연결 수, 요청 대기 큐, 타임아웃이 Redis 쪽 지연과 맞물려 p99 지연이 튀지 않는지 함께 봐야 합니다. Redis가 잠깐 응답하지 않으면 ioredis는 명령을 내부 큐에 쌓아 두고 재연결을 시도하다가, 재시도 한도를 넘기면 MaxRetriesPerRequestError: Reached the max retries per request limit으로 실패시킵니다. 이 기본 동작 때문에 Redis 장애가 곧바로 에러가 아니라 요청 지연으로 먼저 나타나므로, 캐시 조회에는 짧은 명령 타임아웃을 따로 두는 편이 좋습니다.
캐싱 패턴 다섯 가지
아래 코드는 ioredis 기준의 최소 스케치입니다. db.query는 pg처럼 { rows }를 돌려주는 드라이버를 가정합니다.
// lib/redis.js
import Redis from 'ioredis';
export const redis = new Redis(process.env.REDIS_URL);
Cache-Aside는 앱이 먼저 캐시를 보고, 없으면 DB에서 읽어 캐시에 넣는 방식입니다. 가장 흔한 패턴이며, 데이터가 바뀔 때 캐시를 지우는 것도 앱의 책임입니다.
async function getUser(id) {
const key = `user:${id}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const { rows } = await db.query('SELECT * FROM users WHERE id = $1', [id]);
const row = rows[0];
if (!row) return null;
await redis.set(key, JSON.stringify(row), 'EX', 300);
return row;
}
EX로 TTL을 걸었으므로 300초가 지나면 키가 사라지고, 무효화를 놓친 경우에도 옛 데이터가 무한히 남지 않습니다. TTL은 무효화의 안전망이지 대체재가 아닙니다. 이 코드는 없는 사용자를 캐시하지 않으므로, 존재하지 않는 id로 반복 요청이 들어오면 매번 DB까지 갑니다(캐시 관통). 악의적인 요청이나 버그로 이런 요청이 몰릴 수 있다면 “없음”을 나타내는 값을 짧은 TTL로 캐시합니다. 반대로 “없음”을 오래 캐시하면 새로 만들어진 사용자가 한동안 보이지 않으므로, 이 TTL은 정상 데이터보다 훨씬 짧게 잡습니다.
Cache-Aside에는 잘 알려진 경쟁 조건도 있습니다. 요청 A가 캐시 미스로 DB에서 옛 값을 읽은 직후, 요청 B가 DB를 갱신하고 캐시를 지웁니다. 그 뒤 A가 들고 있던 옛 값을 캐시에 넣으면, 다음 TTL까지 옛 값이 캐시에 남습니다. 발생 확률은 낮지만 트래픽이 많은 키에서는 실제로 일어납니다. 갱신 후 짧은 지연을 두고 한 번 더 지우는 “지연 이중 삭제”나, 값에 버전을 함께 저장해 더 오래된 버전으로 덮어쓰지 못하게 하는 방법으로 완화합니다. TTL을 너무 길게 잡지 않는 것도 이 창을 줄이는 현실적인 방법입니다.
Read-through는 캐시 계층(또는 래퍼)이 미스일 때 로더를 대신 실행합니다. 앱은 readThrough 같은 API만 알면 됩니다.
async function readThrough(key, ttlSec, loader) {
const hit = await redis.get(key);
if (hit) return JSON.parse(hit);
const value = await loader();
if (value != null)
await redis.set(key, JSON.stringify(value), 'EX', ttlSec);
return value;
}
코드만 보면 Cache-Aside와 같지만, 차이는 어디에 이 로직이 있는가입니다. 캐시 조회·적재·직렬화·TTL 정책이 한 함수에 모이므로, 키 이름 규칙이나 스탬피드 방지를 한 곳에서 바꿀 수 있습니다. Cache-Aside를 여러 서비스 코드에 복사해 쓰다가 TTL과 키 형식이 제각각이 되는 문제를 막아 주는 것이 이 패턴의 실질적인 가치입니다.
Write-through는 쓸 때 DB와 캐시를 함께 갱신합니다. 읽기 경로는 단순해지지만 쓰기마다 두 번 기록하므로 쓰기 지연이 늘어날 수 있습니다.
async function updateUser(id, data) {
await db.query('UPDATE users SET ... WHERE id = $1', [id]);
const { rows } = await db.query('SELECT * FROM users WHERE id = $1', [id]);
await redis.set(`user:${id}`, JSON.stringify(rows[0]), 'EX', 300);
}
Write-through에서 주의할 점은 동시 쓰기의 순서입니다. 두 요청이 거의 동시에 같은 사용자를 갱신하면, DB에는 B의 값이 마지막으로 남았는데 캐시에는 A의 SET이 나중에 도착해 A의 값이 남을 수 있습니다. 또 DB 갱신은 성공했는데 Redis SET이 실패하면 캐시에는 옛 값이 그대로 남습니다. 그래서 실무에서는 쓰기 시 값을 넣는 대신 지우는(다음 읽기가 새로 채우는) 방식이 더 흔합니다. 삭제는 순서가 뒤바뀌어도 결과가 같기 때문입니다.
Write-behind는 캐시에 먼저 쓰고 DB 반영은 나중에 비동기로 합니다. 쓰기 응답은 빨라지지만, DB에 반영되기 전에 Redis가 죽거나 큐가 유실되면 데이터가 사라지고, 반영 순서가 꼬였을 때 복구가 어렵습니다. 조회수나 좋아요 수처럼 잠깐의 유실을 감수할 수 있는 카운터에는 잘 맞지만 결제·재고처럼 정확성이 필요한 데이터에는 맞지 않습니다.
Refresh-ahead는 키가 만료되기 전에 백그라운드에서 미리 갱신해 미스를 줄입니다. 조회가 매우 많은 핫 키에 효과적이지만, 여러 서버가 동시에 같은 키를 갱신하지 않도록 중복 갱신 방지가 필요하고, 거의 읽히지 않는 키까지 미리 갱신하면 DB 부하만 늘어납니다. “남은 TTL이 전체의 20% 이하일 때 읽힌 키만 갱신한다”처럼 실제로 읽히는 키에만 적용하는 조건을 두는 것이 일반적입니다.
패턴별 지연·일관성 비교
패턴별 특성을 감각으로 정리하면 다음과 같습니다. Cache-Aside는 히트일 때 읽기 지연이 낮고, 일관성은 TTL과 명시적 무효화에 기댑니다. Read-through도 읽기 지연은 낮고, 복잡도는 로더 한 곳에 모입니다. Write-through는 읽기가 빠르고 쓰기가 다소 느려지며, 일관성은 상대적으로 낫지만 동시 쓰기 순서 문제가 남습니다. Write-behind는 쓰기가 가장 빠를 수 있지만 일관성과 내구성을 보장하기 어렵습니다. Refresh-ahead는 히트율이 가장 높을 수 있지만 갱신 시점 설계가 전부입니다. 표로 요약할 수도 있지만, 실제 운영에서 문제가 되는 것은 대부분 표에 없는 경계 상황(동시 쓰기, 부분 실패, 배포 직후의 빈 캐시)이라는 점을 기억해 두는 편이 낫습니다.
TTL, 무효화, 캐시 스탬피드
키에 TTL이 있으면 오래된 데이터가 자연스럽게 사라집니다. 짧으면 DB 부하가 늘고, 길면 불일치 기간이 길어집니다. 여러 키를 같은 TTL로 한꺼번에 채우면 같은 순간에 한꺼번에 만료되어 DB로 요청이 몰리므로, TTL에 몇십 초 정도의 무작위 값(지터)을 더하는 것이 좋습니다. Cache-Aside와 궁합이 좋은 무효화 방식은 쓰기 시 삭제입니다.
async function updateUser(id, data) {
await db.query('UPDATE users SET ... WHERE id = $1', [id]);
await redis.del(`user:${id}`);
}
순서도 중요합니다. 캐시를 먼저 지우고 DB를 갱신하면, 그 사이에 들어온 읽기 요청이 옛 DB 값을 다시 캐시에 채워 넣을 수 있으므로 DB 갱신 후 삭제가 기본입니다. DB가 트랜잭션 안이라면 커밋이 끝난 뒤에 지워야 합니다. 커밋 전에 지우면 다른 요청이 커밋되지 않은 이전 값을 읽어 다시 캐시할 수 있습니다.
조회가 많은 키가 만료되는 순간 수많은 요청이 동시에 DB로 몰리는 것을 캐시 스탬피드라고 합니다. 만료 직전에 확률적으로 미리 갱신하는 방법(확률적 조기 만료)이나, 잠금 키로 한 요청만 DB에 가게 하는 싱글 플라이트로 막습니다.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function getWithSingleFlight(key, ttl, loader) {
const lockKey = `lock:${key}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const got = await redis.set(lockKey, '1', 'EX', 10, 'NX');
if (!got) {
await sleep(50);
return getWithSingleFlight(key, ttl, loader);
}
try {
const value = await loader();
await redis.set(key, JSON.stringify(value), 'EX', ttl);
return value;
} finally {
await redis.del(lockKey);
}
}
SET ... NX EX는 “키가 없을 때만 만들고 동시에 만료 시간을 건다”를 한 명령으로 원자적으로 처리합니다. SETNX와 EXPIRE를 따로 호출하던 옛 방식은 두 명령 사이에 프로세스가 죽으면 만료 없는 잠금이 영원히 남는 문제가 있었는데, 한 명령으로 합치면 잠금을 잡은 프로세스가 죽어도 10초 뒤에는 풀립니다.
이 스케치에는 실무에서 보완해야 할 점이 두 가지 있습니다. 첫째, loader()가 잠금 만료 시간(10초)보다 오래 걸리면 잠금이 먼저 풀려 다른 요청이 새 잠금을 잡고, 그 뒤 원래 요청의 finally가 남의 잠금을 지워 버립니다. 잠금 값에 요청마다 고유한 토큰을 넣고, 토큰이 같을 때만 지우는 Lua 스크립트로 해제해야 안전합니다. 둘째, 기다리는 쪽의 재귀 호출에 횟수 제한이 없어서 로더가 계속 실패하면 요청이 끝없이 대기합니다. 최대 대기 시간을 넘기면 DB를 직접 조회하거나 오래된 값을 반환하는 탈출구를 두어야 합니다. 같은 프로세스 안의 중복 요청은 Redis까지 가지 않고 진행 중인 Promise를 Map에 보관해 공유하는 것만으로도 상당 부분 줄일 수 있습니다.
RDB·AOF와 메모리 축출 정책
순수 캐시 용도라면 영속성을 끄고, 재시작 후 DB에서 다시 채우는 선택도 충분히 합리적입니다. 다만 같은 Redis에 세션·큐·카운터가 섞여 있다면 RDB 스냅샷과 AOF(appendfsync everysec가 흔한 타협)를 함께 켜는 팀이 많습니다. RDB 저장과 AOF rewrite는 fork()로 자식 프로세스를 만들어 수행하는데, 쓰기가 많은 동안에는 copy-on-write 때문에 메모리 사용량이 순간적으로 크게 늘 수 있습니다. 메모리 여유 없이 maxmemory를 물리 메모리에 가깝게 잡아 두면 이 시점에 OOM이 나거나 스왑으로 지연이 튀므로, 영속성을 켠 인스턴스는 여유 메모리를 넉넉히 남겨 둬야 합니다.
maxmemory에 도달했을 때의 동작은 축출 정책이 정합니다. volatile-lru와 volatile-lfu는 TTL이 붙은 키만 희생시키고, allkeys-lru와 allkeys-lfu는 모든 키가 대상입니다. 순수 캐시라면 allkeys-*가 흔한 선택입니다. noeviction은 한계에 닿으면 쓰기를 OOM command not allowed when used memory > 'maxmemory' 오류로 거부하므로 캐시에는 잘 맞지 않습니다. 반대로 캐시와 원천 데이터(세션 등)를 한 인스턴스에 섞은 상태에서 allkeys-lru를 쓰면, 메모리가 부족할 때 세션이 캐시와 똑같이 지워집니다. 용도가 다른 데이터는 인스턴스를 나누는 것이 가장 확실합니다. 또 큰 값 몇 개가 메모리를 차지하면 LRU가 의도와 다른 작은 키들을 먼저 내보낼 수 있으므로, 값 크기의 상한도 함께 관리해야 합니다.
프로덕션 운영 팁
연결 수는 워커 수(PM2 클러스터, 컨테이너 개수)와 곱해진다는 점을 염두에 두고 정합니다. 타임아웃 후 재시도는 멱등한 읽기에만 적용하는 것이 안전합니다. MGET과 파이프라인은 왕복 횟수를 줄여 주지만, 한 번에 보내는 양이 너무 크면 Redis의 단일 처리 구간을 오래 붙잡아 다른 요청까지 느려집니다. 키 이름은 서비스:엔티티:id 같은 형식으로 통일하고, 캐시에 담는 객체의 구조가 바뀌면 user:v2: 같은 버전 접두어를 붙여 배포 중에 옛 코드와 새 코드가 서로의 캐시를 잘못 읽지 않게 합니다.
Redis가 느려지거나 죽었을 때의 동작도 미리 정해야 합니다. 캐시는 성능을 위한 계층이므로, Redis 장애가 곧 서비스 장애가 되지 않도록 짧은 타임아웃과 서킷 브레이커로 DB 직통이나 기능 축소로 넘어가게 합니다. 다만 평소 캐시가 흡수하던 트래픽이 한꺼번에 DB로 가면 DB까지 무너질 수 있으므로, DB 직통 경로에도 동시 요청 수 제한을 두는 편이 안전합니다. 배포나 재시작 직후 캐시가 비어 있을 때도 같은 일이 생기므로, 핵심 키를 미리 채우는 워밍이나 앞의 싱글 플라이트를 함께 씁니다.
수평 확장된 Node 서버에서 프로세스 메모리 캐시만 쓰면 서버마다 다른 값을 보게 됩니다. 그래서 Redis를 중앙 캐시로 두고, 조회가 특히 많은 값만 각 프로세스의 로컬(L1) 캐시에 짧게 두면서 Redis Pub/Sub으로 무효화 신호를 보내는 이중 캐시 구조도 씁니다. Pub/Sub은 구독자가 잠깐 끊겨 있던 동안의 메시지를 받지 못하므로, 정확성이 중요한 경로는 짧은 TTL이나 버전 키로 한 번 더 보호해야 합니다. TLS, ACL, 방화벽·VPC 내부 배치는 기본으로 갖춥니다. 인증 없이 인터넷에 노출된 Redis는 짧은 시간 안에 스캔되어 악용되는 사례가 오래전부터 반복되어 왔습니다.
장애 시 확인할 것
간헐적으로 옛 데이터가 보인다면 무효화 경로 누락부터 의심하고, 데이터를 바꾸는 모든 코드 경로에 삭제나 버전 갱신이 있는지 추적합니다. 메모리가 계속 늘어난다면 큰 객체, TTL 없이 쌓이는 키, maxmemory-policy 설정을 확인합니다(redis-cli --bigkeys와 MEMORY USAGE key가 도움이 됩니다). 미스가 예상보다 잦다면 volatile-* 정책인데 TTL이 없는 키와 있는 키가 섞여 있지 않은지 봅니다. 특정 키만 느리다면 핫 키나 O(N) 명령을 의심하고 SLOWLOG GET으로 느린 명령을 찾습니다. DB 부하가 주기적으로 치솟는다면 같은 TTL로 채워진 키들의 동시 만료(스탬피드)를, 타임아웃이 연쇄적으로 난다면 Redis 지연과 연결 수, 클라이언트의 요청 큐 적체를 확인합니다.
마무리
대부분의 팀은 Cache-Aside로 시작해서, 필요할 때 Read-through로 로직을 모으고 핫 키에만 Refresh-ahead를 더하는 순서로 발전시킵니다. TTL과 명시적 무효화는 둘 중 하나가 아니라 함께 써야 하는 장치입니다. epoll 기반 단일 스레드 구조, RDB·AOF, 축출 정책은 어떤 패턴을 고르든 지연·내구성·메모리 동작을 결정하므로 따로 이해해 두어야 합니다. 무엇보다 먼저 이 Redis 인스턴스에 지워져도 되는 캐시만 둘지, 원천 데이터도 섞을지를 정하세요. 그 결정에 따라 영속성과 축출 정책이 달라집니다. 함께 읽을 글로는 Node 성능 가이드, Redis 가이드, C++ 캐싱 전략이 있습니다.
같이 보면 좋은 글
- C++ 애플리케이션 캐싱
- Redis Caching Patterns in Node.js: Cache-Aside, Write-Through, Refresh-Ahead and Stampedes
- Linux 시리즈 #09 — 디스크·블록 계층: 저널·복구·할당·I/O 스케줄러