01 — GraphQL over HTTP
질문: GraphQL 쿼리를 HTTP 위에 어떻게 실어 보내고, 응답은 어떤 status code·Content-Type으로 돌아오는가? 한 줄 답: *spec.graphql.org가 관리하는
graphql-over-http사양이 표준이다 — POST + JSON body가 기본, 응답은 **HTTP 200 +errors[]*가 정상이며, 새 Content-Typeapplication/graphql-response+json이application/json을 대체하고 있다.
Why — 왜 별도 사양이 필요했나
GraphQL spec(spec.graphql.org)을 처음부터 끝까지 읽어도 HTTP라는 단어는 거의 안 나온다. spec은 다음만 정의한다:
- 쿼리 문법 (
§2 Language) - 타입 시스템 (
§3 Type System) - 검증 (
§5 Validation) - 실행 알고리즘 (
§6 Execution) - 응답의 JSON 모양 (
§7 Response)
transport에 대한 언급은 §7 Response의 Note 한 줄뿐이다 — “이 응답은 보통 HTTP로 직렬화되지만 다른 transport도 가능하다.”
이 공백은 10년 동안 두 가지 문제를 만들었다:
- method 충돌 — 어떤 서버는 POST만 받고, 어떤 서버는 GET도 받는다.
- status code 충돌 — 어떤 서버는 GraphQL 에러를 4xx로 돌려보내고, 어떤 서버는 200 + errors로 돌려보낸다.
2018년 GraphQL Working Group이 graphql-over-http 사양을 시작했고, 2025년에 stable 단계에 진입했다. 이 사양이 그 공백을 메운다.
How — 표준 요청/응답의 모양
요청 (POST)
POST /graphql HTTP/1.1
Host: api.example.com
Content-Type: application/json
Accept: application/graphql-response+json, application/json
{
"query": "query GetUser($id: ID!) { user(id: $id) { name email } }",
"variables": { "id": "u_42" },
"operationName": "GetUser",
"extensions": { }
}요청 body는 항상 JSON이고, 4개의 키를 가진다:
| 키 | 필수 | 의미 |
|---|---|---|
query | ✅ | GraphQL 쿼리 문자열 (또는 persisted query ID — 02 문서 참조) |
variables | ⭕ | 변수 객체. $id 같은 자리표시자에 채워질 값 |
operationName | ⭕ | 한 document에 operation이 여러 개일 때만 필수 |
extensions | ⭕ | 비표준 메타데이터 (APQ, tracing 등) — 사양이 자리만 비워둔 자유 영역 |
응답
HTTP/1.1 200 OK
Content-Type: application/graphql-response+json; charset=utf-8
{
"data": { "user": { "name": "Lee", "email": null } },
"errors": [
{
"message": "Email field is restricted",
"path": ["user", "email"],
"extensions": { "code": "FORBIDDEN" }
}
]
}응답의 모양은 spec §7이 정의한다:
| 키 | 의미 |
|---|---|
data | 쿼리 결과. 에러로 인해 일부 필드는 null일 수 있다 — 이게 partial response |
errors | 실행 중 발생한 에러들. 있어도 200 OK가 정상 |
extensions | 비표준 메타데이터 (tracing, complexity 등) |
What — Content-Type 두 종류
graphql-over-http는 두 개의 미디어 타입을 정의한다.
1) application/json (legacy)
Accept: application/json이면 서버는 항상 200을 돌려보낸다. 에러도 200 + errors[]다. 이게 10년간의 de facto였고, 지금도 대부분의 GraphQL 클라이언트(특히 Apollo Client v3)가 기본으로 쓴다.
2) application/graphql-response+json (new)
Accept: application/graphql-response+json이면 서버는 에러 종류에 따라 다른 status를 돌려보낼 수 있다.
| 상황 | Status | 설명 |
|---|---|---|
| 정상 실행 (errors 없거나 runtime 에러만) | 200 | data 키가 응답에 있어야 한다 |
| 파싱 / validation 실패 — data 키가 응답에 없음 | 400 | request well-formed 하지 않음 |
| 인증 실패 | 401 | 사양 외 영역 |
| 권한 실패 | 403 | 사양 외 영역 |
| 서버 내부 오류 | 5xx | 사양 외 영역 |
핵심 차이: 새 Content-Type은 *“data 키가 응답에 있는가”*를 200 vs 4xx의 경계로 삼는다. data 키가 있으면(=실행은 됐으면) 그 안에 partial이 있더라도 200이다.
Accept negotiation 예시
Accept: application/graphql-response+json; charset=utf-8, application/json; q=0.9서버는 더 구체적인(q 높은) 것부터 시도한다. 최신 클라이언트(Apollo Client v3.8+, urql, Relay)는 두 개를 다 받겠다고 광고한다.
What-if — REST 패러다임으로 GraphQL을 다루면
GraphQL을 처음 도입하는 팀이 가장 많이 만드는 버그가 이거다.
// ❌ REST 사고방식
const res = await fetch('/graphql', { method: 'POST', body });
if (!res.ok) {
throw new Error('GraphQL failed');
}
// → 200 + errors[]인 정상 실패는 그냥 통과해버린다.
const json = await res.json();
return json.data; // data는 partial null, errors는 무시됨// ✅ GraphQL 사고방식
const res = await fetch('/graphql', { method: 'POST', body });
const json = await res.json();
// status code는 transport 에러만 검사
if (res.status >= 500) throw new TransportError(res.status);
// 비즈니스 에러는 errors[]에서
if (json.errors?.length) {
// partial result일 수도 있고, 전체 실패일 수도 있다 — data 유무로 판단
if (!json.data) throw new GraphQLError(json.errors);
reportPartial(json.errors); // 로깅만, 데이터는 그대로 사용
}
return json.data;왜 200 + errors인가 — GraphQL 응답은 부분 실패가 정상이기 때문이다 (02 챕터 §5 errors-and-partial-response 참조). users { posts } 쿼리에서 user 5명 중 1명의 posts만 실패했다면, 그 1명은 null이 되지만 나머지 4명의 데이터는 살아 있다. 이걸 HTTP 4xx로 표현할 방법이 없다.
Insight — 200 + errors는 철학이다
REST는 리소스 1개에 대한 요청 1개다. 그 요청이 실패하면 HTTP status로 알리면 된다 (404, 403, 500). GraphQL은 여러 리소스를 한 번에 묻는 그래프 쿼리다. 그래프의 일부만 실패했을 때 그걸 표현할 HTTP status는 없다 — 그래서 errors라는 별도 채널을 만들었다.
이 그림이 왜 GraphQL이 HTTP status로 표현 안 되는지를 보여준다. GraphQL의 errors는 HTTP status의 대체가 아니라, 그래프 부분 실패라는 새 차원에 대한 표현이다.
흥미로운 이야기 — 새 Content-Type이 추가된 진짜 이유
2018년부터 working group은 *“왜 GraphQL이 캐시가 안 되는가”*를 풀고 있었다. 그 답이 두 갈래로 갈렸다:
- GET을 쓰자 (→ 02 문서). URL을 캐시 키로 쓰면 CDN이 캐시한다.
- status code를 의미있게 쓰자. 캐시는 200 응답만 캐시하는 경향이 있는데, GraphQL의 parse error가 200으로 오면 CDN이 그걸 캐시해버린다. parse error를 4xx로 분리하면 CDN이 캐시하지 않는다.
application/graphql-response+json이 등장한 진짜 이유는 HTTP 캐싱 인프라와 친해지기 위해서다. 이게 왜 신규 미디어 타입이 필요했는지에 대한 답이다 — 기존 application/json은 항상 200이라 캐시 인프라가 GraphQL의 의미를 읽을 수 없었기 때문에.
요약 (Pyramid Top 재정렬)
graphql-over-http가 GraphQL HTTP의 표준이다. 표준은 다음 세 가지를 정한다:
- POST + JSON body가 기본, GET도 허용 (
02).application/graphql-response+json이 새 Content-Type. 기존application/json은 legacy.- **200 + errors[]**가 정상. data 키가 응답에 있느냐가 200 vs 4xx의 경계.
다음 문서는 이 HTTP 위에서 GET vs POST가 어떻게 갈리는지, 그리고 CDN 캐시를 어떻게 얻는지를 다룬다.
다음: 02 — GET vs POST — URL 기반 캐시 가능성 vs body의 자유.