느린 상품 조회 API 앞에 캐시를 붙였더니 응답 시간이 800ms에서 20ms로 줄었습니다. 며칠 뒤 가격을 수정했는데 일부 고객에게 이전 가격이 보이고, 인기 상품의 캐시가 만료되는 순간 데이터베이스 부하가 평소의 수십 배로 치솟습니다. 캐시 서버가 잠시 끊기자 모든 요청이 DB로 몰리며 서비스 전체가 멈춥니다.
캐시는 성능을 높이는 가장 강력한 도구 중 하나지만 원본 데이터의 복사본을 하나 더 만드는 일이기도 합니다. 복사본이 있으면 언제 갱신할지, 원본과 다를 때 무엇을 믿을지, 동시에 많은 요청이 같은 값을 만들려 할 때 누가 수행할지를 결정해야 합니다.
“캐시 무효화는 어렵다”는 말은 단순히 키를 삭제하기 어렵다는 뜻이 아닙니다. 데이터 신선도, 장애 허용 범위, 동시성과 비용 사이에서 제품에 맞는 규칙을 정해야 한다는 뜻입니다. 이번 글에서는 Cache-Aside부터 TTL, 일관성, Cache Stampede와 운영 지표까지 차례대로 살펴보겠습니다.
캐시는 원본이 아니라 다시 만들 수 있는 복사본이다
캐시를 설계할 때 가장 먼저 정할 것은 원본(Source of Truth)입니다. 일반적으로 주문과 가격의 원본은 데이터베이스이며 캐시는 원본에서 다시 만들 수 있어야 합니다. 캐시가 사라졌을 때 데이터를 복구할 수 없다면 그것은 캐시가 아니라 저장소입니다.
이 구분은 장애 대응을 결정합니다. 캐시 값이 의심스러우면 삭제하고 원본에서 다시 읽을 수 있어야 하고, 캐시와 DB가 다르면 DB의 확정된 상태를 기준으로 복구합니다. 다만 세션이나 임시 작업처럼 캐시 서버에만 저장하는 데이터가 있다면 손실 허용 여부와 복제·백업 정책을 별도로 설계해야 합니다.
캐시를 도입할 데이터는 읽기 빈도가 높고 계산 또는 원본 조회 비용이 크며, 일정 시간 오래되어도 업무상 허용되는 경우가 적합합니다. 매번 값이 달라지고 강한 일관성이 필요한 잔액 차감 같은 작업은 단순 캐시로 처리하기 어렵습니다.
캐시는 한 계층만 있는 것이 아니다
브라우저와 모바일 앱의 로컬 캐시, CDN, API Gateway, 애플리케이션 프로세스 메모리, 분산 캐시와 데이터베이스 버퍼 캐시가 동시에 존재할 수 있습니다. 한 계층만 삭제하고 “캐시를 비웠다”고 생각하면 다른 계층에서 오래된 응답이 계속 나옵니다.
HTTP의 Cache-Control, ETag, CDN purge, 애플리케이션 키와 ORM 캐시의 관계를 요청 경로별로 그려야 합니다. 각 계층의 소유자, 최대 신선도, 무효화 방법을 문서화하면 장애 때 어디를 확인할지 알 수 있습니다.
개인화 응답이 CDN이나 공용 캐시에 섞이지 않도록 인증 정보와 Vary 기준을 검토해야 합니다. 사용자별 가격이나 권한 결과를 잘못된 키로 캐시하면 성능 문제가 아니라 개인정보와 보안 사고가 됩니다.
Cache-Aside는 단순하지만 경쟁 조건이 있다
가장 흔한 Cache-Aside 패턴에서 애플리케이션은 먼저 캐시를 조회합니다. 값이 있으면 바로 반환하고, 없으면 DB에서 읽어 캐시에 저장한 뒤 반환합니다. 쓰기 시에는 DB를 갱신하고 관련 캐시 키를 제거합니다.

쓰기를 캐시에 먼저 반영한 뒤 DB를 갱신하면 DB 쓰기가 실패했을 때 존재하지 않는 최신값이 노출될 수 있습니다. 일반적으로 DB 커밋 뒤 캐시를 삭제하는 순서가 안전합니다. 그러나 DB 갱신은 성공하고 캐시 삭제 요청만 실패하면 오래된 값이 TTL까지 남습니다.
이를 줄이기 위해 짧고 합리적인 TTL을 안전망으로 두고, DB 트랜잭션의 Outbox나 변경 데이터 캡처를 통해 무효화 이벤트를 재전송할 수 있습니다. 무효화가 누락돼도 영원히 남지 않게 하고, 이벤트는 중복 처리돼도 안전해야 합니다.
삭제와 갱신 중 무엇이 더 안전할까
쓰기 뒤 새 값을 캐시에 직접 갱신하면 다음 읽기가 빠르지만, 캐시 표현이 여러 쿼리와 집계를 포함한다면 관련 키를 모두 정확히 계산해야 합니다. 두 쓰기가 순서가 뒤바뀌어 캐시에 도착하면 오래된 값이 최신값을 덮을 수도 있습니다.
키를 삭제하면 다음 읽기가 최신 DB 값으로 다시 채우므로 구현이 단순하고 순서 역전 위험이 줄어듭니다. 대신 삭제 직후 첫 요청의 지연과 동시에 몰리는 재생성 문제를 처리해야 합니다. 쓰기 빈도와 읽기 빈도, 생성 비용에 따라 선택합니다.
Write-through는 쓰기를 캐시 계층이 DB와 함께 처리해 일관된 경로를 만들지만 캐시 시스템이 쓰기 가용성에 포함됩니다. Write-behind는 캐시에 먼저 쓰고 DB에 비동기 반영해 빠르지만 장애 시 데이터 손실과 순서, 재처리가 훨씬 복잡합니다. 이름보다 업무가 허용하는 손실과 지연을 먼저 판단해야 합니다.
TTL은 기술 숫자가 아니라 데이터의 신선도 예산이다
모든 키에 관성적으로 5분 TTL을 주기보다 “이 데이터가 최대 몇 분 오래되어도 되는가?”를 제품 담당자와 정해야 합니다. 상품 설명은 한 시간 지연돼도 괜찮을 수 있지만 재고와 가격은 수 초의 차이도 주문 결과에 영향을 줄 수 있습니다.
TTL이 너무 짧으면 hit rate가 낮아지고 원본 조회가 늘며, 너무 길면 오래된 데이터의 노출 시간이 커집니다. 변경 시 명시적 무효화를 사용하더라도 이벤트 누락과 운영 실수에 대비한 최대 상한으로 TTL을 두는 편이 좋습니다.
많은 키를 배포 시각에 한꺼번에 만들고 동일 TTL을 주면 모두 같은 순간 만료됩니다. 기본 TTL에 일정 범위의 무작위 jitter를 더해 만료 시점을 분산하면 DB 부하가 한 번에 몰리는 것을 줄일 수 있습니다.
Cache Stampede는 인기 키 하나가 DB를 무너뜨리는 현상이다
초당 5,000번 조회되는 인기 상품 키가 만료되면 거의 동시에 도착한 요청들이 모두 cache miss를 봅니다. 각각 DB 조회와 무거운 계산을 수행하면 캐시가 없을 때보다 더 큰 순간 부하가 발생합니다. 이것이 Cache Stampede 또는 Thundering Herd입니다.
가장 직접적인 해결책은 Single-flight입니다. 같은 키를 재생성하는 작업을 하나만 허용하고 나머지는 그 결과를 기다립니다. 단일 프로세스에서는 promise 공유나 키별 lock을 사용할 수 있고, 여러 인스턴스에서는 짧은 분산 lock을 검토할 수 있습니다.
분산 lock에는 소유권 토큰과 만료 시간이 필요합니다. 작업보다 lock이 먼저 만료되면 두 생성자가 동시에 실행될 수 있고, 오래된 소유자가 다른 프로세스의 lock을 해제해서는 안 됩니다. 정확성이 매우 중요한 쓰기 작업과 달리 캐시 재생성은 중복 실행을 완전히 없애기보다 DB 부하를 제한하는 방향으로 단순하게 설계할 수 있습니다.

Stale-While-Revalidate로 기다림을 줄인다
값에 soft TTL과 hard TTL을 둘 수 있습니다. soft TTL이 지나면 데이터는 오래된 상태지만 잠시 반환할 수 있고, 한 요청이 백그라운드에서 갱신합니다. hard TTL까지 지나면 더 이상 사용할 수 없어 원본 결과를 기다립니다. 이를 Stale-While-Revalidate 방식으로 볼 수 있습니다.
이 전략은 뉴스 목록, 추천, 상품 설명처럼 짧은 오래됨을 허용하는 읽기에 적합합니다. 결제 승인 상태나 접근 권한처럼 오래된 값이 위험한 데이터에는 그대로 적용하면 안 됩니다. 값과 함께 생성 시각이나 버전을 저장하고 업무별 허용 기준을 확인합니다.
만료 직전에 확률적으로 일부 요청이 미리 갱신하는 early refresh도 사용할 수 있습니다. 인기 키일수록 만료 전에 갱신될 가능성이 높아지고 요청이 적은 키에는 불필요한 작업을 줄일 수 있습니다.
존재하지 않는 값도 짧게 캐시한다
공격자나 잘못된 크롤러가 존재하지 않는 ID를 반복 조회하면 매번 DB까지 도달합니다. “없음” 결과를 짧은 TTL로 negative caching하면 원본 부하를 줄일 수 있습니다. 다만 해당 ID가 곧 생성될 수 있다면 너무 긴 TTL 때문에 생성 후에도 없다고 보일 수 있습니다.
오류를 없음으로 캐시해서는 안 됩니다. DB 타임아웃과 실제 404를 구분하지 않으면 일시 장애가 장시간 데이터 없음으로 굳어집니다. 권한 부족 결과도 사용자나 테넌트가 다른 공용 키에 저장되지 않게 주의합니다.
캐시 키는 데이터 모델의 일부다
product:123처럼 단순한 키는 이해하기 쉽지만 언어, 통화, 테넌트, 권한과 응답 버전이 다른 값을 구분하지 못할 수 있습니다. 키에는 결과를 바꾸는 모든 안정적인 입력이 들어가야 합니다.
product:v3:{tenant_id}:{locale}:{currency}:{product_id}
키 버전을 포함하면 직렬화 구조가 바뀐 배포에서 전체 키를 일일이 삭제하지 않고 새 공간으로 전환할 수 있습니다. 이전 키는 TTL에 따라 자연스럽게 사라집니다. 하지만 사용자 입력을 무제한 키에 넣으면 캐시 오염과 메모리 고갈이 생길 수 있으므로 정규화와 길이 제한이 필요합니다.
목록 캐시는 개별 항목 변경이 어떤 목록 키에 영향을 주는지 추적하기 어렵습니다. 태그 기반 무효화, 세대 번호(generation key), 짧은 TTL과 이벤트 조합을 검토할 수 있습니다. 모든 관련 키를 검색하는 wildcard 삭제는 운영 캐시에 큰 부하를 줄 수 있습니다.
Hot Key는 분산 캐시도 한 지점으로 모은다
분산 캐시 클러스터가 여러 노드로 구성돼도 하나의 인기 키는 보통 한 노드에 저장됩니다. 전체 평균 CPU는 낮은데 특정 샤드의 네트워크와 단일 스레드가 포화될 수 있습니다. hit rate가 높다고 해서 시스템이 건강한 것은 아닙니다.
프로세스 내부의 짧은 L1 캐시를 추가해 원격 요청을 줄이거나, 읽기 복제, 키 복제와 요청 병합을 사용할 수 있습니다. L1 캐시는 인스턴스마다 값이 달라질 수 있으므로 매우 짧은 TTL과 허용 가능한 오래됨이 전제되어야 합니다.
큰 값 하나가 네트워크를 압박할 수도 있습니다. 객체 전체를 캐시하기 전에 실제 필요한 필드와 압축 비용, 직렬화 시간을 측정합니다. 캐시 응답이 빠르더라도 역직렬화에 CPU가 많이 들면 애플리케이션 병목이 됩니다.
메모리 상한과 Eviction 정책을 이해한다
캐시 메모리가 가득 차면 LRU나 LFU와 유사한 정책으로 키가 제거될 수 있습니다. TTL이 남아 있어도 eviction될 수 있으므로 “10분 동안 반드시 존재한다”는 보장은 아닙니다. 재생성 비용이 큰 키와 일회성 키가 같은 공간을 경쟁하면 중요한 키가 밀려날 수 있습니다.
캐시 항목 수뿐 아니라 바이트 크기, eviction rate, 메모리 단편화, 네트워크 대역폭을 관찰합니다. 대형 객체와 작은 hot key, 세션 데이터를 서로 다른 인스턴스나 정책으로 격리하면 한 워크로드가 다른 워크로드를 밀어내는 것을 줄일 수 있습니다.
캐시 장애 때 무조건 DB로 우회하면 더 큰 장애가 난다
캐시가 연결되지 않을 때 모든 요청을 DB로 보내는 fail-open은 기능을 유지하는 것처럼 보이지만 평소 캐시가 흡수하던 트래픽이 한 번에 원본으로 몰립니다. DB가 감당하지 못하면 복구가 더 어려운 전체 장애가 됩니다.
원본이 버틸 수 있는 동시성으로 우회를 제한하고, 중요하지 않은 요청은 빠르게 거절하거나 오래된 로컬 값과 기본 응답을 제공합니다. Circuit Breaker로 반복 연결 시도를 줄이고 캐시가 회복될 때 트래픽을 점진적으로 돌립니다.
일부 캐시 노드가 재시작된 뒤 빈 상태로 돌아오면 정상처럼 보여도 대규모 cold miss가 발생합니다. 인기 키를 미리 warming하거나 트래픽을 천천히 늘리고, 스탬피드 방지 장치가 이 상황에서도 작동하는지 테스트해야 합니다.
일관성 요구에 따라 캐시하지 않을 선택도 필요하다
사용자가 방금 수정한 정보를 바로 다시 읽을 때 이전 값이 보이면 신뢰가 떨어집니다. 쓰기 응답에 최신 객체를 포함해 화면을 갱신하거나, 일정 시간 해당 사용자의 읽기를 원본으로 보내는 read-your-writes 전략을 사용할 수 있습니다.
재고 차감과 잔액 같은 명령은 캐시 값을 믿고 확정하지 않고 DB의 조건부 갱신이나 트랜잭션으로 보호합니다. 캐시는 표시와 후보 선택을 빠르게 할 수 있지만 최종 불변식 검사는 원본에서 수행합니다.
강한 일관성이 꼭 필요한 데이터와 몇 초의 지연을 허용할 데이터를 분류하면 모든 키에 복잡한 무효화 프로토콜을 적용하지 않아도 됩니다. 캐시를 사용하지 않는 것이 가장 단순하고 안전한 설계일 때도 있습니다.
Hit Rate 하나만으로 캐시를 평가하지 않는다
hit rate가 95%여도 나머지 5%가 모두 비싼 쿼리이거나 같은 순간 몰리면 장애가 날 수 있습니다. 반대로 hit rate가 낮아도 매우 비싼 일부 결과를 캐시해 전체 비용을 크게 줄일 수 있습니다.
다음 지표를 키 그룹과 서비스별로 함께 봅니다.
- hit, miss, stale hit와 negative hit 비율
- 캐시와 원본의 응답 시간 분포
- miss가 만든 DB QPS와 동시 재생성 수
- 키별 크기, 전체 메모리와 eviction rate
- 무효화 이벤트 지연·실패와 오래된 값 탐지 건수
- hot key별 요청량과 샤드별 CPU·네트워크
- 캐시 장애 시 우회·거절·기본값 응답 비율
배포와 캐시 설정 변경 시각을 대시보드에 표시하고, TTL 조정 전후의 사용자 지연과 DB 부하를 비교합니다. 무효화가 실제로 필요한 모든 키에 도달했는지 샘플 검증도 유용합니다.
반드시 재현해야 할 실패 시나리오
- 인기 키가 높은 트래픽 중 만료된다.
- 수많은 키가 같은 초에 동시에 만료된다.
- DB 쓰기는 성공하지만 캐시 삭제가 실패한다.
- 두 쓰기와 무효화 이벤트가 순서가 바뀌어 도착한다.
- 캐시 노드 전체가 빈 상태로 재시작된다.
- 특정 hot key가 한 샤드에 집중된다.
- 분산 lock 보유자가 갱신 중 종료된다.
- 캐시는 느리게 응답하지만 완전히 실패하지는 않는다.
- 권한과 테넌트가 다른 사용자가 같은 리소스를 조회한다.
- 새 배포가 이전 직렬화 형식의 키를 읽는다.
정상 부하 테스트보다 만료 경계와 부분 장애에서 DB 최대 연결 수, 큐, 복구 시간을 확인하는 것이 중요합니다. 캐시를 끈 상태가 아니라 느리고 불완전한 상태도 시험해야 합니다.
작게 시작하는 캐시 도입 순서
- 느리고 자주 호출되는 읽기를 측정합니다. 추측이 아니라 원본 비용과 사용자 지연이 큰 경로를 선택합니다.
- 원본과 허용 신선도를 정합니다. 값이 몇 초 오래되어도 되는지, 언제 캐시하지 않을지 정의합니다.
- 키 스키마와 크기 상한을 설계합니다. 테넌트, 권한, 지역과 버전을 포함합니다.
- Cache-Aside와 제한된 TTL로 시작합니다. 쓰기 뒤 삭제와 실패 재처리를 구현합니다.
- 스탬피드 방지를 추가합니다. Single-flight, jitter와 stale 응답 중 업무에 맞는 방식을 선택합니다.
- 원본 보호를 준비합니다. 캐시 장애 시 동시성 제한, 기능 저하와 거절 정책을 둡니다.
- hit rate와 원본 부하를 함께 관찰합니다. hot key와 무효화 실패를 지속적으로 정리합니다.
좋은 캐시는 빠른 복사본이 아니라 통제 가능한 복사본이다
캐시의 목적은 모든 데이터를 오래 보관하는 것이 아니라 비싼 반복 작업을 줄이면서 허용 가능한 신선도를 지키는 것입니다. 빠른 정상 경로만 구현하면 만료와 장애 순간에 원본 시스템을 위협할 수 있습니다.
핵심 원칙은 간단합니다. 원본을 명확히 하고, TTL을 신선도 예산으로 정하며, 무효화 실패에 복구 경로를 두고, 인기 키는 한 번만 갱신하며, 캐시 장애 때 원본을 무제한으로 노출하지 마라. 이 원칙을 지키면 캐시는 숨은 위험이 아니라 성능과 복원력을 함께 높이는 계층이 됩니다.