GraphQL 명세 ⑦: 결과를 담아 돌려주는 응답(Response)
지난 편에서 쿼리가 실행되어 데이터가 채워지는 과정까지 따라왔는데요. 이제 그 결과를 클라이언트에게 어떤 모양으로 돌려줄지를 정하는 마지막 단계, 명세 제7장 응답(Response) 차례입니다. 우리가 GraphQL을 쓸 때마다 마주하는 그 { "data": ... } JSON이 왜 항상 그렇게 생겼는지, 에러는 왜 별도의 배열로 따로 담기는지가 이번 편에서 분명해집니다.
응답은 명세에서 가장 짧은 장에 속하지만, 클라이언트 코드를 작성할 때 매일 부딪히는 부분이라 정확히 알아두면 두고두고 도움이 됩니다.
응답의 최상위에는 세 개의 키가 있습니다
GraphQL 응답은 맵(map), 즉 JSON 객체 형태입니다. 그리고 그 최상위에는 정확히 세 개의 키만 올 수 있는데요. data, errors, 그리고 extensions입니다.
data는 실행 결과를 담습니다. 지난 편에서 본 것처럼 요청한 필드의 구조를 그대로 닮은 모양으로 채워지는데요. errors는 실행 중 발생한 에러들의 목록이고, extensions는 명세가 정하지 않은 추가 정보를 서버가 자유롭게 담는 자리입니다.
여기서 명세는 두 키 사이의 미묘한 규칙을 정합니다. 에러가 하나도 없으면 errors 키는 응답에 아예 등장하면 안 됩니다. 즉 "errors": []처럼 빈 배열을 넣는 게 아니라, 키 자체를 빼야 하는데요. 그래서 클라이언트는 errors 키의 존재 여부만으로 “에러가 있었는지”를 간단히 판단할 수 있습니다.
{
"data": {
"user": {
"name": "김철수"
}
}
}
data가 있을 때와 없을 때
data 키는 상황에 따라 세 가지 상태를 가질 수 있습니다. 이 구분이 클라이언트 입장에서 꽤 중요한데요.
실행이 정상적으로 시작되어 결과가 나왔다면 data에 그 결과가 담깁니다. 일부 필드가 실패해 지난 편에서 본 null 전파가 일어났다면, data는 존재하되 그 안의 일부가 null인 부분적 결과가 됩니다.
반면 요청 자체가 실행도 시작하지 못하고 실패했다면, 예를 들어 5편에서 본 검증에서 막혔거나 문법 파싱에 실패했다면, data 키는 아예 등장하지 않습니다. 이때는 errors만 담겨 돌아오는데요.
{
"errors": [
{
"message": "Cannot query field \"age\" on type \"User\"."
}
]
}
그래서 명세는 이렇게 구분합니다. data가 null인 것과 data 키가 아예 없는 것은 다른 의미입니다. 전자는 “실행은 했지만 최상위까지 null이 번졌다”는 뜻이고, 후자는 “실행을 시작조차 못 했다”는 뜻인데요. 클라이언트 코드를 짤 때 이 둘을 구분하면 에러 상황을 더 정확하게 다룰 수 있습니다.
에러 객체에는 정해진 필드가 있습니다
errors 배열의 각 항목은 그냥 문자열이 아니라, 정해진 구조를 가진 객체입니다. 명세는 에러 객체에 들어갈 수 있는 필드를 규정하는데요.
{
"errors": [
{
"message": "Cannot return null for non-nullable field User.name.",
"locations": [{ "line": 3, "column": 5 }],
"path": ["user", "name"]
}
]
}
여기서 message는 사람이 읽을 수 있는 에러 설명으로, 모든 에러 객체에 반드시 있어야 하는 유일한 필수 필드입니다. locations는 그 에러가 쿼리 텍스트의 몇 번째 줄, 몇 번째 칸과 관련되는지를 가리키고요. path는 지난 편에서 잠깐 본 것처럼, 응답 트리에서 에러가 발생한 정확한 위치를 가리킵니다.
특히 path가 실무에서 유용한데요. null 전파 때문에 user가 통째로 null이 됐더라도, path: ["user", "name"]을 보면 실제로 문제가 시작된 곳이 name이라는 걸 정확히 짚어낼 수 있습니다. 에러가 어디서 비롯됐는지 추적할 때 이 path만큼 든든한 단서가 없습니다.
에러는 두 종류로 나뉩니다
명세는 에러를 성격에 따라 두 종류로 구분합니다. 이 구분을 알면 앞에서 본 data의 세 가지 상태가 왜 그렇게 갈리는지 한결 또렷해지는데요.
하나는 요청 에러(request error) 입니다. 요청 자체에 문제가 있어서 실행을 시작조차 못 한 경우인데요. 2편에서 본 문법 파싱 실패나 5편에서 본 검증 실패가 여기 해당합니다. 실행을 못 했으니 결과랄 게 없어서, 이때는 data 키가 아예 등장하지 않고 errors만 돌아옵니다.
다른 하나는 필드 에러(field error) 입니다. 실행은 정상적으로 시작했는데 특정 필드를 처리하다 문제가 생긴 경우인데요. 6편에서 본 리졸버 실패나 Non-Null 위반이 여기 속합니다. 이때는 실행이 진행됐으므로 data가 존재하되, 문제가 난 부분은 null로 채워지고 errors에 그 내역이 함께 담깁니다.
{
"data": {
"user": {
"name": "김철수",
"avatar": null
}
},
"errors": [
{
"message": "이미지 서버에 연결할 수 없습니다.",
"path": ["user", "avatar"]
}
]
}
정리하면 요청 에러는 “시작도 못 했다”라서 data가 없고, 필드 에러는 “하다가 일부가 어긋났다”라서 부분적인 data와 errors가 나란히 담깁니다. 클라이언트는 이 둘을 구분해, 요청 에러면 쿼리 자체를 고치고 필드 에러면 받은 데이터만큼은 화면에 살려두는 식으로 다르게 대응할 수 있습니다.
extensions로 명세 너머의 정보를 담습니다
에러 객체에는 extensions라는 필드도 올 수 있고, 응답 최상위에도 extensions 키가 올 수 있습니다. 이 자리는 명세가 강제하지 않는, 서버가 자유롭게 쓰는 공간인데요.
{
"errors": [
{
"message": "로그인이 필요합니다.",
"path": ["me"],
"extensions": {
"code": "UNAUTHENTICATED",
"httpStatus": 401
}
}
]
}
message는 사람이 읽는 용도라 코드에서 분기 처리하기엔 적합하지 않습니다. 그래서 많은 서버가 extensions.code에 UNAUTHENTICATED 같은 기계가 읽기 좋은 에러 코드를 담는데요. 클라이언트는 이 코드를 보고 “로그인 화면으로 보내자” 같은 판단을 안정적으로 내릴 수 있습니다. 실제로 Apollo Server도 이 방식으로 에러 코드를 전달하는데, 구체적인 활용법은 Apollo Server의 에러 처리에서 다룹니다.
extensions는 에러뿐 아니라 응답 전체에도 붙일 수 있어서, 쿼리 실행 시간이나 캐시 적중 여부, 요청 추적 ID 같은 메타데이터를 담는 데도 쓰입니다. 명세가 이렇게 열린 자리를 일부러 마련해둔 덕분에, 표준을 깨지 않고도 각 서버가 필요한 정보를 자유롭게 실어 보낼 수 있습니다. September 2025 에디션에서는 요청 쪽에도 extensions를 보낼 수 있다는 점이 더 분명하게 정리되었는데, 이 부분은 다음 편에서 마저 다루겠습니다.
직렬화 형식은 JSON만 있는 게 아닙니다
마지막으로 한 가지 짚을 점이 있습니다. 우리는 응답을 당연히 JSON이라고 생각하지만, 명세는 사실 JSON을 강제하지 않습니다. 응답을 “맵, 리스트, 문자열, 불리언, 정수, 실수, null로 이루어진 값”이라는 추상적인 구조로만 정의하고, 그걸 어떤 형식으로 직렬화할지는 열어둔 건데요.
물론 명세도 JSON을 가장 흔하고 권장되는 형식으로 언급하고, 실제로 거의 모든 GraphQL 서비스가 JSON을 씁니다. 다만 원리상으로는 다른 직렬화 형식을 써도 된다는 점이, 1편에서 강조한 “GraphQL은 특정 기술에 묶이지 않는다”는 철학과 일맥상통합니다. 응답의 모양은 명세가 정하되, 그걸 바이트로 바꾸는 방식까지는 강제하지 않는 셈입니다.
마치며
이번 편에서는 GraphQL 명세 제7장 Response를 따라, 실행 결과가 어떤 모양으로 클라이언트에게 돌아오는지 살펴봤습니다. 응답 최상위에는 data, errors, extensions 세 키만 올 수 있고, 에러가 없으면 errors 키는 빠지며, data가 null인 것과 키 자체가 없는 것은 의미가 다르다는 규칙을 확인했는데요. 에러 객체의 message, locations, path가 각각 무슨 역할을 하는지, 그리고 extensions가 표준 너머의 정보를 담는 자리라는 점도 짚어봤습니다.
여기까지 오면 우리는 명세의 본문 여섯 개 장을 모두 통과한 셈입니다. 1편에서 그렸던 지도를 끝까지 따라온 거죠. 이제 시리즈의 마지막 편이 남았습니다. 다음 편 September 2025 에디션의 새로움에서는 그동안 곳곳에서 예고만 했던 이번 에디션의 신규 기능들을 한자리에 모아, 그것들이 왜 하필 지금 GraphQL에 들어왔는지를 함께 짚어보겠습니다.
응답 형식의 정확한 규칙은 GraphQL September 2025 명세의 Response 장에서 확인할 수 있습니다.
This work is licensed under
CC BY 4.0