🔷 GraphQL4. 전송 계층01 — GraphQL over HTTP

01 — GraphQL over HTTP

질문: GraphQL 쿼리를 HTTP 위에 어떻게 실어 보내고, 응답은 어떤 status code·Content-Type으로 돌아오는가? 한 줄 답: *spec.graphql.org가 관리하는 graphql-over-http 사양이 표준이다 — POST + JSON body가 기본, 응답은 **HTTP 200 + errors[]*가 정상이며, 새 Content-Type application/graphql-response+jsonapplication/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년 동안 두 가지 문제를 만들었다:

  1. method 충돌 — 어떤 서버는 POST만 받고, 어떤 서버는 GET도 받는다.
  2. 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개의 키를 가진다:

필수의미
queryGraphQL 쿼리 문자열 (또는 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 에러만)200data 키가 응답에 있어야 한다
파싱 / validation 실패 — data 키가 응답에 없음400request 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이 캐시가 안 되는가”*를 풀고 있었다. 그 답이 두 갈래로 갈렸다:

  1. GET을 쓰자 (→ 02 문서). URL을 캐시 키로 쓰면 CDN이 캐시한다.
  2. 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의 표준이다. 표준은 다음 세 가지를 정한다:

  1. POST + JSON body가 기본, GET도 허용 (02).
  2. application/graphql-response+json 이 새 Content-Type. 기존 application/json은 legacy.
  3. **200 + errors[]**가 정상. data 키가 응답에 있느냐가 200 vs 4xx의 경계.

다음 문서는 이 HTTP 위에서 GET vs POST가 어떻게 갈리는지, 그리고 CDN 캐시를 어떻게 얻는지를 다룬다.

다음: 02 — GET vs POST — URL 기반 캐시 가능성 vs body의 자유.