GraphQL 명세 ⑥: 쿼리가 데이터로 채워지는 실행(Execution)
지난 편에서 검증을 통과한 쿼리는 이제 실제로 데이터를 채워 넣을 준비가 됐습니다. 그 일을 다루는 것이 명세 제6장 실행(Execution) 인데요. GraphQL 명세에서 가장 흥미로운 동작들이 바로 이 장에 숨어 있습니다. 특히 “왜 필드 하나가 실패했는데 부모 객체까지 통째로 null이 되지?” 같은, 실무에서 한 번쯤 당황했을 동작의 정체가 여기서 밝혀집니다.
이번 편에서는 서버가 쿼리를 어떻게 한 필드씩 실행하는지, query와 mutation의 실행 순서가 왜 다른지, 그리고 에러가 났을 때 null이 어떻게 번지는지를 차근차근 풀어보겠습니다.
실행은 필드마다 리졸버를 부르는 일입니다
GraphQL 실행의 기본 단위는 필드입니다. 서버는 쿼리에 등장한 각 필드마다, 그 필드의 값을 구하는 함수를 호출하는데요. 이 함수를 흔히 리졸버(resolver) 라고 부릅니다. 명세에서는 이를 내부 함수 ResolveFieldValue로 기술합니다.
{
user(id: "1") {
name
posts {
title
}
}
}
이 쿼리를 실행하면 서버는 먼저 user 필드의 리졸버를 불러 User 객체를 얻습니다. 그 결과를 가지고 다시 name 리졸버와 posts 리졸버를 부르고, posts가 돌려준 각 Post에 대해 또 title 리졸버를 부르는데요. 이렇게 부모의 결과가 자식 리졸버의 입력으로 이어지면서, 쿼리 트리를 위에서 아래로 훑어 내려갑니다. 3편에서 GraphQL을 그래프라고 불렀던 이유가 여기서 실감 나는데, 실행이란 결국 그 그래프를 쿼리가 그린 경로대로 따라가며 값을 길어 올리는 과정입니다.
리졸버가 데이터를 어디서 가져오는지는 명세가 전혀 상관하지 않습니다. 1편에서 “명세는 저장소를 규정하지 않는다”고 했던 게 바로 이 지점인데요. 리졸버 안에서 데이터베이스를 조회하든, 다른 REST API를 호출하든, 메모리의 값을 그냥 돌려주든 명세는 “필드값을 구하는 함수가 있다”고만 가정합니다.
query는 병렬로, mutation은 직렬로 실행됩니다
여기서 명세의 중요한 규칙이 하나 나옵니다. 같은 단계에 있는 필드들을 어떤 순서로 실행하느냐가 연산 타입에 따라 다르다는 점인데요.
query의 필드들은 병렬로(in parallel) 실행해도 됩니다. 데이터를 읽기만 하므로 서로 영향을 주지 않고, 따라서 순서가 결과에 영향을 미치지 않기 때문입니다. 서버는 성능을 위해 여러 필드의 리졸버를 동시에 돌릴 수 있습니다.
반면 mutation의 최상위 필드들은 반드시 직렬로(serially), 즉 쿼리에 적힌 순서대로 하나씩 실행해야 합니다.
mutation {
deposit(amount: 100) {
balance
}
withdraw(amount: 50) {
balance
}
}
만약 입금과 출금이 동시에 실행된다면 잔액이 엉킬 수 있습니다. 그래서 명세는 “데이터를 변경하는 mutation은 위에서부터 순서대로, 앞 작업이 끝난 뒤 다음 작업을 시작하라”고 못 박아둔 건데요. 1편에서 “왜 mutation은 순서대로 실행되는데 query는 그렇지 않을까”라는 질문을 던졌던 것 기억하시나요? 그 답이 바로 이 규칙입니다. 읽기는 순서가 상관없지만 쓰기는 순서가 결과를 바꾸기 때문입니다.
값은 타입에 맞춰 다듬어집니다
리졸버가 돌려준 날것의 값은 그대로 응답에 들어가지 않습니다. 명세는 그 값을 필드의 타입에 맞춰 다듬는 과정을 정의하는데요. 명세에서는 이를 CompleteValue라고 부릅니다.
스칼라나 열거형이면 정해진 방식으로 직렬화하고, 객체 타입이면 그 아래 하위 필드들을 다시 실행해 내려갑니다. 리스트면 각 원소에 대해 이 과정을 반복하고요. 3편에서 본 인터페이스나 유니온이면, 먼저 실제 타입이 무엇인지 가려낸 다음 그 타입으로 값을 완성합니다. 이렇게 타입에 맞춰 값을 완성하는 단계가 있기 때문에, 응답이 항상 스키마가 약속한 모양으로 나오는 겁니다.
에러가 나면 null이 위로 번집니다
이제 이번 편의 하이라이트입니다. 리졸버가 에러를 던지거나 값을 구하지 못하면 어떻게 될까요? 핵심은 그 필드의 타입이 Non-Null이냐 아니냐에 달려 있습니다.
필드가 nullable, 즉 String처럼 null을 허용하는 타입이면 이야기가 간단합니다. 그 필드 자리에 null을 넣고, 무슨 일이 있었는지 에러 목록에 기록한 다음, 나머지 필드는 그대로 실행을 이어갑니다.
문제는 3편에서 본 Non-Null(!) 필드입니다. Non-Null 필드는 정의상 절대 null이 될 수 없는데, 리졸버가 값을 못 구했다고 거기 null을 넣으면 스키마의 약속이 깨집니다. 그래서 명세는 이렇게 정합니다. Non-Null 필드에서 에러가 나면, null을 그 필드가 아니라 가장 가까운 nullable한 부모로 끌어올린다.
{
user(id: "1") {
name
email
}
}
User.name이 String!(Non-Null)이라고 해보겠습니다. 만약 name 리졸버가 실패하면, name 자리에 null을 둘 수 없으니 한 단계 위인 user로 null이 번집니다.
{
"data": {
"user": null
},
"errors": [
{
"message": "Cannot return null for non-nullable field User.name.",
"path": ["user", "name"]
}
]
}
분명히 실패한 건 name 하나인데, 응답에서는 user 전체가 null이 되어버렸습니다. 이 동작을 흔히 null 전파(null propagation) 또는 null 버블링이라고 부르는데요. Apollo 같은 라이브러리를 쓰면서 “필드 하나 때문에 객체 전체가 사라졌다”며 당황한 적이 있다면, 그건 버그가 아니라 명세가 규정한 정상 동작이었던 겁니다. 흥미로운 건 에러 응답에도 path가 함께 담겨서, 실제로 어디서 문제가 시작됐는지를 정확히 가리켜준다는 점입니다.
이 전파에는 끝도 있습니다. null이 위로 올라가다가 nullable한 부모를 만나면 거기서 멈추지만, 부모마저 Non-Null이면 계속 더 위로 올라갑니다. 최악의 경우 최상위 data까지 올라가 data가 통째로 null이 되기도 하는데요. 그래서 Non-Null을 어디에 붙일지는 신중하게 결정해야 합니다. !를 많이 붙일수록 스키마는 더 엄격해지지만, 작은 실패 하나가 더 넓은 범위를 무너뜨릴 위험도 함께 커지기 때문입니다.
에러가 나도 응답은 돌아옵니다
여기서 GraphQL의 또 다른 특징이 드러납니다. 일부 필드가 실패해도 GraphQL은 요청 전체를 실패시키지 않고, 성공한 부분과 실패한 부분을 함께 담아 돌려준다는 점인데요. 위 예제에서도 name은 실패했지만, 만약 user가 nullable였다면 email은 멀쩡히 값을 채워 함께 응답됐을 겁니다.
이런 부분적 결과(partial result) 는 REST와 사뭇 다른 지점입니다. REST에서는 보통 200이냐 500이냐로 요청 전체의 성패가 갈리지만, GraphQL은 하나의 응답 안에 성공한 데이터(data)와 발생한 에러(errors)를 나란히 담는데요. 이 응답을 정확히 어떤 모양으로 조립하는지, 에러 객체에는 어떤 정보가 들어가는지는 바로 다음 편에서 이어집니다. 에러를 실무에서 어떻게 설계하고 다룰지가 궁금하시다면 Apollo Server의 에러 처리도 함께 살펴보시면 좋습니다.
마치며
이번 편에서는 GraphQL 명세 제6장 Execution을 따라, 쿼리가 데이터로 채워지는 과정을 살펴봤습니다. 실행은 필드마다 리졸버를 부르는 일이고, query는 병렬로 mutation은 직렬로 실행되며, 리졸버가 돌려준 값은 타입에 맞춰 완성된다는 흐름을 짚었는데요. 무엇보다 Non-Null 필드에서 에러가 나면 null이 가장 가까운 nullable 부모까지 번진다는, GraphQL에서 가장 헷갈리기 쉬운 동작의 정체를 명세 차원에서 확인했습니다.
실행이 끝나면 남은 일은 그 결과를 약속된 형식에 담아 돌려주는 것입니다. 다음 편 응답(Response)에서는 data와 errors가 정확히 어떤 구조로 조립되는지, 에러 객체에는 무엇이 담기는지를 마무리로 살펴보겠습니다.
실행 알고리즘의 단계별 정의는 GraphQL September 2025 명세의 Execution 장에서 확인할 수 있습니다.
This work is licensed under
CC BY 4.0