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 종류 | 형제 필드 실행 방식 | 이유 |
|---|---|---|
| query | parallel (구현이 가능하면) | 부작용 없음 → 순서 무관 |
| subscription | parallel | 첫 필드만 publish 트리거 |
| mutation | serial (강제) | 부작용 있음 → 순서가 결과를 결정 |
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 재귀 |
NonNull | null이면 에러, 아니면 안쪽 타입으로 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안에서 실제로 호출되는 그 함수의 모양.