🔷 GraphQL5. 캐시 & 성능01 · Why HTTP cache fails

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는 아니다

RFC 7231 §4.2.3:

Cacheable methods include only GET, HEAD, and POST with 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모든 요청 /graphqlbody가 key에 안 들어감
GET = safe & cacheable기본 메서드POST가 표준RFC상 POST는 캐시 불가가 기본
Cache-Control: max-age자원 단위 freshness어디에 붙이지?모든 응답이 같은 URL이라 분리 불가
ETag + If-None-Matchcontent hashcontent가 매번 다른 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 — 클라이언트가 자기 손으로 캐시 데이터베이스가 되는 법.