도입
캐시는 단순히 “빨리 꺼내 쓰는 임시 저장소” 정도로 설명되곤 하지만, 실제로는 시스템 성능과 운영 안정성에 직접 영향을 주는 설계 요소입니다.
데이터베이스를 매번 직접 조회하면 지연이 커지고, 트래픽이 몰릴 때 원본 저장소가 병목이 되기 쉽습니다. 이때 캐시는 같은 요청을 더 빠르게 처리하고, 원본 시스템이 감당해야 할 읽기 부하를 줄이며, 사용자 체감 속도까지 개선합니다. 다만 그 대가로 신선도, 일관성, 무효화, 메모리 한도 같은 새로운 문제가 따라옵니다.
필요성
캐시는 보통 읽기 성능을 개선하기 위해 도입합니다. 자주 조회되는 데이터를 메모리나 더 가까운 위치에 복사해 두면, 같은 요청을 훨씬 더 싸고 빠르게 처리할 수 있기 때문입니다.
하지만 캐시는 단순한 가속기가 아닙니다. 캐시가 도입되면 데이터 흐름이 “애플리케이션 → 원본 저장소”라는 단순 경로에서 “애플리케이션 → 캐시 → 원본 저장소” 구조로 바뀌고, 그 순간부터 성능 외에도 캐시 적중률, 만료 정책, 삭제 시점, 정합성, 메모리 압박을 함께 관리해야 합니다.
- 같은 데이터가 반복해서 읽히는 읽기 중심 워크로드
- 원본 조회나 계산 비용이 큰 API / 페이지 / 집계 결과
- 트래픽 스파이크를 원본 DB가 직접 받으면 위험한 구조
- 사용자와 가까운 곳에서 응답을 재사용할 수 있는 정적/반정적 콘텐츠
- 짧은 시간 동안 같은 결과를 여러 번 재사용해도 큰 문제가 없는 데이터
정의
캐시는 보통 애플리케이션과 원본 데이터 저장소 사이에 위치합니다. 요청이 들어오면 먼저 캐시에 데이터가 있는지 확인하고, 있으면 즉시 반환하며, 없으면 원본 저장소에서 가져와 캐시에 넣은 뒤 반환합니다.
이때 중요한 용어가 두 가지 있습니다. cache hit는 캐시에 원하는 데이터가 있어 바로 반환된 경우이고, cache miss는 캐시에 없어 원본 저장소까지 내려가야 하는 경우입니다. 결국 캐시 성능은 “얼마나 빨리 처리하느냐”만큼이나 “얼마나 자주 hit를 만들 수 있느냐”에 달려 있습니다.
핵심 원리
캐싱은 결국 반복을 이용하는 최적화입니다. 같은 데이터를 많은 사용자가 반복해서 보고, 같은 API가 짧은 시간 안에 여러 번 호출되고, 같은 계산 결과가 쉽게 변하지 않는다면, 그 결과를 매번 다시 만들 필요가 없습니다.
다만 이 최적화는 전제 조건이 있습니다. 읽기 재사용성이 있어야 하고, 캐시된 값이 잠시 오래되어도 서비스가 감당 가능해야 하며, 캐시를 두는 비용이 원본을 직접 치는 비용보다 낮아야 합니다. 그래서 캐시는 모든 데이터에 만능으로 적용하는 장치가 아니라, 적합한 데이터에만 효과적인 선택적 가속기입니다.
기본 구조
| 종류 | 위치 | 장점 | 주의점 |
|---|---|---|---|
| 로컬(in-process) 캐시 | 애플리케이션 프로세스 내부 메모리 | 가장 빠르고 네트워크 홉이 없음 | 인스턴스마다 값이 달라질 수 있어 정합성 관리가 어려움 |
| 분산 캐시 | 별도 캐시 서버 또는 클러스터 | 여러 인스턴스가 같은 캐시를 공유 가능 | 네트워크 비용이 있고 장애 지점이 하나 더 생김 |
| HTTP / CDN 캐시 | 브라우저, 프록시, 엣지 | 사용자와 가까운 위치에서 응답 재사용 가능 | 헤더 정책, purge, stale 응답 관리가 중요함 |
기본 흐름
1) 요청이 들어온다
2) 캐시에서 키를 조회한다
3) 있으면 hit → 즉시 반환
4) 없으면 miss → 원본 저장소 조회
5) 결과를 캐시에 저장
6) 다음 요청부터는 hit를 기대
패턴 1. Cache-Aside
가장 널리 쓰이는 전략은 Cache-Aside입니다. 흔히 lazy loading이라고도 부릅니다. 이 패턴에서는 애플리케이션이 캐시와 원본 저장소를 모두 알고 있고, 먼저 캐시를 조회한 뒤 miss일 때만 원본 저장소에서 읽어 와 캐시에 채웁니다.
장점은 단순하고 효과가 빠르다는 점입니다. 실제로 요청된 데이터만 캐시에 올라가므로 공간을 낭비하지 않고, 서비스 코드에 점진적으로 도입하기 쉽습니다. 단점은 초기 miss 때는 캐시와 DB를 모두 거쳐야 하므로 첫 응답 비용이 더 크고, 쓰기 이후 무효화를 직접 설계해야 한다는 점입니다.
String key = "product:v1:" + productId;
Product cached = cache.get(key);
if (cached != null) {
return cached; // cache hit
}
Product product = repository.findById(productId);
if (product != null) {
cache.set(key, product, 300); // TTL 300초
}
return product;
패턴 2. Read-Through / Write-Through / Write-Behind
| 전략 | 읽기 / 쓰기 방식 | 장점 | 단점 |
|---|---|---|---|
| Read-Through | 캐시가 miss 시 원본을 대신 읽어 채움 | 애플리케이션 코드가 단순해짐 | 캐시 계층이 더 많은 책임을 가짐 |
| Write-Through | 원본 저장소 업데이트 직후 캐시도 동기식으로 갱신 | 캐시 최신성 유지에 유리함 | 자주 안 읽는 데이터까지 캐시에 쌓일 수 있음 |
| Write-Behind | 먼저 캐시에 쓰고, 원본 반영은 비동기식으로 지연 | 쓰기 성능이 좋고 원본 저장소 부하를 줄이기 쉬움 | 장애 시 유실 / 지연 반영 리스크가 커짐 |
실무에서는 한 가지 전략만 고집하기보다 조합하는 경우가 많습니다. 예를 들어 읽기는 Cache-Aside로 처리하고, 일부 핵심 엔터티만 Write-Through로 동기화하는 방식이 흔합니다.
핵심은 성능만 볼지, 정합성까지 같이 볼지, 그리고 캐시가 애플리케이션 로직 바깥에서 자동으로 동작할지 여부를 먼저 정하는 것입니다.
패턴 3. TTL, Eviction, Invalidation
| 개념 | 언제 일어나나 | 핵심 목적 | 대표 예시 |
|---|---|---|---|
| TTL / Expiration | 정해 둔 시간이 지나면 | 오래된 데이터가 영원히 남지 않도록 하기 | 5초, 5분, 1시간 만료 |
| Eviction | 메모리 한도나 정책 조건을 넘으면 | 메모리 압박 해소 | LRU, LFU, TTL-shortest 우선 제거 |
| Invalidation / Purge | 데이터가 바뀌었을 때 명시적으로 | 오래된 복사본 즉시 제거 또는 교체 | 키 삭제, 태그 purge, 버전 키 변경 |
TTL은 시간 기반 만료이고, eviction은 메모리 압박 시 정책에 따른 제거이며, invalidation은 데이터 변경에 맞춘 명시적 삭제입니다. 이 셋을 섞어 생각하면 “왜 분명 TTL이 남아 있는데 키가 없어졌지?”, “왜 만료 안 됐는데 새 값이 안 보이지?” 같은 혼란이 생깁니다.
- LRU : 최근에 덜 쓰인 키 제거
- LFU : 자주 쓰이지 않은 키 제거
- short TTL : 빠르게 변하는 데이터는 짧게 유지
- versioned key :
product:v2:123처럼 버전으로 새 키 사용 - purge : CDN/HTTP 캐시는 URL, 태그, 호스트 기준으로 제거
계층형 캐시
캐시는 보통 한 겹으로 끝나지 않습니다. 사용자 브라우저의 HTTP 캐시가 먼저 응답을 재사용하고, 그다음 CDN이 엣지에서 응답을 제공하고, 애플리케이션은 로컬 메모리나 Redis 같은 분산 캐시를 보고, 마지막에야 원본 DB까지 내려가는 구조가 흔합니다.
이 구조의 장점은 각 계층이 서로 다른 비용을 줄인다는 점입니다. 브라우저와 CDN은 네트워크와 원본 서버 부하를 줄이고, 애플리케이션 캐시는 DB 읽기를 줄입니다. 다만 계층이 많아질수록 어느 층의 오래된 값이 보이는지 추적하는 것은 더 어려워집니다.
예시
브라우저 캐시
→ CDN / 프록시 캐시
→ 애플리케이션 로컬 캐시
→ 분산 캐시(Redis 등)
→ 원본 DB / 원본 API
한계와 대가
캐시의 가장 큰 단점은 오래된 값입니다. 원본은 이미 갱신되었는데 캐시가 아직 살아 있다면, 사용자는 이전 값을 보게 됩니다. Cache-Aside에서는 외부 프로세스가 원본을 바꾼 경우 캐시가 자동으로 따라가지 못할 수도 있습니다.
또 다른 문제는 cache stampede입니다. 인기 키가 만료되는 순간 많은 요청이 동시에 miss를 내고 한꺼번에 원본 저장소를 때리면, 캐시가 오히려 장애를 유발할 수 있습니다. 이 경우에는 request coalescing, lock, early refresh, stale-while-revalidate, prewarm 같은 보호 전략이 필요합니다.
마지막으로, 모든 데이터가 캐시에 적합한 것도 아닙니다. 매우 민감한 데이터, 즉시 최신성이 중요한 데이터, 거의 재사용되지 않는 데이터는 캐싱 이득보다 위험이나 비용이 더 클 수 있습니다.
- stale data로 인한 최신성 문제
- local cache 간 값 불일치
- cold start 시 hit rate 급락
- 키 폭증과 메모리 비용 증가
- stampede / thundering herd
- 잘못 설계된 키로 인한 데이터 누수 또는 오염
자주 하는 실수
- 캐시를 원본처럼 취급해서 stale data를 과소평가함
- TTL을 두지 않음 또는 TTL을 감으로만 잡음
- eviction과 expiration을 같은 개념으로 이해함
- 사용자별·권한별 데이터를 같은 키로 캐시해서 섞어 버림
- 쓰기 후 invalidation을 빼먹어 오래된 값을 오래 노출함
- 모든 데이터를 캐시하려고 하다 메모리만 낭비함
- local cache를 여러 인스턴스에 쓰면서 값 불일치를 무시함
- 인기 키 만료 시 stampede를 전혀 대비하지 않음
실무 루틴
- 반복 조회가 많고 원본 비용이 큰 데이터부터 찾는다.
- 그 데이터의 source of truth가 어디인지 분명히 한다.
- 로컬 캐시, 분산 캐시, HTTP/CDN 캐시 중 어디에 둘지 정한다.
- Cache-Aside인지 Write-Through인지 전략을 고른다.
- TTL, invalidation, eviction policy를 함께 정한다.
- cache key에 버전 / 사용자 / locale / tenant 같은 구분자가 필요한지 확인한다.
- hit rate, latency, DB load, evictions, stale incidents를 모니터링한다.
디버깅
자주 보는 지표
hit rate = hits / (hits + misses)
miss rate = misses / (hits + misses)
함께 봐야 할 것
- p95 / p99 latency
- DB read QPS
- cache evictions
- expired keys
- stampede 발생 키
- stale data 관련 오류 수
요약
- ✅ 캐시는 더 빠른 계층에 복사본을 둬 같은 요청을 더 빠르게 처리하는 가속 장치다.
- ✅ cache hit / miss를 기준으로 성능과 부하가 갈린다.
- ✅ Cache-Aside는 가장 흔한 기본 전략이다.
- ✅ Read-Through, Write-Through, Write-Behind는 읽기/쓰기 책임을 어디에 둘지에 대한 설계 선택이다.
- ✅ TTL, eviction, invalidation은 모두 다르며 반드시 구분해서 설계해야 한다.
- ✅ 캐시는 브라우저, CDN, 애플리케이션 메모리, 분산 캐시처럼 여러 층으로 쌓일 수 있다.
- ✅ stale data, stampede, key design, invalidation 누락이 가장 흔한 운영 리스크다.
- ✅ 좋은 캐시는 “빠른 캐시”가 아니라 “빠르면서도 운영 가능한 캐시”다.