🔷 GraphQL2. 실행 & 리졸버01 — 실행 모델 (Execution Model)

01 — 실행 모델 (Execution Model)

질문: 검증된 쿼리 한 개가 들어오면, 서버는 어떤 알고리즘으로 응답 JSON을 만드는가? 한 줄 답: spec §6의 4단계 사이클 — CollectFields → ExecuteSelectionSet → ExecuteField → CompleteValue — 을 트리의 모든 노드에 적용한다. query/subscription은 형제 필드를 parallel로, mutation은 serial로 실행한다.


Why — 왜 “정해진 알고리즘”이 필요한가

REST에서는 각 엔드포인트가 자기 알고리즘을 가진다. /users는 DB select, /users/:id/posts는 join, 형식은 자유다. 서버 구현마다 동작이 달라도 클라이언트는 그 한 엔드포인트만 신경 쓰면 된다.

GraphQL은 다르다.

  • 클라이언트가 쿼리 트리를 보낸다.
  • 응답은 그 트리와 동형이어야 한다.
  • 어떤 서버 구현이든 같은 쿼리에 같은 모양의 응답을 내놔야 한다.

→ 그래서 spec이 알고리즘 자체를 의사코드로 못 박는다. 이게 §6 Execution이다. graphql-js, graphql-java, graphql-go, Apollo Router 모두 이 사이클의 구현체다.

핵심 invariant: 서버 구현이 달라져도, 같은 (schema, query, variables, root)에 대해 응답 모양은 동일하다.


How — 4단계 사이클

ExecuteRequest(query, variables, initialValue)

  ExecuteOperation(operation, schema, ...)

    1. CollectFields        ← 어떤 필드를 평가할지 모은다
    2. ExecuteSelectionSet  ← 그 모음을 평가한다 (parallel or serial)

       각 필드마다:
       3. ExecuteField       ← 인자 coerce → resolver 호출

          4. CompleteValue   ← 결과를 스키마 타입에 맞춰 정형화
             (재귀적으로 다시 ExecuteSelectionSet)

1) CollectFields — “어떤 필드를 평가할 차례인가”

쿼리 selection은 세 가지 종류가 섞여 있다.

query {
  me {
    id
    name
    ...UserProfile        # FragmentSpread
    ... on Admin {        # InlineFragment
      permissions
    }
  }
}

CollectFields는 이 세 종류(Field / FragmentSpread / InlineFragment)를 펼쳐서 { fieldName: [fieldNode, ...] } 형태의 그룹화된 맵으로 만든다. 동일한 이름의 필드가 여러 fragment에 등장하면 하나로 합쳐진다.

query {
  me {
    name           # 1번 fieldNode
    ... on Admin {
      name         # 2번 fieldNode
    }
  }
}
# CollectFields 결과 → { name: [1번, 2번] }
# → resolver는 한 번만 호출되고, 두 fieldNode는 info.fieldNodes에 모인다

이게 spec이 보장하는 중복 제거다.

2) ExecuteSelectionSet — parallel vs serial

ExecuteSelectionSet(selectionSet, objectType, objectValue):
  groupedFields = CollectFields(...)
  resultMap = {}
  for each (responseKey, fields) in groupedFields:
     resultMap[responseKey] = ExecuteField(...)
  return resultMap

여기서 결정적인 분기:

operation 종류형제 필드 실행 방식이유
queryparallel (구현이 가능하면)부작용 없음 → 순서 무관
subscriptionparallel첫 필드만 publish 트리거
mutationserial (강제)부작용 있음 → 순서가 결과를 결정

spec 인용 (§6.2.2):

“If the operation is a mutation, the result of the operation is the result of executing the operation’s top level selection set on the mutation root object type. This selection set should be executed serially.

mutation {
  createUser(name: "A") { id }   # 1번째 — 끝나야
  createUser(name: "B") { id }   # 2번째 — 시작
  createUser(name: "C") { id }   # 3번째 — 시작
}

A → B → C 순서가 spec으로 보장된다. 같은 mutation을 query로 했다면 셋이 동시에 실행된다.

단, top-level만 serial이다. mutation의 반환 객체 내부 필드는 query와 똑같이 parallel.

3) ExecuteField — 한 노드의 평가

ExecuteField(objectType, objectValue, fieldType, fields):
  fieldNode = fields[0]
  argumentValues = CoerceArgumentValues(...)         # 변수 substitution + type coerce
  resolvedValue = ResolveFieldValue(
    objectType, objectValue,
    fieldName, argumentValues
  )
  return CompleteValue(fieldType, fields, resolvedValue, ...)

ResolveFieldValue가 바로 리졸버 호출 지점이다. 다음 문서 02-resolver-function에서 자세히 다룬다.

4) CompleteValue — 결과 정형화

리졸버가 어떤 값을 반환했든, 스키마가 약속한 타입에 맞아야 한다.

스키마 타입CompleteValue가 하는 일
Scalar (Int/String/…)serialize() 호출해서 JSON-safe 값으로
Enum값이 enum에 속하는지 검증
Object다시 ExecuteSelectionSet 호출 (재귀!)
Interface/Union__resolveType 또는 isTypeOf로 구체 타입 결정
List각 요소에 대해 CompleteValue 재귀
NonNullnull이면 에러, 아니면 안쪽 타입으로 CompleteValue

이 재귀가 트리 순회의 정체다. Object를 반환하면 그 객체의 selection set으로 다시 들어간다.


What — spec 의사코드를 그대로

§6.2.3 ExecuteField 의사코드 (요약):

ExecuteField(objectType, objectValue, fieldType, fields, variableValues):
  fieldName = fields[0].name
  argumentValues = CoerceArgumentValues(objectType, fields[0], variableValues)
  resolvedValue = ResolveFieldValue(objectType, objectValue, fieldName, argumentValues)
  return CompleteValue(fieldType, fields, resolvedValue, variableValues)

§6.4.3 CompleteValue (NonNull 부분):

If fieldType is a Non-Null type:
   Let innerType be the inner type of fieldType.
   Let completedResult be the result of calling CompleteValue(innerType, ...).
   If completedResult is null, throw a field error.
   Return completedResult.

→ 이 한 줄이 null propagation의 spec적 근거다 (05 문서에서 자세히).

introspection은 같은 사이클을 탄다

query { __schema { types { name } } }

__schema, __type숨겨진 root 필드다. 이 필드들의 리졸버는 graphql-js가 기본 구현을 갖고 있어 사용자가 짤 필요가 없다. 하지만 실행은 같은 사이클을 탄다 — 그래서 introspection도 N+1을 만들 수 있고, depth limit에 걸릴 수 있다 (07-security-governance).


What-if — 잘못 이해하면

1) “mutation도 parallel이겠지” 가정

mutation {
  withdraw(amount: 100) { balance }  # 잔액 200 → 100
  deposit(amount: 50)   { balance }  # 잔액 100 → 150
}

parallel이면 두 mutation이 같은 200을 읽고 둘 다 실행해서 race condition. spec이 serial로 강제하기에 순서가 보장된다. 단, 이는 한 operation 안에서다 — 두 개의 HTTP 요청이 동시에 오면 여전히 transaction이 필요하다.

2) “리졸버를 한 번 호출하면 끝이겠지”

query {
  users {           # 1번 호출 → [u1, u2, u3]
    posts {         # u1.posts, u2.posts, u3.posts — 3번 호출
      title
    }
  }
}

리스트 안의 각 형제는 독립된 노드다 — 따라서 리졸버는 형제 수만큼 호출된다. 이게 03 챕터의 N+1의 출발점.

3) “CompleteValue의 NonNull 체크를 무시”

리졸버가 null을 반환했는데 스키마가 String!이라면, spec은 에러를 던지고 null을 부모로 전파하라고 한다. 부모도 NonNull이면 또 위로. 결과적으로 root data 전체가 null이 될 수 있다.

4) “serial이면 한 번에 하나 — 안전”

serial은 순서만 보장한다. 트랜잭션이 아니다. 첫 mutation이 끝나고 두 번째 시작 사이에 다른 요청이 끼어들 수 있다 — DB 락은 별개 책임.


Insight — 사이클이 만든 universality

”graphql-js의 execute()는 800줄짜리 사이클”

graphql-js의 execute.ts는 사실상 spec 의사코드의 1:1 번역이다. 함수 이름이 executeFieldsSerially, executeFields, completeValue로 spec과 정확히 매칭된다.

이게 GraphQL이 “spec-driven” 언어인 이유다 — 새 언어로 서버를 짤 때, 이 사이클을 그대로 번역하면 끝이다. graphql-java, graphql-go, juniper(Rust)가 모두 이 동일한 골격을 공유한다.

”왜 parallel은 ‘should’고 serial은 ‘must’인가”

spec 표현이 미묘하다:

  • query: “the executor should execute these fields in parallel” — 권장
  • mutation: “this selection set should be executed serially” — 권장처럼 보이지만, 의미상 강제

should로 적힌 이유는 single-threaded 환경(예: Node.js)에서 진짜 parallel은 불가능하기 때문이다. “부작용 없으니 순서를 보장하지 않아도 됨”이 핵심 의미고, 실제 구현은 Promise.all동시 진행한다 — concurrent지 parallel은 아니다.

”subscription은 세 번째 실행 모드

spec §6.2가 정의하는 ExecuteOperation은 query/mutation/subscription을 분기하는데, subscription은 publish 트리거만 첫 필드에서 일어나고, 이후 각 이벤트마다 selection set이 재실행된다. 그래서 subscription은 N개의 실행 라운드다 (04-transport에서 자세히).


요약

사이클은 4단계: CollectFields(무엇을), ExecuteSelectionSet(어떻게 모아서), ExecuteField(개별 평가), CompleteValue(타입 맞추기). 분기는 하나: mutation top-level만 serial, 나머지는 parallel. 재귀는 하나: Object 타입은 ExecuteSelectionSet로 다시 들어간다 — 이게 트리 순회의 정체.

다음: 02 — 리졸버 함수ResolveFieldValue 안에서 실제로 호출되는 그 함수의 모양.