Ray Book
에세이

캐시 무효화는 왜 30년째 풀리지 않는가, Phil Karlton의 한 줄과 현대 프레임워크의 절반

1996년 Netscape 엔지니어가 남긴 한 줄의 농담이 왜 2026년의 우리 프레임워크를 설명하는가. 캐시 무효화가 안 풀리는 건 기술이 부족해서가 아니다.

essaycachingengineeringphilosophyopinion

캐시 관련 API가 너무 많다

SWR, React Query, Next.js의 revalidateTagrevalidatePath, RSC 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는 오프라인. 그런데 한 걸음 물러서서 보니 결국 다 같은 질문을 풀고 있다.

"이거, 아직도 같은가?"

1996년에 이미 요약된 문제

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. 각 층이 다른 기준으로 "이건 언제까지 믿어도 되는가"를 판정한다. revalidateTagrevalidatePath는 그 판정을 뒤집는 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이 1996년에 이미 짚은 그대로다. 기계로 풀 수 없는 문제니까, 결국 사람이 개입한다.

"데이터가 왜 안 바뀌어요"라는 질문을 받았을 때 범인이 한두 군데가 아닌 것도 그래서다. CDN, SWR 키, Service Worker, 의존성 배열, Next.js tag. 각 레이어가 각자의 기준으로 "아직도 같다"고 판단한 결과가 한 화면 위에 겹쳐 있는 거다.

이 문제는 30년이 지나도 안 풀렸고, 앞으로도 안 풀린다. 우리는 그냥 가장 최근의 버전으로 이 문제를 안고 있을 뿐이다.