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를 쓴다. 이유는 단순하다:
- URL 길이 제한 — HTTP RFC는 URL 길이를 정하지 않지만, 실제 인프라는 정한다. 브라우저 ~2000자, Cloudflare ~16KB, Apache 8190바이트가 통상. GraphQL 쿼리는 쉽게 이 한계를 넘는다.
- 민감 데이터 노출 — URL은 access log·proxy log에 그대로 기록된다. 변수에 토큰·이메일·PII가 들어가면 모든 로그에 남는다.
- 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·operationName을 URL query string에 넣는다. variables와 extensions는 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.1curl로:
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에서 명시한다.
| 요청 종류 | GET | POST |
|---|---|---|
| query | ✅ 허용 | ✅ 허용 |
| mutation | ❌ MUST 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가지
-
URL 길이 제한
- 쿼리가 500자만 넘어도 일부 프록시(특히 corporate proxy)가 414 Request-URI Too Long을 돌려준다.
- 해결책: persisted query (아래 §APQ).
-
민감 데이터가 로그에 남음
- access log·CDN log·browser history에 전부 평문.
- 토큰이나 PII가 변수에 들어가면 절대 GET 금지.
-
변수 인코딩의 까다로움
variables는 JSON을 URL-encode해서 넣어야 한다. 클라이언트마다 인코딩 디테일이 다르면 캐시 키가 깨진다.- 같은 의미의 쿼리도 공백 한 칸·키 순서가 다르면 CDN은 다른 캐시 키로 본다.
-
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%7DURL이 수십 바이트로 줄어든다. 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 mutation POST (spec 강제) 일반 query POST 기본, 민감하거나 큰 데이터 — 그대로 POST 캐시 가능한 read (공개 카탈로그) GET + Cache-Control 모바일/SPA의 반복 read persisted query + GET + CDN POST의 자유와 GET의 캐시 가능성은 spec이 둘 다 허용했고, persisted query가 그 둘을 합쳤다.
다음: 03 — Multipart File Upload — JSON body 하나로는 못 보내는 것: 파일.