캐시 관련 API가 너무 많다
SWR, React Query, Next.js의 revalidateTag와 revalidatePath, Full Route Cache, Router Cache, Data Cache, Request Memoization, Service Worker Cache, HTTP Cache-Control, ETag, Vary, useMemo, React.memo, React.cache, useCallback. 프론트엔드 코드에서 "캐시"라는 단어가 들어간 API를 나열하면 한 문단으로도 모자란다.
나도 처음엔 이것들이 각자 다른 문제를 푸는 줄 알았다. React Query는 서버 데이터, useMemo는 비싼 계산. CDN은 응답 전송, Service Worker는 오프라인. 그런데 한 걸음 물러서서 보니 결국 다 같은 질문을 풀고 있다.
"이거, 아직 안 바뀌었나?"
30년 전에 이미 나온 얘기다
Netscape의 엔지니어였던 Phil Karlton이 1990년대에 남긴 한 줄짜리 농담이 30년 동안 돌아다닌다.
"There are only two hard things in Computer Science: cache invalidation and naming things."

30년이 지난 지금도 이 문장은 어딘가에 계속 인용된다. 문장이 재밌기도 하지만 아직도 안 풀린 문제라서일 거다.
왜 안 풀릴까
캐시가 있는 이유는 단순하다. 같은 걸 반복 안 하려고. 그런데 안 바뀌었다는 걸 어떻게 판정하나.
방법은 크게 두 가지다.
하나는 시간이다. TTL. 30분 지났으면 낡았다고 친다. 간단한데 정확하진 않다. 30분 안에 바뀌었으면 그동안 틀린 값을 주고 있었던 거고, 안 바뀌었으면 쓸데없이 다시 가져온 거다.
다른 하나는 실제로 비교하는 거다. ETag, 해시, 버전 번호. 그런데 비교하려면 원본을 봐야 한다. 원본을 볼 거면 캐시가 왜 있나. 그래서 원본 대신 "지문"만 비교하는 식으로 타협한다.
여기서 앞뒤가 안 맞기 시작한다. 바뀌었는지 알려면 봐야 하는데, 안 보려고 캐시를 쓴다. 도구가 좋아져도 이 대목은 도통 없어지지 않는다. 기술이 부족해서가 아니라 문제 자체가 그렇게 생긴 것 같다.
프론트엔드의 절반이 이 문제를 푼다
과장처럼 들릴 수 있으니 하나씩 보자.
React Query와 SWR은 하는 일이 거의 다 서버 데이터의 캐시 무효화다. SWR이라는 이름 자체가 stale-while-revalidate의 약자다. "낡았다는 걸 알고 있지만 일단 보여주고, 뒤에서 조용히 새로 받아온다." 이것도 일종의 타협이다.
Next.js App Router의 캐시 계층은 네 층이다. Full Route Cache, Router Cache, Data Cache, Request Memoization. 각 층이 다른 기준으로 "이건 언제까지 믿어도 되는가"를 판정한다. revalidateTag와 revalidatePath는 그 판정을 뒤집는 API다.
React 내부로 들어가자. useMemo, useCallback, React.memo. 이게 뭐 하는 거냐면, 의존성 배열을 얕게 비교해서 "안 바뀌었나"를 판정하는 캐시 무효화 장치다. useEffect의 의존성 배열도 같은 질문이다. "언제 다시 실행할 거냐"는 "언제 이전 결과를 버릴 거냐"와 같은 말이다.
브라우저 쪽으로 내려가면 HTTP의 Cache-Control, ETag, Last-Modified, Vary, stale-while-revalidate가 있다. Service Worker에는 Cache API가 있고 CDN에는 edge cache와 purge API가 있다.
프론트엔드 개발자가 매일 다루는 거의 모든 레이어가 이 한 질문에 각자의 답을 내고 있다.
레이어마다 답이 다르다
CDN은 보통 TTL로 간다. 거칠고 단순하다. 사용자 몇 명이 잠깐 옛날 데이터를 보는 정도는 감수한다.
React Query는 key와 stale-time으로 간다. 개발자가 "이 키의 데이터는 30초 동안 믿어도 된다"고 선언한다. 선언적이긴 한데, 결국 매번 개발자가 판단한다.
Next.js는 tag로 간다. fetch에 태그를 붙여두고 revalidateTag("posts")로 한 번에 무효화한다. "무엇이 바뀌었는가"를 개발자가 명시해주는 방식이다.
React는 의존성 배열로 간다. 참조를 얕게 비교한다. 빠르지만 틀리기 쉽다. 그래서 React Compiler가 나왔다. "의존성 배열을 네가 직접 쓰지 말고, 컴파일러가 넣게 하자." 같은 문제를 한 층 위에서 다시 풀고 있는 거다.
한 질문에 이렇게 많은 답이 붙어 있는 걸 보면, 하나로 정리되는 정답은 없는 것 같다. 남는 건 상황에 맞는 선택이다. 어느 쪽에서 틀리는 게 덜 아픈지, 개발자가 어디까지 개입할지, 그 도메인이 얼마나 엄격한지.
결국 판단은 사람 몫이다
캐시 무효화를 알아서 다 처리해주는 프레임워크는 아직 못 봤고, 나오기도 어려울 것 같다. "무엇이 변했는가"가 도메인마다 다르기 때문이다. 주식 가격은 1초의 오차도 안 된다. 블로그 글은 한 시간 늦어도 되고, 유저 프로필은 본인에겐 즉시 남에겐 하루쯤 괜찮다.
이걸 프레임워크가 미리 알기는 어렵다. 그래서 매개변수로 개발자에게 넘어온다. stale-time, revalidate, Cache-Control, 의존성 배열. 이 설정들은 프레임워크가 정해줄 수 없어서 사람에게 넘어온 것이다.
이걸 그냥 설정이라고 부르면서 별생각 없이 받아들인다. 그런데 따지고 보면 Karlton이 30년 전에 이미 짚은 그대로다. 기계로 풀 수 없는 문제니까 결국 사람이 개입한다.
"데이터가 왜 안 바뀌어요"라는 질문을 받았을 때 원인이 한두 군데가 아닌 것도 그래서다. 어디부터 봐야 할지 감이 안 올 때가 많다. CDN, SWR 키, Service Worker, 의존성 배열, Next.js tag. 각 레이어가 각자의 기준으로 "안 바뀌었다"고 판단한 결과가 한 화면 위에 겹쳐 있는 거다.
이 문제는 30년이 지나도 안 풀렸고, 한동안은 계속 그럴 거다. 지나고 보니 도구만 계속 바뀌었지 문제는 그대로다.
참고 자료
- Martin Fowler, TwoHardThings, Phil Karlton의 문장과 그 출처를 추적한 글
- Next.js 공식 문서, Caching and Revalidating (Next.js 16 Cache Components 이전 모델 문서)