[API Design] GraphQL: 단일 endpoint, N+1, persisted queries
GraphQL, Apollo, Relay, DataLoader, N+1 GraphQL, persisted queries, federation
정의
GraphQL (Facebook, 2015) 은 클라이언트가 필요한 필드만 명시 하는 query language + 런타임. 단일 endpoint, typed schema, over-fetching / under-fetching 해소.
REST vs GraphQL
| 항목 | REST | GraphQL |
|---|---|---|
| Endpoint | 여러 개 | 단일 (/graphql) |
| 응답 형태 | 서버 결정 | 클라이언트 결정 |
| Over-fetching | 흔함 | 적음 |
| Multiple resources | N 번 요청 | 1번 쿼리 |
| Type | 옵션 (OpenAPI) | 필수 (SDL) |
| 캐싱 | HTTP cache | 응답 단위 (별도) |
| 모니터링 | URL 별 | 쿼리 별 (복잡) |
| 학습 곡선 | 낮음 | 중간 |
Schema (SDL)
type Query {
user(id: ID!): User
users(limit: Int = 10, cursor: String): UserConnection!
}
type Mutation {
createUser(input: CreateUserInput!): User!
deleteUser(id: ID!): Boolean!
}
type User {
id: ID!
name: String!
email: String!
orders(status: OrderStatus): [Order!]!
}
enum OrderStatus { PENDING PAID CANCELLED }
Query / Response
query GetUserWithOrders($id: ID!) {
user(id: $id) {
name
email
orders(status: PAID) {
id
total
items {
product { name }
quantity
}
}
}
}
{
"data": {
"user": {
"name": "koa",
"email": "koa@x.com",
"orders": [
{
"id": "o_1",
"total": 19900,
"items": [{ "product": { "name": "Book" }, "quantity": 2 }]
}
]
}
}
}
N+1 문제 (GraphQL 의 함정)
query {
users { # 1 query
name
orders { # 각 user 마다 별도 query → N+1!
total
}
}
}
sequenceDiagram
Client->>GraphQL: query users { orders }
GraphQL->>DB: SELECT * FROM users (1)
DB-->>GraphQL: 100 users
loop 각 user
GraphQL->>DB: SELECT * FROM orders WHERE user_id = ?
end
Note over GraphQL,DB: 1 + 100 = 101 queries (N+1!)
해결: DataLoader
const orderLoader = new DataLoader(async (userIds) => {
const orders = await db.orders.find({ user_id: { $in: userIds } });
return userIds.map(uid => orders.filter(o => o.user_id === uid));
});
const resolvers = {
User: {
orders: (user) => orderLoader.load(user.id),
},
};
DataLoader 가 같은 tick 의 모든 load() 호출을 모아 1 query 로:
sequenceDiagram
GraphQL->>DataLoader: load(1), load(2), load(3), ...
Note over DataLoader: 같은 tick 의 호출 모음
DataLoader->>DB: SELECT * FROM orders WHERE user_id IN (1,2,3,...)
DB-->>DataLoader: 모든 orders
DataLoader-->>GraphQL: per-user 결과 분배
IMPORTANT
DataLoader 는 GraphQL 의 필수 동반자. 없으면 거의 모든 nested query 가 N+1.
Persisted Queries
매 요청에 전체 쿼리 문자열 보내면 대역폭 + 보안 문제 (악의적 deep nested query 로 DoS).
Persisted Query: 쿼리를 미리 등록 + 해시로 호출.
flowchart LR
Build[빌드 시] -->|쿼리 → 해시| Reg[(서버에 등록)]
Client[클라이언트] -->|hash 만 전송| Server
Server -->|hash → 쿼리 조회| Cache[(query cache)]
Cache --> Exec[실행]
- 대역폭 대폭 감소 (긴 쿼리 → 32바이트 해시).
- whitelisted query 만 실행 → 보안 강화.
- Apollo Persisted Queries / Relay Compiler 가 자동.
Subscription (실시간)
WebSocket 또는 SSE 위에서.
subscription OnNewMessage($roomId: ID!) {
messageAdded(roomId: $roomId) {
id
body
sender { name }
}
}
내부적으로 WebSocket (graphql-ws) 또는 SSE.
Federation (단일 graph, 다중 서비스)
여러 서비스가 각자 schema 일부 를 담당. gateway 가 통합 schema.
flowchart TB
Client -->|query| Gateway[Apollo Router]
Gateway -->|user subgraph| UserSvc[User Service]
Gateway -->|order subgraph| OrderSvc[Order Service]
Gateway -->|payment subgraph| PaySvc[Payment Service]
마이크로서비스 환경의 GraphQL 표준 패턴.
캐싱
GraphQL 은 HTTP cache 가 어려움 (POST + body):
- 응답 캐시: 같은 query + 변수 → 같은 응답. CDN level 어렵, Apollo Cache / @cacheControl 디렉티브.
- DataLoader: per-request 캐싱.
- Persisted Query + GET = URL 캐시 가능. CDN 친화.
깊이 / 복잡도 제한 (보안)
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
schema,
validationRules: [
depthLimit(7),
createComplexityLimitRule(1000),
],
});
CAUTION
클라이언트가 극단적 nested query (user { friends { friends { friends { ... } } } }) 를 보내면 서버 DoS. depth + complexity 제한 필수.
흔한 함정
WARNING
- DataLoader 미사용 = 모든 nested 필드 N+1. 처음부터 도입.
- Depth limit 없음 = DoS 공격.
- 모든 필드 nullable = 응답 전체가 null union 형태. 명확한 nullability 명시.
- HTTP cache 의 부재 = GraphQL 의 큰 단점. 무거운 쿼리는 외부 cache + persisted query.
관련 위키
이 글의 용어 (4개)
- [API Design] REST API: 원칙, 자원 모델링, HATEOAS, 버전 관리api-design
- 정의 REST (Representational State Transfer) 는 Roy Fielding 의 2000년 박사논문에서 정립된 분산 시스템 아키텍처 스타일. HTTP 의…
- [Network] gRPC: HTTP/2 + Protobuf, 4가지 streaming 패턴network
- 정의 gRPC 는 HTTP/2 위에서 Protobuf 직렬화 로 동작하는 고성능 RPC 프레임워크. Google 내부 Stubby 의 오픈소스 후계. 핵심 4가지: 1. Prot…
- [Redis] Cache Patterns: Cache-Aside, Stampede, Evictiondatabase-internals
- 정의 Cache Pattern 은 Redis (또는 다른 캐시) 를 어떤 경로로 읽고 쓰는지 의 디자인 결정. 같은 인프라로도 패턴 선택에 따라 일관성 / 지연 / 부하 가 완전…
- N+1 쿼리 문제database-internals
- 정의 N+1 쿼리 문제 = ORM 이 부모 entity N 개를 조회한 뒤, 각 부모의 연관 entity 를 하나씩 로드하면서 추가 SQL 이 N 번 발생하는 성능 안티패턴. 이…
💬 댓글