캐시만큼 조용히 일하는 장치도 드물다. 잘 돌아갈 때는 존재감이 없다가, 어긋나는 순간에만 모습을 드러낸다. 이를테면 이런 장면들이다. 금요일 오후에 배포를 마쳤는데 고객센터에 "버튼이 안 눌려요"라는 문의가 들어오기 시작한다. 확인해보니 사용자 절반이 아직 지난주 화면을 보고 있고, 누군가 "새로고침을 해보시라"고 안내하고 있다. 다른 장면. 커머스 앱에서 상품을 보다가 뒤로가기를 눌렀더니 방금 보던 목록이 사라지고 스피너가 돈다. 스크롤 위치도 날아갔다. 마지막 장면. 네트워크 탭을 열어보니 같은 /api/me 요청이 한 화면에서 네 번 나가고 있다. 헤더에서 한 번, 사이드바에서 한 번, 본문에서 두 번.
세 장면의 공통점은 캐싱이다. 첫 번째는 캐시가 너무 오래 살아서, 두 번째는 캐시가 없어서, 세 번째는 캐시를 쓸 줄 몰라서 생긴 일이다. 캐싱이란 결국 한 번 구한 답을 저장해두고 다시 쓰는 일인데, 이 단순한 정의 뒤에는 두 개의 어려운 질문이 붙어 있다. 어디에 저장할 것인가, 그리고 언제 버릴 것인가. 컴퓨터 과학에는 어려운 문제가 두 개뿐인데 그중 하나가 캐시 무효화라는 오래된 농담이 괜히 나온 것이 아니다.1
이 글은 프론트엔드 개발자의 시점에서 이 두 질문에 답해본다. 브라우저 안의 캐시부터 CDN과 서버의 캐시까지 계층 전체를 훑고, 서버 사이드와 클라이언트 사이드 중 어디에 캐시를 둘지 판단하는 기준, 캐시 히트율을 끌어올리는 방법, 그리고 캐시와 원본 데이터를 어긋나지 않게 동기화하는 방법까지 다룬다. 주장마다 실측 수치와 애니메이션을 붙여뒀으니 눈으로 확인하며 따라오면 된다.
캐시는 한 곳이 아니다
캐싱 이야기를 하려면 먼저 지도를 펴야 한다. "캐시를 적용했다"는 말은 사실 아무 정보가 없는 말이다. 브라우저가 응답을 디스크에 저장한 것도 캐시고, React Query가 메모리에 들고 있는 서버 응답도 캐시고, CDN 엣지 서버에 얹힌 HTML도 캐시다. 사용자가 주소창에 URL을 입력한 순간부터 데이터베이스에 닿기까지, 요청은 여러 겹의 캐시를 통과한다.
각 계층을 사용자에게 가까운 쪽부터 짚어보자.
메모리 캐시는 브라우저가 현재 탭에서 방금 받은 리소스를 메모리에 들고 있는 것이다. 같은 페이지 안에서 같은 이미지를 두 번 쓰면 두 번째는 네트워크 요청 없이 여기서 해결된다. 표준으로 정해진 동작이 아니라 브라우저 구현에 맡겨져 있고, 탭을 닫으면 사라지며, 개발자가 손댈 방법도 마땅치 않다.2
HTTP 캐시는 브라우저가 디스크에 유지하는 캐시로, 우리가 "브라우저 캐시"라고 부르는 것의 본체다. 서버가 응답에 붙인 Cache-Control 헤더의 지시를 따르며, HTTP 명세에 동작이 정의되어 있다.3 뒤에서 자세히 다룬다.
서비스 워커는 페이지와 네트워크 사이에 끼어들어 요청을 대신 처리하는 중개인, 즉 프로그래밍 가능한 프록시(proxy)다. 개발자가 자바스크립트로 "이 요청은 캐시에서 줘라, 저 요청은 네트워크로 보내라"를 직접 코딩할 수 있다. 오프라인 지원이 필요할 때 주로 등장한다.
CDN(Content Delivery Network)은 전 세계에 흩어진 지점 서버(엣지 서버)가 원본 서버의 응답을 대신 들고 있다가 가까운 사용자에게 응답하는 것이다. 여기서부터는 성격이 달라지는데, 브라우저 캐시가 사용자 한 명을 위한 사설(private) 캐시라면 CDN은 모든 사용자가 공유하는 공유(shared) 캐시다. 한 명의 요청으로 만들어진 캐시를 수만 명이 나눠 쓴다.
서버 캐시는 원본 서버 안쪽의 캐시다. 렌더링된 페이지를 통째로 저장하거나(페이지 캐시), 데이터베이스 조회 결과를 Redis 같은 저장소에 두는 것(애플리케이션 캐시)이 여기 속한다. 프론트엔드 개발자가 직접 만지지 않더라도, 서버 사이드 렌더링과 정적 생성이 보편화되면서 이 계층은 사실상 프론트엔드의 관할이 됐다.
이 지도에서 하나의 원칙이 나온다. 사용자에게 가까운 캐시일수록 빠르지만 통제하기 어렵고, 원본에 가까운 캐시일수록 느리지만 통제하기 쉽다. 사용자의 디스크에 저장된 캐시는 0ms에 가깝게 응답하지만, 잘못된 내용이 저장됐을 때 우리가 손댈 방법이 마땅치 않다. 배포 후 "새로고침 해보세요"라고 안내하는 순간이 바로 사용자 브라우저의 캐시를 통제하지 못해 사용자에게 무효화를 부탁하는 순간이다. 반대로 CDN이나 서버의 캐시는 우리가 명령 한 번으로 지울 수 있다. 캐시를 어느 층에 둘지 고민할 때 이 긴장 관계가 계속 등장할 것이다.
그리고 사설이냐 공유냐의 구분은 이 글 전체를 관통하는 뼈대다. 모두에게 같은 데이터는 공유 캐시에 올릴수록 이득이 커지고, 사용자마다 다른 데이터는 사설 캐시에 머물러야 한다. 이 구분이 무너지면 성능 문제가 아니라 보안 사고가 된다. 남의 마이페이지가 CDN에 저장되어 다른 사용자에게 응답되는 사고는 실제로 반복해서 일어난다.4
HTTP 캐싱: 모든 계층의 공용어
브라우저의 HTTP 캐시, CDN, 중간 프록시는 전부 같은 언어로 대화한다. 응답에 붙는 Cache-Control 헤더다. 이 헤더 하나를 정확히 읽고 쓸 줄 알면 캐시 계층의 절반이 손에 들어오므로, 여기서부터 기초를 다지자.
신선도: 캐시의 유통기한
HTTP 캐싱의 핵심 개념은 **신선도(freshness)**다. 캐시에 저장된 응답에는 유통기한이 있고, 기한이 지나지 않은 응답은 "신선하다(fresh)"고 부른다. 신선한 응답은 서버에 물어보지 않고 그대로 재사용해도 된다. 기한이 지난 응답은 "낡았다(stale)"고 부르는데, 낡았다고 바로 버리는 것이 아니라 서버에 "이거 아직 쓸 만한가요?"라고 확인한 뒤 재사용한다. 이 확인 절차를 **재검증(revalidation)**이라 한다.
유통기한은 서버가 정한다.
Cache-Control: max-age=60이 응답은 60초 동안 신선하다. 60초 안에 같은 리소스가 필요하면 브라우저는 네트워크에 나가지 않고 디스크의 사본을 쓴다. 개발자 도구에서 "(disk cache)"라고 표시되고 소요 시간이 1~2ms로 찍히는 응답이 이것이다.
자주 쓰는 지시어 몇 개를 더 보자. 여기서 이름 때문에 오해가 잦은 것이 두 개 있다.
Cache-Control: no-cache # "캐시하지 마라"가 아니다 — 저장은 하되, 쓸 때마다 재검증하라
Cache-Control: no-store # 이쪽이 진짜 "캐시하지 마라" — 아예 저장하지 마라
Cache-Control: private # 브라우저(사설 캐시)만 저장 가능, CDN(공유 캐시)은 저장 금지
Cache-Control: public # 공유 캐시도 저장 가능
Cache-Control: s-maxage=300 # 공유 캐시에서만 적용되는 유통기한no-cache는 이름과 달리 캐시를 허용한다. 다만 재사용할 때마다 서버에 재검증을 거치라는 뜻이다. 재검증 결과 내용이 그대로라면 본문을 다시 받지 않으니, "항상 최신을 보장하되 전송량은 아끼고 싶다"는 요구에 정확히 맞는 지시어다. 정말 어디에도 저장되면 안 되는 응답, 예컨대 개인 금융 정보 같은 것에는 no-store를 쓴다.
public도 이름이 주는 인상과 실제가 다르다. 이 지시어가 없다고 공유 캐시가 저장을 못 하는 것이 아니다. 캐시 가능한 응답은 public 없이도 공유 캐시에 저장될 수 있고, public의 실질적인 용도는 로그인 토큰(Authorization 헤더)이 붙은 요청의 응답처럼 기본적으로는 공유 캐시에 들어가지 않는 것을 예외적으로 저장 가능하게 풀어주는 쪽이다. 그래서 "public을 안 썼으니 안전하다"는 판단은 위험하다. 공유되면 안 되는 응답에는 반드시 private을 명시해야 한다.
한 가지 함정이 더 있다. Cache-Control을 아예 안 붙이면 캐시가 안 될 것 같지만, 그렇지 않다. 명시적인 지시가 없으면 브라우저는 휴리스틱 캐싱이라고 해서 Last-Modified 같은 다른 헤더를 근거로 유통기한을 임의로 추정한다.5 "우리는 캐시 설정을 한 적이 없는데 왜 옛날 응답이 보이죠?"라는 질문의 답이 대개 이것이다. 캐시 정책은 안 정하는 것도 정하는 것이다.
재검증: 안 바뀌었으면 본문은 생략
낡은 캐시를 확인하는 재검증 절차는 생각보다 저렴하다. 서버는 응답에 지문을 붙여준다.
HTTP/1.1 200 OK
ETag: "ad87509d56607299"
Content-Type: application/jsonETag는 응답 본문의 버전을 식별하는 값이다(본문의 해시를 쓰기도 하고, nginx처럼 수정 시각과 크기로 만들기도 한다). 브라우저는 캐시가 낡으면 이 지문을 들고 서버에 묻는다.
GET /api/products HTTP/1.1
If-None-Match: "ad87509d56607299"서버가 확인해보고 내용이 그대로라면, 본문 없이 상태 코드만 보낸다.
HTTP/1.1 304 Not Modified
ETag: "ad87509d56607299"이 왕복이 얼마나 아까운지 감을 잡기 위해 실제로 측정해봤다. 상품 300건짜리 목록 API를 로컬에 만들어 응답 크기를 재보면, 첫 요청은 200 응답으로 본문 127,799바이트를 받는다. 캐시가 낡은 뒤의 재검증 요청은 304 응답으로 본문 0바이트다. 헤더 몇 줄만 오간다. 재검증은 왕복 시간(RTT)은 그대로 내지만 전송량은 거의 내지 않는 절차인 셈이다. 그래서 HTTP 캐싱의 이득은 두 단계로 나뉜다. 신선한 동안은 왕복 자체가 사라지고, 낡은 뒤에도 전송량이 사라진다.
ETag 말고 Last-Modified(마지막 수정 시각)와 If-Modified-Since 조합도 같은 일을 한다. 초 단위 정밀도라는 한계가 있어 지금은 ETag가 우선이지만, 정적 파일 서버들은 보통 둘 다 붙여준다.
불변 에셋 패턴: 프론트엔드 배포의 표준
여기까지의 도구만으로 프론트엔드 정적 리소스 배포의 표준 패턴이 만들어진다. 핵심 아이디어는 내용이 바뀌면 URL도 바꾸는 것이다.
빌드 도구가 만들어주는 파일명을 보면 app-3f2a9c.js처럼 해시가 붙어 있다. 이 해시는 파일 내용에서 계산되므로, 내용이 한 글자라도 바뀌면 파일명이 바뀐다. 뒤집어 말하면 같은 URL의 내용은 영원히 같다. 그렇다면 이 파일은 무기한 캐시해도 안전하다.
# 해시가 붙은 정적 에셋 (JS, CSS, 폰트, 이미지)
Cache-Control: public, max-age=31536000, immutable
# HTML
Cache-Control: no-cache에셋은 1년(31,536,000초) 동안 재검증조차 없이 재사용된다.6 대신 HTML은 no-cache로 매번 재검증한다. HTML 안에 어떤 해시의 에셋을 쓸지가 적혀 있으므로, HTML만 최신이면 나머지는 URL이 알아서 갈아치워진다. 배포가 "낡은 캐시를 지우는 일"이 아니라 "새 URL을 참조하는 일"이 되는 것이다. 글 서두의 "새로고침 해보세요" 장면은 대부분 HTML 자체가 길게 캐시되고 있어서 생긴다.
이 패턴에는 실무에서 겪어야 아는 함정이 하나 있다. 배포 순서다. 새 HTML을 먼저 내보내고 에셋을 나중에 올리면, 그 짧은 틈에 들어온 사용자는 존재하지 않는 app-9d4b1e.js를 요청하고 화면은 하얗게 된다. 에셋을 전부 올린 뒤에 HTML을 교체해야 한다. 그리고 반대 방향의 함정도 있다. 배포하면서 이전 버전 에셋을 즉시 지우면, 배포 전에 페이지를 열어둔 사용자가 필요할 때 나눠 받도록 쪼개둔 코드 조각(청크)을 뒤늦게 내려받는 순간 옛 파일을 못 찾는다. 실제로 배포 직후마다 에러 트래커에 ChunkLoadError가 쏟아진다면 십중팔구 이 문제다. 이전 버전 에셋은 한동안 함께 서빙하는 것이 안전하다.
낡은 것을 일단 내주기: stale-while-revalidate
재검증에는 여전히 왕복 시간이 든다. 캐시가 낡을 때마다 사용자가 그 왕복을 기다려야 한다면, 유통기한을 짧게 잡은 리소스는 체감 성능이 나빠진다. 그래서 나온 절충이 있다.
Cache-Control: max-age=60, stale-while-revalidate=300이 응답은 60초 동안 신선하다. 그런데 60초가 지난 뒤 300초 안에 요청이 오면, 일단 낡은 응답을 즉시 내주고 재검증은 뒤에서 몰래 한다.7 사용자는 기다리지 않고, 캐시는 다음 사용자를 위해 갱신된다. 낡은 데이터를 잠깐 보여주는 대가로 대기 시간을 없애는 절충이다.
이 아이디어는 헤더 하나로 끝나지 않고 이 글에서 계속 다시 등장한다. 클라이언트 데이터 캐싱 라이브러리의 이름(SWR)이 여기서 왔고, CDN의 캐시 갱신 전략도 이 위에 서 있다. "최신이 아닐 수 있는 답을 즉시 주는 것과 확실한 답을 늦게 주는 것 중 무엇이 나은가"라는 질문은 캐싱 설계 전체에서 가장 자주 마주치는 갈림길인데, 사용자 인터페이스에서는 대부분 전자가 이긴다.
유통기한과 재검증, stale-while-revalidate가 어떻게 맞물리는지는 아래 애니메이션이 보여준다. 같은 캐시인데도 요청이 타임라인의 어느 구간에 떨어지느냐에 따라 응답 경로가 달라지는 것을 눈여겨보자.
Vary: 같은 URL, 다른 응답
기초 개념이 하나 더 남았다. 같은 URL이라도 요청 헤더에 따라 응답이 달라질 수 있다. 예컨대 Accept-Language에 따라 한국어와 영어 페이지를 내준다면, 캐시가 URL만 보고 응답을 재사용하면 한국어 사용자에게 영어 페이지가 나간다. 그래서 서버는 응답에 이렇게 선언한다.
Vary: Accept-Language"이 응답은 Accept-Language 값에 따라 다르니, 캐시 키에 그 헤더 값도 포함시켜라"는 뜻이다. 캐시는 URL + 헤더 값 조합마다 응답을 따로 저장하게 된다.
Vary는 필요악이다. 정확성을 지켜주지만, 헤더 하나를 추가할 때마다 캐시가 그 값의 가짓수만큼 쪼개진다. 극단적인 예가 Vary: Cookie인데, 쿠키는 사용자마다 다르므로 사실상 캐시를 사용자 수만큼 쪼개는, 다시 말해 공유 캐시를 무력화하는 선언이 된다. 캐시가 쪼개지는 이 현상은 뒤에서 히트율을 다룰 때 다시 만난다.
리소스별 권장 설정
기초를 정리하는 의미로, 프론트엔드에서 다루는 대표 리소스에 어떤 헤더를 붙이면 되는지 모아보면 이렇다.
| 리소스 | 권장 헤더 | 이유 |
|---|---|---|
| 해시 붙은 JS/CSS/폰트 | public, max-age=31536000, immutable | URL이 곧 버전이므로 무기한 안전 |
| HTML | no-cache | 매번 재검증하되 304로 전송량 절약 |
| 사용자 업로드 이미지 | public, max-age=86400 안팎 | 수정되면 URL을 바꾸는 쪽이 이상적 |
| 공용 API (상품 목록 등) | public, max-age=30, stale-while-revalidate=300 류 | 짧은 신선도 + 부드러운 갱신 |
| 개인화 API (장바구니 등) | private, no-cache 또는 no-store | 공유 캐시 유입 차단이 최우선 |
물론 숫자는 서비스마다 다르다. 중요한 것은 각 행의 판단 근거, 즉 "이 응답은 누구에게 같은가"와 "얼마나 낡아도 되는가"라는 두 질문이다.
서버 사이드 캐싱: 한 번 만들어 모두에게
이제 계층 지도의 오른쪽, 공유 캐시의 세계로 가자. 서버 사이드 캐싱의 본질은 한 사용자를 위해 만든 응답을 다른 사용자에게 재사용하는 것이다. 브라우저 캐시가 아무리 잘 동작해도 첫 방문자에게는 아무것도 해주지 못하지만, 공유 캐시는 첫 방문자에게도 이미 만들어진 응답을 준다.
페이지를 통째로 캐시하기
서버 사이드 렌더링(SSR)을 쓰는 서비스라면 페이지 하나를 만드는 비용이 만만치 않다. 데이터베이스를 조회하고, 컴포넌트 트리를 렌더링해서 HTML을 만든다. 그런데 상품 상세 페이지를 생각해보면, 이 비용을 들여 만든 HTML은 같은 상품을 보는 모든 사용자에게 같다. 그렇다면 첫 요청에 만든 HTML을 CDN에 얹어두고 이후 요청은 원본 서버에 오지도 않게 할 수 있다.
Cache-Control: public, s-maxage=300, max-age=0s-maxage=300은 CDN에게 "5분 동안 이 페이지를 재사용하라", max-age=0은 브라우저에게 "너는 매번 확인하라"는 뜻이다. 공유 캐시와 사설 캐시의 유통기한을 따로 주는 것인데, 이렇게 나누는 이유가 있다. CDN의 캐시는 우리가 배포나 API 호출로 즉시 지울 수 있지만 브라우저의 캐시는 지울 수 없으므로, 통제 가능한 쪽에 긴 기한을 주고 통제 불가능한 쪽에는 짧은 기한을 주는 것이다. 앞서 본 "가까울수록 빠르지만 통제하기 어렵다"는 원칙이 헤더 한 줄에 그대로 담겨 있다.
빌드 시점에 페이지를 미리 만들어두는 정적 생성(SSG)은 이 아이디어의 극단이고, 정적 생성과 SSR 사이에 증분 정적 재생성(ISR, Incremental Static Regeneration)이 있다. 페이지를 정적으로 캐시하되 일정 주기로, 또는 신호를 받으면 서버가 뒤에서 다시 만들어 갈아끼우는 방식이다. 갱신되는 동안 사용자에게는 기존 페이지가 나간다. 어디서 본 구조 아닌가? stale-while-revalidate를 페이지 단위로 구현한 것이다.
개인화라는 경계선
페이지 캐싱을 도입하려는 순간 바로 부딪히는 벽이 있다. 상품 상세 페이지의 본문은 모두에게 같지만, 우측 상단에는 "김철수님"이라는 이름과 장바구니 개수가 떠 있다. 이 페이지는 공유 캐시에 넣을 수 있는가? 넣으면 김철수님의 이름이 박힌 HTML이 다른 사용자에게 나간다. 앞서 언급한 스팀의 사고가 정확히 이 유형이다.
정석적인 해법은 분리다. 페이지를 "모두에게 같은 부분"과 "사용자마다 다른 부분"으로 쪼개서, 껍데기(shell)는 공유 캐시에 올리고 개인화 데이터는 클라이언트에서 별도 API로 채운다. 상품 상세 HTML은 CDN에서 즉시 내려오고, 헤더의 이름과 장바구니 개수는 브라우저가 /api/me를 호출해 그린다. 개인화 API 응답에는 Cache-Control: private을 붙여 공유 캐시 유입을 차단한다.
이 분리는 성능 최적화이기 이전에 안전장치다. 캐시 사고의 무서운 점은 평소에는 절대 안 드러난다는 것이다. 코드 리뷰로도, QA로도 잡기 어렵고, 트래픽이 몰려 캐시가 본격적으로 일하기 시작하는 순간, 그러니까 최악의 타이밍에 터진다. 개인화 응답과 공용 응답을 API 주소(엔드포인트) 수준에서 갈라놓으면 "실수로 공유 캐시에 들어갈 수 있는 것" 자체가 줄어든다.
만료의 순간: 캐시 스탬피드
공유 캐시에는 사설 캐시에 없는 고유한 문제가 하나 있다. 캐시 하나를 수만 명이 쓰고 있다는 사실은, 그 캐시가 만료되는 순간 수만 명의 요청이 동시에 원본으로 쏟아진다는 뜻이기도 하다. 이를 **캐시 스탬피드(cache stampede)**라 부른다.8
말로만 들으면 와닿지 않아서 직접 만들어봤다. 원본 응답에 100ms가 걸리는 API 앞에 유통기한(TTL, Time To Live) 1초짜리 캐시 서버를 세우고, 5초 동안 초당 200개의 요청을 보냈다. 소박하게 "만료됐으면 원본에 다녀온다"라고만 구현한 캐시의 결과가 첫 줄이다. 표의 p50과 p95는 응답 시간의 중앙값과 상위 5% 지점, 즉 보통의 사용자와 운 나쁜 사용자가 겪는 대기 시간이다.
| 구현 | 원본 도달 | p50 | p95 | 최대 |
|---|---|---|---|---|
| 단순 TTL | 89회 | 0.7ms | 102.2ms | 128.0ms |
| 요청 병합 | 5회 | 0.7ms | 53.5ms | 102.9ms |
| 요청 병합 + SWR | 5회 | 0.5ms | 1.1ms | 102.2ms |
유통기한 1초에 5초 동안 돌렸으니 원본에는 이론상 5번만 가면 된다. 그런데 단순 구현은 89번 갔다. 만료 직후 캐시가 채워지기 전까지의 100ms 동안 도착한 요청 20개가 전부 "캐시 없음"을 보고 제각기 원본으로 달려갔기 때문이다. 원본 입장에서는 1초마다 요청이 20배로 튀는 것이고, 원본이 데이터베이스라면 새벽 세일 오픈 직후에 알람이 울리는 바로 그 패턴이 된다.
해법은 두 단계다. 먼저 요청 병합(request coalescing). 같은 키에 대한 원본 요청이 이미 진행 중이면 새 요청을 원본에 또 보내지 않고 진행 중인 요청에 합류시킨다.
let inflight: Promise<string> | null = null;
async function getProducts() {
if (cache && cache.expiresAt > Date.now()) return cache.body;
// 이미 원본에 다녀오는 중이면 그 결과를 같이 기다린다
inflight ??= fetchOrigin().finally(() => {
inflight = null;
});
return inflight;
}표의 두 번째 줄이다. 원본 도달이 5회로 떨어졌다. 하지만 p95 지연이 53ms로 여전히 높은데, 병합에 합류한 요청들도 원본 응답을 기다려야 하기 때문이다. 여기에 stale-while-revalidate를 더하면, 즉 만료 순간에도 일단 낡은 캐시를 내주고 갱신은 뒤에서 하면 세 번째 줄이 된다. 원본 도달 5회에 p95가 1.1ms다. 만료의 순간이 사용자에게 보이지 않게 된 것이다.
아래 애니메이션은 같은 요청 흐름을 세 구현에 동시에 흘려보낸 것이다. 만료가 오는 순간 원본 도달 수가 어떻게 갈라지는지 지켜보자.
이 문제는 CDN을 쓰면 상당 부분 대신 처리해준다.9 그러나 직접 만든 캐시, 예컨대 프론트엔드 전용으로 두는 중간 서버인 BFF(Backend For Frontend) 안의 메모리 캐시나 Redis 캐시에는 이 방어가 기본으로 들어 있지 않다. 캐시를 직접 구현한다면 스탬피드 방어도 함께 구현해야 한다.
서버 사이드 캐싱을 선택하는 기준
정리하면 서버 사이드 캐싱이 유리한 조건은 세 가지다. 응답이 모두에게, 적어도 많은 사용자에게 같을 것. 응답을 만드는 비용이 클 것. 그리고 첫 방문 성능이 중요할 것. 상품 상세, 블로그 글, 랜딩 페이지, 카테고리별 상품 목록이 전형적으로 여기 해당한다.
반대로 사용자마다 응답이 다르면 공유 캐시는 원천적으로 재사용이 일어나지 않으니 이득이 없고, 위험만 남는다. 장바구니, 주문 내역, 알림 목록 같은 것들이다. 이런 데이터의 캐싱은 다음 장, 클라이언트의 몫이다.
클라이언트 사이드 캐싱: 세션의 기억
이제 계층 지도의 왼쪽 끝, 자바스크립트 메모리 안의 캐시로 가자. 여기서 다루는 것은 HTTP 캐시가 아니라 애플리케이션이 직접 관리하는 서버 데이터의 사본이다. React Query, SWR 같은 라이브러리가 활약하는 영역이고, 개인화 데이터 캐싱의 주 무대다.
우리는 이미 캐시를 만들고 있었다
"클라이언트에 캐시를 도입한다"고 하면 거창하게 들리지만, 사실 서버 데이터를 화면에 그리는 모든 앱은 이미 캐시를 갖고 있다. 이 흔한 코드를 보자.
function ProductList() {
const [products, setProducts] = useState<Product[]>([]);
useEffect(() => {
fetchProducts().then(setProducts);
}, []);
// ...
}products라는 상태가 무엇인가? 서버 데이터의 사본이다. 서버의 데이터가 바뀌어도 이 사본은 저절로 안 바뀌고, 컴포넌트가 사라지면 사본도 사라지며, 같은 데이터가 필요한 다른 컴포넌트는 자기만의 사본을 또 만든다. 즉 이것은 유통기한도, 재검증도, 공유도 없는 캐시다. 글 서두에서 /api/me가 한 화면에서 네 번 나가던 장면이 바로 컴포넌트마다 사본을 따로 만든 결과다.
클라이언트 데이터 캐싱 라이브러리들이 하는 일은 이 사본 관리에 HTTP 캐싱에서 본 개념들을 이식하는 것이다. 캐시 키가 있고, 신선도가 있고, 재검증이 있다.
function ProductList({ category, sort }: Props) {
const { data } = useQuery({
queryKey: ["products", { category, sort }],
queryFn: () => fetchProducts(category, sort),
staleTime: 60_000,
});
// ...
}queryKey가 캐시 키다. 같은 키로 조회하는 컴포넌트는 몇 개든 하나의 캐시를 공유하고, 동시에 화면에 붙으면(마운트) 요청도 한 번만 나간다(요청 중복 제거). 그 자체로 네 번 나가던 /api/me가 한 번이 된다.
staleTime: 클라이언트의 유통기한
위 코드의 staleTime이 클라이언트 캐시의 유통기한, HTTP의 max-age에 해당한다. 그런데 동작 방식에 결정적인 차이가 있다. HTTP 캐시는 낡은 응답을 쓰기 전에 재검증을 기다리지만, React Query류의 라이브러리는 낡은 캐시가 있으면 일단 즉시 화면에 그리고 재검증을 뒤에서 한다. 기본 동작 자체가 stale-while-revalidate다.
그래서 staleTime은 "화면이 얼마나 빨리 뜨는가"가 아니라 **"네트워크 요청이 얼마나 자주 나가는가"**를 조절하는 다이얼이다. 이 구분이 실제 숫자로 어떻게 나타나는지 시뮬레이션해봤다. 커뮤니티 앱에서 사용자가 10분 동안 피드와 글 상세를 오가는 세션(화면 진입 32회, 같은 글 재방문 포함)을 만들어 staleTime만 바꿔가며 돌린 결과다.
| staleTime | 네트워크 요청 | 스피너 노출 | 캐시 즉시 렌더 |
|---|---|---|---|
| 0 (기본값) | 32회 | 10회 | 22회 |
| 30초 | 29회 | 10회 | 22회 |
| 5분 | 15회 | 10회 | 22회 |
| ∞ | 10회 | 10회 | 22회 |
주목할 점은 스피너 노출과 즉시 렌더 횟수가 모든 행에서 같다는 것이다. 캐시에 데이터가 있는 한 화면은 항상 즉시 그려지므로, staleTime을 0으로 두어도 사용자 경험이 느려지지는 않는다. 대신 화면에 진입할 때마다 백그라운드 refetch가 나가서 네트워크 요청이 32회까지 늘어난다. staleTime을 5분으로 올리면 요청이 절반 이하로 줄어든다. 트래픽과 서버 부하, 모바일 데이터가 아까운 상황이라면 이 다이얼을 돌리는 것만으로 절감이 된다.
그럼 staleTime을 무조건 늘리면 되는가 하면, 대가는 신선도다. staleTime 5분은 "5분 낡은 데이터가 화면에 떠 있어도 refetch하지 않겠다"는 선언이다. 피드 글 목록이라면 5분 낡아도 아무도 모른다. 하지만 결제 직전의 재고 수량이라면 이야기가 다르다. 리소스마다 "얼마나 낡아도 되는가"를 답하고, 그 답을 staleTime으로 옮기면 된다.
아래 애니메이션은 같은 탐색 세션을 staleTime 0과 5분 두 설정으로 나란히 재생한 것이다. 스피너 횟수는 양쪽이 같고, 달라지는 것은 네트워크 요청 수뿐이다.
참고로 staleTime과 자주 혼동되는 gcTime(옛 이름 cacheTime)은 전혀 다른 다이얼이다. staleTime이 "언제부터 낡은 것으로 볼까"라면, gcTime은 "화면에서 안 쓰는 캐시를 메모리에서 언제 지울까"다. 위 표에서 재방문 때 스피너 없이 즉시 렌더가 가능했던 것은 캐시가 지워지지 않고 살아 있었기 때문이고(시뮬레이션에서는 gcTime을 세션 길이보다 길게 뒀다), staleTime은 그 뒤의 refetch 여부만 정했다. gcTime 기본값인 5분을 그대로 쓴다면 5분 넘게 들르지 않은 화면의 캐시는 지워지므로, 오랜만의 재방문에는 스피너가 다시 보일 수 있다.
쿼리 키 설계 = 캐시 키 설계
queryKey는 단순한 식별자가 아니라 나중에 무효화의 단위가 되는 값이라서, 처음부터 구조를 갖춰두는 것이 좋다. 실무에서 검증된 관례는 넓은 범위에서 좁은 범위로 계층을 쌓는 것이다.
[
"products",
] // 상품 도메인 전체
[
("products", "list")
] // 모든 상품 목록
[
("products", "list", { category, sort })
] // 특정 조건의 목록
[("products", "detail", productId)]; // 특정 상품 상세이렇게 해두면 뒤에서 보겠지만 "상품 42번에 관련된 모든 캐시"나 "모든 상품 목록"을 접두사 매칭 한 번으로 무효화할 수 있다. 캐시 키의 계층 구조가 곧 무효화의 조준 단위가 된다.
주의할 점은 키와 요청의 일대일 대응이다. queryFn이 사용하는 변수는 전부 queryKey에 들어가야 한다. 키에 없는 변수가 요청에 끼어들면, 같은 키에 다른 응답이 저장되는 캐시 오염이 생긴다. HTTP 캐싱에서 Vary를 빠뜨린 것과 정확히 같은 사고다.
클라이언트 캐싱을 선택하는 기준
클라이언트 캐시가 유리한 조건은 서버 사이드와 정확히 반대다. 사용자마다 다른 데이터(장바구니, 주문 내역, 알림), 한 세션 안에서 반복 조회되는 데이터(뒤로가기, 탭 전환), 그리고 상호작용 직후 즉시 반영이 필요한 데이터(좋아요, 팔로우)다. 서버 입장에서는 사용자 수만큼 파편화되어 캐시할 수 없는 데이터가, 클라이언트 입장에서는 "그 사용자의 것 하나만" 들고 있으면 되는 데이터가 된다. 같은 데이터가 계층에 따라 캐시 가능성이 뒤집히는 것이다.
서버냐 클라이언트냐
두 세계를 봤으니 판단 기준을 정리할 차례다. 사실 "서버 사이드냐 클라이언트 사이드냐"는 양자택일 질문이 아니다. 계층 지도가 보여주듯 캐시는 겹겹이 쌓이는 것이고, 실제 질문은 **"이 데이터를 어느 층까지 올릴 수 있는가"**다.
판단 축은 세 개다.
첫째는 공유 범위다. 이 응답은 누구에게 같은가? 모두에게 같으면 CDN까지 올릴 수 있다. 사용자 집단(지역, 언어)별로 같으면 캐시 키를 그 단위로 쪼개서 올릴 수 있다. 사용자마다 다르면 브라우저와 자바스크립트 메모리까지만 가능하다. 위로 올릴수록 한 번의 생성으로 혜택을 보는 사용자가 늘어난다.
둘째는 신선도 요구다. 얼마나 낡아도 되는가? 초 단위로 정확해야 하는 데이터(재고, 가격, 잔액)는 유통기한을 길게 줄 수 없고, 캐시하더라도 재검증이나 실시간 갱신 수단이 함께 필요하다. 분 단위로 낡아도 되는 데이터(글 목록, 프로필)는 넉넉하게 캐시할 수 있다.
셋째는 생성 비용이다. 만드는 데 비싼 응답일수록 캐시의 이득이 크다. 여러 서비스를 조합하는 무거운 SSR 페이지는 캐시 히트 하나하나가 크게 남는 장사고, 단순 키 조회 API는 캐시를 안 해도 그만이다.
이 축을 실무 화면에 대입해보면 이렇게 된다.
| 시나리오 | 공유 범위 | 신선도 요구 | 배치 |
|---|---|---|---|
| 상품 상세 페이지 | 전체 공유 | 분 단위 | CDN 페이지 캐시(ISR), 재고·가격만 별도 API |
| 카테고리 상품 목록 | 전체 공유 | 분 단위 | CDN s-maxage + SWR 헤더 |
| 커뮤니티 피드 | 전체~집단별 | 분 단위 | 서버 캐시 + 클라이언트 staleTime 수 분 |
| 장바구니 | 개인 | 항상 최신처럼 보여야 함 | 클라이언트 캐시 + 변경 시 즉시 무효화 |
| 주문 내역 | 개인 | 세션 내 고정적 | 클라이언트 캐시, staleTime 길게 |
| 어드민 대시보드 | 소수 공유 | 최신 중요 | 캐시 최소화, 필요하면 짧은 staleTime만 |
표에서 눈여겨볼 것은 상품 상세의 행이다. "페이지는 CDN, 재고·가격은 별도 API"로 한 화면 안에서도 데이터의 성격에 따라 캐시 층이 갈린다. 개인화 분리에서 본 것과 같은 원리로, 캐싱 전략의 단위는 페이지가 아니라 데이터다. 화면 하나를 놓고 "이 화면을 캐시할까?"라고 물으면 답이 안 나오지만, 화면을 데이터 조각으로 분해해서 조각마다 세 가지 축을 적용하면 각자 갈 곳이 정해진다.
캐시 히트율 높이기
캐시를 배치했으면 이제 성적표를 볼 차례다. **히트율(hit ratio)**은 전체 요청 중 캐시가 응답한 비율이다. 히트율이 낮은 캐시는 있으나 마나가 아니라 있어서 해롭다. 캐시 조회라는 비용은 그대로 내면서 이득은 못 보고, 게다가 "캐시가 있으니 원본은 이 정도 트래픽만 받겠지"라는 가정으로 산정된 원본 용량을 위협한다.
히트율을 깎아먹는 요인은 대부분 파편화다. 같은 내용의 응답이 여러 캐시 항목으로 쪼개져 각자 미스를 내는 것이다. 그리고 파편화의 주범은 캐시 키다.
캐시 키 정규화
CDN의 기본 캐시 키는 URL이다. 그런데 URL은 우리 생각보다 훨씬 지저분하게 들어온다. 광고를 타고 온 유입에는 utm_source, fbclid 같은 추적 파라미터가 붙는다. 이 파라미터들은 응답 내용과 아무 상관이 없지만, URL이 다르므로 캐시는 전부 다른 키로 취급한다. 심지어 fbclid는 클릭마다 값이 달라서 캐시 항목을 클릭 수만큼 만든다.
이것도 수치로 확인해봤다. 카테고리 20개 x 정렬 3종, 즉 실제로 서로 다른 페이지가 60개뿐인 상품 목록에 10만 건의 트래픽을 시뮬레이션했다. 트래픽의 45%에는 광고 추적 파라미터가 붙고 파라미터 순서도 제각각인, 흔한 커머스 유입 패턴이다. 캐시 유통기한은 60초로 뒀다.
| 캐시 키 | 히트율 | 생성된 캐시 항목 |
|---|---|---|
| URL 그대로 | 73.2% | 1,037개 |
| 파라미터 정렬 | 77.4% | 855개 |
| 추적 파라미터 제거 + 정렬 | 97.1% | 60개 |
내용이 60가지뿐인데 캐시 항목이 1,037개 만들어졌다. 히트율 73.2%는 그럭저럭으로 보일 수 있지만 뒤집으면 미스가 26.8%, 정규화했을 때(2.9%)의 9배가 넘는 트래픽이 원본에 가고 있다는 뜻이다. 추적 파라미터를 캐시 키에서 제거하고 파라미터를 정렬하는 것만으로 히트율이 97.1%가 되고 캐시 항목 수는 정확히 실제 페이지 수인 60개로 떨어진다.
이렇게 캐시 키를 다듬는 일을 **정규화(normalization)**라 한다. 주요 CDN은 쿼리 파라미터 무시 목록과 정렬을 설정으로 제공하므로 대개 코드 없이 켤 수 있다. 클라이언트 캐시도 사정은 같아서, ['products', { category, sort }]라는 쿼리 키에 화면 전용 상태나 매번 달라지는 값이 섞여 들어가면 정확히 같은 파편화가 일어난다.
정규화가 있고 없고의 차이를 아래 애니메이션이 같은 요청 스트림으로 비교해서 보여준다. 위 표보다 훨씬 작은 스트림(요청 24개, 실제 페이지 6개)이라 수치 자체는 다르지만, 왼쪽에서만 파편이 쌓이는 모양은 같다.
같은 원리로 Vary 헤더도 다이어트 대상이다. 앞서 봤듯 Vary에 오르는 헤더 하나하나가 캐시를 그 값의 가짓수만큼 쪼갠다. 정말 응답을 가르는 헤더만 남기고, 특히 Vary: Cookie나 Vary: User-Agent처럼 사실상 무한 가짓수인 헤더가 들어 있다면 다른 설계(쿠키 대신 URL로 사용자 집단을 표현, User-Agent 대신 반응형)로 풀 수 없는지 먼저 의심해야 한다.
유통기한이 겁날 때: TTL 대신 SWR
히트율을 올리는 가장 단순한 방법은 유통기한(TTL)을 늘리는 것이다. 60초 캐시를 10분으로 늘리면 히트율은 당연히 오른다. 그런데 이 다이얼은 심리적으로 돌리기 어렵다. "10분 동안 낡은 데이터가 나가도 정말 괜찮은가?"라는 질문에 확신 있게 답하기 어렵기 때문이다.
이때 기억할 것이 TTL과 SWR의 조합이다. max-age=30, stale-while-revalidate=600은 "30초는 확실히 신선, 이후 10분은 낡은 것을 내주되 곧바로 갱신"이라는 뜻이다. 사용자가 볼 수 있는 낡음의 정도는 짧게 유지하면서, 캐시가 응답하는 비율(그리고 대기 없는 응답의 비율)은 크게 올라간다. 히트율과 신선도를 맞바꾸는 것이 아니라, 갱신을 사용자의 대기 시간에서 백그라운드로 옮기는 것이다. 스탬피드 실험의 세 번째 줄에서 이미 그 효과를 봤다.
미리 채우기
히트율의 남은 절반은 미스가 일어나는 시점을 옮기는 것이다. 사용자가 상품 카드에 마우스를 올렸을 때 상세 데이터를 미리 받아두면(prefetch), 실제 클릭 시점에는 캐시 히트가 된다.
<ProductCard
onMouseEnter={() =>
queryClient.prefetchQuery({
queryKey: ["products", "detail", product.id],
queryFn: () => fetchProduct(product.id),
staleTime: 60_000,
})
}
/>prefetch는 추측이 빗나가도 크게 다치지 않는다는 점에서 부담이 적은 최적화다. 사용자가 결국 클릭하지 않으면 안 쓴 캐시 하나가 남을 뿐이다. 다만 공짜는 아니라서, 목록의 모든 카드에 마우스 오버 prefetch를 걸면 스쳐 지나가는 커서만으로도 요청 수십 개가 나간다. 일정 시간 머문 경우에만 발동시키고, 위 코드처럼 staleTime을 함께 줘서 방금 받아온 데이터를 클릭 직후 또 받지 않게 하는 것이 관례다.
서버 사이드에서도 같은 개념이 있다. 배포 직후나 캐시를 비운 직후에는 모든 요청이 미스가 되므로, 주요 페이지를 미리 요청해서 캐시를 채워두는 것(cache warming)이다. 세일 오픈처럼 트래픽이 예고된 이벤트 전에 워밍을 돌려두면 첫 파도가 원본을 때리는 것을 막을 수 있다.
캐시 동기화: 언제, 어떻게 버릴 것인가
이제 두 번째 어려운 질문, "언제 버릴 것인가"에 정면으로 답할 차례다. 원본 데이터가 바뀌었는데 캐시가 옛 데이터를 들고 있는 상태, 이 어긋남을 다루는 것이 캐시 무효화(invalidation)이고, 여러 캐시와 원본이 어긋나지 않게 유지하는 일이 넓은 의미의 캐시 동기화다.
무효화 전략은 크게 두 부류다. 시간이 지나면 버리는 것(TTL)과 바뀌었다는 신호를 받고 버리는 것(이벤트 기반). 그리고 이벤트 기반에서는 "무엇을 버릴 것인가"라는 범위의 문제가 따라온다. 순서대로 보자.
TTL: 신호가 없을 때의 안전망
지금까지 다룬 max-age와 staleTime이 전부 TTL(Time To Live) 방식이다. TTL의 본질은 낡음의 상한선을 정하는 것이다. 60초 TTL은 "우리 시스템에서 이 데이터는 최대 60초 낡을 수 있다"는 계약이고, 그 이상은 아무것도 보장하지 않는다.
TTL의 미덕은 신호가 필요 없다는 점이다. 데이터가 어디서 어떻게 바뀌는지 몰라도, 변경을 감지할 수단이 없어도 동작한다. 외부 API의 응답처럼 변경 신호를 받을 수 없는 데이터에는 사실상 유일한 선택지다. 약점은 두 방향으로 어긋난다는 것이다. 데이터가 안 바뀌었는데 만료되면 낭비고(불필요한 재조회), 만료 전에 바뀌면 낡은 데이터가 나간다. TTL만으로 시스템을 설계하면 "더 신선하게"와 "더 효율적으로"가 항상 충돌한다.
그래서 변경 신호를 만들 수 있는 데이터라면 다음 단계로 간다.
이벤트 기반: 바뀐 순간에 버리기
프론트엔드에서 가장 확실한 변경 신호는 **사용자 자신의 변경(mutation)**이다. 사용자가 장바구니에 상품을 담았다면, 장바구니 캐시가 낡았다는 것을 우리는 그 즉시 안다. TTL이 만료되기를 기다릴 이유가 없다.
const addToCart = useMutation({
mutationFn: (item: CartItem) => api.addToCart(item),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["cart"] });
},
});invalidateQueries는 해당 키의 캐시를 지우는 것이 아니라 "낡음" 표시를 하고, 화면에 떠 있는 쿼리라면 즉시 refetch한다. 사용자는 담기 버튼을 누른 직후 갱신된 장바구니를 본다.
여기서 무효화의 진짜 난제가 드러난다. 바로 범위다. 상품 42번의 가격을 수정했다고 하자. 낡아지는 캐시는 상품 42번 상세만이 아니다. 42번이 포함된 카테고리 목록, 검색 결과, 메인의 추천 목록, 42번을 담아둔 장바구니의 합계까지 전부 낡는다. 이 관련 캐시들을 어떻게 한 번에 조준하는가?
앞서 쿼리 키를 계층으로 설계해둔 것이 여기서 빛을 발한다. invalidateQueries는 접두사 매칭으로 동작하므로, ['products']를 무효화하면 그 아래 모든 목록과 상세가 함께 낡음 처리된다. 넓게 조준하면 안전하지만 refetch가 많아지고, 좁게 조준하면 효율적이지만 놓치는 캐시가 생긴다. 확신이 없다면 넓은 쪽이 낫다. 무효화 과잉의 비용은 불필요한 요청 몇 개지만, 무효화 누락의 비용은 사용자가 보는 낡은 화면이기 때문이다.
서버 사이드에도 같은 구조가 있다. Next.js는 데이터 요청에 태그를 달고 태그 단위로 서버 캐시를 무효화한다.
// 데이터를 가져올 때 태그를 달아둔다
fetch(`https://api.example.com/products/${id}`, {
next: { tags: [`product:${id}`, "products"] },
});// app/actions.ts — 가격 수정이 일어나는 곳에서 태그를 조준해 무효화한다
"use server";
import { revalidateTag } from "next/cache";
export async function updatePrice(id: string, price: number) {
await api.updatePrice(id, price);
revalidateTag(`product:${id}`); // 이 상품의 상세와
revalidateTag("products"); // 이 상품이 포함된 목록들까지
}CDN에도 서로게이트 키(surrogate key), 캐시 태그라는 이름으로 같은 기능이 있다. 응답에 Surrogate-Key: product-42 products처럼 태그를 붙여두고, 변경이 일어나면 태그를 지정해 캐시를 지우는(purge) API를 호출하는 식이다. 계층은 달라도 사고방식은 하나다. 캐시를 저장할 때 "이것은 어떤 데이터에 의존하는가"를 기록해두고, 그 데이터가 바뀔 때 기록을 따라 무효화한다.
아래 애니메이션은 변경 이벤트가 태그를 조준해 관련 캐시만 골라 갱신하는 흐름을 보여준다.
낙관적 업데이트: 무효화조차 기다리지 않기
이벤트 기반 무효화도 refetch의 왕복 시간만큼은 기다린다. 좋아요 버튼처럼 즉각적인 반응이 생명인 상호작용에서는 그 수백 ms도 길다. 그래서 한 발 더 나간다. 서버 응답을 기다리지 않고 캐시를 먼저 고쳐서 화면에 반영하고, 요청은 뒤에서 보내는 낙관적 업데이트(optimistic update)다.
const toggleLike = useMutation({
mutationFn: (postId: string) => api.toggleLike(postId),
onMutate: async (postId) => {
await queryClient.cancelQueries({
queryKey: ["posts", "detail", postId],
});
const previous = queryClient.getQueryData(["posts", "detail", postId]);
// 서버 응답 전에 캐시를 미리 고친다 (캐시가 비어 있으면 건드리지 않는다)
queryClient.setQueryData(
["posts", "detail", postId],
(old: Post | undefined) =>
old && {
...old,
liked: !old.liked,
likeCount: old.likeCount + (old.liked ? -1 : 1),
},
);
return { previous };
},
onError: (_err, postId, context) => {
// 실패하면 원래 값으로 되돌린다
queryClient.setQueryData(["posts", "detail", postId], context?.previous);
},
onSettled: (_data, _err, postId) => {
// 성공이든 실패든 마지막엔 서버 값으로 맞춘다
queryClient.invalidateQueries({
queryKey: ["posts", "detail", postId],
});
},
});코드가 길어진 이유는 실패에 대비해서다. 낙관적 업데이트는 "요청은 성공할 것"이라는 낙관 위에 화면을 먼저 바꾸는 것이므로, 낙관이 빗나갔을 때 되돌릴 스냅샷(previous)을 반드시 챙겨야 한다. 그리고 마지막의 invalidateQueries가 중요한데, 손으로 고친 캐시는 어디까지나 추측이므로 최종적으로는 서버의 진실로 덮어쓰는 것이다. 낙관적 업데이트는 무효화를 대체하는 것이 아니라 무효화 앞에 붙는 선반영이다.
화면 밖의 변경: 탭, 그리고 다른 사용자
지금까지의 동기화는 전부 "사용자 자신이 일으킨 변경"이었다. 그런데 변경은 지금 보고 있는 화면 밖에서도 일어난다. 사용자가 탭을 두 개 열어놓고 한쪽에서 장바구니를 비웠다면? 지금 읽고 있는 글에 다른 사용자가 댓글을 달았다면?
가장 값싼 방어부터 쌓자. React Query류 라이브러리에 기본으로 켜져 있는 **포커스 시 재검증(refetchOnWindowFocus)**이다. 사용자가 탭에 돌아온 순간 낡은 쿼리를 재검증한다. "다른 곳을 보고 온 사용자의 화면은 일단 의심한다"는 소박한 규칙인데, 탭 전환·창 전환으로 생기는 어긋남의 대부분을 이것만으로 잡는다.
같은 브라우저의 탭끼리는 더 적극적으로 맞출 수 있다. BroadcastChannel은 같은 출처(origin)의 탭들이 메시지를 주고받는 브라우저 API다. 한 탭에서 변경이 성공하면 다른 탭들에게 무효화를 방송한다.
const channel = new BroadcastChannel("cache-sync");
// 변경에 성공한 탭이 방송한다
channel.postMessage({ type: "invalidate", queryKey: ["cart"] });
// 다른 탭들은 받아서 자기 캐시를 무효화한다
channel.onmessage = (e) => {
if (e.data.type === "invalidate") {
queryClient.invalidateQueries({ queryKey: e.data.queryKey });
}
};다른 사용자의 변경까지 실시간으로 반영해야 한다면 서버가 신호를 밀어주는 수밖에 없다. 웹소켓이나 SSE(Server-Sent Events)로 변경 이벤트를 받아 해당 쿼리를 무효화하거나 캐시를 직접 갱신하는 것이다. 여기서 유용한 절충이 있는데, 서버가 바뀐 데이터 전체를 밀어주는 대신 "이 키가 낡았다"는 신호만 밀어주는 방식이다. 밀어줄 데이터의 형태(페이로드)를 설계할 필요 없이 기존 refetch 경로를 재활용할 수 있어서 도입 비용이 낮다.
다만 실시간 동기화는 분명한 필요가 있을 때만 꺼내는 것이 좋다. 채팅, 협업 편집, 시세처럼 초 단위 어긋남이 제품 결함인 영역이 아니라면, 포커스 시 재검증과 적절한 staleTime의 조합이 비용 대비 충분한 경우가 대부분이다. 동기화 수단은 강할수록 인프라(연결 유지, 재연결, 이벤트 순서)의 무게도 무거워진다.
지속 캐시와 오프라인
지금까지의 클라이언트 캐시는 전부 메모리에 있었다. 새로고침하면 사라진다는 뜻이다. 끝으로 세션을 넘어 살아남는 캐시들, 그리고 세션 안에서 브라우저가 거저 주는 캐시 하나를 짚어보자.
캐시를 디스크에 남기기
React Query의 캐시는 localStorage나 IndexedDB에 저장해뒀다가 다음 방문 때 복원할 수 있다(persist). 앱을 다시 열었을 때 스피너 대신 지난 세션의 데이터가 즉시 뜨고, 뒤에서 재검증이 도는 것이다. stale-while-revalidate를 세션 경계 너머로 확장한 셈인데, 모바일 웹처럼 재방문이 잦고 네트워크가 느린 환경에서 첫인상을 크게 바꾼다.
대신 두 가지를 챙겨야 한다. 첫째는 스키마 버전이다. 배포로 데이터 구조가 바뀌었는데 옛 구조의 캐시가 복원되면 런타임 에러가 된다. persist 도구들이 buster라는 버전 문자열을 받는 이유로, 구조가 바뀔 때 버전을 올리면 옛 캐시는 통째로 폐기된다.
import { persistQueryClient } from "@tanstack/react-query-persist-client";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
persistQueryClient({
queryClient,
persister: createSyncStoragePersister({ storage: window.localStorage }),
maxAge: 24 * 60 * 60 * 1000, // 이보다 오래된 캐시는 복원하지 않는다
buster: BUILD_ID, // 배포 버전 — 값이 바뀌면 이전 캐시를 통째로 버린다
});둘째는 민감 정보다. 디스크에 남는 캐시는 로그아웃 후에도, 공용 PC에서도 남는다. 무엇을 persist 대상에 포함할지는 편의가 아니라 보안 기준으로 골라야 한다.
서비스 워커: 요청 가로채기의 세계
서비스 워커는 페이지의 모든 네트워크 요청을 가로채서 캐시 응답과 네트워크 응답 중 무엇을 줄지 코드로 결정한다. 조합은 무한하지만 실전에서 쓰는 전략은 사실상 세 가지로 수렴하고, 전부 이 글에서 본 개념의 재조합이다.
Cache First는 캐시에 있으면 네트워크에 아예 안 나간다. 해시 붙은 정적 에셋처럼 안 바뀌는 리소스용이다. Network First는 네트워크를 먼저 시도하고 실패하면 캐시로 물러난다(폴백). 최신성이 중요하지만 오프라인에서도 뭔가는 보여주고 싶은 HTML이나 API용이다. Stale While Revalidate는 이제 설명이 필요 없을 것이다. 캐시를 즉시 주고 뒤에서 갱신한다.
서비스 워커가 고유하게 열어주는 것은 오프라인이다. HTTP 캐시는 어디까지나 최적화라 오프라인 동작을 보장하지 않지만, 서비스 워커의 캐시는 명시적으로 저장한 것을 명시적으로 지울 때까지 보관하므로 "비행기 모드에서도 열리는 앱"을 만들 수 있다. 뒤집으면 지우는 것도 온전히 개발자 책임이라는 뜻이다. 무효화 로직 없이 Cache First로 API 응답을 담기 시작하면 어떤 TTL도 없이 영원히 사는 캐시가 탄생한다. 서비스 워커 도입이 부담스러운 이유가 대개 캐싱 로직 자체보다 이 수명 관리에 있다.
뒤로가기는 공짜여야 한다: bfcache
앞서 예고한 "브라우저가 거저 주는 캐시"의 차례다. 이것은 설정해서 얻는 캐시가 아니라 망가뜨리지 않아야 하는 캐시다. 최신 브라우저는 사용자가 페이지를 떠날 때 페이지 전체를, 그러니까 DOM과 자바스크립트 상태까지 통째로 메모리에 얼려둔다. 뒤로가기를 누르면 네트워크도 재렌더링도 없이 얼려둔 페이지를 그대로 되살린다. 이것이 bfcache(back/forward cache)다.10
글 서두의 두 번째 장면, 뒤로가기에 스피너가 돌고 스크롤이 날아가는 문제의 가장 근본적인 해법이 이것이다. bfcache가 동작하면 클라이언트 캐시 설계조차 필요 없이 떠나기 직전 화면이 그대로 돌아온다. 문제는 이 캐시가 조건부라는 것이다. 페이지에 unload 이벤트 핸들러가 있으면 bfcache를 포기하는 브라우저가 많다(특히 데스크톱). 오래된 분석 스크립트가 습관처럼 걸어둔 unload 리스너 하나가 사이트 전체의 뒤로가기를 느리게 만들고 있는 경우가 실제로 흔하다. 정리 작업은 pagehide로 옮기고, 복원 시점의 처리가 필요하면 pageshow 이벤트의 persisted 플래그를 확인하면 된다.
// unload 대신 pagehide로 — bfcache를 막지 않는다
window.addEventListener("pagehide", flushAnalytics);
window.addEventListener("pageshow", (e) => {
if (e.persisted) {
// bfcache에서 복원된 화면 — 데이터가 낡았을 수 있으니 재검증만 건다
queryClient.invalidateQueries();
}
});Cache-Control: no-store가 붙은 페이지도 bfcache 대상에서 제외될 수 있으므로, 습관적으로 붙인 no-store가 없는지도 볼 일이다.
마치며
처음의 두 질문으로 돌아가자. 어디에 저장할 것인가, 언제 버릴 것인가. 이 글에서 다룬 내용은 결국 데이터 조각마다 세 가지 질문을 던지는 절차로 요약된다.
첫 질문은 이 데이터가 누구에게 같은가다. 모두에게 같으면 CDN과 서버 캐시까지 올려서 한 번의 생성을 모두가 나눠 쓰게 하고, 사용자마다 다르면 브라우저와 클라이언트 캐시에 머문다. 경계가 헷갈리는 화면은 껍데기와 개인화 데이터로 쪼개면 된다. 두 번째 질문은 얼마나 낡아도 되는가다. 그 답이 곧 max-age이자 staleTime이 된다. 답하기 겁나면 stale-while-revalidate로 갱신을 사용자의 대기 시간 밖으로 밀어내면 된다. 세 번째 질문은 바뀌었다는 신호를 만들 수 있는가다. 만들 수 있으면 이벤트 기반 무효화(invalidateQueries, revalidateTag, 캐시 태그 purge)로 바뀐 순간에 버리고, TTL은 신호가 닿지 않는 곳의 안전망으로 깐다. 무효화의 범위는 캐시 키와 태그의 계층 설계가 정한다.
캐싱은 흔히 성능 기법으로 분류되지만, 실제로 해보면 성능은 절반이고 나머지 절반은 일관성이다. 빠르게 만드는 것은 캐시를 늘리면 되니 쉽다. 어려운 것은 빨라진 시스템이 여전히 진실을 말하게 하는 것이다. 다행히 필요한 도구는 이미 다 있다. 침착하게 데이터를 분해하고 조각마다 세 질문을 던지면, 생각보다 갈 길이 명확하다.
Footnotes
-
"There are only two hard things in Computer Science: cache invalidation and naming things." 넷스케이프의 필 칼튼(Phil Karlton)이 남긴 말로 알려져 있다. ↩
-
크롬 개발자 도구의 네트워크 탭에서 "(memory cache)"라고 표시되는 응답이 이것이다. "(disk cache)"는 다음에 설명할 HTTP 캐시에서 온 것이다. ↩
-
RFC 9111. HTTP 캐싱의 동작을 정의한 표준 문서로, 브라우저뿐 아니라 CDN과 프록시도 이 명세를 따른다. ↩
-
2015년 크리스마스에 스팀(Steam) 상점이 캐싱 설정 실수로 다른 사용자의 계정 페이지를 보여준 사고가 대표적이다. DoS 공격으로 급증한 트래픽에 대응하며 급히 배포한 캐시 설정이 개인화된 페이지를 공유 캐시에 저장해버렸다. ↩
-
RFC 9111은 마지막 수정 시점부터 지금까지 흐른 시간의 10%를 유통기한으로 쓰는 방법을 예시로 들고 있고, 실제로 주요 브라우저가 이 방식을 쓴다. 1년 전에 수정된 파일이라면 약 36일 동안 재검증 없이 재사용될 수 있다는 뜻이다. ↩
-
immutable은 "유통기한 안에는 재검증 요청도 보내지 말라"는 힌트다. 사용자가 새로고침을 누르면 브라우저는 신선한 캐시도 재검증하려 드는데, 어차피 내용이 안 바뀌는 파일이니 그것도 생략하라는 뜻이다. 파이어폭스와 사파리가 이 지시어를 지원하고, 크롬은 지시어 대신 새로고침 때 서브리소스를 재검증하지 않도록 기본 동작 자체를 바꾸는 쪽을 택했다. 어느 쪽이든 결과는 같다. ↩ -
RFC 5861에 정의된 확장 지시어다. 브라우저 HTTP 캐시 기준으로는 크롬과 파이어폭스가 지원하고 사파리는 지원하지 않는다. CDN 계층에서는 대부분의 제품이 지원하므로 공유 캐시에서 특히 유용하다. ↩
-
thundering herd, dog-piling이라고도 부른다. 여럿이 몰려든다는 뜻은 다 같다. ↩
-
CDN 제품들은 같은 리소스에 대한 원본 요청을 하나로 합치는 request collapsing을 대부분 내장하고 있다. 원본 앞에 중간 캐시 계층을 하나 더 두는 origin shield도 원본 부하를 줄이는 같은 계열의 장치다. ↩
-
크롬 개발자 도구의 Application > Back/forward cache 패널에서 현재 페이지가 bfcache 가능한지, 안 된다면 무엇이 막고 있는지 확인할 수 있다. ↩