2026년 캐시 계층: CDN·앱·DB·쿼리, 어디에 배치할까
출처: Dev.to
도서: System Design Pocket Guide: Fundamentals — Core Building Blocks for Scalable Systems
또한 제가 쓴 책: Thinking in Go (2권 시리즈) — Go 프로그래밍 완전 정복 + Go 헥사고날 아키텍처
내 프로젝트: Hermes IDE | GitHub — Claude Code와 기타 AI 코딩 도구를 활용하는 개발자를 위한 IDE
저: xgabriel.com | GitHub
네 개의 캐시 레이어가 사용자의 요청과 그들이 찾는 행 사이에 존재합니다. 대부분의 팀은 그 중 두 개만 사용합니다(보통 DB 앞에 Redis, 정적 자산을 위한 CDN). 나머지 두 레이어는 남의 문제라고 여기죠.
그 결과 CDN이 해야 할 일을 Redis가 담당하고, 데이터베이스는 조용히 플랜 캐시를 품질 완충재처럼 사용하며, 핫키가 만료될 때마다 스탬프ede가 발생합니다. 각 레이어는 서로 다른 질문에 답합니다. 잘못된 레이어를 선택하면 선택하지 않은 레이어에 대한 비용을 지불하게 됩니다.
질문이 쌓이는 이유
- CDN: “원본 서버를 완전히 건너뛸 수 있을까?”
- 애플리케이션 캐시: “DB 라운드 트립을 피할 수 있을까?”
- 데이터베이스 캐시: “결과를 다시 계산할 필요가 있을까?”
- 쿼리 캐시: “문장을 파싱하고 플랜을 짜는 과정을 건너뛸 수 있을까?”
각 “예”는 아래 레이어들을 단축시킵니다. “아니오”는 요청을 아래로 넘깁니다. 네 레이어를 모두 통과하든 전혀 통과하지 않든, 차이는 비용과 지연 시간뿐이어야 합니다.
흔한 실수
이 네 레이어를 하나로 합쳐버리는 것입니다. 팀은 이미 가지고 있는 Redis에 모든 것을 집어넣습니다. Redis는 훌륭한 애플리케이션 캐시이지만, CDN 역할은 형편없고, 물리화된 뷰도 못하며, 준비된 문장 플래너를 전혀 도와주지 못합니다.
CDN
CDN은 사용자에 가장 가깝습니다. 요청이 서버에 도달하지 않으므로 가장 저렴한 서비스 지점이죠. 2026년의 핵심은 CDN이 .jpg 파일뿐 아니라 API 응답도 캐시한다는 점입니다. 방법을 알려주면 됩니다.
// Cloudflare Worker - cache /api/products/* responses for 60s at edge
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const cache = caches.default;
// only cache GETs for the public catalog
if (request.method !== "GET" ||
!url.pathname.startsWith("/api/products/")) {
return fetch(request);
}
// strip auth-affecting query params from the cache key
url.searchParams.delete("trace_id");
const cacheKey = new Request(url.toString(), request);
let response = await cache.match(cacheKey);
if (response) {
return response; // edge hit, never touches origin
}
response = await fetch(request);
if (response.status === 200) {
const cached = new Response(response.body, response);
cached.headers.set("Cache-Control", "public, max-age=60, s-maxage=60");
cached.headers.set("CDN-Cache-Control", "max-age=60");
ctx.waitUntil(cache.put(cacheKey, cached.clone()));
return cached;
}
return response;
},
};
s-maxage는 공유 캐시 지시자이며, CDN이 응답을 유지할 시간을 브라우저와 별개로 지정합니다. 두 개의 Cache-Control 헤더를 사용해 CDN에는 60초, 브라우저에는 다른 값을 줄 수 있습니다(보통 max-age=0, must-revalidate).
여기에 적합한 경우: 공개 GET, 익명 응답, 수천 명에게 동일하게 보이는 콘텐츠. 제품 목록, 마케팅 페이지, 공개 프로필 페이지, 개인화되지 않은 검색 결과, OG 이미지 엔드포인트, sitemap.xml 등. 사용자별 개인화가 필요한 경우라면 사용자 ID를 캐시 키에 포함해야 합니다.
여기에 부적합한 경우: Set-Cookie가 포함된 응답, 인증이 필요한 응답, 쓰기 부작용이 있는 응답. /api/*에 대한 CDN 히트율이 5% 이상이면 대부분의 팀보다 이미 좋은 편입니다.
애플리케이션 캐시
사람들이 “캐시”라고 하면 보통 이 레이어를 의미합니다. 서비스 프로세스 내부 혹은 그 옆에 있는 Redis에 존재합니다. CDN이 처리하지 못한(인증 필요 혹은 개인화된) 요청을 잡아 DB 라운드 트립 없이 응답합니다.
import json
import redis
from typing import Optional
r = redis.Redis(host="cache.internal", port=6379, decode_responses=True)
def get_user_profile(user_id: str) -> dict:
key = f"user:profile:{user_id}"
# try cache first
cached = r.get(key)
if cached is not None:
return json.loads(cached)
# cache miss - hit the DB
profile = db.fetch_one(
"SELECT id, name, plan, created_at FROM users WHERE id = $1",
user_id,
)
# set with a TTL so stale data times out even if invalidation fails
r.setex(key, 300, json.dumps(profile, default=str))
return profile
def invalidate_user_profile(user_id: str) -> None:
# called from any writer that mutates the user row
r.delete(f"user:profile:{user_id}")
사람들이 흔히 놓치는 두 가지: TTL 안전망과 쓰기‑사이드 무효화. TTL만 있으면 쓰기 후 최대 5분 동안 오래된 데이터를 읽게 되고, 무효화만 있으면 네트워크 오류로 DEL이 떨어졌을 때 영원히 오래된 데이터가 남습니다. 두 가지를 모두 적용해야 합니다. 벨트와 서스펜더, 그리고 세 번째 손까지 준비된 셈이죠.
여기에 적합한 경우: 사용자별 데이터, 세션 상태, 재계산 비용이 큰 집계, 자주 변하지 않지만 매 요청마다 재계산하고 싶지 않은 데이터. 80/20 법칙을 적용해 DB 부하의 80%를 차지하는 20% 쿼리를 먼저 캐시합니다.
여기에 부적합한 경우: 강한 일관성이 요구되는 데이터(예: 바로 사용될 은행 잔액). 읽기보다 쓰기가 더 빈번한 데이터. 오래된 읽기가 50 ms 지연보다 더 큰 문제를 일으키는 경우.
물리화된 뷰 (Materialised Views)
많은 팀이 존재한다는 사실 자체를 잊어버립니다. DB 내부에 위치해 비용이 큰 쿼리 결과를 미리 계산해 두고, 테이블처럼 저장합니다. 읽기는 O(1) 조회가 되며, 복잡한 조인이나 윈도우 함수를 피할 수 있습니다.
CREATE MATERIALIZED VIEW account_revenue_daily AS
SELECT
account_id,
date_trunc('day', created_at)::date AS day,
sum(amount_cents) AS revenue_cents,
count(*) AS txn_count
FROM transactions
WHERE status = 'settled'
GROUP BY account_id, day;
CREATE UNIQUE INDEX ON account_revenue_daily (account_id, day);
-- refresh policy: every 10 minutes via pg_cron
-- CONCURRENTLY needs the unique index above to work
SELECT cron.schedule(
'refresh-account-revenue',
'*/10 * * * *',
$$REFRESH MATERIALIZED VIEW CONCURRENTLY account_revenue_daily$$
);
REFRESH ... CONCURRENTLY는 거의 알려지지 않은 옵션입니다. 이를 사용하지 않으면 ACCESS EXCLUSIVE 락이 걸려 읽기가 차단됩니다. CONCURRENTLY를 쓰면 그림자 복사본에 새 데이터를 쓰고 원자적으로 교체합니다. 교체 시 약간의 디스크 비용이 증가하지만 대시보드가 차단되지 않습니다.
여기에 적합한 경우: 집계, 4개 이상 테이블 조인, 기본 데이터가 쿼리보다 느리게 변하는 경우. 일일 롤업, 리더보드, 검색 파싯 등 분석가가 CTE를 쓰는 상황.
여기에 부적합한 경우: 실시간성이 요구되는 결과. 물리화된 뷰는 리프레시 사이에 오래된 상태이기 때문에, 사용자가 즉시 반영된 결과를 기대한다면 적합하지 않습니다.
주의점: 물리화된 뷰는 시간이 지나면서 노후화됩니다. 팀이 한 번 만들고 대시보드 목표를 달성하면 잊어버리기 쉽습니다. 몇 달 뒤 기본 테이블이 3배 커지고 리프레시가 12분이 걸리면, 뷰는 새로 고침보다 오래된 경우가 더 많아집니다. 쿼리 플랜을 감사하듯이 리프레시 시간을 정기적으로 점검하세요.
가장 깊은 레이어: 쿼리 플래닝 캐시
드라이버가 SQL 문을 보낼 때마다 데이터베이스는 파싱, 플래닝, 실행을 해야 합니다. 첫 두 단계는 준비된 문(prepared statements)을 사용하면 캐시할 수 있습니다.
대부분