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 Query | Automatic Persisted Query (APQ) |
|---|---|---|
| 등록 시점 | 빌드 타임에 명시적 | 첫 요청 시 자동 |
| 보안 | 완전 allowlist | allowlist 효과 약함 (모든 query 자동 등록) |
| 운영 부담 | manifest 배포 필요 | 0 |
| 첫 요청 비용 | 0 | 1회 추가 round trip |
| 어디서 쓰나 | 모바일·공개 API·금융 | 일반 웹·prototyping |
이 두 가지는 다른 자리에서 쓴다. Build-time persisted는 보안이 본 목적이고, APQ는 대역폭이 본 목적이다.
Shopify·Facebook·GitHub의 사용 사례
| 회사 | 어떻게 쓰나 |
|---|---|
| Shopify | Storefront API는 build-time persisted query 의무 — 공식 SDK가 매니페스트 자동 처리 |
| Facebook (Meta) | 내부 모바일 앱은 100% persisted query. introspection은 production에서 꺼져 있고 staging에서만 켜진다 |
| GitHub v4 | persisted query 의무 아님 — 공개 API라 자유도 우선 |
| Coinbase | Relay + persisted query 의무 — 보안 통제 |
| 1Password | persisted 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인 변종.