GraphQL: 쿼리 언어, 해석기 및 N+1 문제
GraphQL은 REST API의 근본적인 문제 중 하나인 클라이언트가 제어할 수 없다는 문제를 해결합니다.
어떤 데이터를 수신하는지. REST를 사용하면 GET /users/123 항상 모든 사용자 필드를 반환합니다.
클라이언트가 두 개만 필요한 경우에도 마찬가지입니다. 주문과 함께 전체 사용자 프로필을 업로드하려면
최신 및 생성된 경우에는 종종 3-4개의 별도 HTTP 호출이 필요합니다. GraphQL은 이 문제를 해결합니다.
클라이언트가 단일 쿼리에 필요한 데이터를 정확하게 지정할 수 있습니다.
그러나 GraphQL에는 자체적인 복잡성이 발생합니다. N+1 문제 그리고 위험 무해해 보이는 쿼리를 쿼리로 변환할 수 있는 보다 교활한 아키텍처입니다. 수십 또는 수백 개의 데이터베이스 쿼리가 쏟아져 나옵니다. 이 가이드에서는 작동 방식을 설명합니다. 리졸버 시스템, N+1 문제가 어떻게 나타나는지, 그리고 어떻게 데이터로더 자동 일괄 처리 및 캐싱으로 이 문제를 해결합니다.
무엇을 배울 것인가
- GraphQL 스키마: 유형, 쿼리, 변형 및 구독
- 해결 루프: GraphQL이 쿼리를 실행하는 방법
- Resolver : 각 필드에 대한 데이터를 제공하는 기능
- N+1 문제: 그것이 어떻게 나타나고 왜 위험한가
- DataLoader: N+1 문제를 해결하기 위한 일괄 처리 및 캐싱
- Apollo Server 및 TypeScript로 설정 완료
GraphQL 스키마: 형식화된 계약
모든 GraphQL API는 사용 가능한 모든 유형을 정의하는 계약인 스키마로 시작합니다. 쿼리(읽기), 변형(쓰기) 및 구독(실시간 스트림). 스키마는 SDL(스키마 정의 언어)로 작성됩니다.
// schema.graphql
type User {
id: ID!
name: String!
email: String!
createdAt: String!
orders: [Order!]! # Un utente ha molti ordini
profile: UserProfile # Nullable: non tutti gli utenti hanno un profilo
}
type Order {
id: ID!
total: Float!
status: OrderStatus!
createdAt: String!
items: [OrderItem!]! # Un ordine ha molti item
user: User! # Relazione bidirezionale
}
type OrderItem {
id: ID!
quantity: Int!
price: Float!
product: Product! # L'item ha un prodotto
}
type Product {
id: ID!
name: String!
price: Float!
category: String!
}
type UserProfile {
bio: String
avatarUrl: String
website: String
}
enum OrderStatus {
PENDING
CONFIRMED
SHIPPED
DELIVERED
CANCELLED
}
# Query: operazioni di lettura (equivalente GET in REST)
type Query {
user(id: ID!): User
users(limit: Int = 20, offset: Int = 0): [User!]!
order(id: ID!): Order
searchProducts(query: String!, limit: Int = 10): [Product!]!
}
# Mutation: operazioni di scrittura (equivalente POST/PUT/DELETE)
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
createOrder(input: CreateOrderInput!): Order!
}
# Subscription: stream real-time (WebSocket)
type Subscription {
orderStatusChanged(orderId: ID!): Order!
newUserRegistered: User!
}
input CreateUserInput {
name: String!
email: String!
password: String!
}
input UpdateUserInput {
name: String
email: String
}
해결 루프: GraphQL이 쿼리를 실행하는 방법
클라이언트가 GraphQL 쿼리를 보내면 서버는 다음과 같은 프로세스를 통해 쿼리를 실행합니다. 함수를 호출하여 트리처럼 스키마를 순회하는 "해상도" 리졸버 각 필수 필드에 대해:
// Questa query del client:
query {
users(limit: 5) {
id
name
orders {
id
total
status
}
}
}
// Produce questo albero di risoluzione:
// 1. Query.users -> chiama il resolver "users"
// 2. Per ogni User restituito:
// - User.id -> valore direttamente dall'oggetto
// - User.name -> valore direttamente dall'oggetto
// - User.orders -> chiama il resolver "orders" con parent = user
// 3. Per ogni Order dentro ogni User:
// - Order.id -> valore direttamente dall'oggetto
// - Order.total -> valore direttamente dall'oggetto
// - Order.status -> valore direttamente dall'oggetto
Apollo Server로 리졸버 구현
리졸버는 특정 필드에 대한 데이터를 검색하는 방법을 아는 기능입니다. 각 확인자는 4개의 인수를 받습니다: parent(상위 요소의 객체), args (쿼리 인수), 컨텍스트(데이터베이스 및 인증과 같은 공유 데이터) 및 정보 (쿼리 메타데이터):
// src/resolvers/index.ts
import { Resolvers } from './generated/types'; // Tipi generati da GraphQL Code Generator
import DataLoader from 'dataloader';
// Context condiviso tra tutti i resolver
export interface GraphQLContext {
db: DatabaseClient;
currentUser: User | null;
loaders: {
userById: DataLoader<string, User>;
ordersByUserId: DataLoader<string, Order[]>;
productById: DataLoader<string, Product>;
};
}
export const resolvers: Resolvers<GraphQLContext> = {
Query: {
// parent = {} (root query), args = { limit, offset }, ctx = context
users: async (_, { limit = 20, offset = 0 }, { db, currentUser }) => {
if (!currentUser) throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' }
});
return db.users.findMany({
take: Math.min(limit, 100), // Limite massimo di sicurezza
skip: offset,
orderBy: { createdAt: 'desc' }
});
},
user: async (_, { id }, { db }) => {
return db.users.findUnique({ where: { id } });
},
searchProducts: async (_, { query, limit }, { db }) => {
return db.products.findMany({
where: {
OR: [
{ name: { contains: query, mode: 'insensitive' } },
{ category: { contains: query, mode: 'insensitive' } }
]
},
take: Math.min(limit, 50)
});
}
},
// Resolver per i campi del tipo User
User: {
// parent = User object, args = {}, ctx = context
orders: async (user, _, { loaders }) => {
// USA DataLoader invece di db.orders.findMany({ where: { userId: user.id } })
// Spiegazione nella sezione successiva!
return loaders.ordersByUserId.load(user.id);
},
profile: async (user, _, { db }) => {
return db.userProfiles.findUnique({ where: { userId: user.id } });
}
},
// Resolver per i campi del tipo Order
Order: {
items: async (order, _, { db }) => {
return db.orderItems.findMany({ where: { orderId: order.id } });
},
user: async (order, _, { loaders }) => {
return loaders.userById.load(order.userId);
}
},
// Resolver per i campi del tipo OrderItem
OrderItem: {
product: async (item, _, { loaders }) => {
return loaders.productById.load(item.productId);
}
},
Mutation: {
createUser: async (_, { input }, { db }) => {
const hashedPassword = await bcrypt.hash(input.password, 12);
return db.users.create({
data: { ...input, password: hashedPassword }
});
},
createOrder: async (_, { input }, { db, currentUser }) => {
if (!currentUser) throw new GraphQLError('Authentication required');
return db.orders.create({
data: { ...input, userId: currentUser.id, status: 'PENDING' }
});
}
}
};
문제 N+1: 숨겨진 위험
N+1 문제는 GraphQL에서 가장 일반적인 아키텍처 위험입니다. 다음 쿼리를 상상해 보세요.
query {
users(limit: 10) { # 1 query: SELECT * FROM users LIMIT 10
name
orders { # 10 query: SELECT * FROM orders WHERE userId = ?
total # Una per ogni utente!
}
}
}
이 "순수한" 쿼리는 11개의 데이터베이스 쿼리(사용자당 1개 + 각 사용자당 1개)를 생성합니다. 그의 명령을 위해. 사용자가 10명이면 쿼리는 11개입니다. 사용자가 100명이면(한도를 늘리면) 101명이 됩니다. 관리 페이지에 1000명의 사용자가 있나요? 1001개의 쿼리입니다. 쿼리 수는 N(사용자) + 1 — from 여기서는 "N+1 문제"라는 이름을 사용합니다.
// SENZA DataLoader: N+1 in azione
const User_orders = async (user, _, { db }) => {
// Questa riga viene chiamata UNA VOLTA PER OGNI UTENTE nella lista
return db.orders.findMany({ where: { userId: user.id } });
// Con 10 utenti = 10 query separate al database
// SELECT * FROM orders WHERE userId = '1'
// SELECT * FROM orders WHERE userId = '2'
// ... (10 volte!)
};
// SENZA DataLoader: prodotti per ogni item di ogni ordine
const OrderItem_product = async (item, _, { db }) => {
return db.products.findUnique({ where: { id: item.productId } });
// Se hai 10 utenti x 5 ordini x 3 items = 150 query solo per i prodotti!
};
DataLoader: N+1 문제에 대한 솔루션
데이터로더 (Facebook에서 만들고 현재는 커뮤니티에서 관리함)는 다음과 같은 문제를 해결합니다. 두 가지 메커니즘을 사용하는 N+1 문제: 일괄 처리 (여러 요청을 하나로 그룹화합니다. 단일 쿼리) e 캐싱 (동일한 키에 대한 결과 재사용):
// src/loaders/index.ts
import DataLoader from 'dataloader';
import { DatabaseClient } from './db';
export function createLoaders(db: DatabaseClient) {
return {
// Batch function: riceve un ARRAY di chiavi, ritorna un ARRAY di risultati
// L'ordine dei risultati DEVE corrispondere all'ordine delle chiavi
userById: new DataLoader<string, User | null>(
async (userIds) => {
// UNA SOLA QUERY per tutti gli ID! (invece di N query)
const users = await db.users.findMany({
where: { id: { in: [...userIds] } }
});
// Mappa risultati mantenendo l'ordine originale delle chiavi
const userMap = new Map(users.map(u => [u.id, u]));
return userIds.map(id => userMap.get(id) ?? null);
}
),
ordersByUserId: new DataLoader<string, Order[]>(
async (userIds) => {
// UNA SOLA QUERY invece di N query
const orders = await db.orders.findMany({
where: { userId: { in: [...userIds] } }
});
// Raggruppa per userId
const ordersByUser = new Map<string, Order[]>();
for (const order of orders) {
const existing = ordersByUser.get(order.userId) ?? [];
ordersByUser.set(order.userId, [...existing, order]);
}
return userIds.map(id => ordersByUser.get(id) ?? []);
},
{
// Ottimizzazione: raggruppa le chiavi duplicate
cacheKeyFn: (key) => key,
// Massimo 100 chiavi per batch (evita query enormi)
maxBatchSize: 100
}
),
productById: new DataLoader<string, Product | null>(
async (productIds) => {
const products = await db.products.findMany({
where: { id: { in: [...productIds] } }
});
const productMap = new Map(products.map(p => [p.id, p]));
return productIds.map(id => productMap.get(id) ?? null);
},
{
// Cache per tutta la durata della request (default)
cache: true
}
)
};
}
// Come funziona DataLoader internamente:
// 1. Il resolver chiama loader.load('userId-1') e loader.load('userId-2')
// 2. DataLoader aspetta il "tick" successivo di JavaScript
// 3. Raggruppa tutte le chiamate .load() accumulate in quel tick
// 4. Chiama la batch function UNA SOLA VOLTA con ['userId-1', 'userId-2']
// 5. Distribuisce i risultati ai chiamanti originali
DataLoader 사용: 쿼리는 111개 대신 3개의 쿼리를 생성합니다.
// Query: 10 utenti con 5 ordini ciascuno, ogni ordine con 3 prodotti
// SENZA DataLoader: 1 + 10 + 50 + 150 = 211 query database
// SENZA DataLoader con 100 utenti: 1 + 100 + 500 + 1500 = 2101 query!
// CON DataLoader:
// 1 query: SELECT * FROM users LIMIT 10
// 1 query: SELECT * FROM orders WHERE userId IN (1,2,3,...,10)
// 1 query: SELECT * FROM products WHERE id IN (product-id-1, ..., product-id-15)
// = 3 query totali, indipendentemente da quanti utenti/ordini/prodotti!
TypeScript를 사용하여 Apollo 서버 설정 완료
// src/server.ts
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { readFileSync } from 'fs';
import { resolvers } from './resolvers';
import { createLoaders } from './loaders';
import { GraphQLContext } from './types';
import { db } from './db';
import { authenticateRequest } from './auth';
const typeDefs = readFileSync('./schema.graphql', 'utf8');
const server = new ApolloServer<GraphQLContext>({
typeDefs,
resolvers,
// Plugin per logging e monitoring
plugins: [
// Log ogni richiesta in production
{
requestDidStart: async () => ({
didResolveOperation: async ({ request, document }) => {
console.log('GraphQL operation:', request.operationName);
}
})
}
],
// Limiti di sicurezza
introspection: process.env.NODE_ENV !== 'production'
});
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: async ({ req }) => {
// I DataLoader devono essere creati PER REQUEST (non globali!)
// Ogni request ha il suo scope di caching
return {
db,
currentUser: await authenticateRequest(req.headers.authorization),
loaders: createLoaders(db)
// IMPORTANTE: crea nuovi loaders per ogni request
// (altrimenti il cache persiste tra requests e puo causare
// problemi di autorizzazione)
};
}
});
console.log(`GraphQL server running at: ${url}`);
깊이 제한 및 복잡성 분석
GraphQL을 사용하면 클라이언트가 임의로 깊거나 복잡한 쿼리를 만들 수 있습니다. DoS 공격에 사용될 수 있습니다. 보호 조치를 구현하는 것이 중요합니다.
// Protezione con graphql-depth-limit e graphql-query-complexity
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
// Massima profondita della query: previene query tipo user.orders.items.product.vendor.orders.items...
depthLimit(7),
// Massima complessita calcolata
createComplexityLimitRule(1000, {
onCost: (cost) => console.log('Query complexity:', cost),
formatErrorMessage: (cost) =>
`Query too complex (${cost}). Maximum complexity: 1000`
})
]
});
GraphQL 모범 사례
- 관계에는 항상 DataLoader를 사용하십시오. 유형의 해석기(User.orders, Order.items 등)에서 직접 데이터베이스 쿼리를 수행하지 마십시오.
- 전역이 아닌 요청에 대한 DataLoader를 만듭니다. DataLoader 캐싱은 각 요청이 끝날 때마다 만료되어야 합니다. 서로 다른 사용자 간의 인증 문제
- 깊이 제한 및 복잡성 분석 구현: 이러한 검사가 없으면 GraphQL은 복잡한 쿼리를 통한 DoS 공격에 취약합니다.
- 프로덕션에서 지속 쿼리를 사용합니다. 클라이언트 측 쿼리를 미리 저장하고 해시만 보내 쿼리를 방지합니다. 생산 중 임의적
- 프로덕션에서 자체 검사를 비활성화합니다. 자체 검사를 통해 전체 계획이 잠재적인 공격자에게 노출됩니다.
결론 및 다음 단계
GraphQL은 다양한 요구 사항을 가진 클라이언트에 서비스를 제공해야 하는 API를 위한 강력한 도구입니다. 그러나 N+1 문제는 실제 문제이며 올바르게 해결하려면 DataLoader가 필요합니다. API DataLoader가 없고 깊이 제한이 없으며 기술적으로 안전하지 않은 프로덕션의 GraphQL 그리고 비효율적이다. 올바른 구성을 사용하면 Apollo Server가 포함된 GraphQL은 다음을 제공합니다. REST를 통한 탁월한 개발자 경험 및 경쟁력 있는 성능.
다음 기사에서 살펴보겠습니다. GraphQL 페더레이션: 아폴로 라우터와 같습니다 (Rust로 작성) 여러 개의 독립적인 하위 그래프를 통합된 상위 그래프로 구성하여 다음을 가능하게 합니다. 자율적인 팀이 계획의 일부를 독립적으로 개발합니다.
시리즈: API 디자인 — REST, GraphQL, gRPC 및 tRPC 비교
- 기사 1: 2026년 API 환경 — 의사결정 매트릭스
- 기사 2: 2026년 REST — 모범 사례, 버전 관리 및 Richardson 성숙도 모델
- 제3조(본): GraphQL — 쿼리 언어, 해결자 및 N+1 문제
- 기사 4: GraphQL 페더레이션 - Supergraph, Subgraph 및 Apollo Router
- 5조: gRPC - Protobuf, 성능 및 서비스 간 통신
- 기사 6: tRPC — 코드 생성 없이 종단 간 형식 안전성
- 기사 7: 웹후크 - 패턴, 보안 및 재시도 논리
- 8조: API 버전 관리 - URI, 헤더 및 지원 중단 정책
- 9조: 속도 제한 및 제한 - 알고리즘 및 구현
- 기사 10: 하이브리드 API 아키텍처 - 2026년 REST + tRPC + gRPC







