API 캐시 전략 실전 — 응답 속도와 데이터 신선도를 함께 잡는 설계
Redis 붙이면 끝이라고 생각했는데, 캐시 무효화가 진짜 지옥이었다.
우리 팀이 음성 채팅 앱의 백엔드에서 경험한 캐시 전략 실전기를 공유한다.
문제: 캐시가 구원이라고 생각했다
초기 설계:
"모든 API 응답을 1시간 캐시하면 DB 부하가 90% 줄어들겠지?"
결과:
사용자 A가 설정을 바꿈 → 데이터 업데이트
사용자 B가 같은 설정을 읽음 → 1시간 전 캐시된 데이터를 받음
혼돈, 버그 리포트, 야식 디버깅
캐시는 쌍날 검이었다.
캐시 전략 1: Cache-Aside (Most Common)
구조:
클라이언트
↓
API 서버
├→ Redis (캐시)에 데이터가 있나?
│ YES: 반환
│ NO: DB에서 읽고, Redis에 저장, 반환
↓
Redis / Database
구현:
async function getUserProfile(userId: string) {
// 1. 캐시 확인
const cached = await redis.get(`user:${userId}:profile`);
if (cached) {
return JSON.parse(cached);
}
// 2. 캐시 미스: DB에서 읽기
const user = await db.users.findOne(userId);
// 3. 캐시에 저장 (TTL: 1시간)
await redis.setex(
`user:${userId}:profile`,
3600, // seconds
JSON.stringify(user)
);
return user;
}
장점:
- 구현이 간단
- 캐시 미스 시에만 DB 접근
단점:
- 캐시 무효화가 복잡
- 데이터 업데이트 후 오래된 캐시를 읽을 수 있음
캐시 전략 2: Write-Through (데이터 일관성 중요)
구조:
쓰기 요청
↓
Redis에 쓰기
↓
DB에 쓰기 (순차 또는 병렬)
↓
응답
구현:
async function updateUserProfile(userId: string, data: any) {
// 1. 캐시에 업데이트
const cacheKey = `user:${userId}:profile`;
await redis.setex(cacheKey, 3600, JSON.stringify(data));
// 2. DB에 업데이트
await db.users.update(userId, data);
return data;
}
장점:
- 캐시와 DB 항상 일치
- 읽기가 항상 최신 데이터
단점:
- 쓰기가 느림 (캐시 + DB 둘 다)
- 캐시 쓰기 실패 시 불일치
캐시 전략 3: SWR (Stale-While-Revalidate)
최신 데이터가 반드시 필요하지 않을 때:
async function getMessages(conversationId: string) {
const cached = await redis.get(`messages:${conversationId}`);
if (cached) {
// 1. 즉시 반환 (오래된 데이터라도)
const data = JSON.parse(cached);
// 2. 백그라운드에서 업데이트 (사용자 체감 없음)
redis.setex(
`messages:${conversationId}:updating`,
1,
'1' // 업데이트 중 표시
).then(async () => {
const fresh = await db.messages.findMany(conversationId);
await redis.setex(
`messages:${conversationId}`,
300, // 5분 TTL
JSON.stringify(fresh)
);
});
return data; // 사용자는 바로 받음
}
// 캐시 미스: 기존 cache-aside
const data = await db.messages.findMany(conversationId);
await redis.setex(
`messages:${conversationId}`,
300,
JSON.stringify(data)
);
return data;
}
장점:
- 사용자 응답 시간 최고
- 백그라운드 업데이트로 항상 신선함
단점:
- 복잡도 증가
- 오래된 데이터를 먼저 받을 수 있음 (메시지 같은 경우 OK, 금액 같은 경우 NO)
실전: 캐시 무효화 패턴
문제: 데이터가 업데이트되면 캐시를 지워야 한다.
패턴 1: 명시적 무효화
async function updateUserName(userId: string, newName: string) {
// 1. DB 업데이트
await db.users.update(userId, { name: newName });
// 2. 캐시 무효화 (명시적)
await redis.del(`user:${userId}:profile`);
await redis.del(`user:${userId}:settings`);
// ...
}
문제: 연관된 모든 캐시 키를 기억해야 한다.
해결:
// 패턴: 네임스페이스 관리
async function invalidateUserCache(userId: string) {
// 와일드카드로 모든 user 캐시 삭제
const keys = await redis.keys(`user:${userId}:*`);
if (keys.length) {
await redis.del(...keys);
}
}
패턴 2: TTL에 의존
// ✅ TTL에 따라 자동 무효화
await redis.setex(
`messages:${conversationId}`,
300, // 5분 후 자동 삭제
JSON.stringify(messages)
);
// 실시간 업데이트가 아니어도 괜찮은 데이터에 사용
패턴 3: 버전 기반
// 캐시 키에 버전 포함
const version = user.updatedAt.getTime();
const cacheKey = `user:${userId}:profile:v${version}`;
await redis.setex(cacheKey, 86400, JSON.stringify(user));
// 데이터 업데이트 시 새 버전의 키가 생김
// 이전 캐시는 자연스럽게 오래됨
HTTP 캐시 헤더
또 다른 계층의 캐싱: 브라우저
// Express.js 예시
app.get('/api/user/:id', (req, res) => {
const user = getUser(req.params.id);
res.set('Cache-Control', 'public, max-age=3600'); // 1시간
res.set('ETag', `"${user.id}-${user.updatedAt}"`);
res.json(user);
});
효과:
- 캐시 헤더가 있으면 브라우저가 캐싱 (서버 요청 X)
- ETag가 있으면 업데이트 확인 (If-None-Match)
Redis 구성: 성능과 안정성
우리 구성:
// Redis 클러스터
- 마스터: 1개 (쓰기)
- 복제본: 2개 (읽기)
- 센티널: 3개 (고가용성)
// 메모리 정책
maxmemory-policy: allkeys-lru
maxmemory: 16gb // 가용 메모리의 70%
// 지속성
save 900 1 // 900초마다 최소 1개 키 변경 시 저장
appendonly yes // AOF (Append-Only File) 사용
캐시 워밍업 (Cache Warming)
앱 시작 시 자주 사용되는 데이터를 미리 캐싱:
// App startup
async function warmUpCache() {
// 1. 인기 있는 대화 미리 로드
const popularConversations = await db.conversations
.find({ messageCount: { $gt: 100 } })
.limit(1000);
for (const conv of popularConversations) {
const messages = await db.messages.findMany(conv.id);
await redis.setex(
`messages:${conv.id}`,
3600,
JSON.stringify(messages)
);
}
console.log(`Warmed up cache for ${popularConversations.length} conversations`);
}
// main.ts
app.listen(3000, async () => {
await warmUpCache();
console.log('Server started');
});
모니터링
// 캐시 히트율 추적
let cacheHits = 0;
let cacheMisses = 0;
async function getWithMetrics(key: string) {
const cached = await redis.get(key);
if (cached) {
cacheHits++;
return JSON.parse(cached);
}
cacheMisses++;
// ...
}
// 1분마다 로그
setInterval(() => {
const hitRate = cacheHits / (cacheHits + cacheMisses);
console.log(`Cache hit rate: ${(hitRate * 100).toFixed(2)}%`);
}, 60000);
결론: 캐시 전략 선택
| 전략 | 속도 | 데이터 일관성 | 사용처 |
|---|---|---|---|
| Cache-Aside | 빠름 | 느슨함 | 읽기 위주 (게시글) |
| Write-Through | 느림 | 높음 | 일관성 중요 (계좌) |
| SWR | 매우 빠름 | 중간 | 실시간 선호 (메시지) |
우리 음성 채팅 앱:
- 사용자 프로필: Cache-Aside (1시간)
- 메시지 리스트: SWR (5분)
- 설정: Write-Through (즉시)
캐시는 은탄환이 아니다. 전략을 잘 선택하면 성능과 데이터 일관성을 둘 다 얻을 수 있다.