5단계
3-layer 캐시 전략
25 분
3-layer 캐시 전략
한 층이 빠르면 다음 층 부하가 줄어듭니다. edge → Redis → PG 세 층이 일반적.
1. 전체 구조
[Browser] ← Cache-Control / ETag
↓
[CDN / Caddy] ← edge cache
↓
[앱] ← unstable_cache · Redis
↓
[PG] ← pg-cache · query cache
각 층의 TTL 을 다르게 설정.
2. Edge (Caddy · CDN)
example.com {
reverse_proxy localhost:3000
header /images/* Cache-Control "public, max-age=31536000, immutable"
header /api/* Cache-Control "no-store"
}
- 정적 자산 — 1 년 (파일명에 hash)
- API — no-store
3. 브라우저 — HTTP 헤더
return NextResponse.json(data, {
headers: {
"Cache-Control": "public, max-age=60, stale-while-revalidate=120",
},
});
stale-while-revalidate — 만료돼도 백그라운드 갱신 동안 기존 값 반환. 체감 빠름.
4. 앱 레이어 — Next.js unstable_cache
import { unstable_cache } from "next/cache";
export const getTopPosts = unstable_cache(
async () => db.query("SELECT * FROM posts WHERE published=true ORDER BY likes DESC LIMIT 20"),
["top-posts"],
{ tags: ["posts"], revalidate: 60 }
);
// mutation 후 무효화
import { revalidateTag } from "next/cache";
revalidateTag("posts");
5. 앱 레이어 — Redis
async function cachedQuery<T>(key: string, ttl: number, fetcher: () => Promise<T>): Promise<T> {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const fresh = await fetcher();
await redis.setex(key, ttl, JSON.stringify(fresh));
return fresh;
}
unstable_cache 와 Redis 의 차이:
- unstable_cache — 프로세스 인스턴스별 메모리
- Redis — 전체 클러스터 공유
여러 앱 인스턴스가 있으면 Redis 가 더 효율적 (같은 키 한 번만 계산).
6. DB 레이어 — pg-cache / query_cache_size
PostgreSQL 은 자체 쿼리 결과 캐시가 없습니다 (MySQL 과 차이). shared_buffers · effective_cache_size 로 간접 튜닝.
# postgresql.conf
shared_buffers = 25% of RAM
effective_cache_size = 75% of RAM
대신 materialized view 로 비싼 집계 미리 계산:
CREATE MATERIALIZED VIEW mv_daily_stats AS
SELECT date_trunc('day', created_at) AS day, count(*) FROM events GROUP BY 1;
REFRESH MATERIALIZED VIEW mv_daily_stats; -- 주기적 (cron)
7. 무효화 전략
| 전략 | 장점 | 단점 |
|---|---|---|
| TTL | 간단 | 사용자가 변경 즉시 반영 안 됨 |
| mutation 시 DEL | 즉시 반영 | 무효화 누락 시 stale |
| stale-while-revalidate | UX 좋음 | 일시적 stale 허용 필요 |
| webhook (revalidateTag) | 정확 | 구현 복잡 |
각 데이터 특성에 맞게. 사용자 프로필 = mutation 시 DEL, 랭킹 = 60초 TTL.
8. 키 네이밍
<domain>:<entity>:<id>[:<variant>]
user:profile:123
post:detail:456:ko
search:results:react:page1
prefix 가 명확하면 디버깅 편리 + DEL user:profile:* 패턴으로 일괄 무효화.
9. stampede — 캐시 만료 시 동시 재계산
인기 캐시 만료 순간 수백 요청이 동시에 DB 호출.
// single-flight pattern
const pending = new Map<string, Promise<any>>();
async function getCached(key: string, fetcher: () => Promise<any>) {
if (pending.has(key)) return pending.get(key);
const p = fetcher().finally(() => pending.delete(key));
pending.set(key, p);
return p;
}
10. 자주 걸리는 자리
- 모든 경로 TTL 같음 — 변하지 않는 것 vs 자주 변하는 것 구분 필요
- 캐시 키 변수 누락 — 언어 · 페이지 · user_id 안 포함해서 cross-user leak
- unstable_cache 에 user-specific 데이터 — 서버 공통 캐시라 사용자 섞임
- Redis OOM — maxmemory-policy 미설정
하고픈 말
3 층 캐시가 늘 필요한 건 아닙니다. 트래픽이 작으면 unstable_cache 만 · 중간이면 + Redis · 대규모에서 + CDN 순차 도입이 자연스러움.
Next
- 06-kafka-when