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 } } }recommendations가 ML 추론을 부른다고 가정하자. node 수는 적지만 한 노드의 cost가 100배다. 이게 cost 기반이 필요한 이유.
| 척도 | 무엇을 세나 | 정밀도 | 구현 난이도 |
|---|---|---|---|
| depth | selection 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: N → cost × 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 pointsGitHub은 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 cost | multiplier |
|---|---|---|
products | 1 | × first/last |
metafields | 2 | × first |
productByHandle | 1 | - |
connection의 pageInfo | 0 | - |
요청 응답의 extensions.cost에 requested cost와 actual 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-limit | depth | Apollo, Yoga의 validation rule |
graphql-query-complexity | complexity (extensible) | Apollo, Yoga |
@envelop/depth-limit | depth (Envelop plugin) | GraphQL Yoga |
@graphql-tools/cost-analysis | cost (deprecated) | 과거 Apollo Engine |
Apollo Studio operationCount | metric only | 모니터링 (limit 아님) |
다른 언어. Hot Chocolate(.NET) — MaxAllowedExecutionDepth, OperationComplexity 빌트인. Strawberry(Python) — QueryDepthLimiter, MaxAliasesRule. gqlgen(Go) — complexity.Root config로 필드별 함수 등록.
depth/complexity 디폴트 권장치
| 환경 | depth | complexity | 비고 |
|---|---|---|---|
| 내부 API | 15 | 5,000 | 정상 쿼리 거의 다 통과 |
| 외부 API (사내 일반) | 10 | 1,000 | 일반적 권장 |
| 공개 API (GitHub-class) | 10 | 점수 시스템 (5000/h) | cost 기반 |
| 모바일 only | 7 | 500 | 모바일은 깊은 쿼리 거의 없음 |
출처: 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-complexity는 fragment 전개 후 계산한다 (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 자체를 얼마나 노출할 것인가의 정책.