01 · Why HTTP cache fails — GraphQL은 왜 HTTP 캐시를 못 쓰나
질문: GraphQL도 HTTP 위에서 동작하는데, 왜 REST와 똑같이 캐시되지 않나? 한 줄 답: 키가 URL이 아니라 body 안의 query·variables이고, 응답 모양도 매 요청마다 달라서 — HTTP가 알고 있는 3가지 약속(URL=key, GET=safe, ETag=validator)이 모두 깨진다.
Pyramid Top
REST의 캐시는 공짜였다. URL이 키, GET이 안전한 메서드, Cache-Control이 정책, ETag가 검증자였고 — 모든 중간 노드(브라우저·프록시·CDN)가 그 약속을 코드 한 줄 없이 알고 있었다.
GraphQL은 그 약속을 한 줄로 깬다 — “모든 요청은 POST /graphql이고 body 안에 query·variables가 있다.” 이 한 줄이 HTTP 캐시 인프라 전체를 무력화한다. 이 문서는 정확히 어디가 깨지는지, 그리고 GET + persisted query가 부분적으로 복구하는 방법을 다룬다.
사고 흐름
Why — 왜 HTTP 캐시가 잘 듣지 않나
HTTP 캐시(브라우저·forward proxy·reverse proxy·CDN)는 3가지 약속 위에 동작한다. GraphQL은 그 3가지를 모두 위반한다.
① URL이 캐시 key다 — 그런데 GraphQL은 URL이 하나다
GET /api/users/42 ← 캐시 key: "GET /api/users/42"
GET /api/users/43 ← 캐시 key: "GET /api/users/43"REST에서는 서로 다른 자원이 서로 다른 URL을 가졌고, 캐시 노드는 URL만 비교해서 분리했다. GraphQL은?
POST /graphql ← body: { query: "user(id:42){...}" }
POST /graphql ← body: { query: "user(id:43){...}" }같은 URL + 같은 메서드. 캐시는 둘을 구분할 수 없다. RFC 7234는 body를 캐시 key로 쓰는 것을 보장하지 않는다 — 즉 거의 모든 중간 노드가 둘을 하나로 본다.
② GET이라야 cacheable의 기본값이다 — POST는 아니다
Cacheable methods include only
GET,HEAD, andPOSTwith explicit freshness information.
POST는 원칙적으로 캐시 불가다. 일부 캐시가 Cache-Control: public이 명시되면 캐시하긴 하지만 — body를 key로 쓰지 않기 때문에 여전히 충돌한다.
GET + query string으로 보내면? — 가능하다. 그러나 query string에 5KB짜리 GraphQL 쿼리를 넣으면 URL 길이 제한(~2KB, 서버마다 다름)을 깬다. 그래서 등장한 우회로가 persisted queries — query를 hash로 줄여서 GET URL에 실는다.
③ ETag·If-None-Match가 의미 있으려면 응답이 같은 모양이어야 한다
REST GET /users/42 응답은 언제 호출해도 같은 필드를 돌려준다. ETag는 내용의 해시고, If-None-Match로 304를 받을 수 있다.
GraphQL은?
# 화면 A
query { user(id: 42) { name } }
# 화면 B
query { user(id: 42) { name email avatar { url } } }같은 user 42인데 응답 JSON이 완전히 다르다. 한 화면의 ETag는 다른 화면의 ETag와 다르다. 의미 있는 검증자가 되려면 ETag가 (query, variables) 단위여야 하는데 — 그건 이미 body 의존이라 캐시 key 문제로 돌아온다.
How — 무엇이 정확히 깨지나, 한 표로
| HTTP 캐시 메커니즘 | REST에서 | GraphQL에서 | 깨진 이유 |
|---|---|---|---|
| URL = cache key | 자원마다 다른 URL | 모든 요청 /graphql | body가 key에 안 들어감 |
| GET = safe & cacheable | 기본 메서드 | POST가 표준 | RFC상 POST는 캐시 불가가 기본 |
Cache-Control: max-age | 자원 단위 freshness | 어디에 붙이지? | 모든 응답이 같은 URL이라 분리 불가 |
ETag + If-None-Match | content hash | content가 매번 다른 selection | 검증자 의미 약화 |
Vary: header | 헤더 차이로 분기 | body 차이를 헤더로 못 만듬 | Vary는 body 변수가 없음 |
| CDN (Cloudflare/Fastly) | URL 단위 캐시 | 모든 키가 /graphql | 단일 키로 collapse |
핵심 결론: HTTP 캐시 인프라는 URL 중심이고, GraphQL은 body 중심이다. 두 패러다임이 호환되지 않는다.
What — GET + Persisted Query가 부분적으로 복구한다
깨진 3가지를 모두 완전히 되돌리는 방법은 없지만, GET + persisted query가 ①과 일부 ②를 살린다.
원리:
- query string은 항상 같은 hash다 → URL에 실을 수 있을 만큼 짧다.
- 그래서 GET으로 보낼 수 있다 → RFC상 cacheable.
- variables만 query string에 추가 → CDN이 완전한 URL을 키로 쓸 수 있다.
여전히 안 풀리는 것:
- ③ ETag는 완전히 의미를 가지진 않는다 — variables만 달라도 응답이 통째로 다르므로, 검증자보다는 fresh/stale이 더 의미 있다.
- 클라이언트 캐시 일관성은 여전히 별개 문제다 — like 카운트가 한 화면에서 바뀌어도 다른 화면이 자동 갱신되지 않는다. 그건 정규화 캐시가 푼다.
즉, GET + persisted query는 바이트의 캐시는 살리지만, 데이터의 캐시는 못 살린다. 이 두 가지는 다른 레이어에 있다.
What-if — HTTP 캐시 약속을 무시하면 무엇이 일어나나
| 시도 | 결과 |
|---|---|
| Apollo Server를 그대로 CDN(Cloudflare) 뒤에 둔다 | 모든 요청이 POST /graphql → CDN은 항상 pass-through. 캐시 hit ratio ≈ 0%. |
Cache-Control: max-age=60을 GraphQL 응답에 붙인다 | 브라우저가 캐시하긴 하는데, body가 같은 다른 요청에도 잘못된 캐시가 hit. 데이터 누설 가능. |
| ETag를 hash로 붙인다 | 매 요청마다 다른 selection → 매번 다른 ETag → 304 거의 없음. 비용 그대로. |
| query string으로 POST를 GET처럼 흉내낸다 | 1KB 넘는 query는 URL too long. 서버·프록시·로그에서 깨진다. |
Vary: x-query-hash로 분기한다 | 클라이언트가 매번 hash 헤더를 보내야 함 + CDN이 그 헤더로 분기해야 함. 결국 persisted query와 같은 길. |
모든 우회로가 결국 persisted query로 수렴한다 — 자연선택이 한 답으로 좁혀진 셈.
흥미로운 이야기
Apollo는 한때 자기 헬프 문서에 “GraphQL을 CDN 뒤에 두지 마시오”라고 명시했다
2017년경 Apollo Server 1.x 문서는 *“CDN 캐시는 GraphQL에 도움이 되지 않는다”*고 명시했다. 같은 시기에 Lee Byron(GraphQL 공동 창시자)이 Reddit AMA에서 “URL이 없는 게 GraphQL의 가장 큰 트레이드오프”라고 답했다. 그리고 그 다음 해인 2018년, Apollo 팀은 Automatic Persisted Queries를 발표하며 GET URL + sha256으로 CDN 캐시 가능성을 복원했다. 일부 회사(Shopify, Facebook)는 이미 build-time persisted query를 자체 구현해서 같은 문제를 풀고 있었다. 즉 **“GraphQL을 CDN 뒤에 두지 마라”는 1년 만에 “persisted query를 거치면 CDN 뒤에 둘 수 있다”로 뒤집혔다 — 도메인 지식의 절반은 어떤 우회로가 표준이 되었나의 역사다.
Insight — 캐시 책임의 재분배
REST의 캐시 모델은 한 곳에 응축되어 있었다 — HTTP 프로토콜이 모든 책임을 떠안았다.
깨진 한 자리에 4개의 대체재가 들어왔다. 이게 GraphQL 캐시는 어렵다가 아니라 분산되었다가 정확한 설명이다.
한 단락 요약
HTTP 캐시는 URL=key, GET=safe, ETag=validator 3가지 약속 위에 동작한다. GraphQL은 POST + body + 가변 selection으로 그 셋을 모두 깬다 — 그 결과 CDN·브라우저 캐시·forward proxy가 거의 무용해진다. GET + persisted query가 ①URL 약속은 부분 복구하지만, 응답 모양이 매번 다르다는 본질적 사실은 못 푼다. 그래서 다음 문서들이 다루는 4개 다른 레이어의 캐시가 필요했다. 다음:
02-normalized-client-cache— 클라이언트가 자기 손으로 캐시 데이터베이스가 되는 법.