Cache는 자주 쓰이거나 곧 다시 쓰일 가능성이 높은 데이터를 더 빠른 위치에 저장해 접근 시간을 줄이는 기법입니다. CPU cache, browser cache, CDN cache, database cache처럼 여러 계층에서 사용됩니다.
핵심은 cache hit가 나면 원본까지 가지 않아 빠르고, cache miss가 나면 원본에서 가져온 뒤 cache에 저장한다는 점입니다. 대신 오래된 값을 보여주는 stale data, invalidation, eviction policy 같은 문제가 생깁니다.
꼬리질문
조금 더 깊게 물어본다면
답변 뒤에 이어질 수 있는 질문들을 하나씩 열어볼 수 있어요.
Cache hit와 miss란?
Cache hit는 요청한 데이터가 cache에 있어 바로 응답하는 경우입니다. Cache miss는 cache에 없어 원본 저장소에서 가져와야 하는 경우입니다.
Cache miss가 발생하면 원본에서 데이터를 가져오면서 cache에도 저장합니다(write-on-miss). 이후 같은 데이터를 요청하면 cache hit가 납니다.
처음 서비스가 시작되거나 cache가 비워진 직후에는 miss가 대부분을 차지합니다. 이 상태를 cold cache라 하고, 데이터가 쌓여 hit율이 올라간 상태를 warm cache라 합니다. hit율(hit ratio)이 낮으면 cache가 오히려 overhead만 추가합니다. 원본 조회 + cache 저장 비용이 원본만 조회하는 것보다 더 비싸지기 때문입니다.
캐시 계층(L1/L2/L3/RAM)은 왜 있나요?
CPU와 메모리 사이의 접근 속도 차이가 크기 때문에 여러 계층을 둡니다. L1은 코어마다 있고 가장 빠르지만 용량이 작습니다. L2, L3로 갈수록 용량이 커지고 속도는 느려집니다. 계층을 두면 자주 쓰이는 데이터는 빠른 캐시에서 처리하고 나머지는 상대적으로 느린 메모리에서 가져올 수 있습니다.
L1/L2/L3 cache는 SRAM으로 만들어집니다. SRAM은 flip-flop 회로로 데이터를 유지해 접근이 빠르지만 트랜지스터가 6개 필요해 비용이 높습니다. 반면 DRAM은 커패시터 하나로 비트를 저장해 저렴하지만 주기적으로 충전(refresh)이 필요해 느립니다.
일반적인 수치로, L1은 코어당 32-64KB에 1-4ns, L2는 코어당 256KB-1MB에 4-12ns, L3는 코어 공유 8-32MB에 30-50ns, DRAM은 8-32GB에 60-100ns 수준입니다. L3까지는 CPU 다이 안에 있고, DRAM은 버스를 타야 합니다.
모든 메모리를 SRAM으로 만들면 속도는 빠르지만 비용과 발열이 감당이 안 됩니다. 자주 쓰이는 데이터는 작고 빠른 SRAM에, 나머지는 크고 저렴한 DRAM에 두는 계층 구조가 현실적인 해답입니다.
지역성(locality)이란?
최근에 접근한 데이터를 다시 사용할 가능성이 높다는 시간 지역성과, 접근한 데이터 주변을 함께 사용할 가능성이 높다는 공간 지역성을 말합니다. 캐시는 이 두 지역성을 활용해 효과를 높입니다.
배열을 순서대로 읽는 것이 캐시 성능이 좋은 이유가 공간 지역성 때문입니다. 배열은 메모리에 연속적으로 저장되고, 한 원소를 읽으면 캐시 라인(보통 64바이트) 단위로 인접 원소들이 함께 캐시에 올라옵니다. 순서대로 순회하면 이후 원소들은 이미 캐시에 있어 cache hit가 납니다.
반면 링크드 리스트는 노드가 메모리 여기저기에 흩어져 있어, 다음 노드를 따라갈 때마다 cache miss가 날 가능성이 높습니다.
이중 루프에서 행 우선으로 접근하느냐, 열 우선으로 접근하느냐도 마찬가지입니다. C, JavaScript처럼 배열이 행 우선(row-major)으로 저장되는 언어에서 열 우선으로 접근하면 cache miss가 훨씬 많아져 성능 차이가 크게 납니다.
LRU eviction 정책이란?
가장 오랫동안 사용되지 않은 데이터를 먼저 제거하는 정책입니다. 최근에 사용한 데이터가 다시 사용될 가능성이 높다는 시간 지역성 가정에 기반합니다.
LRU를 O(1)로 구현하려면 doubly linked list와 hashmap을 조합합니다. 항목을 사용할 때마다 리스트 앞으로 옮기고, 가장 오래된 항목은 리스트 끝에 남습니다. eviction 시 끝 항목을 제거합니다. hashmap으로 key → 리스트 노드를 O(1)에 찾고, 리스트 포인터 조작도 O(1)입니다.
OS의 페이지 교체 알고리즘에도 LRU가 많이 쓰이지만, 모든 메모리 접근마다 순서를 기록하는 완전한 LRU는 오버헤드가 큽니다. 그래서 Clock 알고리즘으로 LRU를 근사합니다. 페이지마다 reference bit를 두고, 한 번 순회하면서 1은 0으로 초기화하고 0인 것을 교체합니다.
Write-through vs Write-back?
Write-through는 cache와 원본에 동시에 씁니다. Write-back은 cache에 먼저 쓴 뒤 나중에 원본에 반영합니다. 전자는 일관성 관리가 단순하고, 후자는 쓰기 성능에 유리하지만 cache가 손실되면 데이터가 유실될 수 있습니다.
Write-back에서는 cache 블록마다 dirty bit를 둡니다. 원본과 내용이 다르면 dirty bit가 1이고, eviction 시 dirty bit가 1인 블록만 원본에 씁니다. 변경 없는 블록은 그냥 버리면 됩니다.
선택 기준은 쓰기 빈도와 데이터 안전성입니다. 쓰기가 잦고 속도가 중요한 경우(CPU L1/L2 cache)는 write-back이 유리합니다. 데이터 안전이 중요한 경우(결제 시스템, 로그)는 write-through가 적합합니다.
LRU 외 다른 정책은?
LFU는 가장 적게 사용된 항목을 먼저 제거합니다.
FIFO는 먼저 들어온 항목을 먼저 제거합니다.
Random은 무작위로 제거합니다.
TTL 기반으로 만료 시간을 설정하는 방식도 흔히 씁니다.
어떤 정책이 적합한지는 access pattern에 따라 다릅니다.
LRU는 최근에 쓴 것을 선호하고, LFU는 자주 쓴 것을 선호합니다. LRU는 구현이 단순하지만 오래됐어도 자주 쓰이는 데이터를 evict할 수 있습니다. LFU는 자주 쓰이는 데이터를 잘 유지하지만, 오래된 빈도 수가 누적되어 최근에 새로 등장한 인기 데이터가 evict될 수 있습니다.
FIFO는 구현이 가장 단순하지만 자주 쓰이는 데이터도 오래됐으면 제거해 hit율이 낮습니다. TTL은 시간 기반이라 정확한 invalidation이 필요할 때 유용하지만, 만료 전까지 stale 데이터가 남습니다.
Cache invalidation이 어려운 이유?
원본 데이터가 바뀌었을 때 여러 cache에 있는 오래된 값을 언제, 어떻게 제거할지 결정해야 하기 때문입니다. 너무 빨리 지우면 성능이 떨어지고, 너무 늦게 지우면 stale data를 보여줄 수 있습니다.
"컴퓨터 과학에서 진짜 어려운 문제는 두 가지뿐이다. cache invalidation과 naming." — Phil Karlton
근본적인 어려움은, 어떤 데이터가 변경됐을 때 그 데이터에 의존하는 cache 항목이 무엇인지 정확히 알기 어렵다는 점입니다. 단순한 key-value cache는 key가 명확해 invalidation이 쉽지만, 여러 데이터의 조합으로 만들어진 cache 항목(예: 게시글 목록 = 게시글 + 유저 정보 + 태그 조합)은 어느 하나가 바뀌어도 전부 invalidate해야 합니다.
분산 환경에서는 여러 cache 노드에 invalidation 이벤트를 전파해야 하는데, 네트워크 지연 사이에 일부 노드는 stale 값으로 응답할 수 있습니다. TTL을 너무 짧게 잡으면 cache 효과가 없고, 길게 잡으면 stale window가 길어지는 딜레마가 생깁니다.
Cache stampede란?
인기 있는 cache 항목이 만료되는 순간 다수의 요청이 동시에 원본 저장소로 몰리는 현상입니다. cache가 없는 상태에서 트래픽 전체가 원본을 직접 타격합니다.
막는 방법으로는 세 가지가 있습니다.
mutex lock: 첫 번째 요청만 원본에서 데이터를 가져오고, lock을 얻지 못한 요청은 stale 값을 돌려주거나 lock 해제를 기다립니다. lock 경합이 심해지면 또 다른 병목이 될 수 있습니다.
background refresh: TTL이 만료되기 전에 미리 값을 갱신해두는 방식입니다. cache에 항상 유효한 값이 있어 stampede 자체가 발생하지 않습니다. 갱신 실패 시 처리 방법을 별도로 설계해야 합니다.
TTL jitter: 만료 시간에 무작위 편차를 더해 여러 항목이 동시에 만료되지 않도록 분산시킵니다. 구현이 단순해 대규모 cache 운영에서 기본적으로 적용하는 방법입니다.
분산 캐시에서 일관성은?
여러 노드에 cache가 분산되면 한 노드의 데이터가 갱신되어도 다른 노드에 오래된 값이 남을 수 있습니다.
일관성을 유지하는 방법으로는 세 가지가 있습니다.
TTL을 짧게: stale 상태로 남는 시간이 줄어들지만 cache miss가 늘어나 원본 저장소 부하가 증가합니다. 완전한 일관성이 아니라 허용 가능한 stale window를 얼마로 볼지 선택하는 방식입니다.
invalidation broadcast: 원본 데이터가 바뀔 때 모든 cache 노드에 삭제 이벤트를 전파합니다. 즉각적이지만 노드가 일시적으로 오프라인이면 이벤트를 놓칠 수 있어, TTL을 safety net으로 함께 두는 경우가 많습니다.
versioned key: 데이터 변경 시 기존 키를 수정하지 않고 새 버전 키(user:123:v2)로 씁니다. 이전 버전은 TTL로 자연 소멸합니다. 읽기는 항상 최신 키를 참조하므로 일관성 관리가 단순하지만 키 조회 방식과 TTL 설계를 미리 잡아야 합니다.
HTTP cache는 어떻게 동작하나요?
브라우저가 서버에서 리소스를 받을 때 응답 헤더의 Cache-Control: max-age 값에 따라 캐시 유효 시간을 설정합니다. 이후 같은 URL을 요청하면 유효 기간 내라면 서버에 요청 없이 브라우저 캐시에서 바로 응답합니다. 유효 기간이 지나면 서버에 재검증 요청을 보내고, 서버가 변경 없음을 확인하면 304 Not Modified만 돌려주고 본문은 전송하지 않습니다.
재검증 요청에는 두 가지 헤더를 씁니다. ETag는 리소스 내용의 해시값으로, 브라우저가 If-None-Match: <etag값> 헤더로 전송합니다. Last-Modified는 마지막 수정 시각으로, 브라우저가 If-Modified-Since: <날짜> 헤더로 전송합니다. 서버는 현재 리소스와 비교해 변경이 없으면 304, 있으면 200과 새 내용을 반환합니다.
304 응답은 본문이 없어 네트워크 전송량이 크게 줄지만, 서버 왕복은 한 번 발생합니다. max-age 내라면 이 왕복도 없습니다.
브라우저 캐시 외에 CDN이나 프록시도 중간에서 캐시를 합니다. Cache-Control: public이면 중간 캐시가 저장 가능하고, private이면 브라우저만 저장 가능합니다. 로그인한 사용자의 개인화된 응답은 private이어야 합니다.
Cache-Control: no-cache와 no-store 차이는?
no-cache는 캐시에 저장은 하지만 사용 전 반드시 서버에 재검증을 요청합니다. 변경이 없으면 304, 있으면 200과 새 내용을 받습니다. 이름과 달리 캐시를 하되 항상 검증하는 방식입니다.
no-store는 캐시 자체를 하지 않습니다. 요청마다 서버에서 전체 응답을 받아야 하며, 절대 저장되면 안 되는 개인정보나 금융 데이터에 씁니다.
no-cache라는 이름이 혼란스럽습니다. "캐시를 하지 않는다"는 뜻처럼 보이지만 실제로는 "캐시는 하되 항상 검증하라"는 뜻입니다. 304 응답으로 본문 전송은 줄이면서 항상 최신 여부를 확인할 수 있어, 자주 업데이트되는 API 응답에 적합합니다.
no-store는 정말로 저장 자체를 금지합니다. 디스크에도, 메모리에도 남기지 않습니다. 민감한 데이터에 쓰는 이유는 cache에 데이터가 남으면 다른 사용자나 프로세스가 접근할 수 있기 때문입니다.
정적 파일 배포 시 cache는 어떻게 관리하나요?
빌드 시 파일 내용 기반의 hash를 파일명에 포함시키는 cache busting을 씁니다. main.a3b8f2e.js처럼 내용이 바뀌면 URL 자체가 달라지므로 브라우저는 자동으로 새 파일을 요청합니다. Cache-Control: max-age를 길게 설정해도 새 배포 시 URL이 바뀌어 항상 최신 파일을 받을 수 있습니다.
content hash는 빌드 도구(webpack, Vite)가 파일 내용을 해싱해 자동으로 생성합니다. 파일 내용이 1바이트라도 바뀌면 hash가 달라져 URL이 달라집니다. 내용이 같으면 배포해도 hash가 같아 기존 캐시를 그대로 쓸 수 있습니다.
index.html은 Cache-Control: no-cache로 설정하는 경우가 많습니다. index.html이 새 JS 파일명을 참조하는 진입점이라서 항상 최신이어야 하기 때문입니다. index.html이 캐시되면 오래된 JS 파일명을 가리켜 새 배포가 반영되지 않습니다.
CDN은 어떻게 동작하나요?
CDN(Content Delivery Network)은 여러 지점에 edge 서버를 두어 사용자에게 지리적으로 가까운 서버에서 응답하게 합니다. edge에 캐시가 없으면 origin에서 가져와 저장하고(origin pull), 이후 같은 리소스 요청은 edge에서 응답합니다.
사용자가 가까운 edge 서버를 찾는 방식은 주로 Anycast DNS입니다. CDN 도메인의 IP가 여러 edge 서버에 동일하게 할당되어 있고, 네트워크 라우팅이 자동으로 가장 가까운 경로를 선택합니다.
콘텐츠를 업데이트하면 edge에 남은 캐시를 purge API로 즉시 만료시키거나 TTL이 끝날 때까지 기다립니다. purge가 전파되는 사이에 edge마다 잠시 다른 버전을 서빙할 수 있다는 일관성 문제가 분산 캐시와 동일하게 존재합니다.
HTTPS termination도 edge에서 처리해 origin 서버 부하를 줄입니다. 정적 파일 외에도 동적 응답을 캐시하기도 하는데, Vary: Accept-Language 같은 헤더로 같은 URL이라도 조건에 따라 다른 캐시를 저장할 수 있습니다.
부가 설명
초기 컴퓨터는 CPU와 메모리 접근 속도 차이가 크지 않았습니다. 그런데 반도체 집적도가 빠르게 높아지면서 CPU 연산 속도는 급격히 빨라진 반면, DRAM의 접근 속도는 그 속도를 따라가지 못했습니다. CPU가 계산을 마쳐도 메모리에서 다음 데이터를 기다리며 사이클을 낭비하는 상황이 생겼고, 이 구조적 격차를 Memory Wall이라고 부릅니다.
해결책으로 CPU와 메모리 사이에 작고 빠른 SRAM 기반의 cache를 두는 방식이 등장했습니다. CPU에 가까울수록 빠르지만 비싸고 용량이 작고, 멀어질수록 느리지만 크고 저렴한 계층 구조가 자연스럽게 자리를 잡았습니다.
같은 원리는 소프트웨어 전반으로 확장됐습니다. DB 조회 결과를 메모리에 올려두는 query cache, 정적 파일을 클라이언트에 보관하는 browser cache, 지역적으로 가까운 서버에서 응답하는 CDN까지, "느린 저장소 앞에 빠른 저장소를 두어 반복 접근 비용을 줄인다"는 아이디어가 계층마다 반복되고 있습니다.
Cache는 성능을 높이기 위한 trade-off입니다. 시간을 줄이기 위해 공간을 더 쓰고, 최신성을 일부 관리해야 합니다.
대표 정책으로는 LRU, TTL, write-through, write-back 등이 있습니다. Cache는 읽기를 빠르게 만들지만 아래와 같은 문제가 따라옵니다.
hit/miss: cache miss가 나면 원본까지 다녀와야 하므로 miss가 잦으면 cache 없이 직접 읽는 것보다 오히려 느려질 수 있습니다. warm-up 전이나 eviction이 빈번한 환경에서 두드러집니다.
invalidation: 원본 데이터가 바뀌면 cache에 있는 오래된 값(stale data)을 언제, 어떻게 제거할지 결정해야 합니다. 너무 빨리 지우면 성능이 떨어지고, 너무 늦게 지우면 오래된 값을 보여줍니다.
consistency: 분산 환경에서 여러 cache 노드가 각자 다른 값을 가질 수 있습니다. 한 노드의 데이터가 갱신되어도 다른 노드에 반영되기까지 시간이 걸려, 그 사이에 요청을 받는 노드에 따라 다른 결과가 나올 수 있습니다.
한 줄 정리
Cache는 느린 저장소나 계산 결과 앞에 더 빠른 저장 공간을 두어 반복 접근을 빠르게 만드는 기법입니다.