🔷 GraphQL7. 보안 & 거버넌스02 — Query Depth & Complexity Limit

02 — Query Depth & Complexity Limit

한 줄 답: 쿼리 한 줄이 DB 한 통째를 긁어 갈 수 있는 구조에서, 서버는 얼마나 깊은(depth)·얼마나 많은(complexity)·얼마나 무거운(cost) 쿼리까지 받을지를 명시적으로 정해야 한다. 셋은 같은 축이 아니라 정밀도와 구현 비용의 사다리다.


Why — 왜 세 가지 척도가 필요한가

01에서 본 cyclic 공격을 떠올려 보자.

{ user { posts { author { posts { author { posts { id } } } } } } }

이 쿼리는 depth 7이다. depth limit 6을 걸면 거절된다. 그런데 다음은 어떤가.

{ users(first: 10000) { posts(first: 1000) { id } } }

depth 2다 — depth limit 6을 통과한다. 하지만 결과는 천만 개의 post. 이게 complexity가 필요한 이유다.

한 번 더. 다음은 depth 2, complexity 낮음이지만 비싸다.

{ user { recommendations(first: 50) { id } } }

recommendationsML 추론을 부른다고 가정하자. node 수는 적지만 한 노드의 cost가 100배다. 이게 cost 기반이 필요한 이유.

척도무엇을 세나정밀도구현 난이도
depthselection set의 깊이낮음매우 쉬움 (graphql-depth-limit)
complexity노드의 곱(arg 반영)중간중간 (graphql-query-complexity)
cost필드별 작업 무게높음높음 (필드마다 cost 함수 + 검증)

→ 세 척도는 경쟁이 아니라 사다리다. depth부터 시작하고, 필요에 따라 complexity, cost로 올라간다. 대부분의 production은 둘 이상을 함께 깐다.


How — 어떻게 측정하고 강제하나

1) Depth Limit — 가장 싸고 가장 무딘 도구

depth는 selection set 트리의 최대 깊이다.

{                  # depth 0
  user {           # depth 1
    posts {        # depth 2
      author {     # depth 3
        name       # depth 4 (leaf 이전이 4)
      }
    }
  }
}

graphql-depth-limit (Node.js, 2017~)는 AST를 한 번 순회하면서 최대 깊이를 잰다 — 실행 전에 fail-fast한다.

import depthLimit from "graphql-depth-limit";
import { ApolloServer } from "@apollo/server";
 
const server = new ApolloServer({
  schema,
  validationRules: [depthLimit(10)], // depth 10 초과 거절
});

한계 — depth는 깊이만 본다. 위에서 본 users(first: 10000)통과한다. 그래서 depth는 하한 방어다.

2) Complexity Scoring — 노드 수 추정

complexity는 response에 나올 수 있는 노드의 추정치다.

{
  users(first: 100) {        # 100 nodes
    posts(first: 50) {       # 100 × 50 = 5000 nodes
      comments(first: 10) {  # 5000 × 10 = 50,000 nodes
        text                 # scalar
      }
    }
  }
}

→ 추정 complexity = 50,000. limit이 10,000이면 거절.

graphql-query-complexity (npm, slicknode)는 필드별 complexity 함수를 받아 AST 순회 시 곱셈으로 누적한다.

import { createComplexityRule, simpleEstimator, fieldExtensionsEstimator }
  from "graphql-query-complexity";
 
const rule = createComplexityRule({
  maximumComplexity: 1000,
  variables: req.body.variables,
  estimators: [
    fieldExtensionsEstimator(),  // schema의 @complexity directive 사용
    simpleEstimator({ defaultComplexity: 1 }),  // fallback
  ],
});

스키마에 다음과 같이 명시한다.

type Query {
  users(first: Int!): [User!]!
    @complexity(value: 1, multipliers: ["first"])
  # complexity = 1 × first
}
 
type User {
  posts(first: Int!): [Post!]!
    @complexity(value: 1, multipliers: ["first"])
}

한계 — complexity는 노드 수만 본다. ML 추론처럼 한 노드가 비싼 경우를 못 잡는다. 그리고 first가 없는 unbounded list디폴트 값을 무엇으로 둘지가 영원한 논쟁거리(보수적으로 1000?).

3) Cost Analysis — 작업의 무게

cost는 필드 작업의 실제 비용 추정치다. 보통 두 가지를 분리한다.

cost 종류의미
static cost결과와 무관한 기본 비용resolver 호출 = 1
multiplier cost결과 개수에 비례하는 비용first: Ncost × N

GitHub API v4의 공식은 공개돼 있다 — 이 챕터 강조의 핵심 사례.

cost = ceil(input_count / 100)  per connection

first: 50이면 1 point, first: 100도 1 point, first: 200이면 2 point. 중첩되면 곱한다.

{
  search(first: 50, query: "graphql") {  # 1 point
    nodes {
      ... on Repository {
        issues(first: 100) {              # 1 point × 50 = 50 points
          nodes { title }
        }
      }
    }
  }
}
# 총 cost = 1 + 50 = 51 points

GitHub은 5000 point/h를 한도로 잡고, 응답에 rateLimit { cost, remaining, resetAt } 필드를 명시적으로 노출한다. 클라이언트는 쿼리 전에 비용을 알 수 있다.

{
  rateLimit { cost remaining resetAt }
  viewer { login }
}
{
  "data": {
    "rateLimit": { "cost": 1, "remaining": 4999, "resetAt": "2026-05-17T15:00:00Z" }
  }
}

4) Shopify Storefront API의 cost calculation

Shopify는 GitHub과 다른 알고리즘을 쓴다 — 필드마다 명시적 cost. 공식 문서의 cost 표.

필드base costmultiplier
products1× first/last
metafields2× first
productByHandle1-
connection의 pageInfo0-

요청 응답의 extensions.costrequested costactual cost가 모두 들어온다.

{
  "data": { ... },
  "extensions": {
    "cost": {
      "requestedQueryCost": 152,
      "actualQueryCost": 100,
      "throttleStatus": {
        "maximumAvailable": 1000,
        "currentlyAvailable": 900,
        "restoreRate": 50
      }
    }
  }
}

→ Shopify는 bucket 모델 (05)을 명시적으로 노출. requested vs actual의 차이는 예측의 정확도다 — 클라이언트는 actual로 학습한다.

5) 사다리로 보기

대부분의 production은 L1 + L2까지, 큰 API(GitHub, Shopify)는 L3까지 간다.


What — 구체 사양

라이브러리 매트릭스 (Node.js)

라이브러리척도사용처
graphql-depth-limitdepthApollo, Yoga의 validation rule
graphql-query-complexitycomplexity (extensible)Apollo, Yoga
@envelop/depth-limitdepth (Envelop plugin)GraphQL Yoga
@graphql-tools/cost-analysiscost (deprecated)과거 Apollo Engine
Apollo Studio operationCountmetric only모니터링 (limit 아님)

다른 언어. Hot Chocolate(.NET) — MaxAllowedExecutionDepth, OperationComplexity 빌트인. Strawberry(Python) — QueryDepthLimiter, MaxAliasesRule. gqlgen(Go) — complexity.Root config로 필드별 함수 등록.

depth/complexity 디폴트 권장치

환경depthcomplexity비고
내부 API155,000정상 쿼리 거의 다 통과
외부 API (사내 일반)101,000일반적 권장
공개 API (GitHub-class)10점수 시스템 (5000/h)cost 기반
모바일 only7500모바일은 깊은 쿼리 거의 없음

출처: Apollo Solutions의 권장값, Hasura의 GraphQL Security Guidelines, Yoga’s @graphql-armor/max-depth 디폴트.

응답 형식 — extension에 cost 노출하기

권장 패턴은 응답 extensions에 cost를 명시하는 것이다 — 클라이언트가 학습할 수 있게.

{
  "data": { ... },
  "extensions": {
    "cost": { "estimated": 152, "actual": 100, "max": 1000 }
  }
}

이는 클라이언트와의 계약이 된다. 다음 호출 때 클라이언트는 쿼리를 좁힐 단서를 갖는다.

검증 vs 실행 — 실행 전에 막아야 한다

세 척도 모두 Validator 단계에서 끝낸다. Resolver가 호출되기 전에 거절해야 의미가 있다 — 실행 후에 막으면 DB 비용은 이미 발생했다.


What-if — 잘못 이해하면

1) depth limit만 끼우면

→ depth 2짜리 { users(first: 10000) { posts(first: 1000) { id } } }천만 노드 폭발. 대응: complexity까지 함께.

2) complexity의 디폴트 값을 안 정하면

first가 없는 list 필드가 complexity 1로 계산되어 무한 노드를 합법적으로 받는다. 대응: list 필드는 first/last가 필수거나, 디폴트 multiplier를 보수적으로 잡는다 (1000 권장).

3) cost를 코드에만 두고 schema에 안 보이면

→ 클라이언트는 어떤 필드가 비싼지 모른다. 매번 시행착오로 학습. 대응: @cost directive로 schema에 명시 (GitHub, Shopify처럼).

4) 실행 후에 cost를 재면

→ DB는 이미 천만 row를 긁었다. limit이 의미 없다. 대응: Validator 단계에서 AST만 보고 추정. 정밀도가 떨어져도 실행 전에 자르는 것이 핵심.

5) Subscription에 cost를 안 매기면

→ subscription은 오랫동안 떠있다. 한 번 통과하면 영구적 비용. depth/complexity는 subscription도 따로 limit. 대응: subscription의 selection set도 동일 검사를 적용 + 동시 subscription 개수 limit.

6) Fragment를 전개 전에 세면

→ 같은 fragment를 100번 사용한 쿼리가 complexity 1로 측정된다. 대응: graphql-query-complexityfragment 전개 후 계산한다 (default). 직접 짤 때 주의.

7) Introspection 쿼리도 cost 부과

__schema { types { fields { ... } } }깊은 쿼리다. introspection을 켜둔다면 별도 cost 정책 필요. 대응: 일반 cost로 잡거나, introspection 전용 rate limit (03).


Insight — 흥미로운 이야기

”graphql-depth-limit는 100줄이다”

andrewcourtice/graphql-depth-limit의 핵심 코드는 100줄 미만이다. AST를 재귀로 내려가며 SelectionSet의 깊이를 카운트하고, 임계 초과 시 GraphQLError를 던진다. 그뿐이다. 보안 도구는 복잡해야 한다는 통념과 달리, GraphQL 방어의 첫 층은 AST 한 번 순회로 끝난다.

→ 교훈: 보안의 가성비는 spec이 정의하지 않은 자리에서 가장 크다.

”GitHub은 cost 알고리즘을 공개했다”

대부분의 SaaS는 rate limit 알고리즘을 영업 비밀로 둔다. GitHub v4는 반대로 공개했다 — 어떻게 cost를 계산하는지, 클라이언트가 어떻게 예측할 수 있는지. 그 결정의 이유는 GitHub Universe 2017 발표에서 나왔다 — “클라이언트가 우리 cost를 예측할 수 있어야 우리 시스템이 보호된다. 비밀로 두면 다들 시행착오를 한다.”

→ 교훈: cost는 방어만이 아니라 클라이언트와의 계약이다.

”Shopify의 ‘leaky bucket’”

Shopify Storefront API는 token bucket이 아니라 leaky bucket이다 — 일정 속도(restoreRate)로 복구된다. burst를 허용하면서도 지속적 폭주를 막는 모델이다. 응답 extension에 restoreRate노출하기 때문에 클라이언트가 얼마나 자주 호출해도 되는지를 알 수 있다.

→ 교훈: 알고리즘 선택이 클라이언트의 행동 패턴을 만든다.

”Yoga의 graphql-armor”

@graphql-armor (The Guild, 2022~)는 7가지 방어 plugin을 한 묶음으로 제공한다 — max-depth, max-aliases, max-directives, max-tokens, cost-limit, block-field-suggestions, character-limit. 디폴트 설정만으로 OWASP 12 권고의 절반을 채운다. The Guild의 메시지 — “보안의 핵심은 빌트인 디폴트다. 옵션이 있어도 안 끼면 의미 없다.”

→ 교훈: 보안 도구의 진짜 척도는 얼마나 강한가가 아니라 얼마나 쉽게 켜지는가다.


요약 + 다이어그램

depth는 깊이, complexity는 노드 수, cost는 작업의 무게. 셋은 정밀도와 구현 비용의 사다리이며, 실행 전에 막아야 의미가 있다. GitHub v4의 5000 point/h가 사실상 표준이며, Shopify는 leaky bucket으로 복구 속도까지 노출한다. 다음 문서는 어디서 cost를 계산하지 않고 그냥 차단할 것인가 — introspection 제어.

다음 문서: 03-introspection-control.mdx — schema 자체를 얼마나 노출할 것인가의 정책.