🔷 GraphQL5. 캐시 & 성능05 · Persisted Queries

05 · Persisted Queries — 한 hash가 본문과 보안을 동시에 푼다

질문: GraphQL query는 수 KB가 흔하다. 모바일에서 매 호출마다 그걸 보내야 하나? 그리고 임의의 query를 누구나 보낼 수 있다는 건 보안상 괜찮나? 한 줄 답: 클라이언트가 query string 대신 hash만 보내면 — 본문은 작아지고, 서버는 등록된 hash만 받는 allowlist로 동작해 대역폭 절약 + 보안을 동시에 얻는다.


Pyramid Top

이전 문서들이 클라이언트 쪽 캐시(정규화·Apollo·Relay)를 다뤘다면, 이번 문서는 네트워크 자체의 비용을 줄이는 레이어다. Persisted Query는 원시적인 만큼 강력한 트릭이다 — “query string은 빌드 타임에 고정되니까 hash만 보내자”. 이 한 줄에서 세 가지 다른 효과가 동시에 나온다 — 바이트, allowlist 보안, CDN cacheability. 부수 효과가 본 효과를 압도하는 드문 사례다.


사고 흐름


Why — 한 트릭이 3가지를 동시에 푼다

① 대역폭 절약 — 5KB → 64 bytes

// 일반 GraphQL 요청
POST /graphql
{
  "query": "query Feed($cursor:String) { posts(after:$cursor, first:10) { edges { node { id title author { id name avatar { url } } likes comments(first:3) { ... } } } pageInfo { hasNext endCursor } } }",  // ~2KB
  "variables": { "cursor": "abc" }
}
 
// Persisted Query 요청
POST /graphql
{
  "id": "a1b2c3d4...",  // 64 hex chars
  "variables": { "cursor": "abc" }
}

모바일에서 매 호출 2KB가 줄어드는 효과 — Shopify가 2017년 발표에서 전체 GraphQL 트래픽의 ~40% 감소를 보고했다.

② Allowlist 보안 — 등록된 query만 실행

서버 { hash → query } registry는 whitelist다.

  • 알려진 hash → 그 query 실행.
  • 모르는 hash → 거부.
  • 전체 query string애초에 전송 안 됨 → 서버는 임의 query를 받을 길이 닫힘.

이게 introspection 차단에 가까운 효과다. 공격자가 서버 스키마를 추측해서 임의 query를 만들어도 hash가 없으니 실행 안 됨.

효과차단되는 공격
Allowlist임의 query injection, 복잡도 폭탄 (depth 100짜리 query)
Build-time registration클라이언트가 모르는 query를 만들 수 없음
Hash 검증introspection으로 발견한 fields를 직접 호출 불가

즉, persisted query는 부수 효과로 보안 강화가 따라온다. 이 효과가 그 자체로 도입 이유가 될 만큼 크다.

③ CDN cacheability — GET URL이 작아지므로 가능

GET /graphql?id=a1b2c3&variables={"cursor":"abc"}

URL이 짧아 GET으로 변환 가능. RFC상 GET은 cacheable. CDN(Cloudflare/Fastly)이 완전한 URL을 key로 쓸 수 있다. → 06-automatic-persisted-queries에서 자세히.


How — 빌드 파이프라인

1단계: 추출

# Apollo 진영
apollo client:extract --output extracted-queries.json
 
# Relay 진영
yarn relay  # relay-compiler가 자동으로 persisted_queries.json 생성
 
# graphql-codegen 진영
graphql-codegen --config codegen.yml

도구가 프로젝트 안의 모든 gql 태그를 스캔해서, 각 query에 sha256 hash를 붙인 manifest를 만든다.

2단계: 서버 등록

// Apollo Server에서
import { PersistedQueryManifest } from "@apollo/server";
 
const server = new ApolloServer({
  persistedQueries: {
    cache: new InMemoryLRUCache(),
    // 또는 build-time manifest
    ttl: null,  // 영구 보존
  },
  plugins: [
    ApolloServerPluginUsageReporting(),
    // 또는 직접:
    {
      async requestDidStart() {
        return {
          async didResolveOperation(ctx) {
            const hash = ctx.request.extensions?.persistedQuery?.sha256Hash;
            if (hash && !registry.has(hash)) {
              throw new GraphQLError("PersistedQueryNotFound", {
                extensions: { code: "PERSISTED_QUERY_NOT_FOUND" },
              });
            }
          },
        };
      },
    },
  ],
});

3단계: 클라이언트 링크

import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries";
import { sha256 } from "crypto-hash";
 
const link = createPersistedQueryLink({
  sha256,
  // 핵심: useGETForHashedQueries: true 면 GET으로 전송 (CDN 친화)
  useGETForHashedQueries: true,
}).concat(httpLink);

이 link가 자동으로:

  • query를 sha256 hash로 치환.
  • 응답이 PersistedQueryNotFound면 자동으로 전체 query를 다시 보냄(이건 APQ — 다음 문서).
  • 성공이면 작은 본문만 보냄.

What — Build-time vs Automatic (APQ)

차원Build-time Persisted QueryAutomatic Persisted Query (APQ)
등록 시점빌드 타임에 명시적첫 요청 시 자동
보안완전 allowlistallowlist 효과 약함 (모든 query 자동 등록)
운영 부담manifest 배포 필요0
첫 요청 비용01회 추가 round trip
어디서 쓰나모바일·공개 API·금융일반 웹·prototyping

이 두 가지는 다른 자리에서 쓴다. Build-time persisted는 보안이 본 목적이고, APQ는 대역폭이 본 목적이다.


Shopify·Facebook·GitHub의 사용 사례

회사어떻게 쓰나
ShopifyStorefront API는 build-time persisted query 의무 — 공식 SDK가 매니페스트 자동 처리
Facebook (Meta)내부 모바일 앱은 100% persisted query. introspection은 production에서 꺼져 있고 staging에서만 켜진다
GitHub v4persisted query 의무 아님 — 공개 API라 자유도 우선
CoinbaseRelay + persisted query 의무 — 보안 통제
1Passwordpersisted query 의무 + 모든 mutation에 별도 hash 등록

보안이 중요한 곳은 persisted query 의무. 공개 자유도가 중요한 곳옵션.


What-if — persisted query 없이 운영하면

사례결과
모바일에서 매 호출 5KB query 전송셀룰러 데이터·배터리·서버 파싱 비용 모두 ↑
임의 query 허용공격자가 depth 50짜리 query로 서버 죽일 수 있음
introspection 켜둠스키마 전체 노출 → mutation 이름까지 알려짐
CDN 캐시 시도URL이 같아 hit 0% (POST + body)
query 변경 시 클라이언트만 배포OK (서버는 모름) — 보안 통제 완전 깨짐

흥미로운 이야기

Facebook은 내부적으로 한 번도 query string을 production에 보낸 적이 없다

2018년 Lee Byron 발표: “FB 모바일 앱은 GraphQL 시작부터 persisted query였다.” 그들에게 query string을 그대로 보낸다는 발상옵션이 아니라 고려 자체가 없었다. 외부 개발자에게 공개된 Apollo Client의 기본query string을 그대로 보내는 것이었고 — Facebook은 한참 뒤에야 *왜 다른 사람들은 저렇게 하지?*라는 의문을 가졌다고 한다. 그 결과 2018년 Apollo의 APQ가 탄생했고, Facebook은 외부 생태계에 보안 모범 사례를 역수출한 셈이 되었다. 때로는 너무 당연한 것너무 늦게 표준화된다.


Insight — hash 하나3개의 문제를 한꺼번에 푼다

한 가지 단순한 메커니즘세 가지 직교 효과를 동시에 낸다. 이게 좋은 추상의 시그니처다 — 얇은 메커니즘 + 두꺼운 효과.


한 단락 요약

Persisted Query는 클라이언트가 query string 대신 sha256 hash만 보내는 메커니즘이다. 이 한 트릭이 대역폭(5KB→64bytes)·보안(allowlist)·CDN cacheability 3가지를 동시에 푼다. 빌드 타임에 manifest를 추출해 서버 registry에 등록하면, 서버는 등록된 hash만 실행하는 화이트리스트로 동작한다. Shopify·Meta·Coinbase의무로 쓰고, GitHub옵션으로 둔다 — 보안 강도가 도입 결정의 변수다. 하지만 build-time 등록의 운영 부담이 있어, 그것을 자동화한 게 다음 문서의 Automatic Persisted Query — 보안은 약하지만 운영 부담 0인 변종.