05 · 캐시 & 성능 (Cache & Performance)
이 챕터가 답하는 질문: GraphQL은 URL이 없는데 어떻게 캐시하나? 그리고 어떻게 네트워크 비용을 줄이나? 한 줄 답 (Pyramid Top): “HTTP 캐시가 안 듣는 자리는 클라이언트 정규화 캐시가 채우고, persisted query가 네트워크와 보안을 동시에 잡는다.”
이전 챕터(04 — 전송 계층)가 바이트를 어떻게 옮기는가를 다뤘다면, 이번 챕터는 그 바이트의 비용을 어떻게 낮추는가다. GraphQL이 HTTP 위에 있으면서도 HTTP 캐시의 혜택을 거의 못 받는다는 사실 — 이 챕터의 출발점이고, 거의 모든 GraphQL 성능 이야기가 여기서 갈라진다.
챕터 지도
읽는 순서
| # | 문서 | 읽는 데 | 누구에게 |
|---|---|---|---|
| 01 | 01-why-http-cache-fails | 10분 | ”왜 CDN이 GraphQL을 못 캐시하지?”가 궁금한 사람 |
| 02 | 02-normalized-client-cache | 14분 | Apollo/Relay/urql 공통 원리를 한 번에 잡고 싶은 사람 |
| 03 | 03-apollo-cache | 18분 | Apollo Client를 실제로 운영하는 사람 |
| 04 | 04-relay-store | 12분 | Relay·페이지네이션·connection이 궁금한 사람 |
| 05 | 05-persisted-queries | 10분 | 모바일/공개 API의 네트워크와 보안을 같이 잡고 싶은 사람 |
| 06 | 06-automatic-persisted-queries | 8분 | build-time 등록을 안 하고 자동으로 풀고 싶은 사람 |
| 07 | 07-response-cache-server-side | 12분 | 서버 사이드에서 응답 자체를 캐시하려는 사람 |
추천 동선: 01 → 02 → 03(또는 04 중 본인의 클라이언트). 그 다음 05·06으로 네트워크 비용, 마지막에 07로 서버 비용.
4개 캐시 레이어 비교
GraphQL 시스템은 캐시를 한 군데가 아닌 4군데 다른 레이어에 둔다. 각 레이어가 다른 문제를 푼다.
| 레이어 | 어디서 | 무엇을 캐시 | 키 | 무엇이 풀리나 | 한계 |
|---|---|---|---|---|---|
| HTTP cache (브라우저·CDN) | 클라이언트·중간 노드 | HTTP 응답 본문 | URL + 헤더 | 정적 자원과 GET 응답 | POST+body → 키 충돌, 거의 무용 |
| Client normalized cache | 브라우저 메모리 | entity(id 기반) | __typename:id | 화면 간 일관성·재요청 제거 | 메모리, id 없으면 깨짐 |
| Persisted Query (네트워크) | 클라이언트 ↔ 서버 사이 | query string의 hash 매핑 | query hash | 본문 크기·CDN 캐시 가능성·allowlist 보안 | 응답 자체는 캐시 아님 |
| Server response cache | 서버 메모리·Redis | 응답 JSON 자체 | (query, variables, auth) | 동일 요청 → DB 안 거치고 즉시 반환 | 권한·실시간성 처리 어려움 |
이 4개는 합쳐서 쓴다. 각각이 다른 비용을 줄인다 — HTTP cache는 바이트, normalized cache는 재요청, persisted query는 업스트림 본문, server response cache는 resolver 실행.
Why — 왜 GraphQL의 캐시는 별개 챕터인가
REST의 성능 모델은 거의 공짜다 — URL이 키, ETag가 검증자, Cache-Control이 정책이고 모든 중간 노드가 그 약속을 알고 있다. GraphQL은 그것을 전부 깬다.
| 깨진 것 | 왜 깨지나 | 이 챕터의 어느 문서가 답하나 |
|---|---|---|
| URL = key | 모든 요청이 /graphql 한 URL | 01 |
| GET 친화성 | 기본은 POST + JSON body | 01, 05, 06 |
| ETag | 응답 모양이 매번 달라 검증 의미 약함 | 01 |
| CDN | URL이 같아 캐시 분리 불가 | 01, 06 |
| 클라이언트 캐시 | 응답이 trees라 합치기 어려움 | 02, 03, 04 |
이 챕터는 깨진 5개 자리에 각각 어떤 대체재가 들어왔는가를 한 층씩 보여준다.
How — 어떻게 읽나
- Apollo를 쓰는 프론트엔드 개발자: 01 → 02 → 03.
typePolicies만 알아도 60% 사고 예방. - Relay 사용자/Facebook 스타일: 01 → 02 → 04. global id와 connection spec이 곧 캐시 전략이다.
- 백엔드 인프라: 01 → 05 → 06 → 07. 네트워크 비용과 서버 비용 절감을 따로.
- 공개 API를 운영(GitHub/Shopify처럼): 01 → 05 → 07. allowlist 보안 + scoped cache hint.
- CDN 캐시까지 가고 싶은 모바일 팀: 06 → 05. APQ + GET 변환이 답.
What — 7개 문서 한 줄 결론
| # | 문서 | 한 줄 답 |
|---|---|---|
| 01 | Why HTTP cache fails | POST+body는 URL 캐시를 깨고, 응답 모양이 매번 달라 ETag도 약하다 — 그래서 다른 4개 레이어가 필요했다. |
| 02 | Normalized client cache | 응답 트리를 id 기준 entity 그래프로 분해해 저장하면, 한 entity의 갱신이 모든 화면에 자동 전파된다. |
| 03 | Apollo InMemoryCache | typePolicies로 key·merge·read 3가지를 정해주는 것이 곧 캐시 정책 그 자체다. |
| 04 | Relay Store | Node interface + 글로벌 id + Connection spec — 이 셋이 Relay의 캐시는 그 자체로 일관된 그래프가 되도록 강제한다. |
| 05 | Persisted Queries | 클라이언트가 query string 대신 hash만 보내면 — 본문은 작아지고, allowlist는 곧 introspection 차단에 가까운 보안이 된다. |
| 06 | Automatic Persisted Queries | 빌드 타임 등록 없이도, 첫 miss에서 query를 함께 보내 자동 등록 — 이후 모든 요청은 GET + hash라 CDN 친화적이 된다. |
| 07 | Server response cache | @cacheControl(maxAge, scope) 힌트를 필드마다 붙이고, plugin이 그 힌트를 합성해 응답 단위 TTL과 scope을 정한다. |
What-if — 이 챕터를 건너뛰면
- HTTP 캐시만 믿으면: 같은 사용자가 같은 화면을 띄울 때마다 서버를 매번 부른다 — 모바일에서 배터리와 데이터가 동시에 깨진다.
- 클라이언트 정규화 캐시를 안 쓰면: 한 화면에서
like를 누르면 다른 화면의 like 카운트가 안 바뀐다 — 사용자는 “버그”라고 느낀다. typePolicies를 안 정하면: id가 없는 type 응답들이 덮어쓰기 충돌로 데이터 손실을 일으킨다 — Apollo의 console warning이 그 신호.- persisted query를 안 쓰면: 5KB 짜리 query를 매 호출마다 보낸다 — 모바일 데이터 + 서버 파싱 비용이 동시에.
- 서버 response cache를 안 쓰면: 같은 인기 글의 동일 쿼리가 수천 번 DB까지 내려간다 — DataLoader도 못 막는 fan-out.
Insight — 한 단락 이야기
“REST는 캐시가 공짜였고, GraphQL은 캐시가 전략이 되었다”
REST 시대에 캐시는 프로토콜의 부산물이었다 — URL이 키,
Cache-Control이 정책, ETag가 검증자, CDN이 운반자. 서버가 코드를 쓰지 않아도 캐시는 동작했다. GraphQL이 들어오면서 그 모든 약속이 한 줄로 깨졌다 — “모든 요청은 POST/graphql”. 캐시는 더 이상 공짜가 아니었다. 그래서 등장한 것이 4개 레이어의 캐시 모자이크다. 브라우저 옆에는 normalized cache가 자랐고, 네트워크 위에는 persisted query가 얹혔고, 서버에는 response cache plugin이 붙었고, 모바일 앱은 APQ로 CDN을 되찾았다. GraphQL의 캐시는 사라진 것이 아니라 — 사방으로 분산되어 각자의 자리에서 다시 짜인 것이다.
한 단락 요약
GraphQL의 캐시는 HTTP 한 자리에 있지 않고 4개 레이어에 분산되어 있다. 01이 왜 분산되었는지를 설명하고, 02
04가 클라이언트 쪽 정규화를, 0506이 네트워크 비용을, 07이 서버 응답 캐시를 다룬다. 이 챕터를 끝내면 “GraphQL은 캐시가 안 된다”라는 오해가 사라지고, 대신 “이 비용은 어느 레이어에서 줄여야 하나” 라는 결정 가능한 질문이 남는다. 다음 챕터(06-federation)는 한 그래프를 여러 팀이 나눠 가질 때 캐시·실행·스키마가 어떻게 다시 짜이는가를 다룬다.