3 단 캐시
3 단 캐시
캐시는 한 곳에만 있는 일이 드뭅니다. 보통 여러 층으로 쌓입니다. 클라이언트 가까이에 두면 빠르고 DB 가까이에 두면 정확합니다.
1. L1 — 인메모리 캐시
가장 짧은 응답 시간을 노리는 층입니다. 같은 프로세스 안의 LRU 맵일 수도 있고, 별도 프로세스인 Redis · Memcached 일 수도 있습니다.
| 도구 | 출자 |
|---|---|
| Memcached | 2003, Brad Fitzpatrick (LiveJournal) |
| Redis | 2009, Salvatore Sanfilippo |
| Hazelcast / Infinispan | JVM 분산 인메모리 |
| Caffeine | JVM 라이브러리 (Ben Manes) |
특징은 휘발성입니다. 재시작·노드 교체 시 데이터가 사라진다는 가정이 기본입니다 (Redis 의 RDB·AOF 로 일부 보존 가능).
2. L2 — 영속 캐시
캐시지만 사라지면 곤란한 자리입니다. PostgreSQL 의 캐시 테이블, 마테리얼라이즈드 뷰, 또는 S3 같은 객체 저장소가 이 자리에 섭니다.
대표 패턴.
- 외부 API 응답을 PostgreSQL 테이블에 적재하고 TTL 컬럼으로 만료 관리.
- 비싼 집계 쿼리 결과를 마테리얼라이즈드 뷰로 굳히고 주기적으로
REFRESH. - 정적 자산 (이미지·PDF) 을 객체 저장소에 두고 CDN 으로 노출.
L1 이 죽었을 때 L2 가 폴백 역할을 합니다. L1 비어있음 → L2 조회 → 결과를 L1 에 채움(cache-aside) 흐름이 흔합니다.
3. L3 — 프레임워크 캐시
웹 프레임워크가 라우트·fetch 단위로 제공하는 캐시입니다.
- Next.js
unstable_cache·fetch의next: { revalidate }— 데이터 페칭 결과를 라우트 라이프사이클에 맞춰 캐싱. - Next.js Full Route Cache — 정적/동적 라우트의 렌더 결과 보관 (버전·구성에 따라 동작이 변합니다).
- HTTP 캐시 헤더 —
Cache-Control·ETag·Last-Modified가 클라이언트·중간 프록시·CDN 에 의해 해석됩니다. - CDN 캐시 — Cloudflare · Fastly · Akamai 가 엣지에서 응답을 보관.
이 층의 특징은 "코드를 거의 바꾸지 않고도 작동" 한다는 점입니다. 동시에 디버깅이 어렵다는 점이 한계입니다 — 어떤 응답이 어디 캐시에 있고 언제 비워지는지 파악이 흩어집니다.
4. Cache-aside (Lazy loading)
가장 흔한 패턴입니다.
read:
v = cache.get(k)
if v is null:
v = db.query(k)
cache.set(k, v, ttl)
return v
write:
db.update(k, v)
cache.delete(k) # 또는 set(k, v)
장점은 단순함, 한계는 캐시 미스 시점의 사이즈드 부하·일관성 빈틈입니다.
5. Write-through · Write-behind · Refresh-ahead
Write-through 는 쓰기 시 캐시와 DB 를 모두 갱신합니다.
write:
cache.set(k, v)
db.update(k, v)
쓰기 일관성이 좋아지지만 자주 갱신되는 키가 있다면 캐시도 같이 부하를 받습니다.
Write-behind 는 쓰기를 일단 캐시에만 기록하고 비동기 워커가 DB 에 반영합니다. 처리량이 큰 자리에서 쓰이지만 캐시 손실 시 데이터 손실 위험이 있습니다.
Refresh-ahead 는 TTL 만료가 임박한 항목을 백그라운드로 미리 갱신합니다. 사용자 응답에서 캐시 미스를 줄이는 효과입니다. JVM 진영의 Caffeine 등이 직접 지원합니다.
6. TTL 과 stale-while-revalidate
TTL 은 "이 값이 얼마나 늦어도 괜찮은가" 의 약속입니다. 너무 짧으면 캐시 의미가 약해지고, 너무 길면 사용자에게 낡은 결과가 갑니다. 결정의 기준은 데이터의 변경 주기와 사용자 허용도입니다.
stale-while-revalidate 패턴은 만료된 값이라도 일단 돌려주고 백그라운드에서 갱신을 끌고 옵니다. HTTP 의 Cache-Control: stale-while-revalidate=... 가 표준화돼 있습니다.
7. Cache stampede
자주 호출되는 키의 TTL 이 동시에 만료되면 여러 요청이 한꺼번에 원본을 조회하며 부하가 튑니다. 완화책은 다음 중 한두 가지를 묶는 형태입니다.
- TTL 에 약간의 무작위 jitter 를 더합니다.
- 단일 요청만 원본을 갱신하도록 분산 락(Redis
SET NX) 을 둡니다. - early refresh — TTL 만료 전에 갱신을 시작합니다.
- request coalescing — 같은 키에 대한 동시 요청을 하나로 합칩니다.
8. 키 네이밍 관례
<service>:<entity>:<id>— 예:users:profile:42.- 버전을 prefix 에 넣어 통째로 무효화:
v3:users:profile:42. - 환경 prefix:
prod:·staging:.
키가 길어질수록 메모리 오버헤드가 커진다는 점은 Redis 같은 인메모리 저장소에서 고려할 만합니다.
9. 직렬화와 모니터링
- JSON — 사람에게 읽기 좋습니다. 직렬화 비용이 큽니다.
- MessagePack · CBOR — 바이너리, 더 작습니다.
- Protobuf · Avro — 스키마 기반, 다언어.
캐시 응답을 빈번히 직렬화/역직렬화한다면 형식 선택이 응답 시간에 보입니다.
모니터링 항목:
- 적중률 (hit rate) — 너무 낮으면 키 설계나 TTL 의 문제.
- 메모리 사용량 · 키 카운트 · eviction 횟수.
- 원본 (DB·외부 API) 호출 횟수 — 캐시 도입 효과 측정.
10. 자주 걸리는 자리
부분 무효화 — 한 사용자 정보를 10 곳에서 캐싱하면 비우기가 어렵습니다. 키 컨벤션·태그가 처음부터 필요합니다.
null 캐싱 — "결과 없음" 도 캐시할지 결정해야 합니다. 안 하면 매 요청이 DB 까지 가고, 하면 새로 생긴 항목이 보이지 않을 수 있습니다.
TTL 무한 — 영속 데이터가 캐시에 남아 새 코드와 충돌합니다. 명시적 만료 또는 버전 prefix 가 안전합니다.
쓰기 후 즉시 읽기 — 클라이언트가 자기 변경을 보지 못합니다. write-through 또는 클라이언트 측 read-your-writes 처리가 필요합니다.
L3 의 빌드 시점 캐시 — Next.js 의 정적 캐시는 빌드 시점 또는 revalidate 주기에 묶입니다. CMS 의 즉시 반영 요구와 어긋날 수 있습니다.
하고픈 말
캐시는 들이는 만큼 무효화가 어려워집니다. "이 캐시 누가 만들고 누가 비우는지" 가 명확한 자리부터 시작하는 게 안전합니다. Phil Karlton 의 농담 ("There are only two hard things in Computer Science: cache invalidation and naming things") 이 괜히 회자되는 게 아닙니다.
Next
- redis-roles
- data-pipeline
HTTP Caching (MDN) · RFC 5861 — stale-while-revalidate · Redis 캐시 패턴 · Next.js Caching · Caffeine GitHub 를 참고합니다.