0Pricing
FastAPI Backend Development Bootcamp · 강의

쿼리 비용 분석과 깊이 제한

깊이 제한, 복잡도 점수화 및 저장된 쿼리를 사용해 악의적인 쿼리로부터 API를 보호합니다.

쿼리 비용 분석과 깊이 제한은(는) CoddyKit의 무료 FastAPI Backend Development Bootcamp 강의입니다. 이것은 4개 중 4번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 FastAPI Backend Development Bootcamp 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. FastAPI Backend Development Bootcamp 강의에는 총 4개의 강의가 포함되어 있습니다.

이 강의의 일부는 아직 번역되지 않았으며 영어로 표시됩니다.

Why GraphQL Needs Query Guards

REST endpoints have a fixed cost: you call /users/1/posts and the server decides how much work that is. GraphQL flips control to the client. A single endpoint accepts arbitrary nested queries, so a malicious or careless client can ask for something enormous.

Consider a query that walks user -> friends -> friends -> friends .... Each level multiplies the number of resolvers fired. A few hundred bytes of query text can trigger millions of database round-trips.

  • Depth limiting caps how deeply nested a query can be.
  • Complexity scoring caps the estimated total cost.
  • Persisted queries only allow pre-approved query documents.

In this lesson we build all three for a Strawberry + FastAPI service.

The Nested-Query Attack

The classic abuse is a deeply nested, self-referential query. If your schema lets Author resolve posts and Post resolve author, a client can ping-pong between them indefinitely.

Below is the shape of such a query as plain text. Even without running it, notice that every extra posts { author { ... } } level can multiply fan-out at the data layer.

abusive_query = """
query {
  author(id: 1) {
    posts {
      author {
        posts {
          author {
            posts { title }
          }
        }
      }
    }
  }
}
"""

# A simple depth counter on the raw text gives a rough idea.
depth = abusive_query.count("{")
print(f"Approximate nesting braces: {depth}")

Measuring Depth Properly

Counting braces in text is unreliable (string literals, fragments, aliases all break it). The correct way is to walk the parsed AST that graphql-core produces. Each FieldNode with a nested selection set adds one to the depth.

Here is a standalone, dependency-free walker that computes the maximum selection depth of a nested structure. It mirrors how a real validation rule recurses.

def max_depth(node, current=0):
    # node is a dict: {"name": str, "selections": [child, ...]}
    selections = node.get("selections", [])
    if not selections:
        return current
    return max(max_depth(child, current + 1) for child in selections)

query = {
    "name": "author",
    "selections": [
        {"name": "posts", "selections": [
            {"name": "author", "selections": [
                {"name": "title", "selections": []}
            ]}
        ]}
    ],
}

print("Max depth:", max_depth(query))

A Depth-Limiting Validation Rule

GraphQL exposes a validation phase that runs after parsing but before execution. If any validation rule reports an error, execution never starts, so no resolvers fire. This is exactly where depth limiting belongs.

The graphql-core library ships a ValidationRule base class. We override enter_operation_definition to measure depth and append a GraphQLError when the limit is exceeded.

from graphql import ValidationRule, GraphQLError


def depth_limit_validator(max_allowed: int):
    class DepthLimitRule(ValidationRule):
        def enter_operation_definition(self, node, *args):
            def depth_of(selection_set, level=1):
                if selection_set is None:
                    return level - 1
                return max(
                    (depth_of(getattr(f, "selection_set", None), level + 1)
                     for f in selection_set.selections),
                    default=level,
                )

            found = depth_of(node.selection_set)
            if found > max_allowed:
                self.report_error(GraphQLError(
                    f"Query depth {found} exceeds limit of {max_allowed}.",
                    nodes=node,
                ))

    return DepthLimitRule

Wiring the Rule into Strawberry

Strawberry's Schema accepts an extensions list and forwards extra validation rules through its execution config. The cleanest path is the AddValidationRules extension, which injects custom rules into every request.

Because depth validation happens before resolvers run, an over-deep query is rejected with a 400-style GraphQL error and zero database work.

import strawberry
from strawberry.extensions import AddValidationRules


@strawberry.type
class Query:
    @strawberry.field
    def hello(self) -> str:
        return "world"


schema = strawberry.Schema(
    query=Query,
    extensions=[
        AddValidationRules([depth_limit_validator(max_allowed=7)]),
    ],
)

Why Depth Alone Is Not Enough

Depth limiting stops vertical abuse, but it ignores horizontal abuse. A query only 2 levels deep can still be catastrophic:

  • search(first: 100000) returns a huge list.
  • Requesting 50 sibling fields at the same level multiplies resolver work.
  • Aliasing the same expensive field 100 times bypasses naive per-field caps.

To cover these we need complexity scoring: assign each field a cost, multiply by list-size arguments, and sum the whole tree. Reject when the total crosses a budget.

A Simple Complexity Estimator

The core idea: walk the query tree, give each field a base cost (default 1), and multiply a subtree's cost by any first/limit argument it carries. A list field that fetches 100 items and contains 3 sub-fields costs roughly 100 * 3.

This standalone estimator captures that multiplier logic so you can reason about budgets before plugging it into a framework.

def complexity(node):
    base = 1
    children = node.get("selections", [])
    child_cost = sum(complexity(c) for c in children)
    multiplier = node.get("first", 1)
    return base + multiplier * child_cost


feed = {
    "name": "feed", "first": 100, "selections": [
        {"name": "title", "selections": []},
        {"name": "comments", "first": 20, "selections": [
            {"name": "body", "selections": []},
        ]},
    ],
}

score = complexity(feed)
print("Estimated cost:", score)
print("Allowed?", score <= 1000)

Per-Field Cost Annotations

Hard-coding cost 1 everywhere is too blunt. Some fields are cheap (a string column) and some are expensive (a fan-out join or an external API call). In production you annotate each field with its own weight.

A common pattern is a metadata dictionary keyed by field name, looked up during scoring. Strawberry lets you attach such metadata via field directives or a custom extension that reads from a registry like the one below.

FIELD_COSTS = {
    "id": 0,
    "title": 1,
    "author": 2,        # a join
    "recommendations": 25,  # an ML call
}


def weighted_cost(node, costs):
    own = costs.get(node["name"], 1)
    multiplier = node.get("first", 1)
    children = sum(weighted_cost(c, costs) for c in node.get("selections", []))
    return own + multiplier * children


query = {"name": "feed", "first": 10, "selections": [
    {"name": "title", "selections": []},
    {"name": "recommendations", "selections": []},
]}

print("Weighted cost:", weighted_cost(query, FIELD_COSTS))

Enforcing a Cost Budget as an Extension

To enforce complexity in Strawberry, write a SchemaExtension that hooks on_validate (or on_operation). It computes the score from the parsed document and raises a GraphQLError if the budget is blown, halting before execution.

Keep the budget configurable per client tier: anonymous traffic gets a small budget, authenticated partners get more. The score and budget are great metrics to log and alert on.

from strawberry.extensions import SchemaExtension
from graphql import GraphQLError


class ComplexityLimiter(SchemaExtension):
    def __init__(self, *, max_cost: int = 1000):
        self.max_cost = max_cost

    def on_validate(self):
        document = self.execution_context.graphql_document
        if document is None:
            yield
            return
        cost = estimate_document_cost(document)  # your AST walker
        if cost > self.max_cost:
            self.execution_context.errors = [
                GraphQLError(f"Query cost {cost} exceeds {self.max_cost}.")
            ]
        yield

Persisted Queries: Allowlisting Documents

Depth and complexity limits are heuristics. The strongest control is the persisted query allowlist: clients send only a hash, and the server executes only documents it has pre-registered (usually extracted from your own frontend at build time).

Arbitrary ad-hoc queries are rejected outright, so attackers cannot craft anything new. The client sends {"extensions": {"persistedQuery": {"sha256Hash": "..."}}} and the server resolves it from a store.

import hashlib

# Build-time: register approved documents by their hash.
ALLOWLIST = {}


def register(query_text: str) -> str:
    h = hashlib.sha256(query_text.encode()).hexdigest()
    ALLOWLIST[h] = query_text
    return h


def resolve_persisted(sha256_hash: str) -> str:
    if sha256_hash not in ALLOWLIST:
        raise ValueError("PersistedQueryNotFound")
    return ALLOWLIST[sha256_hash]


hash_a = register("query { me { id name } }")
print("Registered:", hash_a[:12])
print("Resolved:", resolve_persisted(hash_a))

Layering the Defenses

These controls are complementary, not alternatives. A hardened FastAPI + Strawberry gateway typically stacks them in this order:

  • Persisted queries first — in strict mode, reject anything not on the allowlist before parsing.
  • Depth limit — cheap structural guard during validation.
  • Complexity budget — weighted cost ceiling, tiered per client.
  • Rate limiting — cap requests per token/IP at the FastAPI middleware layer.

Validation runs before resolvers, so depth and complexity rejections cost almost nothing. Always return the computed score in logs so you can tune budgets from real traffic instead of guessing.

Quick Check

A client sends a query that is only 2 levels deep but uses search(first: 50000) and selects 30 sibling fields under each result. Your server has a depth limit of 10 but no complexity scoring. What happens?

Recap

You now have a layered defense against abusive GraphQL queries on FastAPI + Strawberry:

  • Depth limiting walks the AST during the validation phase and rejects over-nested queries before any resolver runs.
  • Complexity scoring assigns weighted, per-field costs and multiplies by list-size arguments to enforce a budget, catching wide and aliased queries that depth misses.
  • Persisted queries allowlist hashed documents so only pre-approved queries execute — the strongest guarantee.
  • Stack them with rate limiting, tier budgets per client, and log the computed scores to tune limits from real data.

Remember the key trade-off: depth guards vertical abuse, complexity guards horizontal abuse, and persisted queries eliminate the unknown entirely.

자주 묻는 질문

“쿼리 비용 분석과 깊이 제한” 강의는 무료인가요?

네 — “쿼리 비용 분석과 깊이 제한” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 FastAPI Backend Development Bootcamp 강의 전체를 잠금 해제할 수 있습니다. FastAPI Backend Development Bootcamp 강의에는 총 4개의 강의가 포함되어 있습니다.

“쿼리 비용 분석과 깊이 제한”에서 뭘 배우나요?

깊이 제한, 복잡도 점수화 및 저장된 쿼리를 사용해 악의적인 쿼리로부터 API를 보호합니다. 브라우저에서 직접 실행하는 실습 코드로 FastAPI Backend Development Bootcamp을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

FastAPI Backend Development Bootcamp을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 FastAPI Backend Development Bootcamp은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 4번째 강의입니다.

“쿼리 비용 분석과 깊이 제한” 강의는 얼마나 걸리나요?

대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.

이 FastAPI Backend Development Bootcamp 강의에서 코드를 작성하고 실행할 수 있나요?

네. 모든 FastAPI Backend Development Bootcamp 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.

이 강의의 모든 강의

  1. 타입, 쿼리 및 변형 정의
  2. DataLoaders로 N+1 쿼리 해결
  3. 실시간 GraphQL 구독
  4. 쿼리 비용 분석과 깊이 제한
← FastAPI Backend Development Bootcamp(으)로 돌아가기