03 — Introspection Control
한 줄 답: introspection은 GraphQL spec이 모든 서버에 강제하지만, production에 켜둘지 말지는 spec 밖의 정책이다. 끄면 디버깅이 불편하고, 켜면 schema가 공격자에게 합법적으로 노출된다. 그 trade-off는 persisted query + schema registry 조합이 증발시킨다.
Why — 왜 이게 정책 문제인가
GraphQL spec은 다음 한 줄을 강제한다.
“Every GraphQL service must support introspection.” —
spec.graphql.org§4
즉 __schema, __type, __typename 메타 필드는 spec 의무다. 하지만 spec은 *“production에 introspection을 켜둬야 한다”*고는 말하지 않는다.
그래서 두 진영이 존재한다.
| 진영 | 주장 | 디폴트 |
|---|---|---|
| ”끄자” — security-first | schema 노출이 공격 recon을 자동화한다 | Apollo 4 production, AWS AppSync 권장 |
| ”켜자” — DX-first | 클라이언트 도구·codegen·디버깅이 introspection에 의존 | GitHub v4, Shopify Storefront |
둘 다 근거가 있다. 그래서 이건 정책 결정이지 정답이 아니다. 단, 셋째 길이 있다 — persisted query를 도입하면 클라이언트는 introspection을 안 봐도 되고, 공격자는 임의 쿼리를 못 보낸다.
How — 어떻게 제어하나
1) 그냥 끄기 — NoIntrospection rule
Apollo Server 4는 production일 때 자동으로 끈다.
import { ApolloServer } from "@apollo/server";
const server = new ApolloServer({
schema,
introspection: process.env.NODE_ENV !== "production",
});내부적으로 validation rule NoSchemaIntrospectionCustomRule를 끼운다. 이 rule은 __schema / __type 필드가 selection set에 나오면 validation 에러를 던진다 — 즉 Validator 단계에서 거절. __typename은 허용된다 (이건 자기 자신의 type을 묻는 거라 보안 이슈 없음).
다른 서버.
| 서버 | 방법 |
|---|---|
| Yoga | useDisableIntrospection() plugin (@graphql-yoga/plugin-disable-introspection) |
| Hot Chocolate (.NET) | .AddIntrospectionAllowedRule() policy |
| Strawberry (Python) | schema.disable_introspection() |
| gqlgen (Go) | extension.Introspection을 빼는 것으로 비활성 |
2) GraphiQL/Playground 같은 IDE 비활성
introspection을 껐는데 GraphiQL이 켜져 있으면 의미가 없다 — GraphiQL은 내부적으로 introspection을 부른다.
new ApolloServer({
introspection: false,
plugins: [ApolloServerPluginLandingPageDisabled()],
});또는 내부망에서만 GraphiQL을 열기. 외부에는 POST only.
3) 에러 메시지 정제 — 간접 introspection 차단
introspection을 꺼도 에러 메시지가 schema를 누설할 수 있다.
{ userr { name } }→ 디폴트 응답: “Cannot query field userr on type Query. Did you mean user?”
이 did you mean suggestion이 clairvoyance 같은 도구의 무기다 — 오타 응답을 fuzzing해서 schema를 역추적한다.
→ 방어는 @graphql-armor/block-field-suggestions — error message의 suggestion을 제거.
import { blockFieldSuggestionsPlugin } from "@escape.tech/graphql-armor-block-field-suggestions";
useServer({ plugins: [blockFieldSuggestionsPlugin()] });4) Persisted Query — 셋째 길
핵심 발상은 클라이언트가 보낼 수 있는 쿼리를 미리 등록해두는 것.
빌드 시점에 클라이언트가 작성한 모든 GraphQL 쿼리를 추출 → SHA-256 해시화 → 서버에 allowlist로 등록. 런타임에는 클라이언트가 해시만 보낸다 — 임의 쿼리는 애초에 못 보낸다.
POST /graphql
{ "id": "a1b2c3d4..." } # 등록된 쿼리의 해시만
# 또는 APQ — Automatic Persisted Queries
{ "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "..." } } }→ 이 모델에서는 introspection이 필요 없다. 클라이언트는 빌드 시에 schema를 보고 쿼리를 짜고, 런타임에는 schema를 전혀 안 본다. 그러면서도 임의 쿼리 자체가 차단된다 — 01의 4대 공격이 전부 무효가 된다.
Persisted Query를 쓰는 곳: Facebook 모바일 (2016~), Apollo Client APQ, Relay Compiler, GitHub Enterprise(옵션), Shopify Admin(내부).
5) Schema Registry — 분리된 schema 저장소
introspection을 끄고 persisted query도 안 쓴다면, 클라이언트는 schema를 어떻게 알지? 답은 schema registry다.
대표 구현.
| Registry | 운영자 | 특징 |
|---|---|---|
| Apollo Studio (GraphOS) | Apollo | schema check · usage analytics · federation 통합 |
| GraphQL Hive | The Guild | 오픈소스 · self-host 가능 |
| Stellate (구 GraphCDN) | Stellate | CDN + registry |
| AWS AppSync | AWS | console에서 schema 관리 |
→ 즉 schema는 서버가 introspection으로 노출하지 않고, 별도 시스템이 인증된 채널로만 제공한다. 외부에는 production endpoint만 보이고 schema는 안 보인다.
What — 구체 사양
Apollo Server 4의 introspection 정책
Apollo 4는 환경에 따라 3가지 모드.
introspection 값 | 동작 |
|---|---|
true | 항상 켜짐 (개발) |
false | 항상 꺼짐 (production) |
| 미지정 | NODE_ENV === "production" ? false : true |
(Apollo 3까지는 디폴트 켜짐이었다 — 4부터 production 디폴트가 꺼짐으로 바뀐 것이 이 챕터 인사이트의 핵심 사건.)
Yoga의 useDisableIntrospection
import { createYoga } from "graphql-yoga";
import { useDisableIntrospection } from "@graphql-yoga/plugin-disable-introspection";
const yoga = createYoga({
schema,
plugins: [useDisableIntrospection()],
});조건부로 켜기 — 내부망 IP만 허용.
useDisableIntrospection({
isDisabled: ({ request }) => !isInternalIp(request.headers.get("x-forwarded-for")),
});Persisted Query — Apollo APQ
Automatic Persisted Queries는 최초 1회만 쿼리를 보내고, 이후엔 해시만 보내는 모델이다.
// Apollo Client
import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries";
import { sha256 } from "crypto-hash";
const link = createPersistedQueryLink({ sha256 }).concat(httpLink);서버는 apollo-server-plugin-operation-registry나 Apollo Studio에 허용 쿼리 목록을 등록한다. 모르는 해시는 PersistedQueryNotFound 에러.
Strict mode에서는 해시만 허용하고 full query는 거절한다 — 이 모드가 진짜 보안 효과를 낸다.
@graphql-armor/block-field-suggestions
import { useBlockFieldSuggestions } from "@escape.tech/graphql-armor-block-field-suggestions";
useServer({
plugins: [
useBlockFieldSuggestions({
mask: "[Suggestion hidden]",
}),
],
});이걸 안 끼면 introspection을 꺼도 clairvoyance로 schema가 추출된다.
What-if — 잘못 이해하면
1) introspection만 끄면 안전하다고 믿으면
→ error message의 did you mean으로 schema가 역추적된다.
대응: block-field-suggestions 함께 끼우기.
2) GraphiQL을 켜둔 채 introspection만 끄면
→ GraphiQL이 동작을 멈춘다 (introspection 의존). 디버깅 도중 production에 GraphiQL을 켜는 사고가 난다. 대응: GraphiQL은 내부망에서만, 별도 endpoint에 둔다.
3) __typename까지 끄면
→ Apollo Client / Relay가 cache key를 만들지 못해 작동 정지.
대응: __typename은 허용. NoSchemaIntrospectionCustomRule는 이걸 알아서 예외 처리한다.
4) Persisted Query를 Optional로만 두면
→ 공격자는 임의 쿼리를 그대로 보낸다. allowlist는 strict mode에서만 보안 효과. 대응: production은 persistedQuery만 허용, full query는 거절.
5) Schema Registry 인증을 약하게 두면
→ registry가 공개 endpoint가 되어 schema가 다시 새어 나간다. 대응: registry는 반드시 인증 필요 — Apollo Studio는 API key, Hive는 token.
6) Subscription에 introspection 정책을 깜빡하면
→ WebSocket으로 introspection이 가능. HTTP만 막아도 의미 없음. 대응: HTTP/WS 모두 동일 plugin 적용.
7) “내부 API니까 introspection 켜둬도 됨”이라고 보면
→ 내부에서 유출된 credential로 schema가 새 나간다. defense in depth 관점에서 기본은 OFF. 대응: 내부도 기본 OFF, 개발자는 별도 registry로 schema 받기.
Insight — 흥미로운 이야기
”Apollo 4의 break-change 한 줄”
Apollo Server 4.0 changelog의 한 줄짜리 break-change가 이 챕터의 핵심 사건이다.
“Introspection is now disabled by default when
NODE_ENV === 'production'.”
이전까지 모르고 켠 채 배포한 production이 수만 곳 — Shodan 검색에서 __schema로 응답하는 GraphQL endpoint가 5만+ 잡혔다. Apollo는 문서로 안내했지만 잘 안 지켜졌다. break-change로 디폴트를 바꾸자 한 번의 마이너 버전으로 수만 production이 동시 안전해졌다.
→ 교훈: 보안의 위치는 문서가 아니라 디폴트다.
”Facebook은 introspection을 처음부터 안 켰다”
GraphQL을 만든 Facebook 자신은 production introspection을 한 번도 켜지 않았다. 그들이 만든 Relay Compiler는 빌드 시에 schema를 읽고 persisted query 해시를 만들었기 때문에, 런타임 introspection이 필요 없었다. 외부 사용자들이 GraphQL을 도입하면서 Relay 같은 빌드 도구 없이 시작했고, 그래서 introspection을 런타임에 의존하기 시작한 것이다.
→ 교훈: GraphQL의 원래 운영 모델은 persisted query 기반이었다. introspection-on은 외부의 편의에서 생긴 것.
”GitHub은 켜뒀다”
GitHub v4는 production introspection이 켜져 있다. 그 이유는 GitHub Universe 2017에서 명시됐다 — “우리는 third-party 도구 생태계가 schema를 보고 자라기를 원한다.” GitHub은 cost-based rate limit(05)으로 introspection 남용을 경제적으로 차단한다 — __schema 덤프는 cost가 매우 높게 매겨져 있다.
→ 교훈: introspection을 켤지 끌지는 비즈니스 모델과 결합한 결정이다. 공개 API라면 켜되 cost로 막고, 내부 API라면 그냥 끄는 게 표준 답.
”clairvoyance는 0.3초만에 schema를 복원한다”
nikitastupin/clairvoyance는 introspection이 꺼진 서버에서 did you mean error message만 보고 schema를 복원한다. 한 번의 fuzzing 라운드가 수십 초, 전체 schema 복원이 수 분. introspection을 끄는 것이 진짜 보안 효과를 내려면 error suggestion까지 차단해야 한다.
→ 교훈: 보안은 조합이다. introspection OFF만으로는 부족.
요약 + 다이어그램
introspection 끄기 vs 켜기는 trade-off다 — 디버깅 vs 노출. 셋째 길은 persisted query + schema registry — 클라이언트는 빌드 시 schema를 보고, 런타임엔 해시만 보낸다. 끄기로 결정했다면 error message suggestion까지 차단해야 진짜 닫힌다. 다음 문서는 누가 어느 필드를 볼 수 있는가 — 인증과 권한.
다음 문서:
04-authentication-and-authorization.mdx— 누가, 어느 필드에, 어떻게 접근하는가.