05 — Mental Model

한 줄 답: GraphQL은 graph가 아니라 graph traversal language다. 응답은 항상 요청 selection set과 동형이며, 그 invariant 하나가 GraphQL의 모든 매력과 함정을 설명한다.


Why — 왜 멘탈 모델이 핵심인가

기술의 문법은 외울 수 있다. 하지만 왜 그렇게 동작하는가를 모르면 새 문제 앞에서 항상 막힌다. GraphQL의 모든 의외성 — partial response, N+1, single endpoint, 200 OK에 errors — 은 하나의 멘탈 모델에서 따라 나온다.

이 문서는 그 멘탈 모델을 세 개의 invariant로 압축한다.

  1. GraphQL은 graph가 아니라 graph traversal이다.
  2. 응답은 요청 selection set과 동형(isomorphic)이다.
  3. Partial response는 기능이지 버그가 아니다.

이 셋을 받아들이면 GraphQL의 모든 동작이 자연스럽게 설명된다.


How — 세 가지 invariant

Invariant 1 — Graph가 아니라 Graph Traversal

흔한 오해:

“GraphQL은 Neo4j 같은 그래프 DB 위에서 동작하는 언어다.”

틀렸다. GraphQL의 ‘Graph’는 데이터의 형태가 아니라 접근 방식을 가리킨다.

측면Graph DB (Cypher 등)GraphQL
다루는 것노드·엣지 저장소노드·엣지 질의 언어
백엔드그래프 DB 전용무엇이든 (SQL, NoSQL, REST, gRPC…)
쿼리 의미”이 패턴과 매칭되는 모든 경로""이 진입점에서 시작해 이 트리를 따라가라”

GraphQL은 진입점(root)에서 시작해 selection set이 그리는 트리를 따라 traversal한다. 트리의 각 노드에서 resolver가 호출된다.

핵심: 데이터가 그래프 모양일 필요가 없다. resolver가 어떤 데이터 소스에서든 값을 가져오면 된다. GraphQL은 백엔드 무지향이다.

Invariant 2 — 응답은 요청 selection set과 동형

이것이 GraphQL의 가장 강력한 invariant다.

응답의 JSON 구조는 요청의 selection set과 정확히 같은 모양이다.

# 요청
{
  user(id: 123) {
    name
    posts {
      title
    }
  }
}
// 응답 — 모양이 *정확히* 일치
{
  "data": {
    "user": {
      "name": "Alice",
      "posts": [
        { "title": "Hello" },
        { "title": "World" }
      ]
    }
  }
}

이 동형성에서 따라 나오는 결과:

결과설명
클라이언트 타입 추론요청에 적힌 필드만 응답에 있다 → 컴파일러가 타입을 정확히 추론
모양에 대한 협상 불필요서버가 예상 외 필드를 보낼 일이 없다
버전 관리가 필드 단위URL 버전 없이도 deprecated 필드만 표시하면 됨
테스트 가능요청과 응답을 대칭으로 검증

reasonably 큰 함정도 같이 따라 나온다 — 클라이언트가 안 적은 필드는 받을 수 없다. 백엔드가 “이건 항상 같이 보내야 안전한데”라고 생각해도, 클라이언트가 적지 않으면 안 보낸다.

동형성의 예외 — 메타 필드

{
  user(id: 123) {
    name
    __typenameintrospection
  }
}

→ 응답에 __typename: "User"가 추가된다. 메타 필드는 동형성의 의도된 예외다.

Invariant 3 — Partial Response는 기능이다

REST는 보통 all-or-nothing이다. 한 필드라도 실패하면 5xx로 전부 실패. GraphQL은 다르다.

각 resolver가 독립적으로 실패할 수 있다. 일부가 실패해도 나머지는 정상 응답된다.

{
  user(id: 123) {
    name           # 성공
    email          # 실패 (권한 없음)
    posts {
      title        # 성공
    }
  }
}
{
  "data": {
    "user": {
      "name": "Alice",
      "email": null,                  // null로 채워짐
      "posts": [{ "title": "..." }]
    }
  },
  "errors": [
    {
      "message": "Permission denied",
      "path": ["user", "email"]      // 어디서 실패했는지
    }
  ]
}

핵심 규칙:

  • 실패한 필드가 nullable이면 그 위치만 null (예시처럼)
  • 실패한 필드가 non-null이면 부모를 null로 전파 → 가장 가까운 nullable 조상까지 거슬러 올라감
  • HTTP status는 보통 200 (실행은 됐으니까)

이 invariant 덕에 부분 장애를 견디는 UI를 짤 수 있다 — 이메일 영역은 비워두고, 게시글은 그대로 표시.


What — 정확한 사양 (Execution §6, October 2021)

Resolver 호출 알고리즘 (단순화)

ExecuteQuery(query, schema, variables):
    1. Validate query against schema
    2. Resolve root operation type (Query/Mutation/Subscription)
    3. ExecuteSelectionSet(root, selectionSet, variables, initialValue)

ExecuteSelectionSet(type, selectionSet, vars, parentVal):
    For each field in selectionSet:
        - 병렬 실행 (query/subscription) 또는 순차 실행 (mutation)
        - Resolve field's resolver(parentVal, args, context, info) → value
        - If field type is object/interface/union:
              ExecuteSelectionSet(fieldType, subSelectionSet, vars, value)
        - Else: coerce scalar
    Return Map { fieldName → value }

핵심:

  • Query / Subscription: 같은 selection set 안의 필드는 병렬 실행 가능 (resolver가 부수 효과 없다고 가정)
  • Mutation: 같은 selection set 안의 필드는 순차 실행 (부수 효과가 있으니 순서가 의미 있음)
  • 중첩 selection set은 부모 resolver의 결과를 받아 자식 resolver에 전달

Null Propagation

type User {
  id: ID!
  name: String!     # non-null
  email: String     # nullable
}
  • email 실패 → email: null + errors 배열에 항목
  • name 실패 → name 자체를 null로 못 만듦 → user를 null로 전파 → user도 non-null이면 data 전체가 null
// name(non-null) 실패 시
{
  "data": null,                       // 전체가 null로 전파
  "errors": [{"message": "...", "path": ["user", "name"]}]
}

이 규칙이 non-null의 진짜 의미다 — “이 자리에 null이 절대 오지 않는다”는 클라이언트의 약속. 깨지면 위로 전파해서라도 약속을 지킨다.

Subscription의 특수성

subscription OnNewMessage($room: ID!) {
  messageAdded(room: $room) {
    id text author
  }
}
  • 하나의 root field만 허용 (selection set에 필드 한 개)
  • 이벤트마다 전체 selection set이 실행되어 응답이 push됨
  • 트랜스포트는 spec 외 — WebSocket(graphql-ws) 또는 SSE(graphql-sse)가 사실상 표준

What-if — 멘탈 모델을 잘못 잡으면

1) “GraphQL = DB query”라고 보면

증상: 한 쿼리당 DB 쿼리 1번 기대 → 실제론 N+1. 원인: GraphQL은 resolver 트리 실행이지 SQL이 아니다. 대응: DataLoader 패턴으로 같은 필드 호출을 배치. (KB 03-n-plus-1-dataloader)

2) “응답이 항상 fully populated”라고 보면

증상: 클라이언트가 data.user.email을 무조건 string으로 받으려다 NPE. 원인: nullable 필드는 언제든 null일 수 있다 (partial response). 대응: nullable·non-null을 스키마 단계에서 신중히 결정. 클라이언트는 nullable을 항상 방어.

3) “Errors 배열을 무시해도 된다”고 보면

증상: 200 OK인데 data.user.posts가 비어 있고 왜인지 모름. 원인: posts resolver가 실패해서 null로 강등됐는데 errors를 안 봄. 대응: 클라이언트 라이브러리(Apollo Client·urql·Relay)는 기본으로 errors를 노출. 항상 확인.

4) “HTTP status code로 성공/실패를 판단”하면

증상: 200인데 실제론 실패한 요청을 성공으로 분류. 원인: GraphQL은 거의 항상 200 (실행 자체가 성공했으면). 대응: 모니터링은 operation name + errors 배열 기반. HTTP status는 transport-level 실패만.

5) Mutation을 병렬로 보내면

증상: race condition·중복 실행. 원인: spec은 한 selection set 안의 mutation만 순차 보장. 서로 다른 요청 간에는 보장 없음. 대응: 동시 mutation은 클라이언트가 큐잉하거나, 서버가 idempotency key를 받음.

6) Subscription을 사용해 상태 동기화

증상: 새 클라이언트가 과거 이벤트를 못 받아 화면이 비어 있음. 원인: subscription은 미래 이벤트 push용 — 현재 상태 조회가 아니다. 대응: 초기 상태는 query로 받고, 그 이후 subscription으로 갱신.


Insight — 흥미로운 이야기

”GraphQL이 graph가 아니라 traversal인 이유”

Lee Byron이 한 인터뷰에서 한 말 — “이름을 다시 짓는다면 ‘GraphQL’ 대신 ‘ShapeQL’이라고 했을 것이다.” GraphQL의 본질은 데이터의 모양을 클라이언트가 선언하는 것이지, 그래프 구조 자체가 아니다. ‘Graph’는 Facebook 내부의 Open Graph에서 따온 명칭일 뿐, 데이터베이스의 그래프와 무관하다.

이 사실을 받아들이면 어떤 백엔드 위에도 GraphQL이 얹힐 수 있다는 게 명백해진다 — 실제로 Hasura는 PostgreSQL 위에, AWS AppSync는 DynamoDB 위에, Apollo Federation은 여러 서비스의 합성에서 동작한다.

”동형성(isomorphism)이 만든 React-GraphQL 결혼”

Facebook이 React와 GraphQL을 같은 시기에 같은 팀에서 만든 것은 우연이 아니다. React 컴포넌트는 자기가 그릴 트리의 모양을 선언하고, GraphQL 쿼리도 자기가 받을 데이터의 모양을 선언한다. 둘의 모양이 동형이라는 게 그 결혼의 핵심 — Relay 같은 라이브러리가 컴파일 타임에 컴포넌트의 fragment를 합성데이터와 UI를 동형으로 만든다. 이 패턴은 후에 colocated data fetching이라는 이름으로 일반화됐다.

”Partial response는 mobile에서 왔다”

2012년 Facebook iOS에서 셀룰러 네트워크는 순간 끊기는 게 일상이었다. 한 필드 실패로 전체 News Feed가 못 뜨면 UX가 죽는다. 부분 성공이 곧 UX라는 모바일 현실이 GraphQL의 partial response 디자인을 강제했다. 같은 이유로 non-null을 신중히 쓰라는 community 가이드가 굳어졌다 — non-null은 null 전파를 일으켜 partial response의 이점을 깎는다.

”Why graph traversal scales — Federation의 출발점”

쿼리가 그래프 traversal이라는 사실은 분산 시스템과 잘 맞는다. selection set의 각 sub-tree를 다른 서비스가 처리해도 결과를 합치기만 하면 된다. Apollo Federation은 이 통찰을 production 수준의 합성 시스템으로 만들었고, 2022년 Federation 2가 사실상 산업 표준이 됐다. GraphQL이 마이크로서비스 BFF의 기본 선택이 된 것은 이 invariant 덕이다.


요약 + 다이어그램

GraphQL은 세 invariant 위에 서 있다 — graph가 아니라 traversal이고, 응답은 요청과 동형이며, partial response는 기능이다. 이 셋만 받아들이면 단일 endpoint·200 OK·null propagation·federation까지 모두 같은 원리로 설명된다.

챕터 종료. 이제 01-schema-sdl 챕터로 — 언어를 알았으니 타입으로 모양을 잡을 차례다.