05 — Rate Limiting & Cost Analysis
한 줄 답: REST의 요청 수 기반 rate limit (
100 req/min)을 GraphQL에 그대로 쓰면 한 요청 안의 100개 필드에 당한다. GraphQL의 rate limit은 쿼리당 cost로 환산해야 하고, GitHub API v4의 5000 point/h가 공개된 사실상 표준이다.
Why — 왜 token bucket을 그대로 쓰면 안 되나
REST의 req/sec 모델은 다음 두 가정 위에 서 있다.
- 한 요청의 비용이 비슷하다 —
GET /users/123은GET /users/456과 거의 같은 비용. - path가 비용을 알려준다 —
/users/:id는 1회 DB 조회,/search는 10회로 path별 정책이 가능.
GraphQL은 둘 다 깨진다.
# 쿼리 A — req 1개, 비용 1
{ me { id } }
# 쿼리 B — req 1개, 비용 1,000,000
{ users(first: 1000) { posts(first: 1000) { comments(first: 1000) { id } } } }같은 1 req다. req/sec로는 두 쿼리를 구분할 수 없다. 그래서 GraphQL에서는 cost가 요청의 단위가 된다.
How — 어떻게 측정하고 강제하나
1) Token Bucket — REST의 기본
먼저 REST의 token bucket을 복습한다. 사용자마다 bucket이 있고, 일정 속도로 token이 차오른다.
bucket capacity = 100 tokens
refill rate = 100 tokens / minute
요청 1개 = 1 token 소비bucket이 비면 429 Too Many Requests. 이 모델을 GraphQL에 그대로 적용하면 쿼리 비용 차이를 못 잡는다.
2) Cost-based token bucket — GraphQL 적용
핵심 발상은 단순하다 — 요청 1개 = 1 token이 아니라, 요청 1개 = N cost points.
bucket capacity = 5000 points
refill rate = 5000 points / hour
요청 1개 = ceil(query cost) points이 모델은 GitHub v4가 공식 발표한 것이고, Shopify가 leaky bucket 변형으로 쓴다.
3) GitHub API v4의 정확한 알고리즘 (공개)
GitHub의 Rate Limits for the GraphQL API 문서는 공식 공개된 계산식이다.
A query’s score is calculated as follows:
- Each connection in the query is normalized —
first,last,limit인자가 결정자.- 각 connection에서 1 + ceil(n / 100) 의 score (단, n이 100 미만이어도 최소 1).
- Nested connection은 부모 × 자식 (곱집합).
- Mutation은 연결 수만큼 score.
실제 예시
{
viewer { login } # connection 아님 = 0
repository(owner: "octo", name: "Hello") { # connection 아님 = 0
issues(first: 50) { # 1 point (50 ≤ 100)
nodes {
comments(first: 60) { # 1 × 50 = 50 points
nodes { author { login } }
}
}
}
}
}
# 총 cost = 0 + 0 + 1 + 50 = 51 points응답에 rateLimit 필드를 명시적으로 요청하면 예측 cost와 남은 quota를 받는다.
{
rateLimit { cost remaining resetAt limit }
viewer { login }
}{
"data": {
"rateLimit": {
"cost": 1,
"remaining": 4999,
"limit": 5000,
"resetAt": "2026-05-17T16:00:00Z"
}
}
}→ 예측 cost(클라이언트가 쿼리 보내기 전 받을 수 있음)와 실행 cost(응답의 cost 필드)가 분리됨. GitHub은 둘 다를 공개 API로 노출한다.
4) Shopify의 leaky bucket
Shopify Storefront API는 leaky bucket이다.
bucket capacity = 1000 cost (max)
restore rate = 50 cost/secburst를 허용한다 — 순간적으로 1000까지 가능. 하지만 지속 호출은 50/sec가 한계.
"extensions": {
"cost": {
"requestedQueryCost": 152,
"actualQueryCost": 100,
"throttleStatus": {
"maximumAvailable": 1000,
"currentlyAvailable": 900,
"restoreRate": 50
}
}
}→ token bucket vs leaky bucket의 차이는 burst 허용 폭과 복구 속도다. Shopify는 burst 가능 + 지속 제한, GitHub은 순간 한계가 곧 시간 한계.
5) Per-field 비용 추정 — 누가 cost를 정하나
cost를 정하는 방법은 3가지.
| 방법 | 어디서 정함 | 예 |
|---|---|---|
| (a) Schema directive | SDL의 @cost | GitHub · Shopify |
| (b) 코드 함수 | resolver 옆 cost function | graphql-query-complexity |
| (c) 측정 + 학습 | 과거 실행 시간을 학습 | Apollo Insights (Studio) |
(a) Schema directive로 매기기
directive @cost(complexity: Int!, multipliers: [String!]) on FIELD_DEFINITION
type Query {
users(first: Int!): [User!]! @cost(complexity: 1, multipliers: ["first"])
search(query: String!): SearchResult! @cost(complexity: 10)
recommendations(first: Int!): [Item!]! @cost(complexity: 5, multipliers: ["first"])
}(b) 코드에서 매기기
const rule = createComplexityRule({
maximumComplexity: 1000,
estimators: [
fieldExtensionsEstimator(),
fieldConfigEstimator({
defaultComplexity: 1,
complexity: {
"Query.search": 10,
"Query.recommendations": ({ args }) => 5 * args.first,
},
}),
],
});(c) 측정 기반
Apollo Studio는 모든 resolver의 실제 실행 시간을 수집해서 p95 latency × 호출 빈도를 cost로 표시한다. 이게 데이터 기반 cost다 — 처음엔 (a)/(b)로 시작하고, production 데이터로 cost를 보정한다.
6) 사용자별 정책 — tier 시스템
cost limit은 사용자 tier에 따라 다르게 매겨진다. GitHub의 예시.
| Tier | Quota |
|---|---|
| Unauthenticated | 60 req/h (cost 모름) |
| Authenticated (PAT) | 5000 point/h |
| GitHub App | 12,500 point/h |
| GitHub Enterprise | 무제한 (내부 정책) |
7) 분산 환경의 bucket — Redis
서버가 여러 대면 bucket이 공유돼야 한다. 표준은 Redis.
// 간단한 Lua 스크립트 — atomic 차감
const LUA = `
local current = redis.call("GET", KEYS[1]) or 0
if tonumber(current) + tonumber(ARGV[1]) > tonumber(ARGV[2]) then
return -1
end
redis.call("INCRBY", KEYS[1], ARGV[1])
redis.call("EXPIRE", KEYS[1], 3600)
return tonumber(current) + tonumber(ARGV[1])
`;
const consumed = await redis.eval(LUA, 1, `rl:${userId}`, cost, 5000);
if (consumed === -1) throw new GraphQLError("RATE_LIMITED");→ 모든 분산 GraphQL 서버가 Redis로 bucket을 공유. atomic 차감을 위해 Lua 스크립트.
What — 구체 사양
라이브러리 매트릭스
| 라이브러리 | 모델 | 통합 |
|---|---|---|
graphql-rate-limit (teamplanes) | per-field, Redis 옵션 | Apollo, Yoga |
@envelop/rate-limiter | Envelop plugin | Yoga |
graphql-query-complexity | cost 계산만 (limit은 별도) | 모든 서버 |
| Apollo Studio Operation Limits | 측정 기반 | Apollo Cloud |
Hasura rate-limit action permission | role별 cost | Hasura |
| AWS AppSync throttling | tier별 | AppSync |
응답 header 컨벤션
HTTP/1.1 200 OK
X-RateLimit-Limit: 5000
X-RateLimit-Remaining: 4949
X-RateLimit-Reset: 1747497600
X-RateLimit-Cost: 51또는 GraphQL extension으로.
"extensions": {
"rateLimit": { "limit": 5000, "remaining": 4949, "cost": 51, "resetAt": "..." }
}→ GitHub은 둘 다 보낸다. header는 non-GraphQL 도구가, extension은 GraphQL 클라이언트가 본다.
429 응답의 형식
{
"errors": [{
"message": "API rate limit exceeded",
"extensions": {
"code": "RATE_LIMITED",
"retryAfter": 3600,
"http": { "status": 429, "headers": { "Retry-After": "3600" } }
}
}]
}retryAfter는 초 단위. 클라이언트는 exponential backoff로 재시도.
Subscription의 cost
subscription은 오랫동안 떠 있는 연결이다. cost 정책 2가지.
- 연결 시점 — depth/complexity만 cost로 매김.
- 이벤트 시점 — 각 이벤트 발생마다 bucket에서 차감. event flood 방어.
대부분의 production은 연결 + 동시 connection 수 제한(예: user당 10개)으로 처리.
Mutation의 cost
Mutation은 부수 효과가 있어서 단순 cost 합산이 위험하다. 권장 정책.
- Mutation은 높은 base cost (예: 5 points)
- list mutation (
createUsers(inputs: [...]))은 입력 수에 비례 - 외부 API 호출 mutation (결제·이메일)은 매우 높은 cost (예: 100 points)
What-if — 잘못 이해하면
1) req/sec만 끼우면
→ 1 req로 100만 cost가 들어와 DB가 죽는다.
대응: cost 기반 bucket으로 전환.
2) cost를 실행 후에 차감하면
→ DB는 이미 다녀온 후. bucket 차감은 사후 통보가 된다. 대응: Validator 단계에서 추정 cost로 사전 차감. 실행 후 actual로 보정.
3) 사용자별 bucket이 서버별 분리되면
→ 5대 서버에 라운드로빈하면 각 bucket이 1000씩 → 실효 5000으로 5배 누출. 대응: Redis로 공유 bucket. atomic 연산.
4) bucket key를 IP로 잡으면
→ NAT/proxy 뒤의 수천 사용자가 같은 IP. 한 명이 bucket을 공유함. 대응: 인증 사용자는 user id, 익명은 IP + UA 해시 또는 익명은 더 엄격하게.
5) Mutation cost를 낮게 두면
→ createUser mutation으로 brute force 가능.
대응: Mutation base cost 5+, 외부 호출 mutation 100+.
6) rateLimit { remaining } 필드를 cost 무료로 두면
→ 공격자가 rateLimit만 조회해서 상태 정찰. 무제한 조회. 대응: rateLimit 필드도 1 cost로 매기거나, 호출 자체에 rate.
7) Persisted query인데 cost를 실행 시 계산하면
→ 같은 쿼리(같은 해시)인데 매번 AST 파싱 + cost 계산. 낭비. 대응: 등록 시점에 cost를 미리 계산해서 저장. 런타임은 lookup만.
Insight — 흥미로운 이야기
”GitHub은 cost 공식을 발표하기 전에 3년간 무료로 두었다”
GitHub v4는 2016년 출시 시 rate limit이 없었다. 1년 차에 botnet들이 introspection을 분당 수천 회 부르며 schema fingerprint DB를 만들었다. 2년 차에 GitHub은 block이 아니라 cost 산정 모델을 발표했다 — 5000 point/h, ceil(n/100) 공식. 그 발표 자체가 교과서가 됐다.
→ 교훈: rate limit의 가치는 알고리즘이 아니라 공개된 알고리즘이다. 비공개면 클라이언트가 시행착오만 한다.
”Shopify는 ‘leaky bucket’을 마케팅했다”
Shopify는 bucket이라는 단어 자체를 공식 문서 1면에 노출한다. “Your app starts with a bucket of 1000 cost points. Each query reduces your bucket. The bucket refills at 50 points per second.” 이 비유가 개발자의 멘탈 모델과 정확히 맞물려서, Shopify API 개발자는 rate limit 이해도가 평균적으로 높다.
→ 교훈: API 정책의 언어 선택이 클라이언트 행동을 만든다. bucket이라는 단어 하나가 수년의 학습을 절약했다.
”Apollo Studio는 cost를 측정으로 푼다”
Apollo Studio는 cost를 미리 산정하지 않는다. 대신 모든 resolver의 실행 시간을 수집해서 p95 cost를 operation 단위로 표시한다. “이 쿼리가 평균 142ms, p95 380ms” — 이 데이터 자체가 rate limit 정책의 입력이 된다. 측정이 알고리즘을 대체한다.
→ 교훈: cost는 선험적 정의도 가능하지만 경험적 측정이 더 정확하다. 큰 production은 둘 다 한다.
”Hasura는 역할마다 cost를 다르게 매긴다”
Hasura는 role permission 안에 rate limit이 내장되어 있다.
# user role
- table: posts
select_permissions:
- role: user
permission:
rate_limit:
unique_params: [user_id]
max_reqs_per_min: 60
- table: posts
select_permissions:
- role: admin
permission:
# admin은 rate limit 없음→ 권한 + rate limit이 같은 구조. 같은 곳에 적힌다. 권한 grant가 rate limit grant와 함께 간다.
→ 교훈: rate limit은 권한의 한 차원이다. 분리해서 보면 둘 다 늦게 갱신된다.
요약 + 다이어그램
REST의
req/sec는 GraphQL을 못 막는다 — 쿼리당 cost로 환산해야 한다. GitHub v4의 5000 point/h, ceil(n/100) 공식이 공개된 사실상 표준. Shopify는 leaky bucket으로 burst 허용 + 복구 속도를 노출. cost는 (a) schema directive, (b) 코드 함수, (c) 측정 기반 셋 중 하나로 매긴다. 다음 문서는 쿼리 너머의 운영 — schema linting과 거버넌스.
다음 문서:
06-schema-linting-and-governance.mdx— 큰 그래프를 컨벤션으로 운영하는 방법.