02 — GET vs POST

질문: GraphQL 요청은 GET·POST 중 어느 쪽을 써야 하는가? 그 선택은 무엇을 잠그고 무엇을 열어주는가? 한 줄 답: Read-only query는 GET·POST 둘 다 가능하다 — GET은 URL을 캐시 키로 쓸 수 있어 CDN 캐시가 열린다. 반면 mutation은 spec상 POST만 허용된다 (idempotent 보장 위반). 실무에서는 POST가 기본 + persisted query에 한해 GET으로 캐시가 패턴이다.

이전 문서(01 — GraphQL over HTTP)는 HTTP가 어떤 모양의 body·status·Content-Type을 가지는지 다뤘다. 이 문서는 그 위에서 method를 어떻게 고를지다.


Why — POST가 default가 된 이유

대부분의 GraphQL 클라이언트(Apollo, urql, Relay)는 기본으로 POST를 쓴다. 이유는 단순하다:

  1. URL 길이 제한 — HTTP RFC는 URL 길이를 정하지 않지만, 실제 인프라는 정한다. 브라우저 ~2000자, Cloudflare ~16KB, Apache 8190바이트가 통상. GraphQL 쿼리는 쉽게 이 한계를 넘는다.
  2. 민감 데이터 노출 — URL은 access log·proxy log에 그대로 기록된다. 변수에 토큰·이메일·PII가 들어가면 모든 로그에 남는다.
  3. mutation 제약 — spec이 mutation의 GET을 금지한다 (아래 §spec 참조).

그래서 기본은 POST가 됐다. GET은 *특수 케이스(캐시 가능한 read)*로 빠졌다.


How — 두 method의 정확한 모양

POST 요청 (default)

POST /graphql HTTP/1.1
Content-Type: application/json
 
{
  "query": "query GetUser($id: ID!) { user(id: $id) { name } }",
  "variables": { "id": "u_42" },
  "operationName": "GetUser"
}

curl로 보면:

curl -X POST https://api.example.com/graphql \
  -H 'Content-Type: application/json' \
  -d '{
    "query": "query GetUser($id: ID!) { user(id: $id) { name } }",
    "variables": { "id": "u_42" },
    "operationName": "GetUser"
  }'

GET 요청

query·variables·operationNameURL query string에 넣는다. variablesextensions는 JSON을 URL-encode해서 넣는다.

GET /graphql?query=query+GetUser($id:ID!){user(id:$id){name}}&variables=%7B%22id%22%3A%22u_42%22%7D&operationName=GetUser HTTP/1.1

curl로:

curl -G https://api.example.com/graphql \
  --data-urlencode 'query=query GetUser($id: ID!) { user(id: $id) { name } }' \
  --data-urlencode 'variables={"id":"u_42"}' \
  --data-urlencode 'operationName=GetUser'

What — spec이 강제하는 제약

graphql-over-http 사양의 §5.1에서 명시한다.

요청 종류GETPOST
query✅ 허용✅ 허용
mutationMUST NOT✅ 허용
subscription사양 외 (WS)사양 외 (WS)

mutation을 GET으로 받으면 안 되는 이유 — HTTP 표준상 GET은 safe & idempotent다. CDN·browser prefetch·crawler가 마음대로 재시도할 수 있다. mutation을 GET으로 받으면 Slack의 메시지 전송이 검색엔진 크롤링으로 트리거되는 사고가 가능하다. 그래서 사양이 금지한다.


What — GET이 풀어주는 단 하나 — CDN 캐시

CDN(Cloudflare, Fastly, AWS CloudFront)은 기본적으로 GET만 캐시한다. 이유는 위와 같다 — POST는 사이드이펙트가 있을 수 있다고 가정한다.

GraphQL 응답을 CDN 캐시에 올리고 싶다면 — 예: 공개 카탈로그 페이지, 도움말 컨텐츠 — GET이 유일한 길이다.

GET /graphql?query=... HTTP/1.1
 
HTTP/1.1 200 OK
Content-Type: application/graphql-response+json
Cache-Control: public, max-age=300, s-maxage=3600
 
{ "data": { ... } }

What — GET의 함정 4가지

  1. URL 길이 제한

    • 쿼리가 500자만 넘어도 일부 프록시(특히 corporate proxy)가 414 Request-URI Too Long을 돌려준다.
    • 해결책: persisted query (아래 §APQ).
  2. 민감 데이터가 로그에 남음

    • access log·CDN log·browser history에 전부 평문.
    • 토큰이나 PII가 변수에 들어가면 절대 GET 금지.
  3. 변수 인코딩의 까다로움

    • variables는 JSON을 URL-encode해서 넣어야 한다. 클라이언트마다 인코딩 디테일이 다르면 캐시 키가 깨진다.
    • 같은 의미의 쿼리도 공백 한 칸·키 순서가 다르면 CDN은 다른 캐시 키로 본다.
  4. mutation은 못 씀

    • 위 §spec.

Insight — Persisted Query가 GET 문제를 한 번에 해결한다

GET의 문제는 URL이 너무 크다는 것이다. 그러면 쿼리 본문을 보내지 말고 쿼리의 해시만 보내자가 답이 된다. 이게 persisted query (또는 Automatic Persisted Queries, APQ)다.

실제 요청:

GET /graphql?operationName=GetUser
  &variables=%7B%22id%22%3A%22u_42%22%7D
  &extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22abc123...%22%7D%7D

URL이 수십 바이트로 줄어든다. persisted query + GET + CDN 캐시가 production GraphQL의 읽기 성능 패턴이다.

보안 이득도 크다 — 서버가 등록된 hash만 받겠다고 강제하면, 임의의 쿼리(특히 복잡도 폭탄·introspection 남용)를 막을 수 있다. 이건 07-security-governance에서 더 다룬다.


What-if — GET을 잘못 쓰면

  • mutation을 GET으로 보냄 → 사양 위반, 서버는 405 Method Not Allowed를 돌려보내야 한다 (graphql-over-http §5.1).
  • 민감 변수를 GET으로 보냄 → 모든 access log에 남아서 7년 후 감사에서 발견된다.
  • GET을 쓰는데 Cache-Control을 안 보냄 → CDN이 캐시 안 한다. 캐시 가능한 GET이라도 명시적으로 Cache-Control을 줘야 캐시된다.
  • persisted query 없이 long query를 GET으로 보냄 → 414 또는 CDN이 truncate해서 의미 없는 쿼리로 origin에 전달.

흥미로운 이야기 — Apollo Federation이 GET을 부활시켰다

2017~2020년경, GraphQL은 CDN 캐시 못 함이라는 평을 들었다. 그러다 Apollo가 Automatic Persisted Queries (APQ)를 밀면서 패턴이 바뀌었다.

  • 클라이언트는 처음에 hash만 GET으로 보냄.
  • 서버가 hash를 모르겠다 (PersistedQueryNotFound)고 응답하면, 클라이언트가 쿼리 본문 + hash를 POST로 보냄.
  • 그 뒤로는 클라이언트가 hash만 GET으로 보내면 됨.

fallback 흐름빌드 타임 등록의 번거로움을 없앴고, 그 결과 GraphQL이 CDN 캐시 가능해졌다. GET vs POST의 진짜 답은 둘 다이며, persisted query가 그 둘을 자연스럽게 잇는 다리다.


요약

상황method
mutationPOST (spec 강제)
일반 queryPOST 기본, 민감하거나 큰 데이터 — 그대로 POST
캐시 가능한 read (공개 카탈로그)GET + Cache-Control
모바일/SPA의 반복 readpersisted query + GET + CDN

POST의 자유와 GET의 캐시 가능성은 spec이 둘 다 허용했고, persisted query가 그 둘을 합쳤다.

다음: 03 — Multipart File Upload — JSON body 하나로는 못 보내는 것: 파일.