- 책: 시스템 설계 포켓 가이드: 기초 — 확장 가능한 시스템의 핵심 구성 요소
- 저의 다른 작품: Go로 사고하기 (2권 시리즈) — Go 프로그래밍 완전 가이드 + Go에서의 헥사곤 아키텍처
- 저의 프로젝트: 헬머스 IDE |GitHub — 개발자들이 Claude Code와 다른 AI 코딩 도구와 함께 출시하는 데 사용되는 IDE
- 나: xgabriel.com | GitHub
사용자의 요청과 그들이 요청하는 행 사이에 네 개의 캐시 레이어가 있습니다. 대부분의 팀은 두 개를 사용합니다(보통 데이터베이스 앞에 Redis를 사용하고 정적 자산에는 CDN을 사용하며) 나머지 두 개는 다른 사람의 문제로 처리합니다.
그렇게 Redis 계층이 CDN이 해야 할 일을 하고, 데이터베이스가 침묵 속에서 그의 계획 캐시를 품질 충격 흡수체로 사용하고, 핫 키가 만료될 때마다 무리수를 벌게 됩니다. 각 계층은 다른 질문에 답합니다. 잘못 선택하면 선택하지 않은 계층을 치르게 됩니다.
캐싱이 계층적으로 선택되는 이유
패턴이 중요합니다. 왜냐하면 질문들이 쌓인다는 이유 때문입니다.
- CDN은 "원본을 전혀 피할 수 있나요?"라고 답합니다.
- 애플리케이션 캐시는 "데이터베이스 라운드 트립을 피할 수 있나요?"라고 답합니다.
- 데이터베이스 캐시는 "결과를 다시 계산하지 않을 수 있나요?"라고 답합니다.
- 쿼리 캐시는 "문장을 분석하고 계획하지 않을 수 있나요?"라고 답합니다.
각 "예"는 아래 층을 단락시킵니다. 각 "아니오"는 요청을 아래로 전달합니다. 모든 네 층을 만나는 요청은 아무것도 만나지 않는 요청과 비슷해야 하며, 비용과 지연 시간 외에는 차이가 없어야 합니다.
오류는 이를 단일 레이어로 축소하는 것입니다. 팀들은 Redis에 모든 것을 밀어 넣습니다. Redis는 좋은 애플리케이션 캐시입니다. 그것은 나쁜 CDN, 더 나쁜 구현된 뷰, 그리고 준비된 문장 계획자를 전혀 돕지 못합니다.
레이어 1: CDN 엣지 캐시
CDN은 사용자에게 가장 가깝게 위치해 있습니다. 요청을 처리하는 최저비용 지점입니다. 요청이 본 서버에 도달하지 않기 때문입니다. 2026년의 기술은 CDN이 .jpg보다 더 많은 파일을 캐싱하는 것입니다. API 응답도, 당신이 그들에게 방법을 알려주면 캐싱합니다.
공개 제품 목록 엔드포인트용 Cloudflare 캐시 규칙은 다음과 같습니다.
// 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;
},
};
Thes-maxage는 공유 캐시 지시어입니다. 이는 브라우저의 동작과는 별도로 CDN이 응답을 얼마 동안 유지할지 알려줍니다. 두 개의 Cache-Control 지시어는 CDN에게 60초를 주면서 브라우저에게는 다른 것을 알리게 해줍니다 (주로 max-age=0, must-revalidate).
여기에 무엇이 들어가는지: 공개적인 GET 요청, 익명의 응답, 수천 명의 사용자에게 동일하게 보이는 항목들. 제품 목록, 마케팅 페이지, 공개적인 프로필 페이지, 개인화 없는 검색 결과, OG 이미지 엔드포인트, sitemap.xml. 사용자별로 개인화되는 모든 것이 이곳에서는 잘못된 것이 아니라면, 사용자 ID에 캐시 키를 적용해야 합니다.
여기에 들어가지 않는 것:Set-Cookie에 포함되어 있는 응답, 인증된 모든 것, 쓰기 부작용이 있는 모든 것. 만약 당신의 CDN이 /api/*에 대한 히트율이 5%를 넘는다면, 이미 대부분의 팀보다 더 잘 하고 있다.
레이어 2: 애플리케이션 캐시 (Redis)
애플리케이션 캐시는 "캐시"라고 할 때 사람들이 의미하는 것입니다. 이는 당신의 서비스 프로세스에 있거나 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 부하의 20%를 차지하는 쿼리를 선택하고 먼저 캐싱하세요.
무엇이 아님: 강력한 일관성이 필요한 모든 것 (앞으로 사용할 예정인 은행 잔액). 더 자주 쓰이고 읽히지 않는 모든 것. 구식 읽기가 50ms 지연보다 나은 모든 것.
레이어 3: 데이터베이스 캐시 (물리화된 뷰)
물리화된 뷰는 대부분의 팀이 존재한다고 생각하지 않는 계층입니다. 그들은 데이터베이스 내부에 있으며, 비싼 쿼리의 결과를 미리 계산합니다. 데이터베이스는 결과를 테이블처럼 저장합니다. 읽기는 7개의 조인과 윈도우 함수 대신 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 락을 걸어 읽기를 차단합니다. 그것이 있으면 새로 고침은 그림자 사본에 쓰고 원자적으로 교체합니다. 교체 중 디스크 비용이 약간 더 들며 대시보드 차단을 멈춥니다.
여기에 무엇이 들어갑니다: 4개 이상의 테이블 간의 집계, 기초 데이터가 쿼리보다 더 느리게 변경되는 모든 사항. 일별 요약, 랭킹 보드, 검색 기능, 분석가가 CTE를 작성할 만한 모든 사항.
여기에 들어가지 않음: 실시간으로 결과가 필요한 경우. 재현된 시점에서 물리화된 뷰는 항상 최신이 아닙니다. 사용자가 자신의 작업이 즉시 반영되기를 기대할 경우 이 레이어가 답이 아닙니다.
여기서의 핵심은 조용하다: 물리화된 뷰의 나이. 팀이 하나를 배포하고 대시보드 지연 시간 목표를 달성하면, 계속해서 다른 일을 한다. 여섯 달 후에 기반이 되는 테이블의 크기는 세 배로 커지고, 새로고침이 12분 동안 실행되며, 뷰는 더 자주 구식이고 최신이 아니다. 쿼리 계획과 같이 새로고침 지속 시간을 검토하라.
레이어 4: 쿼리 캐시(준비된 문장 계획 캐시)
가장 깊은 레이어는 또한 가장 보이지 않습니다. 매번 드라이버가 SQL 문을 보낼 때마다 데이터베이스는 그것을 분석하고, 계획하고, 실행해야 합니다. 첫 두 단계는 준비된 문을 사용하면 캐시될 수 있습니다.
대부분의 ORMs는 이를 잘 하지 못합니다. 그들은 각 쿼리마다 새로운 문 문자열을 보내기 때문입니다 (WHERE id = 1 대비 WHERE id = 2), 캐시를 무효화합니다. 해결책은 매개변수 바인딩입니다:
# bad - new statement every call, plan cache miss every time
def get_order_bad(order_id: int):
return db.execute(f"SELECT * FROM orders WHERE id = {order_id}")
# good - same statement text, only parameters change, plan reused
def get_order_good(order_id: int):
return db.execute(
"SELECT * FROM orders WHERE id = $1",
order_id,
)
Postgres에서 캐싱되고 있는 것을 볼 수 있습니다:
-- requires pg_stat_statements extension
SELECT
query,
calls,
mean_exec_time,
rows
FROM pg_stat_statements
WHERE query LIKE 'SELECT % FROM orders WHERE id = $1'
ORDER BY calls DESC
LIMIT 10;
$1와 다른 문자열 값으로 여러 번 나타나는 동일한 논리적 쿼리를 보는 대신, 당신의 ORM은 계획 캐시를 무시하고 있습니다. ORM 설정을 수정하세요 (Eloquent의 DB::statement 대신)DB::select와 바인딩, GORM의 Raw 대비 Where 등을 조정하기 전에 다른 모든 것을 조정하세요.
여기에 들어가는 것: 뜨거운 OLTP 쿼리. ID로 조회, 주문에 삽입, 고객 최종 조회 업데이트. 쿼리당 이득은 작습니다 (1밀리초, 아마도 두 번). 초당 50,000개의 쿼리에서의 이득은 두 개의 데이터베이스 인스턴스와 십 개의 차이를 만듭니다.
변수적인 모양의 쿼리는 작동하지 않습니다. WHERE 절이 사용자 입력에 따라 구조가 변경되면 계획을 재사용할 수 없으며, 그것은 괜찮습니다.
결정 매트릭스
"이 데이터는 어떤 레이어에 속하는가"에 대한 단축키:
| 데이터 모양 | CDN | 앱 캐시 | 가상 뷰 | 계획 캐시 |
|---|---|---|---|---|
| 정적 자산, 익명 | 예 | 아니요 | 아니요 | 아니요 |
| 공개 GET, 인증 없음 | 예 | 모두가 가능 | 아니요 | 아니요 |
| 개인 프로필, 인기 | 아니요 | 예 | 아니요 | 예 |
| 시간별 분석 롤업 | 아니오 | 아마도 (TTL) | 예 | 아니오 |
| OLTP ID 조회 | 아니오 | 예 | 아니오 | 예 |
| 강력한 일관성 있는 읽기 | 아니오 | 아니오 | 아니오 | 예 |
| 실시간 쓰기 피드백 | 아니요 | 아니요 | 아니요 | 예 |
"아마도" 행은 디자인 리뷰에서 팀이 논쟁하는 곳입니다. 솔직한 답은 "먼저 측정하세요"입니다. 만약 시간당 200번 맞는다면, materialised view는 그 가치를 얻습니다. 만약 하루에 3번만 맞는다면, 30분 TTL을 가진 Redis 캐시가 좋고, materialised view는 과도한 설계입니다.
레이어별 무효화 전략
각 레이어는 다른 무효화 이야기를 원합니다.
CDN. TTL은 대부분의 팀에게 유일한 현실적인 옵션이다. 특정 URL을 API를 통해 정리할 수 있지만, 높은 요청율에서는 정리가 몇 초 걸려 확산되고 정확성에 의존할 수 없다. 짧은 TTL을 사용하라 (뜨거운 엔드포인트용 60초, 카탈로그 데이터용 5분) 그리고 최신성을 받아들이라. 긴-TTL 자산에 대해서는 URL을 버전화하라 (/static/app.a3f7b2.js) 새로운 버전이 새로운 키가 되도록, 무효화가 아니다.
앱 캐시. 이벤트 기반 무효화. 행을 변경하는 모든 쓰기 경로는 해당 행의 모든 캐시된 파생 항목에 대해 r.delete(key)을 호출합니다. 이 작동 방식은 users에 쓰기를 하는 12곳이 있을 때까지 작동합니다. 13번째를 추가할 때 무효화를 기억하지 못하는 사람이 추가하면 중앙 집중화해야 합니다: 모든 쓰기는 무효화를 발동하는 저장소의 post-commit hook의 일부로 저장소를 통해 이동합니다.
구현된 뷰. 예약된 새로고침. 허용 가능한 오래된 상태(10분? 1시간?)를 결정하고 cron을 설정하세요. 오래된 상태 창에서 낮은 지연 시간을 위해 이벤트 기반 REFRESH ... CONCURRENTLY 트리거를 층으로 겹쳐야 하지만 주의하세요: 새로고침 자체가 매우 비용이 많이 드는 작업이므로 분당 100번 발동되는 것은 원하지 않으려고 합니다.
캐시 계획. 그것을 무효화하지 마세요. 데이터베이스가 관리합니다. 당신의 일은 플래너에게 동일하게 보이는 쿼리를 작성하는 것입니다.
함정: 여러 레이어를 가로질러 발생하는 대량 추격
레이어드 설계를 파괴하는 버그: 뜨거운 캐시 항목이 여러 레이어를 동시에 만료될 때, 모든 동시 요청이 모든 레이어를 동시에 놓치고, 그들은 모두 원본을 함께 대량 추격합니다.
실제 버전: CDN 캐시를 위한/api/homepage은 12:00:00에 만료됩니다. 천 개의 동시 요청이 CDN을 놓치고 앱을 만나고 앱 캐시를 놓치는 것(또한 TTL이 60초이고 11:59:00에 설정되었기 때문에 12:00:00에 만료됨)을 하고 데이터베이스를 만나고 모두 동일한 물리화된 뷰 재건성을 트리거하고 데이터베이스가 쓰러집니다.
이를 방지하는 두 가지 패턴이 있습니다. 첫 번째는 singleflight입니다: N개의 동일한 동시 요청을 하나로 합치고 결과를 브로드캐스트합니다.
import (
"context"
"encoding/json"
"github.com/redis/go-redis/v9"
"golang.org/x/sync/singleflight"
)
var sf singleflight.Group
func GetHomepage(ctx context.Context, rdb *redis.Client) ([]byte, error) {
key := "homepage:v3"
if cached, err := rdb.Get(ctx, key).Bytes(); err == nil {
return cached, nil
}
// singleflight: only the first concurrent miss recomputes;
// every other caller waits on the same future
result, err, _ := sf.Do(key, func() (interface{}, error) {
fresh, err := buildHomepageFromDB(ctx)
if err != nil {
return nil, err
}
payload, _ := json.Marshal(fresh)
// SETEX with a small jitter so 1000 keys don't expire on the same second
rdb.SetEx(ctx, key, payload, ttlWithJitter(60))
return payload, nil
})
if err != nil {
return nil, err
}
return result.([]byte), nil
}
두 번째는 잠금 + 서비스 상태가 만료된 것입니다. 만료된 값을 캐시에 TTL 이후에도 유지하고, 재건을 위해 요청이 하나 가는 동안에 반환합니다. 가상 패턴:
def get_with_stale(key: str, fetch_fn, fresh_ttl=60, stale_ttl=600):
payload = r.get(key)
meta = r.get(f"{key}:meta")
if payload and meta and meta == "fresh":
return json.loads(payload)
# try to grab the rebuild lock; if we get it, rebuild
lock_key = f"{key}:lock"
got_lock = r.set(lock_key, "1", nx=True, ex=10)
if got_lock:
try:
fresh = fetch_fn()
r.setex(key, stale_ttl, json.dumps(fresh))
r.setex(f"{key}:meta", fresh_ttl, "fresh")
return fresh
finally:
r.delete(lock_key)
# we didn't get the lock - serve stale if we have it
if payload:
return json.loads(payload)
# no stale, no lock - wait briefly and retry the read
time.sleep(0.05)
payload = r.get(key)
return json.loads(payload) if payload else fetch_fn()
이제 천 개의 동시 요청이 한 번의 재건을 트리거합니다. 다른 999 개는 약간 구식된 값을 (허용 가능) 받거나 50ms를 기다렸다가 새로 작성된 것을 가져옵니다. 데이터베이스는 천 개의 쿼리 대신 하나의 추가 쿼리를 볼 수 있습니다.
모든 레이어에 이 패턴을 적용하세요. CDN은 자연적으로 이를 수행합니다.stale-while-revalidate 지시사항. 애플리케이션 캐시는 네트워크를 연결해야 합니다. 재활용된 뷰는 설계적으로 병목 현상이 발생합니다. 계획 캐시에는 이 문제가 없습니다.
월요일에 할 일
시스템을 통해 요청을 처리하고 각 데이터가 어느 레이어에 살아야 하는지 표시하세요. 대부분의 팀은 하나의 레이어가 세 가지의 작업을 하고 있거나, 같은 것을 캐싱하는 세 개의 레이어가 다른 TTL로 동의하지 않는 경우를 발견합니다.
가장 높은 미스 대비 비용 비율을 가진 레이어를 선택하세요. 추가하고, 측정하세요. 반복하세요. 모든 레이어를 사용하는 것이 목표는 아닙니다. 각 데이터 조각을 그 특정 질문에 답하는 레이어에 배치한 다음, 그 중 하나라도 군중을 이끌지 않도록 확인하세요.
현재 시스템에서 가장 많은 작업을 하는 레이어는 무엇이며, 조용히 부재한 레이어는 무엇인가요?
이것이 유용했다면
4층 캐시 스택은 시스템 설계 면접에서 물어볼 만한 종류의 것이며, 운영팀이 배포해 준 것에 감사할 만한 종류의 것입니다. 시스템 설계 포켓 가이드: 기초는 "성능 원리" 장에서 캐싱을 다루며, 부하 분산, 큐잉, 복제 패턴과 함께 설계가 규모에 따라 얼마나 잘 작동하는지를 결정하는 주제를 다룹니다. 이 포스트가 도움이 되었다면, 책은 250+ 페이지의 구축 요소에서 동일한 목소리로 이어집니다.













