Redis 캐시 + DB 이중 저장
서비스가 성장하면서 DB 조회가 병목이 되는 시점이 온다. 게시글 목록, 트렌드 랭킹, 최근 글 같은 데이터는 매 요청마다 DB에서 읽어올 필요가 없다. 하지만 그렇다고 Redis에만 저장하면 서버가 재시작되거나 메모리가 날아갈 때 데이터가 사라진다.
이 딜레마를 해결하는 패턴이 Redis 캐시 + DB 이중 저장이다. 빠르게 읽어야 하는 데이터는 Redis에, 영속적으로 보관해야 하는 데이터는 DB에 저장한다. 읽기는 Redis에서, 쓰기는 양쪽 모두에 하는 구조다.
왜 캐시가 필요한가
관계형 DB는 디스크 기반이다. 인덱스가 있어도 조인이 들어가면 느려지고, 동시 접속이 늘어나면 커넥션 풀이 바닥난다. 반면 Redis는 메모리 기반이라 읽기 지연이 밀리초 이하다.
[클라이언트] → [서버] → [Redis] (hit) → 바로 응답
→ [DB] (miss) → 응답 + Redis에 캐시
전형적인 Cache-Aside 패턴이다. 하지만 모든 데이터에 이 패턴을 적용하는 게 맞는 건 아니다. 읽기가 빈번하고, 약간의 지연(stale)이 허용되는 데이터에 적합하다.
캐시 전략 분류
캐시 전략은 크게 네 가지로 나뉜다. 각각 장단점이 다르고, 데이터 특성에 따라 선택해야 한다.
Cache-Aside (Lazy Loading)
가장 널리 쓰이는 패턴이다. 애플리케이션이 캐시를 직접 관리한다.
[클라이언트] → [서버] → [Redis] (hit) → 바로 응답
→ [DB] (miss) → 응답 + Redis에 캐시
장점: 필요한 데이터만 캐시되므로 메모리 효율이 좋다. 캐시가 죽어도 DB에서 읽으면 되니 장애에 강하다.
단점: 첫 요청은 항상 느리다(cold start). 캐시와 DB 사이에 불일치가 생길 수 있다.
Write-Through
쓰기 시점에 캐시와 DB를 동시에 업데이트한다.
async function getUser(userId: string) {
// 1. 캐시에서 먼저 조회
const cached = await redis.get(`user:${userId}`);
if (cached) {
return JSON.parse(cached);
}
// 2. 캐시 미스 → DB 조회
const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
// 3. 결과를 캐시에 저장 (TTL 설정)
await redis.setex(`user:${userId}`, 3600, JSON.stringify(user));
return user;
}
장점: 캐시가 항상 최신 상태다. 읽기 시 캐시 미스가 적다.
단점: 쓰기 지연이 늘어난다 (DB + Redis 두 번 쓰기). 읽히지 않는 데이터도 캐시에 올라갈 수 있다.
Write-Behind (Write-Back)
캐시에만 먼저 쓰고, DB 반영은 비동기로 나중에 한다.
async function updateUser(userId: string, data: object) {
// DB와 캐시를 동시에 갱신
await db.query('UPDATE users SET ? WHERE id = ?', [data, userId]);
await redis.setex(`user:${userId}`, 3600, JSON.stringify(data));
}
장점: 쓰기가 매우 빠르다. DB 부하를 줄일 수 있다.
단점: Redis가 죽으면 아직 DB에 반영되지 않은 데이터가 유실된다. 구현 복잡도가 높다.
Read-Through
Cache-Aside와 비슷하지만, 캐시 라이브러리가 알아서 DB를 조회해준다. 애플리케이션은 캐시만 바라본다.
async function incrementViewCount(feedId: number) {
// Redis에만 즉시 반영
await redis.zincrby('feed:trend', 1, feedId.toString());
// DB 반영은 스케줄러가 주기적으로 처리
}
// 별도 스케줄러
async function syncTrendToDB() {
const trends = await redis.zrevrange('feed:trend', 0, -1, 'WITHSCORES');
await db.query('UPDATE feed_stats SET score = ? WHERE feed_id = ?', ...);
}
장점: 애플리케이션 코드가 단순해진다.
단점: 캐시 라이브러리에 대한 의존도가 높아진다.
실전 패턴: 트렌드 랭킹
조회수 기반 인기글 랭킹을 예로 들어보자. 매 요청마다 DB에서 ORDER BY view_count DESC 하면 풀스캔이 일어난다. 이걸 Redis Sorted Set으로 옮기면 O(log N)에 랭킹을 가져올 수 있다.
// 캐시 라이브러리가 loader를 통해 자동으로 DB 조회
const cache = new CacheManager({
loader: async (key: string) => {
return await db.query('SELECT * FROM users WHERE id = ?', [key]);
},
ttl: 3600,
});
// 사용 시 캐시만 호출
const user = await cache.get(`user:${userId}`);
여기서 핵심은 역할 분담이다:
- Redis Set (
feed:{id}:ip): 같은 IP의 중복 조회 방지. 매일 자정에 초기화. - Redis Sorted Set (
feed:trend): 실시간 랭킹 계산. 매일 자정에 초기화. - DB (
view_count컬럼): 누적 조회수 영속 저장. 초기화하지 않음.
Redis에 저장된 데이터는 임시성이고, DB의 데이터가 진짜다. Redis가 날아가면? 오늘의 트렌드는 리셋되지만, 총 조회수는 DB에 남아있다.
실전 패턴: 최근 글 캐시
새로 등록된 글을 Redis에 해시로 저장하고, TTL을 걸어서 자동으로 만료시키는 패턴이다.
// 조회수 증가 시: Redis + DB 동시 업데이트 (Write-Through)
async function incrementView(feedId: number, ip: string) {
const alreadyViewed = await redis.sismember(`feed:${feedId}:ip`, ip);
if (alreadyViewed) return;
await Promise.all([
redis.sadd(`feed:${feedId}:ip`, ip), // IP 중복 방지 (Redis Set)
redis.zincrby('feed:trend', 1, feedId.toString()), // 트렌드 점수 증가 (Redis Sorted Set)
db.query('UPDATE feed SET view_count = view_count + 1 WHERE id = ?', [feedId]), // DB 영속 저장
]);
}
최근 글 목록을 가져올 때는 DB를 거치지 않고 Redis에서 바로 읽는다:
// 새 글 등록 시
async function cacheRecentFeed(feed: Feed) {
const key = `feed:recent:${feed.id}`;
await redis.hmset(key, {
id: feed.id,
title: feed.title,
author: feed.author,
createdAt: feed.createdAt,
tagList: feed.tags.join(','),
});
// 24시간 후 자동 만료
await redis.expire(key, 86400);
}
이 패턴의 장점은 TTL 기반 자동 만료다. 24시간이 지나면 Redis에서 알아서 삭제되므로, "최근 글" 기준을 코드로 관리할 필요가 없다. DB에는 모든 글이 영속 저장되어 있으니, Redis에서 만료된 이후에는 일반 페이지네이션으로 조회하면 된다.
실전 패턴: 트렌드 스냅샷 동기화
30초마다 Redis의 트렌드 데이터를 별도 리스트에 스냅샷하는 패턴이다. 클라이언트가 실시간으로 변하는 Sorted Set을 직접 읽으면 순위가 계속 바뀌어서 UX가 불안정해진다. 그래서 "확정된 트렌드"를 별도 키에 저장하고, 변경이 있을 때만 업데이트한다.
async function getRecentFeeds() {
// 패턴 매칭으로 최근 글 키 전체 조회
const keys = await redis.keys('feed:recent:*');
if (!keys.length) return [];
// Pipeline으로 한 번에 가져오기
const pipeline = redis.pipeline();
for (const key of keys) {
pipeline.hgetall(key);
}
const results = await pipeline.exec();
return results
.map(([err, feed]) => ({
...feed,
tagList: feed.tagList ? feed.tagList.split(',') : [],
isNew: true,
}))
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}
왜 두 개의 키를 쓰는가?
feed:trend(Sorted Set): 조회수 증가가 실시간으로 반영됨. 순위가 수시로 바뀜.feed:origin_trend(List): 30초 간격으로 확정된 스냅샷. 클라이언트는 이 키를 읽음.
이렇게 하면 클라이언트가 보는 랭킹이 30초 단위로만 바뀌어서 안정적이고, 실시간 데이터는 백그라운드에서 계속 축적된다.
캐시 무효화 (Cache Invalidation)
컴퓨터 과학에서 가장 어려운 두 가지가 "이름 짓기"와 "캐시 무효화"라는 유명한 말이 있다. Redis 캐시를 쓸 때 가장 신경 써야 하는 부분이다.
TTL 기반 만료
가장 단순한 전략이다. 일정 시간이 지나면 캐시를 자동으로 삭제한다.
import * as _ from 'lodash';
// 30초마다 실행
async function syncTrendSnapshot() {
const [currentSnapshot, liveRanking] = await Promise.all([
redis.lrange('feed:origin_trend', 0, 3), // 현재 확정 트렌드
redis.zrevrange('feed:trend', 0, 3), // 실시간 랭킹 상위 4개
]);
// 변경이 있을 때만 업데이트
if (!_.isEqual(currentSnapshot, liveRanking)) {
await redis.pipeline()
.del('feed:origin_trend')
.rpush('feed:origin_trend', ...liveRanking)
.exec();
// 변경 알림 (SSE/WebSocket으로 클라이언트에 푸시)
eventEmitter.emit('ranking-update', liveRanking);
}
}
적합한 경우: 데이터가 약간 오래되어도 괜찮을 때. 프로필, 설정 같은 저빈도 변경 데이터.
이벤트 기반 무효화
데이터가 변경될 때 캐시를 명시적으로 삭제한다.
// 1시간 후 자동 만료
await redis.setex('user:profile:123', 3600, JSON.stringify(profile));
적합한 경우: 변경 즉시 반영이 필요할 때. 결제 정보, 권한 같은 민감한 데이터.
스케줄 기반 초기화
주기적으로 캐시를 비우는 방식이다.
async function updateUserProfile(userId: string, data: object) {
await db.query('UPDATE users SET ? WHERE id = ?', [data, userId]);
await redis.del(`user:profile:${userId}`); // 캐시 삭제
}
적합한 경우: 일간/주간 단위로 리셋되는 데이터. 일일 랭킹, 접속 기록.
장애 시나리오와 대응
Redis 다운
Redis가 죽으면 캐시 읽기가 실패한다. 이때 DB로 폴백하는 로직이 필수다.
// 매일 자정에 트렌드 초기화
@Cron('0 0 * * *')
async resetDailyData() {
await redis.del('feed:trend');
// 패턴 매칭으로 IP 추적 데이터 일괄 삭제
const ipKeys = await redis.keys('feed:*:ip');
if (ipKeys.length > 0) {
await redis.del(...ipKeys);
}
}
핵심은 Redis 장애가 서비스 장애로 이어지면 안 된다는 것이다. 캐시는 성능 최적화 수단이지, 필수 인프라가 아니다.
Cache Stampede
캐시가 만료되는 순간 수백 개의 요청이 동시에 DB를 때리는 현상이다. 인기 데이터의 TTL이 동시에 만료되면 발생한다.
async function getFeedWithFallback(feedId: number) {
try {
const cached = await redis.get(`feed:${feedId}`);
if (cached) return JSON.parse(cached);
} catch (error) {
// Redis 장애 → 로그만 남기고 DB로 진행
logger.warn('Redis unavailable, falling back to DB');
}
return await db.query('SELECT * FROM feed WHERE id = ?', [feedId]);
}
데이터 불일치
DB는 업데이트됐는데 Redis 캐시가 옛날 데이터를 들고 있는 상황이다. Write-Through를 쓰면 줄일 수 있지만 완전히 없앨 수는 없다. 네트워크 지연이나 부분 실패 때문이다.
// 해결: 뮤텍스 패턴
async function getWithMutex(key: string, loader: () => Promise<any>) {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
// SETNX로 락 획득 시도 (5초 TTL)
const lockKey = `lock:${key}`;
const acquired = await redis.set(lockKey, '1', 'EX', 5, 'NX');
if (acquired) {
// 락 획득 성공 → DB 조회 후 캐시 갱신
const data = await loader();
await redis.setex(key, 3600, JSON.stringify(data));
await redis.del(lockKey);
return data;
}
// 락 획득 실패 → 잠시 대기 후 재시도
await sleep(100);
return getWithMutex(key, loader);
}
설계 원칙 정리
- Redis는 캐시다, 메인 저장소가 아니다. Redis 데이터가 날아가도 서비스가 돌아가야 한다.
- TTL은 반드시 설정해라. TTL 없는 캐시 키는 메모리 누수의 시작이다.
- 캐시 무효화 전략을 먼저 정해라. 데이터를 캐시하는 건 쉽다. 언제 지울지가 어렵다.
- Pipeline으로 묶어라. Redis 명령을 하나씩 보내면 네트워크 왕복만큼 느려진다.
- 키 네이밍 컨벤션을 정해라.
feed:recent:123,feed:trend,feed:123:ip같은 계층적 네이밍이 관리하기 좋다. - 장애 시 DB 폴백은 필수다. try-catch 한 줄이 서비스 다운을 막는다.
정리
- Cache-Aside가 기본이고, 쓰기 빈도와 일관성 요구에 따라 Write-Through/Write-Behind를 조합한다
- TTL 설정은 필수이고, Cache Stampede는 뮤텍스 패턴으로 방어한다
- Redis는 성능 최적화 수단이지 필수 인프라가 아니므로, 장애 시 DB 폴백 경로를 항상 확보해야 한다
관련 문서
- ioredis - Node.js Redis 클라이언트 연결과 명령어
- Redis Sorted Set 랭킹 - Sorted Set 기반 실시간 랭킹 구현
- Redis Pipeline - 다중 명령어 일괄 전송으로 RTT 절감