04 — Anatomy of a Query
한 줄 답: 모든 GraphQL 쿼리는 operation type → name → variables → selection set의 4층으로 분해되며, 그 위에 alias·fragment·directive·introspection이 얹힌다.
Why — 왜 쿼리를 해부해야 하나
GraphQL을 처음 보는 사람은 흔히 다음과 같이 쓴다.
{
user(id: "123") {
name
}
}이건 동작하지만 production에 들어가면 거의 모든 부분에서 어긋난다 — 캐싱이 안 되고(operation name 없음), 보안 검토가 안 되고(variable 대신 inline literal), 재사용이 안 된다(fragment 없음). 쿼리의 문법 부품 하나하나를 알면, 어디서 무엇이 깨지는지 보인다.
이 문서는 production-ready 쿼리 한 개를 문법 부품 단위로 분해한다.
How — 쿼리의 4층 구조
표본 쿼리 — 모든 부품이 한 번씩 등장
query GetUserDashboard($userId: ID!, $includeDrafts: Boolean = false) {
# ┌─ operation type
# │ └─ operation name
# │ └─ variable definitions
currentUser: user(id: $userId) {
# │ └─ field with argument
# └─ alias
id
name
posts(first: 10) @include(if: $includeDrafts) {
# │ └─ directive
# └─ field with argument
...PostSummary
# └─ fragment spread
}
... on PremiumUser {
# └─ inline fragment (type condition)
subscriptionTier
}
}
__typename # introspection meta-field
}
fragment PostSummary on Post {
# └─ fragment definition
id
title
publishedAt
}이 한 쿼리에 들어 있는 부품은 10가지다. 하나씩 본다.
1) Operation Type — 3개 중 하나
query GetX { ... } # 읽기 (병렬)
mutation DoY { ... } # 쓰기 (순차)
subscription OnZ { ... } # 스트림생략하면 query로 간주된다 ({ user { name } } 형태). production에선 항상 명시 권장 — 도구·로깅이 쉬워진다.
2) Operation Name — 익명 금지
query GetUserDashboard { ... } # ✅
query { ... } # ⚠️ anonymous
{ ... } # ⚠️ shorthand이름이 있어야:
- 로그에서 식별 가능 (Apollo Studio·Datadog 등)
- persisted query로 hash 등록 가능
- 복수 operation을 한 document에 둘 수 있음 (그땐 필수)
3) Variables — 절대 inline literal로 쓰지 말 것
# ❌ inline
query { user(id: "123") { name } }
# ✅ variable
query GetUser($id: ID!) {
user(id: $id) { name }
}// HTTP body
{
"query": "query GetUser($id: ID!) { user(id: $id) { name } }",
"variables": { "id": "123" }
}Variable을 쓰면:
- 쿼리 텍스트가 재사용 가능 → persisted query·캐시
- 타입 검증을 서버가 함
- SQL injection 같은 공격 surface가 줄어듦
타입 표기:
$id: ID!—!는 non-null. 누락하면 validation 에러$includeDrafts: Boolean = false— 기본값 지정 가능
4) Selection Set — 중괄호 안의 트리
{
user { ← selection set 시작
name ← field
posts { ← nested selection set
title ← field
}
}
}규칙: scalar 타입(Int/String/Boolean/ID/Float/Enum/custom scalar)은 selection set이 없어야 하고, object/interface/union 타입은 selection set이 반드시 있어야 한다. 위반하면 validation 에러.
5) Alias — 같은 필드를 다른 이름으로
{
alice: user(id: "1") { name }
bob: user(id: "2") { name }
}{
"data": {
"alice": { "name": "Alice" },
"bob": { "name": "Bob" }
}
}같은 user 필드를 두 번 호출하면서 응답에서 충돌 없이 둘 다 받는다. dashboard처럼 같은 타입을 여러 번 쿼리하는 화면에서 핵심.
6) Fragment — 재사용 가능한 selection set
Named fragment:
fragment PostSummary on Post {
id
title
publishedAt
}
query {
user { posts { ...PostSummary } }
trending { ...PostSummary }
}Inline fragment (타입 분기):
{
search(q: "foo") {
... on User { name }
... on Post { title }
... on Comment { text }
}
}Union·interface 타입을 받을 때 타입별로 다른 필드를 쓰는 유일한 방법.
7) Directive — @include / @skip / @deprecated
GraphQL spec October 2021이 정의하는 built-in directive는 정확히 3개다.
| Directive | 위치 | 동작 |
|---|---|---|
@include(if: Boolean!) | field, fragment spread, inline fragment | true일 때만 포함 |
@skip(if: Boolean!) | field, fragment spread, inline fragment | true일 때 제외 |
@deprecated(reason: String) | field/enum value definition (스키마) | 도구에 deprecated 표시 |
query Profile($includeEmail: Boolean!) {
user {
name
email @include(if: $includeEmail)
}
}추가 directive(@cached, @auth, @connection 등)는 spec 외다 — 라이브러리·서버가 정의한 custom directive.
8) Field Arguments
posts(first: 10, after: "cursor123", orderBy: CREATED_DESC) { ... }각 argument는 스키마에 타입과 함께 선언돼 있어야 하며, 기본값을 가질 수 있다. 위치 인자는 없다 — 항상 named다.
9) Introspection — 메타 필드 3개
GraphQL 서버는 자기 자신을 쿼리할 수 있어야 한다. spec이 정의하는 introspection 진입점은 정확히 세 개.
| 메타 필드 | 어디서 | 용도 |
|---|---|---|
__schema | Query 타입의 root | 전체 스키마 메타데이터 |
__type(name: String!) | Query 타입의 root | 특정 타입 조회 |
__typename | 모든 object/interface/union 위치 | 런타임 타입 이름 |
# 전체 스키마 살펴보기
{
__schema {
types { name kind }
queryType { name }
mutationType { name }
subscriptionType { name }
}
}
# 한 타입 자세히
{
__type(name: "User") {
name
fields { name type { name kind } }
}
}
# Union/Interface에서 실제 타입 알아내기
{
search(q: "foo") {
__typename
... on User { name }
... on Post { title }
}
}GraphiQL·Apollo Sandbox 같은 도구가 자동완성과 문서를 보여주는 비결이 바로 이 introspection이다.
10) Comments
# 한 줄 주석 — '#'로 시작
query GetUser {
user { name } # 줄 끝에서도 가능
}블록 주석은 spec에 없다. #만 있다.
What — 정확한 사양 (October 2021)
Document — 쿼리 파일 한 개의 단위
GraphQL spec에서 Document는 한 번에 보내는 텍스트 단위다. 다음을 포함할 수 있다.
# Document = N개의 ExecutableDefinition
query A { ... } # operation definition 1
query B { ... } # operation definition 2
mutation C { ... } # operation definition 3
fragment X on T { ... } # fragment definition
fragment Y on T { ... } # fragment definition여러 operation이 있는 document를 보낼 땐 어떤 operation을 실행할지 HTTP body에 명시.
{
"query": "query A { ... } query B { ... }",
"operationName": "A",
"variables": {}
}Validation 규칙 (spec §5에서 발췌)
| 규칙 | 위반 시 |
|---|---|
| Fields must exist on type | 존재하지 않는 필드 → 거부 |
| Scalar leaf fields | scalar에 { } 붙이면 거부 |
| Object/interface non-leaf | object에 { } 누락 시 거부 |
| Argument types valid | argument 타입 mismatch 시 거부 |
| Variables are input types | variable이 object 타입이면 거부 |
| Fragment spreads must form acyclic graph | fragment가 순환 참조하면 거부 |
| Operation name uniqueness | 같은 이름 operation 두 개면 거부 |
이 모두는 실행 전 정적으로 검증된다. resolver는 호출도 안 된다.
Response 구조
{
"data": { ... }, // selection set과 동형 (실패 시 null 또는 부분)
"errors": [ // 선택 — 실패한 필드들
{
"message": "Permission denied",
"path": ["user", "email"],
"locations": [{ "line": 3, "column": 5 }],
"extensions": { "code": "FORBIDDEN" }
}
],
"extensions": { ... } // 선택 — 서버가 자유롭게 추가 (trace 등)
}세 최상위 키만 spec이 정의한다. 다른 키를 최상위에 두면 non-conformant.
What-if — 잘못 쓰면
1) Anonymous query만 쓴다
증상: 로그·메트릭에서 모든 쿼리가 같은 익명으로 집계.
원인: query { ... }로만 보냄.
대응: 모든 쿼리에 명사 형태의 이름 (GetUserDashboard, UpdateUserName).
2) Variable 대신 inline literal
증상: 쿼리 텍스트가 매번 다름 → cache miss, persisted query 불가.
원인: user(id: "123")처럼 값 박아넣음.
대응: 항상 $id: ID! variable.
3) Fragment 중복
증상: 여러 화면에서 같은 필드 셋을 복사붙여넣기.
원인: fragment를 안 쓰거나 너무 잘게 쪼갬.
대응: 화면 단위 fragment (예: UserCardFields, PostDetailFields).
4) @include/@skip 둘 다 거는 실수
field @include(if: $a) @skip(if: $b)증상: 결과가 둘 다 만족해야 포함됨 — 직관에 어긋남. 대응: 둘을 동시에 쓰지 말고 boolean 식을 클라이언트에서 합성.
5) Introspection을 production에 열어둠
증상: 공격자가 __schema로 전체 스키마를 덤프 → 숨겨진 내부 필드 노출.
원인: GraphQL 라이브러리는 기본적으로 introspection을 켜둔다.
대응: production은 introspection 비공개 + persisted query만 허용. 개발/스테이징만 열기.
6) Subscription에 query만큼의 selection set
증상: subscription 응답이 거대 → WebSocket 트래픽 폭증. 원인: 이벤트 ID만 받으면 되는데 전체 객체를 받음. 대응: subscription은 알림 트리거만, 본 데이터는 별도 query로.
Insight — 흥미로운 이야기
”왜 __schema는 underscore 두 개로 시작하나”
GraphQL spec은 유저가 정의하는 모든 이름이 underscore 두 개로 시작하는 것을 금지한다 (__로 시작하는 이름은 spec 예약). 이 단순한 규칙 덕에 introspection 필드(__schema, __type, __typename)는 유저 정의 필드와 절대 충돌하지 않는다. 이름 충돌을 피하려고 접두사 네임스페이스를 따로 두지 않은 우아한 디자인.
”Fragment는 React에서 가져왔다”
Facebook 내부에서 GraphQL과 React는 같은 팀에서 같은 시기에 만들어졌다. Fragment 개념은 React의 컴포넌트 단위 데이터 의존성에서 직접 영감을 받았다 — 컴포넌트마다 자기가 필요한 필드 셋을 선언하고, 부모가 합성한다. Relay라는 client 라이브러리가 컴파일 타임에 fragment를 합성하는 패턴을 만들었고, 그것이 GraphQL 클라이언트의 표준 패턴이 됐다.
”Directive는 ‘GraphQL의 escape hatch’”
@include/@skip 외의 모든 directive는 spec 외다. 즉 GraphQL spec은 directive 문법만 정의하고 어떤 directive가 있을지는 라이브러리에 위임했다. 이 덕에 Apollo의 @key(Federation), Relay의 @connection(pagination), @defer/@stream(stream draft) 같은 후속 표준이 spec 변경 없이 추가될 수 있었다 — 언어는 작고, 생태계는 크다.
”변하지 않는 5가지 키워드”
10년이 지나도 GraphQL 쿼리의 핵심 키워드는 5개뿐이다 — query, mutation, subscription, fragment, on. 이 작은 어휘가 복잡한 데이터 그래프를 표현한다는 점이 GraphQL의 디자인 미학이다.
요약 + 다이어그램
모든 GraphQL 쿼리는 operation·variables·selection set이라는 3가지 핵심 부품 위에 alias·fragment·directive·introspection이 얹힌 트리다. Production에서는 anonymous 금지, inline literal 금지, fragment 적극 사용, introspection 비공개가 표준 hygiene.
다음 문서:
05-mental-model.mdx— 이 부품들의 왜를 한 줄로: GraphQL은 graph가 아니라 graph traversal이다.